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 0000000..d4c51c9 Binary files /dev/null and b/site/fonts/SofiaSansSemiCondensed.woff2 differ diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..affd77d --- /dev/null +++ b/site/index.html @@ -0,0 +1,64 @@ + + + + + + + Embedded Engineering Roadmap · Explorer + + + + + + + +
+
+ + + + 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*>|