feat(site): add interactive roadmap explorer

Static site in site/ (vanilla ES modules, no build step) that reads
README.md at runtime and draws the roadmap map.

- Map view: Software, Software ∩ Hardware, Hardware and Soft skills
  areas, importance colors, area/importance filters, connections
  between topics, pan/zoom, A4 print stylesheet
- Outline view, search and a details panel with descriptions and
  learning resources
- site/map.json is the editable source of the map (bands, clusters,
  importance); layout.js places it on an A4 page with a minimum spacing
- Credits with creator logo, CC BY-SA badge and build revision
- Sofia Sans Semi Condensed bundled under the OFL
- Tests for the README parser, map mapping and layout
This commit is contained in:
Meysam Parvizi
2026-10-10 01:15:05 +02:00
committed by Meysam
parent 234f89bb38
commit fdb9eaba1c
15 changed files with 4261 additions and 0 deletions
+663
View File
@@ -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();
+199
View File
@@ -0,0 +1,199 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://web.resource.org/cc/"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="120"
height="42"
id="svg2759"
sodipodi:version="0.32"
inkscape:version="0.45+devel"
version="1.0"
sodipodi:docname="by-sa.svg"
inkscape:output_extension="org.inkscape.output.svg.inkscape">
<defs
id="defs2761" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#8b8b8b"
borderopacity="1"
gridtolerance="10000"
guidetolerance="10"
objecttolerance="10"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="179"
inkscape:cy="89.569904"
inkscape:document-units="px"
inkscape:current-layer="layer1"
width="120px"
height="42px"
inkscape:showpageshadow="false"
inkscape:window-width="1198"
inkscape:window-height="624"
inkscape:window-x="488"
inkscape:window-y="401" />
<metadata
id="metadata2764">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
transform="matrix(0.9937807,0,0,0.9936694,-177.69409,-74.436409)"
id="g287"
inkscape:export-filename="/mnt/hgfs/Bov/Documents/Work/2007/cc/identity/srr buttons/big/by-sa.png"
inkscape:export-xdpi="300.23013"
inkscape:export-ydpi="300.23013">
<path
id="path3817_2_"
nodetypes="ccccccc"
d="M 182.23532,75.39014 L 296.29928,75.59326 C 297.89303,75.59326 299.31686,75.35644 299.31686,78.77344 L 299.17721,116.34033 L 179.3569,116.34033 L 179.3569,78.63379 C 179.3569,76.94922 179.51999,75.39014 182.23532,75.39014 z"
style="fill:#aab2ab" />
<g
id="g5908_2_"
transform="matrix(0.872921,0,0,0.872921,50.12536,143.2144)">
<path
id="path5906_2_"
cx="296.35416"
ry="22.939548"
cy="264.3577"
type="arc"
rx="22.939548"
d="M 187.20944,-55.6792 C 187.21502,-46.99896 180.18158,-39.95825 171.50134,-39.95212 C 162.82113,-39.94708 155.77929,-46.97998 155.77426,-55.66016 C 155.77426,-55.66687 155.77426,-55.67249 155.77426,-55.6792 C 155.76922,-64.36054 162.80209,-71.40125 171.48233,-71.40631 C 180.16367,-71.41193 187.20441,-64.37842 187.20944,-55.69824 C 187.20944,-55.69263 187.20944,-55.68591 187.20944,-55.6792 z"
style="fill:#ffffff" />
<g
id="g5706_2_"
transform="translate(-289.6157,99.0653)">
<path
id="path5708_2_"
d="M 473.88455,-167.54724 C 477.36996,-164.06128 479.11294,-159.79333 479.11294,-154.74451 C 479.11294,-149.69513 477.40014,-145.47303 473.9746,-142.07715 C 470.33929,-138.50055 466.04281,-136.71283 461.08513,-136.71283 C 456.18736,-136.71283 451.96526,-138.48544 448.42003,-142.03238 C 444.87419,-145.57819 443.10158,-149.81537 443.10158,-154.74451 C 443.10158,-159.6731 444.87419,-163.94049 448.42003,-167.54724 C 451.87523,-171.03375 456.09728,-172.77618 461.08513,-172.77618 C 466.13342,-172.77618 470.39914,-171.03375 473.88455,-167.54724 z M 450.76657,-165.20239 C 447.81982,-162.22601 446.34701,-158.7395 446.34701,-154.74005 C 446.34701,-150.7417 447.80529,-147.28485 450.72125,-144.36938 C 453.63778,-141.45288 457.10974,-139.99462 461.1383,-139.99462 C 465.16683,-139.99462 468.66848,-141.46743 471.64486,-144.41363 C 474.47076,-147.14947 475.88427,-150.59069 475.88427,-154.74005 C 475.88427,-158.85809 474.44781,-162.35297 471.57659,-165.22479 C 468.70595,-168.09546 465.22671,-169.53131 461.1383,-169.53131 C 457.04993,-169.53131 453.59192,-168.08813 450.76657,-165.20239 z M 458.52106,-156.49927 C 458.07074,-157.4809 457.39673,-157.9715 456.49781,-157.9715 C 454.90867,-157.9715 454.11439,-156.90198 454.11439,-154.763 C 454.11439,-152.62341 454.90867,-151.55389 456.49781,-151.55389 C 457.54719,-151.55389 458.29676,-152.07519 458.74647,-153.11901 L 460.94923,-151.94598 C 459.8993,-150.0805 458.32417,-149.14697 456.22374,-149.14697 C 454.60384,-149.14697 453.30611,-149.64367 452.33168,-150.63653 C 451.35561,-151.62994 450.86894,-152.99926 450.86894,-154.7445 C 450.86894,-156.46008 451.37123,-157.82159 452.37642,-158.83013 C 453.38161,-159.83806 454.63347,-160.34264 456.13423,-160.34264 C 458.35435,-160.34264 459.94407,-159.46776 460.90504,-157.71978 L 458.52106,-156.49927 z M 468.8844,-156.49927 C 468.43353,-157.4809 467.77292,-157.9715 466.90201,-157.9715 C 465.28095,-157.9715 464.46988,-156.90198 464.46988,-154.763 C 464.46988,-152.62341 465.28095,-151.55389 466.90201,-151.55389 C 467.95304,-151.55389 468.68918,-152.07519 469.10925,-153.11901 L 471.36126,-151.94598 C 470.31301,-150.0805 468.74007,-149.14697 466.64358,-149.14697 C 465.02587,-149.14697 463.73095,-149.64367 462.75711,-150.63653 C 461.78494,-151.62994 461.29773,-152.99926 461.29773,-154.7445 C 461.29773,-156.46008 461.79221,-157.82159 462.78061,-158.83013 C 463.76843,-159.83806 465.02588,-160.34264 466.55408,-160.34264 C 468.77027,-160.34264 470.35776,-159.46776 471.3154,-157.71978 L 468.8844,-156.49927 z" />
</g>
</g>
<path
d="M 297.29639,74.91064 L 181.06688,74.91064 C 179.8203,74.91064 178.80614,75.92529 178.80614,77.17187 L 178.80614,116.66748 C 178.80614,116.94922 179.03466,117.17822 179.31639,117.17822 L 299.04639,117.17822 C 299.32813,117.17822 299.55713,116.94922 299.55713,116.66748 L 299.55713,77.17188 C 299.55713,75.92529 298.54297,74.91064 297.29639,74.91064 z M 181.06688,75.93213 L 297.29639,75.93213 C 297.97998,75.93213 298.53565,76.48828 298.53565,77.17188 C 298.53565,77.17188 298.53565,93.09131 298.53565,104.59034 L 215.4619,104.59034 C 212.41698,110.09571 206.55077,113.83399 199.81835,113.83399 C 193.083,113.83399 187.21825,110.09913 184.1748,104.59034 L 179.82666,104.59034 C 179.82666,93.09132 179.82666,77.17188 179.82666,77.17188 C 179.82664,76.48828 180.38329,75.93213 181.06688,75.93213 z"
id="path294" />
<g
enable-background="new "
id="g296">
<path
d="M 265.60986,112.8833 C 265.68994,113.03906 265.79736,113.16504 265.93115,113.26172 C 266.06494,113.35791 266.22119,113.42969 266.40088,113.47608 C 266.58154,113.52296 266.76807,113.54639 266.96045,113.54639 C 267.09033,113.54639 267.22998,113.53565 267.3794,113.51368 C 267.52784,113.4922 267.66749,113.44972 267.79835,113.3877 C 267.92823,113.32569 268.03761,113.23975 268.12355,113.13086 C 268.21144,113.02197 268.25441,112.88379 268.25441,112.71533 C 268.25441,112.53515 268.19679,112.38916 268.08156,112.27685 C 267.9673,112.16455 267.81594,112.07177 267.62941,111.99658 C 267.44386,111.92236 267.23195,111.85693 266.9966,111.80078 C 266.76027,111.74463 266.52101,111.68262 266.27883,111.61377 C 266.02981,111.55176 265.78762,111.47559 265.55129,111.38525 C 265.31594,111.29541 265.10402,111.17822 264.9175,111.03515 C 264.73098,110.89208 264.58059,110.71337 264.46535,110.49853 C 264.35109,110.28369 264.29347,110.02392 264.29347,109.71923 C 264.29347,109.37646 264.36671,109.07958 264.51222,108.82763 C 264.6587,108.57568 264.85011,108.36572 265.08644,108.19726 C 265.32179,108.02929 265.58937,107.90478 265.8882,107.82372 C 266.18605,107.74315 266.48488,107.70263 266.78273,107.70263 C 267.13136,107.70263 267.46535,107.74169 267.78566,107.81982 C 268.105,107.89746 268.39015,108.02392 268.6382,108.19824 C 268.88722,108.37256 269.08449,108.59521 269.23097,108.86621 C 269.37648,109.13721 269.44972,109.46582 269.44972,109.85156 L 268.02784,109.85156 C 268.01514,109.65234 267.97315,109.4873 267.90284,109.35693 C 267.83155,109.22607 267.73682,109.12353 267.61964,109.04834 C 267.50148,108.97412 267.36671,108.9209 267.21534,108.89014 C 267.063,108.85889 266.89796,108.84326 266.71827,108.84326 C 266.60108,108.84326 266.48292,108.85596 266.36573,108.88037 C 266.24757,108.90576 266.14112,108.94922 266.04542,109.01123 C 265.94874,109.07373 265.86964,109.15137 265.80812,109.24463 C 265.7466,109.33838 265.71535,109.45654 265.71535,109.59961 C 265.71535,109.73047 265.73976,109.83643 265.78957,109.91699 C 265.83937,109.99804 265.93801,110.07275 266.08352,110.14111 C 266.22903,110.20947 266.43118,110.27832 266.68899,110.34668 C 266.9468,110.41504 267.28372,110.50244 267.70071,110.60791 C 267.82473,110.63281 267.99661,110.67822 268.21731,110.74365 C 268.43801,110.80908 268.65676,110.91308 268.87454,111.05615 C 269.09231,111.1997 269.27981,111.39111 269.43899,111.63037 C 269.59719,111.87012 269.67629,112.17676 269.67629,112.55029 C 269.67629,112.85547 269.61672,113.13867 269.49856,113.3999 C 269.3804,113.66162 269.20461,113.8872 268.97122,114.07666 C 268.73782,114.26709 268.44876,114.41455 268.10403,114.52051 C 267.75833,114.62647 267.35794,114.6792 266.90481,114.6792 C 266.53762,114.6792 266.18118,114.63379 265.83547,114.54346 C 265.49074,114.45313 265.18508,114.31104 264.92043,114.11768 C 264.65676,113.92432 264.4468,113.67774 264.29055,113.37891 C 264.13528,113.07959 264.06106,112.7251 264.06692,112.31397 L 265.4888,112.31397 C 265.48877,112.53809 265.52881,112.72803 265.60986,112.8833 z"
id="path298"
style="fill:#ffffff" />
<path
d="M 273.8667,107.8667 L 276.35986,114.53076 L 274.8374,114.53076 L 274.33349,113.04638 L 271.84033,113.04638 L 271.31787,114.53076 L 269.84326,114.53076 L 272.36377,107.8667 L 273.8667,107.8667 z M 273.95068,111.95264 L 273.11084,109.50928 L 273.09229,109.50928 L 272.22315,111.95264 L 273.95068,111.95264 z"
id="path300"
style="fill:#ffffff" />
</g>
<g
enable-background="new "
id="g302">
<path
d="M 239.17821,107.8667 C 239.49559,107.8667 239.78563,107.89502 240.04735,107.95068 C 240.30907,108.00683 240.53368,108.09863 240.72118,108.22607 C 240.9077,108.35351 241.05321,108.52295 241.15575,108.73437 C 241.25829,108.94579 241.31005,109.20703 241.31005,109.51806 C 241.31005,109.854 241.23388,110.13329 241.08056,110.35742 C 240.92822,110.58154 240.70165,110.76465 240.40283,110.90771 C 240.81494,111.02587 241.12256,111.23291 241.32568,111.5288 C 241.5288,111.82469 241.63037,112.18114 241.63037,112.59814 C 241.63037,112.93408 241.56494,113.22509 241.43408,113.47119 C 241.30322,113.7168 241.12646,113.91748 240.90576,114.07324 C 240.68408,114.229 240.43115,114.34424 240.14795,114.41845 C 239.86377,114.49365 239.57275,114.53075 239.27295,114.53075 L 236.03662,114.53075 L 236.03662,107.86669 L 239.17821,107.86669 L 239.17821,107.8667 z M 238.99071,110.56201 C 239.25243,110.56201 239.46727,110.5 239.63622,110.37597 C 239.80419,110.25146 239.88817,110.05029 239.88817,109.77099 C 239.88817,109.61572 239.85985,109.48828 239.80419,109.38915 C 239.74755,109.28954 239.67333,109.21239 239.57958,109.15624 C 239.48583,109.10058 239.37841,109.06151 239.25731,109.04003 C 239.13524,109.01806 239.00926,109.00732 238.8784,109.00732 L 237.50535,109.00732 L 237.50535,110.56201 L 238.99071,110.56201 z M 239.07664,113.39014 C 239.22019,113.39014 239.35691,113.37647 239.48777,113.34815 C 239.61863,113.32032 239.73484,113.27344 239.83445,113.2085 C 239.93406,113.14307 240.01316,113.0542 240.07273,112.94239 C 240.1323,112.83058 240.1616,112.68751 240.1616,112.51319 C 240.1616,112.17139 240.06492,111.92725 239.87156,111.78126 C 239.6782,111.63527 239.42234,111.56202 239.10496,111.56202 L 237.50535,111.56202 L 237.50535,113.39014 L 239.07664,113.39014 z"
id="path304"
style="fill:#ffffff" />
<path
d="M 241.88914,107.8667 L 243.53269,107.8667 L 245.09324,110.49854 L 246.64402,107.8667 L 248.27781,107.8667 L 245.80418,111.97315 L 245.80418,114.53077 L 244.33543,114.53077 L 244.33543,111.93604 L 241.88914,107.8667 z"
id="path306"
style="fill:#ffffff" />
</g>
<g
id="g6316_1_"
transform="matrix(0.624995,0,0,0.624995,391.2294,176.9332)">
<path
id="path6318_1_"
cx="475.97119"
ry="29.209877"
cy="252.08646"
type="arc"
rx="29.209877"
d="M -175.0083,-139.1153 C -175.00204,-129.7035 -182.62555,-122.06751 -192.03812,-122.06049 C -201.44913,-122.05341 -209.08512,-129.67774 -209.09293,-139.09028 C -209.09293,-139.09809 -209.09293,-139.10749 -209.09293,-139.1153 C -209.09919,-148.52784 -201.47413,-156.1623 -192.06311,-156.17011 C -182.65054,-156.17713 -175.01456,-148.55207 -175.0083,-139.14026 C -175.0083,-139.13092 -175.0083,-139.1239 -175.0083,-139.1153 z"
style="fill:#ffffff" />
<g
id="g6320_1_"
transform="translate(-23.9521,-89.72962)">
<path
id="path6322_1_"
d="M -168.2204,-68.05536 C -173.39234,-68.05536 -177.76892,-66.25067 -181.35175,-62.64203 C -185.02836,-58.90759 -186.86588,-54.48883 -186.86588,-49.38568 C -186.86588,-44.28253 -185.02836,-39.89416 -181.35175,-36.22308 C -177.67673,-32.55114 -173.29859,-30.71521 -168.2204,-30.71521 C -163.07974,-30.71521 -158.62503,-32.56677 -154.85312,-36.26996 C -151.30307,-39.78558 -149.52652,-44.15827 -149.52652,-49.38568 C -149.52652,-54.6123 -151.33432,-59.03265 -154.94843,-62.64203 C -158.5625,-66.25067 -162.98599,-68.05536 -168.2204,-68.05536 z M -168.17352,-64.69519 C -163.936,-64.69519 -160.33752,-63.20221 -157.37655,-60.21466 C -154.38748,-57.25836 -152.89214,-53.64899 -152.89214,-49.38568 C -152.89214,-45.09186 -154.35466,-41.52856 -157.28438,-38.69653 C -160.36876,-35.64727 -163.99849,-34.12304 -168.17351,-34.12304 C -172.34856,-34.12304 -175.94701,-35.63244 -178.96892,-38.64965 C -181.9908,-41.66918 -183.50176,-45.24657 -183.50176,-49.38567 C -183.50176,-53.52398 -181.97518,-57.13414 -178.92205,-60.21465 C -175.9939,-63.20221 -172.41107,-64.69519 -168.17352,-64.69519 z" />
<path
id="path6324_1_"
d="M -176.49548,-52.02087 C -175.75171,-56.71856 -172.44387,-59.22949 -168.30008,-59.22949 C -162.33911,-59.22949 -158.70783,-54.90448 -158.70783,-49.1372 C -158.70783,-43.50982 -162.57194,-39.13793 -168.39383,-39.13793 C -172.39856,-39.13793 -175.98297,-41.60277 -176.63611,-46.43877 L -171.93292,-46.43877 C -171.7923,-43.92778 -170.1626,-43.04418 -167.83447,-43.04418 C -165.1813,-43.04418 -163.4563,-45.50908 -163.4563,-49.27709 C -163.4563,-53.22942 -164.94693,-55.32244 -167.74228,-55.32244 C -169.79074,-55.32244 -171.55948,-54.57787 -171.93292,-52.02087 L -170.56418,-52.02789 L -174.26734,-48.32629 L -177.96894,-52.02789 L -176.49548,-52.02087 z" />
</g>
</g>
<g
id="g313">
<circle
cx="242.56226"
cy="90.224609"
r="10.8064"
id="circle315"
sodipodi:cx="242.56226"
sodipodi:cy="90.224609"
sodipodi:rx="10.8064"
sodipodi:ry="10.8064"
style="fill:#ffffff" />
<g
id="g317">
<path
d="M 245.68994,87.09766 C 245.68994,86.68116 245.35205,86.34424 244.93603,86.34424 L 240.16357,86.34424 C 239.74755,86.34424 239.40966,86.68115 239.40966,87.09766 L 239.40966,91.87061 L 240.74071,91.87061 L 240.74071,97.52295 L 244.3579,97.52295 L 244.3579,91.87061 L 245.68993,91.87061 L 245.68993,87.09766 L 245.68994,87.09766 z"
id="path319" />
<circle
cx="242.5498"
cy="84.083008"
r="1.63232"
id="circle321"
sodipodi:cx="242.5498"
sodipodi:cy="84.083008"
sodipodi:rx="1.63232"
sodipodi:ry="1.63232" />
</g>
<path
clip-rule="evenodd"
d="M 242.53467,78.31836 C 239.30322,78.31836 236.56641,79.4458 234.32715,81.70215 C 232.0293,84.03516 230.88086,86.79736 230.88086,89.98633 C 230.88086,93.1753 232.0293,95.91846 234.32715,98.21338 C 236.625,100.50781 239.36133,101.65527 242.53467,101.65527 C 245.74756,101.65527 248.53272,100.49853 250.88819,98.18359 C 253.10889,95.98681 254.21827,93.2539 254.21827,89.98632 C 254.21827,86.71874 253.08936,83.95751 250.83057,81.70214 C 248.57178,79.4458 245.80615,78.31836 242.53467,78.31836 z M 242.56396,80.41797 C 245.2124,80.41797 247.46142,81.35156 249.31103,83.21875 C 251.18115,85.06592 252.11572,87.32227 252.11572,89.98633 C 252.11572,92.66992 251.20068,94.89746 249.36963,96.66699 C 247.4419,98.57275 245.17334,99.52539 242.56397,99.52539 C 239.9546,99.52539 237.70557,98.58252 235.81739,96.6958 C 233.92774,94.80957 232.98389,92.57324 232.98389,89.98633 C 232.98389,87.3999 233.93799,85.14404 235.84619,83.21875 C 237.67676,81.35156 239.9165,80.41797 242.56396,80.41797 z"
id="path323"
style="fill-rule:evenodd" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 16 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300" width="300" height="300">
<rect width="300" height="300" fill="#afd074"/>
<g fill="#000">
<rect x="135" y="65" width="30" height="30"/><rect x="170" y="65" width="30" height="30"/><rect x="205" y="65" width="30" height="30"/>
<rect x="135" y="100" width="30" height="30"/><rect x="205" y="100" width="30" height="30"/>
<rect x="65" y="135" width="30" height="30"/><rect x="100" y="135" width="30" height="30"/><rect x="135" y="135" width="30" height="30"/><rect x="170" y="135" width="30" height="30"/><rect x="205" y="135" width="30" height="30"/>
<rect x="65" y="170" width="30" height="30"/><rect x="135" y="170" width="30" height="30"/>
<rect x="65" y="205" width="30" height="30"/><rect x="100" y="205" width="30" height="30"/><rect x="135" y="205" width="30" height="30"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 879 B

