mirror of
https://github.com/m3y54m/Embedded-Engineering-Roadmap.git
synced 2026-10-10 08:18:16 +03:00
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:
+663
@@ -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();
|
||||
@@ -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 |
@@ -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
@@ -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
@@ -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)}` })));
|
||||
}
|
||||
@@ -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.
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
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)));
|
||||
}
|
||||
@@ -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))'),
|
||||
'<b>x</b> <strong>y</strong> <a href="https://e.com/?q=1&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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user