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