+144
View File
@@ -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}`),
};
}
+57
View File
@@ -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)}` })));
}
+93
View File
@@ -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.
Binary file not shown.
+64
View File
@@ -0,0 +1,64 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self' data:; connect-src 'self' https://raw.githubusercontent.com; base-uri 'none'; form-action 'none'; object-src 'none'">
<title>Embedded Engineering Roadmap · Explorer</title>
<meta name="description" content="Interactive map of the Embedded Systems Engineering Roadmap: explore topics, how they connect, and their learning resources.">
<link rel="icon" href="assets/logo.svg" type="image/svg+xml">
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="map.css">
<script type="module" src="app.js"></script>
</head>
<body>
<div class="app">
<header class="topbar">
<a class="brand" href="#/" aria-label="Roadmap overview">
<img class="brand-mark" src="assets/logo.svg" alt="" width="36" height="36">
<span class="brand-text">
<strong>Embedded Engineering Roadmap</strong>
<span>Interactive explorer</span>
</span>
</a>
<div class="search" role="search">
<svg class="search-icon" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2"/><path d="m20 20-4.2-4.2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<input id="search" type="search" placeholder="Search topics and resources" autocomplete="off" spellcheck="false"
role="combobox" aria-label="Search topics and resources" aria-expanded="false" aria-controls="search-results" aria-autocomplete="list">
<kbd aria-hidden="true">/</kbd>
<div id="search-results" class="search-results" role="listbox" aria-label="Search results" hidden></div>
</div>
<div class="actions">
<div class="segmented" role="tablist" aria-label="View">
<button type="button" role="tab" data-view="map" aria-selected="true" aria-controls="diagram">Map</button>
<button type="button" role="tab" data-view="list" aria-selected="false" aria-controls="outline">Outline</button>
</div>
<button type="button" id="print" class="icon-btn" aria-label="Print or save the roadmap as PDF" title="Print or save as PDF (A4)">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M7 9V3h10v6M7 17H5a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-2M7 14h10v7H7z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg>
</button>
<button type="button" id="theme" class="icon-btn" aria-label="Switch color theme" title="Switch color theme">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M12 3a9 9 0 1 0 9 9A7 7 0 0 1 12 3Z" fill="currentColor"/></svg>
</button>
<a class="icon-btn" href="https://github.com/m3y54m/Embedded-Engineering-Roadmap" target="_blank" rel="noopener noreferrer" aria-label="Repository on GitHub" title="Repository on GitHub">
<svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true"><path fill="currentColor" d="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"/></svg>
</a>
</div>
</header>
<main class="layout">
<section class="stage" aria-label="Roadmap">
<div id="diagram" class="map-view" role="tabpanel" hidden></div>
<div id="outline" class="outline" role="tabpanel" hidden></div>
<div id="status" class="status" role="status">
<span class="spinner" aria-hidden="true"></span>
<span>Loading roadmap…</span>
</div>
</section>
<aside id="panel" class="panel" aria-label="Topic details" tabindex="-1"></aside>
</main>
</div>
</body>
</html>
+167
View File
@@ -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,
};
}
+516
View File
@@ -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;
}
}
+266
View File
@@ -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"]
}
}
+572
View File
@@ -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,
};
}
+299
View File
@@ -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*>|<!--|!\[|\[!\[|_{3,}\s*$|\|)/;
function splitSymbols(text) {
const match = text.match(SYMBOLS);
const prefix = match ? match[0] : '';
return {
prefix: prefix.trim(),
symbols: [...prefix].filter((c) => !INVISIBLE.test(c)),
rest: text.slice(prefix.length).replace(/\s+/g, ' ').trim(),
};
}
function headingInfo(text) {
const link = text.match(HEADING_LINK);
const { prefix, symbols, rest } = splitSymbols(link ? link[1] : text);
const generic = symbols.every((s) => LEVEL_MARKERS.has(s));
return { title: rest, icon: generic ? '' : prefix, link: link ? link[2] : null, label: link ? link[1] : null };
}
function makeResource(label, url, trailing = '') {
const { symbols, rest } = splitSymbols(label);
let type = 'link';
for (const s of symbols) if (TYPE_BY_SYMBOL[s]) type = TYPE_BY_SYMBOL[s];
let domain = '';
try {
domain = new URL(url).hostname.replace(/^www\./, '');
} catch {
domain = '';
}
return {
title: rest,
url,
domain,
type,
beginner: symbols.includes('👶'),
gem: symbols.includes('💎'),
note: trailing.replace(/^\s*[-–—:]\s*/, '').trim(),
topic: null,
sharedWith: [],
};
}
export function slugify(text) {
return text
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/\+\+/g, 'pp')
.replace(/#/g, 'sharp')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function normalizeUrl(url) {
try {
const u = new URL(url);
return `${u.hostname.replace(/^www\./, '').toLowerCase()}${u.pathname.replace(/\/+$/, '')}${u.search}`;
} catch {
return url;
}
}
function makeNode(fields) {
return { title: '', icon: '', depth: 0, group: '', parent: null, children: [], resources: [], description: [], ...fields };
}
export function parseRoadmap(markdown) {
const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
const root = makeNode({ id: 'root', slug: '', title: 'Embedded Engineering Roadmap' });
const groups = [];
const stack = [];
let target = root;
let topic = null;
let paragraph = [];
const flush = () => {
if (paragraph.length && target) target.description.push({ type: 'p', text: paragraph.join(' ') });
paragraph = [];
};
for (const line of lines) {
const heading = line.match(HEADING);
if (heading) {
flush();
const level = heading[1].length;
if (level === 1) continue;
const info = headingInfo(heading[2].trim());
if (level === 2) {
const group = makeNode({ title: info.title, icon: info.icon, topics: [] });
groups.push(group);
stack.length = 0;
target = group;
topic = null;
continue;
}
while (stack.length && stack[stack.length - 1].level >= level) stack.pop();
const parent = stack.length ? stack[stack.length - 1].node : root;
const group = groups[groups.length - 1];
const node = makeNode({ title: info.title, icon: info.icon, depth: parent.depth + 1, group: group ? group.title : '', parent });
if (info.link) node.resources.push(makeResource(info.label, info.link));
parent.children.push(node);
if (group && parent === root) group.topics.push(node);
stack.push({ level, node });
target = node;
topic = node;
continue;
}
const resource = line.match(RESOURCE);
if (resource) {
flush();
if (topic) topic.resources.push(makeResource(resource[1], resource[2], resource[3]));
continue;
}
if (SKIP.test(line)) {
flush();
continue;
}
const bullet = line.match(BULLET);
if (bullet) {
flush();
if (!target) continue;
const last = target.description[target.description.length - 1];
if (last && last.type === 'ul') last.items.push(bullet[1].trim());
else target.description.push({ type: 'ul', items: [bullet[1].trim()] });
continue;
}
if (!line.trim()) {
flush();
continue;
}
// Text before the first "##" heading after the badges is the README introduction.
if (target === root && root.description.length >= 2) continue;
paragraph.push(line.trim());
}
flush();
const topics = [];
const used = new Set(['']);
const walk = (node, path) => {
for (const child of node.children) {
let slug = slugify(child.title) || 'topic';
if (used.has(slug)) slug = `${slug}-${slugify(node.title)}`;
for (let i = 2; used.has(slug); i += 1) slug = `${slugify(child.title)}-${i}`;
used.add(slug);
child.slug = slug;
child.id = slug;
child.path = path;
for (const r of child.resources) r.topic = child;
topics.push(child);
walk(child, [...path, child.title]);
}
};
walk(root, []);
const total = (node) => {
node.total = node.resources.length + node.children.reduce((sum, c) => sum + total(c), 0);
return node.total;
};
total(root);
const order = new Map(topics.map((t, i) => [t, i]));
const linkMap = new Map();
const linkFor = (a, b) => {
const [source, target] = order.get(a) <= order.get(b) ? [a, b] : [b, a];
const key = `${source.id}|${target.id}`;
if (!linkMap.has(key)) linkMap.set(key, { source, target, shared: [], mentions: [] });
return linkMap.get(key);
};
const byUrl = new Map();
for (const node of topics) {
for (const r of node.resources) {
const key = normalizeUrl(r.url);
if (!byUrl.has(key)) byUrl.set(key, []);
byUrl.get(key).push(r);
}
}
for (const list of byUrl.values()) {
const owners = [...new Set(list.map((r) => r.topic))];
if (owners.length < 2) continue;
for (const r of list) r.sharedWith = owners.filter((o) => o !== r.topic);
for (let i = 0; i < owners.length; i += 1) {
for (let j = i + 1; j < owners.length; j += 1) linkFor(owners[i], owners[j]).shared.push(list[0].title);
}
}
const matchers = topics.map((t) => ({ topic: t, patterns: aliases(t.title).map(aliasPattern) }));
for (const from of topics) {
const texts = [
...from.resources.map((r) => `${r.title} ${r.note}`),
...from.description.flatMap((b) => (b.type === 'p' ? [b.text] : b.items)),
];
for (const { topic: to, patterns } of matchers) {
if (to === from || isLineage(from, to)) continue;
for (const text of texts) {
if (patterns.some((p) => p.test(text))) linkFor(from, to).mentions.push({ topic: from, text: plainText(text) });
}
}
}
const links = [...linkMap.values()];
for (const link of links) link.weight = link.shared.length * 2 + link.mentions.length;
for (const t of topics) t.links = [];
for (const link of links) {
link.source.links.push(link);
link.target.links.push(link);
}
return {
root,
topics,
groups: groups.filter((g) => g.topics.length),
links,
bySlug: new Map(topics.map((t) => [t.slug, t])),
stats: { topics: topics.length, resources: byUrl.size, links: links.length },
};
}
// Topic names too generic to count as a reference when they appear in another topic's text.
const ALIAS_STOPLIST = new Set(['Electronics', 'Make', 'Projects', 'Testing', 'Logic', 'Sensors', 'Actuators', 'Search and Ask!']);
function aliases(title) {
const found = new Set([title]);
const base = title.replace(/\s*\(([^)]*)\)\s*/g, (m, inner) => {
found.add(inner.trim());
return ' ';
}).trim();
found.add(base);
for (const part of base.split(' / ')) found.add(part.trim());
return [...found].filter((a) => !ALIAS_STOPLIST.has(a) && (a.length >= 3 || /^[A-Z0-9]{2,}$/.test(a)));
}
function aliasPattern(alias) {
const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`(?<![\\w+#-])${escaped}(?![\\w+#-])`, 'u');
}
function isLineage(a, b) {
for (let n = a; n; n = n.parent) if (n === b) return true;
for (let n = b; n; n = n.parent) if (n === a) return true;
return false;
}
function plainText(markdown) {
return markdown.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/[*_`$]/g, '').replace(/\s+/g, ' ').trim();
}
const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
const GREEK = { Pi: 'Π', pi: 'π', lambda: 'λ' };
export function escapeHtml(text) {
return text.replace(/[&<>"']/g, (c) => ESCAPES[c]);
}
function formatText(escaped) {
return escaped
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[\s(])_(.+?)_(?=[\s.,;:)!?]|$)/g, '$1<em>$2</em>')
.replace(/\$\$\\(\w+)\$\$/g, (m, name) => GREEK[name] || name);
}
// Renders README inline markdown to HTML. Input is escaped first; only http(s) links become anchors.
export function renderInline(markdown) {
const link = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
let html = '';
let last = 0;
for (const m of markdown.matchAll(link)) {
html += formatText(escapeHtml(markdown.slice(last, m.index)));
html += `<a href="${escapeHtml(m[2])}" target="_blank" rel="noopener noreferrer">${formatText(escapeHtml(m[1]))}</a>`;
last = m.index + m[0].length;
}
return html + formatText(escapeHtml(markdown.slice(last)));
}
+90
View File
@@ -0,0 +1,90 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { parseRoadmap, renderInline } from './parser.js';
import { linkDiagram } from './diagram.js';
import { SPACE, buildLayout } from './layout.js';
const data = parseRoadmap(readFileSync(new URL('../README.md', import.meta.url), 'utf8'));
const find = (title) => data.topics.find((t) => t.title === title);
const linked = (a, b) => data.links.find((l) => (l.source === a && l.target === b) || (l.source === b && l.target === a));
test('builds the topic tree from the README headings', () => {
assert.ok(data.topics.length > 100);
assert.equal(find('Microcontrollers').depth, 1);
assert.ok(find('Microcontrollers').children.some((c) => c.title === 'GPIO'));
assert.equal(find('1-Wire').parent.title, 'Basic Protocols');
assert.equal(find('High-Performance Computing (HPC)').parent.title, 'Multithreading & Parallel Processing');
assert.equal(find('History'), undefined);
assert.deepEqual(data.groups.map((g) => g.title), ["Don't Know Where to Start!", 'Learning Resources']);
});
test('gives every topic a unique slug', () => {
assert.equal(new Set(data.topics.map((t) => t.slug)).size, data.topics.length);
});
test('parses resource markers, bracketed titles and URLs with parentheses', () => {
const beej = find('C').resources.find((r) => r.title.startsWith("Beej's Guide"));
assert.deepEqual([beej.type, beej.beginner, beej.gem], ['book', true, false]);
const j1939 = find('CAN').resources.find((r) => r.title.startsWith('J1939'));
assert.equal(j1939.title, 'J1939 Explained - A Simple Intro [v2.0 | 2021]');
assert.match(find('PCM').resources[0].url, /_04\(1\)\.pdf$/);
assert.equal(find('GoogleTest - Google Testing and Mocking Framework').resources[0].url, 'https://github.com/google/googletest');
assert.match(find('Career Development').resources[0].note, /job board/);
});
test('keeps descriptions and the README introduction', () => {
assert.match(data.root.description[0].text, /^This roadmap is designed/);
assert.match(find('Microcontrollers').description[0].text, /^Microcontrollers are integrated circuits/);
});
test('links topics that share a resource or mention each other', () => {
assert.ok(linked(find('Docker'), find('CI/CD Pipelines')).shared.length > 0);
assert.ok(linked(find('I2C'), find('Sensors')).mentions.length > 0);
assert.equal(linked(find('Microcontrollers'), find('GPIO')), undefined, 'parent and child are not cross-linked');
assert.equal(linked(find('Thread'), find('RT-Thread')), undefined, 'RT-Thread is not a mention of Thread');
});
test('renderInline escapes HTML and only links http(s) URLs', () => {
assert.equal(
renderInline('<b>x</b> **y** [a](https://e.com/?q=1&r=2) [j](javascript:alert(1))'),
'&lt;b&gt;x&lt;/b&gt; <strong>y</strong> <a href="https://e.com/?q=1&amp;r=2" target="_blank" rel="noopener noreferrer">a</a> [j](javascript:alert(1))',
);
});
const map = JSON.parse(readFileSync(new URL('./map.json', import.meta.url), 'utf8'));
test('places topics in the map areas and their cross-section', () => {
const plan = linkDiagram(map, data);
assert.deepEqual(plan.unmatched, [], 'every map topic matches a README heading (or an alias in diagram.js)');
assert.deepEqual(plan.invalid, [], 'importance is required, recommended or possible');
assert.deepEqual(find('GPIO').areas, ['SOFTWARE', 'HARDWARE']);
assert.deepEqual(find('I2C').areas, ['SOFTWARE', 'HARDWARE']);
assert.deepEqual(find('Sensors & Actuators').areas, ['SOFTWARE', 'HARDWARE']);
assert.deepEqual(find('GDB').areas, ['SOFTWARE']);
assert.deepEqual(find('Oscilloscope').areas, ['HARDWARE']);
assert.deepEqual(find('Soft Skills').areas, ['SOFT SKILLS']);
assert.deepEqual(find('Microcontrollers').areas, ['SOFTWARE', 'HARDWARE']);
assert.equal(find('RTOS Basics').importance, 'required');
assert.equal(find('Rust').importance, 'possible');
assert.deepEqual(find('Linux Device Drivers').areas, ['SOFTWARE']);
assert.equal(find('Projects').areas.length, 0, 'getting-started topics are not on the map');
});
test('lays the map out on one A4 page with a minimum spacing between boxes', () => {
const layout = buildLayout(linkDiagram(map, data));
assert.deepEqual(layout.missing, [], 'every cluster in map.json has a place in layout.js');
assert.ok(layout.height / layout.width <= Math.SQRT2, 'fits A4 portrait');
const boxes = layout.items.filter((i) => i.kind === 'box');
const inside = (a, b) => a.x >= b.x && a.y >= b.y && a.x + a.w <= b.x + b.w && a.y + a.h <= b.y + b.h;
const gap = (a, b) => Math.max(b.x - a.x - a.w, a.x - b.x - b.w, b.y - a.y - a.h, a.y - b.y - b.h);
for (const [i, a] of boxes.entries()) {
for (const b of boxes.slice(i + 1)) assert.ok(gap(a, b) >= SPACE - 0.01, `${a.text} / ${b.text}`);
for (const g of layout.groups) {
if (!inside(a, g.rect)) continue;
const r = g.rect;
const d = Math.min(a.x - r.x, a.y - r.y, r.x + r.w - a.x - a.w, r.y + r.h - a.y - a.h);
assert.ok(d >= SPACE - 0.01, `${a.text} too close to the ${g.name} outline`);
}
}
});
+1121
View File
File diff suppressed because it is too large Load Diff