mirror of
https://github.com/m3y54m/Embedded-Engineering-Roadmap.git
synced 2026-10-12 01:08:13 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c50799fc9 | ||
|
|
4efa2281fc | ||
|
|
2de24aea71 | ||
|
|
5b281a91e6 | ||
|
|
449787c9a9 | ||
|
|
613c328b2a | ||
|
|
18b23cadc9 | ||
|
|
e63a4b7c80 | ||
|
|
342a344b3c | ||
|
|
8ad7827aec | ||
|
|
864e948e3e | ||
|
|
0c24f6dec4 | ||
|
|
febde4a151 | ||
|
|
b94fac3785 | ||
|
|
4d4b0a3059 | ||
|
|
edaf33ad61 | ||
|
|
9775dc5e61 | ||
|
|
336e1c4653 | ||
|
|
92a37ddac9 | ||
|
|
456bcb954b | ||
|
|
4564daca70 | ||
|
|
8db8117719 | ||
|
|
d395a72a75 | ||
|
|
543a253ab4 |
+13
-13
@@ -5,13 +5,13 @@ on:
|
||||
branches: [master]
|
||||
paths:
|
||||
- README.md
|
||||
- "site/**"
|
||||
- "explorer/**"
|
||||
- .github/workflows/pages.yml
|
||||
- .github/scripts/next-version.sh
|
||||
pull_request:
|
||||
paths:
|
||||
- README.md
|
||||
- "site/**"
|
||||
- "explorer/**"
|
||||
- .github/workflows/pages.yml
|
||||
- .github/scripts/next-version.sh
|
||||
workflow_dispatch:
|
||||
@@ -43,21 +43,21 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Test the README parser and map layout
|
||||
run: node --test site/parser.test.mjs
|
||||
run: node --test explorer/parser.test.mjs
|
||||
|
||||
- name: Warn about README sections that are not on the map
|
||||
run: node site/tools/coverage.mjs
|
||||
run: node explorer/tools/coverage.mjs
|
||||
|
||||
- name: Assemble the site
|
||||
run: |
|
||||
mkdir _site
|
||||
cp site/*.html site/*.css site/*.js site/map.json README.md _site/
|
||||
cp -r site/fonts site/assets _site/
|
||||
cp explorer/*.html explorer/*.css explorer/*.js explorer/map.json README.md _site/
|
||||
cp -r explorer/fonts explorer/assets _site/
|
||||
|
||||
- name: Install Chromium
|
||||
run: |
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
id: fingerprint
|
||||
# Rendered before build.json exists, so the revision line is not part of the fingerprint.
|
||||
run: |
|
||||
node site/tools/render.mjs _site / fingerprint
|
||||
node explorer/tools/render.mjs _site / fingerprint
|
||||
echo "sha256=$(sha256sum fingerprint/Embedded-Engineering-Roadmap.png | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Decide the version
|
||||
@@ -87,9 +87,9 @@ jobs:
|
||||
run: |
|
||||
node -e 'console.log(JSON.stringify({ version: process.env.VERSION, date: process.env.DATE }))' > _site/build.json
|
||||
cat _site/build.json
|
||||
node site/tools/render.mjs _site / _site
|
||||
node explorer/tools/render.mjs _site / _site
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: roadmap-render
|
||||
path: |
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
_site/Embedded-Engineering-Roadmap.png
|
||||
if-no-files-found: error
|
||||
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
- uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: _site
|
||||
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: roadmap-render
|
||||
|
||||
@@ -143,4 +143,4 @@ jobs:
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
|
||||
+3
-3
@@ -30,7 +30,7 @@ Contributions from everyone are welcomed. To keep the roadmap practical, accessi
|
||||
- Contributions should match the topics and structure of the roadmap.
|
||||
- Areas where contributors have experience or genuine interest should be focused on.
|
||||
- If a new topic is thought to make the roadmap more complete, it may be suggested. New topics should be proposed thoughtfully, considering their usefulness and relevance for other learners.
|
||||
- Connections between topics on the map are listed in `site/map.json` under `links` as `["Topic", "Other topic", "why they are related"]`, using the names shown on the map. Connect topics that a learner should study together or that depend on each other, and keep the reason to one short sentence. Tests fail if a name is misspelled, a pair is listed twice or a topic has no connection.
|
||||
- Connections between topics on the map are listed in `explorer/map.json` under `links` as `["Topic", "Other topic", "why they are related"]`, using the names shown on the map. Connect topics that a learner should study together or that depend on each other, and keep the reason to one short sentence. Tests fail if a name is misspelled, a pair is listed twice or a topic has no connection.
|
||||
|
||||
## 6. How the README Feeds the Interactive Roadmap
|
||||
|
||||
@@ -38,8 +38,8 @@ The [interactive roadmap](https://m3y54m.github.io/Embedded-Engineering-Roadmap/
|
||||
|
||||
- **Topics** come from heading depth: `##` is a group, `###` a main topic, `####` and deeper are subtopics. The emojis in headings (✳️ 🔵 🔶 🔸) are decoration only. Renaming a heading changes its link (`#/topic-name`).
|
||||
- **Resources** are list items in the form `- [📘👶💎 Title](https://url)`, optionally followed by a dash and a short note. Symbols: 📘 book, 🎞️ video, 📝 article, 🔗 link, 🎧 audio, 👶 beginner, 💎 essential. `python3 .github/scripts/check_readme.py` checks the format and duplicates.
|
||||
- **The map** (`site/map.json`) draws only the main topics. Each label on it opens the README heading with the same name. If the wording differs, or the label has no section of its own, add `"Label": ["README heading"]` to `readme` in `site/map.json`. A new main topic needs a README section and a box in `site/map.json` (and at least one entry in `links`), unless it should stay off the map: then list its title or group in `offMap`. CI warns about main topics that are neither.
|
||||
- **Preview and test locally:** `python3 -m http.server 8765` from the repository root, then open `/site/`; `node --test site/parser.test.mjs` runs the checks CI runs.
|
||||
- **The map** (`explorer/map.json`) draws only the main topics. Each label on it opens the README heading with the same name. If the wording differs, or the label has no section of its own, add `"Label": ["README heading"]` to `readme` in `explorer/map.json`. A new main topic needs a README section and a box in `explorer/map.json` (and at least one entry in `links`), unless it should stay off the map: then list its title or group in `offMap`. CI warns about main topics that are neither. Each box has a learning priority, `required`, `recommended` or `possible` (shown as Optional); it is a general guide, so rate a topic by what a typical embedded systems engineer needs.
|
||||
- **Preview and test locally:** `python3 -m http.server 8765` from the repository root, then open `/explorer/`; `node --test explorer/parser.test.mjs` runs the checks CI runs.
|
||||
|
||||
## 7. Versioning and Releases
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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';
|
||||
import { IMPORTANCE, TITLE, areaDots, areaLabel, h, plural } from './dom.js';
|
||||
|
||||
const SOURCES = [
|
||||
'README.md',
|
||||
@@ -54,9 +54,10 @@ function store(key, value) {
|
||||
// ---------- 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;
|
||||
// Every section before the last one holds getting-started topics; the last holds the learning resources.
|
||||
const learningGroup = data.groups.length > 1 ? data.groups[data.groups.length - 1] : null;
|
||||
const start = learningGroup ? data.groups.slice(0, -1).flatMap((g) => g.topics) : [];
|
||||
const learning = learningGroup ? learningGroup.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) => {
|
||||
@@ -97,7 +98,7 @@ function navigate(node, { push = true, resource = null, reveal = false } = {}) {
|
||||
syncOutline();
|
||||
if (resource) revealResource(resource);
|
||||
const root = state.data.root;
|
||||
document.title = node === root ? 'Embedded Engineering Roadmap · Explorer' : `${node.title} · Embedded Engineering Roadmap`;
|
||||
document.title = node === root ? `${TITLE} · Explorer` : `${node.title} · ${TITLE}`;
|
||||
if (push) {
|
||||
const hash = node === root ? '#/' : `#/${node.slug}`;
|
||||
if (location.hash !== hash) history.pushState(null, '', hash);
|
||||
@@ -134,6 +135,15 @@ function description(blocks) {
|
||||
const p = h('p');
|
||||
p.innerHTML = renderInline(block.text);
|
||||
wrap.append(p);
|
||||
} else if (block.type === 'quote') {
|
||||
const quote = h('blockquote');
|
||||
for (const text of block.paragraphs) {
|
||||
const p = h('p');
|
||||
p.innerHTML = renderInline(text);
|
||||
quote.append(p);
|
||||
}
|
||||
wrap.append(h('figure', { class: 'quote' }, quote, h('figcaption', {},
|
||||
h('a', { href: block.url, target: '_blank', rel: 'noopener noreferrer' }, block.source))));
|
||||
} else {
|
||||
const ul = h('ul');
|
||||
for (const item of block.items) {
|
||||
@@ -300,41 +310,42 @@ function areaSummary() {
|
||||
tile(['HARDWARE'], count((b) => b.areas.length === 1 && b.areas[0] === 'HARDWARE'), 'Hardware only'));
|
||||
}
|
||||
|
||||
// The summary and the first quotation stay visible; the other quotations fold away to keep the landing panel short.
|
||||
function foldedDefinitions(blocks) {
|
||||
const firstQuote = blocks.findIndex((b) => b.type === 'quote');
|
||||
const rest = blocks.slice(firstQuote + 1);
|
||||
return [
|
||||
description(blocks.slice(0, firstQuote + 1)),
|
||||
rest.length ? h('details', { class: 'more' }, h('summary', {}, `${rest.length} more definitions`), description(rest)) : null,
|
||||
];
|
||||
}
|
||||
|
||||
function overview(root) {
|
||||
const { stats, groups } = state.data;
|
||||
// A section whose topic quotes sources (README definitions) is shown in full instead of as a chip.
|
||||
const quoted = (g) => g.topics.find((t) => t.description.some((b) => b.type === 'quote'));
|
||||
const definitions = groups.find((g) => quoted(g));
|
||||
return [
|
||||
section(null, description(root.description)),
|
||||
definitions ? section(definitions.title, ...foldedDefinitions(quoted(definitions).description)) : null,
|
||||
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', {}, state.plan ? state.plan.connections.filter((c) => c.reason).length : stats.links), h('span', {}, 'connections')))),
|
||||
state.plan ? section('Diagram topics by area', areaSummary()) : null,
|
||||
...groups.map((g) => section(g.title, topicChips(g.topics))),
|
||||
...groups.filter((g) => !quoted(g)).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', {}, 'Map follows the roadmap diagram: topics sit in Software, Hardware or both, and are colored by learning priority, a general guide that varies with your specialty and role.'),
|
||||
h('li', {}, 'Click Required, Recommended or Optional in the legend above the map to highlight topics of that priority.'),
|
||||
h('li', {}, 'Hover or select a topic to see solid lines to its related topics. Fainter dashed lines come from resources that share or mention a topic.'),
|
||||
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;
|
||||
@@ -344,7 +355,7 @@ function renderPanel(node) {
|
||||
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 ? 'About this 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,
|
||||
@@ -589,7 +600,7 @@ async function loadMarkdown() {
|
||||
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;
|
||||
if (text.includes(`# ${TITLE}`)) return text;
|
||||
} catch {
|
||||
// Try the next source.
|
||||
}
|
||||
@@ -629,10 +640,24 @@ function wireGlobal() {
|
||||
for (const tab of els.tabs) tab.addEventListener('click', () => setView(tab.dataset.view));
|
||||
document.getElementById('print').addEventListener('click', () => window.print());
|
||||
// The PDF and PNG are rendered by CI next to the deployed site; local previews don't have them.
|
||||
for (const link of document.querySelectorAll('[data-download]')) {
|
||||
const download = document.getElementById('download');
|
||||
for (const link of download.querySelectorAll('[data-download]')) {
|
||||
fetch(link.getAttribute('href'), { method: 'HEAD', cache: 'no-cache' })
|
||||
.then((response) => { link.hidden = !response.ok; }, () => {});
|
||||
.then((response) => {
|
||||
link.hidden = !response.ok;
|
||||
if (response.ok) download.hidden = false;
|
||||
}, () => {});
|
||||
link.addEventListener('click', () => { download.open = false; });
|
||||
}
|
||||
document.addEventListener('pointerdown', (e) => {
|
||||
if (download.open && !download.contains(e.target)) download.open = false;
|
||||
});
|
||||
download.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Escape' || !download.open) return;
|
||||
e.stopPropagation();
|
||||
download.open = false;
|
||||
download.querySelector('summary').focus();
|
||||
});
|
||||
// Printing always produces the diagram, whatever view is open.
|
||||
let viewBeforePrint = null;
|
||||
window.addEventListener('beforeprint', () => {
|
||||
@@ -660,6 +685,7 @@ function wireGlobal() {
|
||||
}
|
||||
|
||||
async function init() {
|
||||
document.getElementById('brand-title').textContent = TITLE;
|
||||
applyTheme(store('roadmap-theme') || (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'));
|
||||
let markdown;
|
||||
const diagramRequest = loadMap();
|
||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 879 B After Width: | Height: | Size: 879 B |
@@ -2,14 +2,15 @@
|
||||
|
||||
export const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
// The roadmap's name; also the README's top heading.
|
||||
export const TITLE = 'Embedded Systems Engineering Roadmap';
|
||||
|
||||
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/',
|
||||
};
|
||||
|
||||
@@ -46,7 +47,7 @@ export const AREAS = {
|
||||
export const IMPORTANCE = {
|
||||
required: 'Required',
|
||||
recommended: 'Recommended',
|
||||
possible: 'Possibility',
|
||||
possible: 'Optional',
|
||||
};
|
||||
|
||||
export const areaClass = (area) => (AREAS[area] ? AREAS[area].cls : 'xx');
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self' data:; connect-src 'self' https://raw.githubusercontent.com; base-uri 'none'; form-action 'none'; object-src 'none'">
|
||||
<title>Embedded Engineering Roadmap · Explorer</title>
|
||||
<title>Embedded Systems Engineering Roadmap · Explorer</title>
|
||||
<meta name="description" content="Interactive map of the Embedded Systems Engineering Roadmap: explore topics, how they connect, and their learning resources.">
|
||||
<link rel="icon" href="assets/logo.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
@@ -17,7 +17,7 @@
|
||||
<a class="brand" href="#/" aria-label="Roadmap overview">
|
||||
<img class="brand-mark" src="assets/logo.svg" alt="" width="36" height="36">
|
||||
<span class="brand-text">
|
||||
<strong>Embedded Engineering Roadmap</strong>
|
||||
<strong id="brand-title"></strong>
|
||||
<span>Interactive explorer</span>
|
||||
</span>
|
||||
</a>
|
||||
@@ -38,12 +38,15 @@
|
||||
<button type="button" id="print" class="icon-btn" aria-label="Print or save the roadmap as PDF" title="Print or save as PDF (A4)">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M7 9V3h10v6M7 17H5a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-2M7 14h10v7H7z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<a class="icon-btn text-btn" href="Embedded-Engineering-Roadmap.pdf" download data-download hidden title="Download the roadmap as PDF (A4)">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M12 4v11m0 0-4.5-4.5M12 15l4.5-4.5M5 19h14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>PDF
|
||||
</a>
|
||||
<a class="icon-btn text-btn" href="Embedded-Engineering-Roadmap.png" download data-download hidden title="Download the roadmap as PNG image">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M12 4v11m0 0-4.5-4.5M12 15l4.5-4.5M5 19h14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>PNG
|
||||
</a>
|
||||
<details class="download-menu" id="download" hidden>
|
||||
<summary class="icon-btn" aria-label="Download the roadmap" title="Download the roadmap">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M12 4v11m0 0-4.5-4.5M12 15l4.5-4.5M5 19h14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</summary>
|
||||
<div class="download-panel">
|
||||
<a class="download-item" href="Embedded-Engineering-Roadmap.pdf" download data-download hidden><b>PDF</b><span>A4, for printing</span></a>
|
||||
<a class="download-item" href="Embedded-Engineering-Roadmap.png" download data-download hidden><b>PNG</b><span>Image</span></a>
|
||||
</div>
|
||||
</details>
|
||||
<button type="button" id="theme" class="icon-btn" aria-label="Switch color theme" title="Switch color theme">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M12 3a9 9 0 1 0 9 9A7 7 0 0 1 12 3Z" fill="currentColor"/></svg>
|
||||
</button>
|
||||
@@ -9,7 +9,7 @@ export const SPACE = 10;
|
||||
const COL = 170;
|
||||
const GUTTER = 26;
|
||||
const LEFT = 86;
|
||||
const TOP = 168;
|
||||
const TOP = 148;
|
||||
const BOX_H = 26;
|
||||
const BOX_H2 = 42;
|
||||
const HEAD_H = 20;
|
||||
@@ -143,7 +143,8 @@ export function buildLayout(plan) {
|
||||
return {
|
||||
width: PAGE_WIDTH,
|
||||
height: footer.y + footer.h + 16,
|
||||
titleY: 58,
|
||||
// Baseline that puts the title's cap tops 24 from the page edge, the same as its left margin.
|
||||
titleY: 49,
|
||||
footer,
|
||||
areas: {
|
||||
SOFTWARE: { x: areaX, y: swTop, w: areaW, h: crossBottom - swTop },
|
||||
+226
-84
@@ -9,38 +9,23 @@
|
||||
|
||||
.map-top {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px 16px 10px;
|
||||
z-index: 3;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.start-strip,
|
||||
.map-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -57,7 +42,6 @@
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.start-chip:hover,
|
||||
.map-chip:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--chip-hover);
|
||||
@@ -75,16 +59,100 @@
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.map-chip.clear {
|
||||
border-style: dashed;
|
||||
background: transparent;
|
||||
.filter-pill {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 14%, var(--surface-2));
|
||||
}
|
||||
|
||||
.map-toolbar-sep {
|
||||
width: 1px;
|
||||
height: 22px;
|
||||
margin: 0 4px;
|
||||
background: var(--border-strong);
|
||||
.pill-x {
|
||||
margin-right: -3px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.filter-pill:hover .pill-x {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.map-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.menu-button::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu-button::after {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin: -3px 2px 0 2px;
|
||||
border-right: 2px solid currentColor;
|
||||
border-bottom: 2px solid currentColor;
|
||||
transform: rotate(45deg);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.map-menu[open] > .menu-button::after {
|
||||
margin-top: 3px;
|
||||
transform: rotate(-135deg);
|
||||
}
|
||||
|
||||
.map-menu[open] > .menu-button {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.map-menu-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
width: max-content;
|
||||
max-width: min(440px, calc(100vw - 24px));
|
||||
max-height: min(70vh, 520px);
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-solid);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.menu-list {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 10px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu-item:hover,
|
||||
.menu-item:focus-visible {
|
||||
background: var(--chip-hover);
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
width: 1.4em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.map-summary {
|
||||
@@ -93,22 +161,6 @@
|
||||
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;
|
||||
@@ -139,27 +191,78 @@
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
@media not print {
|
||||
.page-title {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.legend-text {
|
||||
fill: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.legend-note {
|
||||
.legend-caption {
|
||||
fill: var(--muted);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.legend-info circle:first-child {
|
||||
fill: none;
|
||||
stroke: var(--muted);
|
||||
stroke-width: 1.4;
|
||||
}
|
||||
|
||||
.legend-info path {
|
||||
fill: none;
|
||||
stroke: var(--muted);
|
||||
stroke-width: 1.6;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.legend-info-dot {
|
||||
fill: var(--muted);
|
||||
stroke: var(--muted);
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
|
||||
.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); }
|
||||
|
||||
.legend-hit {
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
/* On screen the legend is the filter: each entry toggles a highlight. */
|
||||
@media not print {
|
||||
.legend-item {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.legend-item:hover .legend-hit {
|
||||
fill: var(--chip-hover);
|
||||
}
|
||||
|
||||
.legend-item:focus-visible .legend-hit {
|
||||
stroke: var(--accent);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.legend-item[aria-pressed="true"] .legend-hit {
|
||||
fill: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
stroke: var(--accent);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.page-legend.filtering .legend-item:not([aria-pressed="true"]) {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
.area-fill {
|
||||
stroke: none;
|
||||
@@ -384,62 +487,101 @@
|
||||
}
|
||||
|
||||
.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);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
padding: 1px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.zoom-controls .icon-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
font-size: 18px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.zoom-controls .icon-btn:hover {
|
||||
background: var(--chip-hover);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: block;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.zoom-level {
|
||||
min-width: 40px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.map-top {
|
||||
padding: 8px 10px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
/* One swipeable row per strip; the fade at the right edge shows there is more. */
|
||||
.start-strip,
|
||||
.map-toolbar {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
padding-right: 28px;
|
||||
mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.start-strip::-webkit-scrollbar,
|
||||
.map-toolbar::-webkit-scrollbar {
|
||||
.map-summary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.start-chip,
|
||||
.map-chip,
|
||||
.map-toolbar-label,
|
||||
.map-summary {
|
||||
flex: none;
|
||||
/* Panels span the bar instead of hanging off their button, so they stay on screen. */
|
||||
.map-menu {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
.map-menu-panel {
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.menu-list {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Phones pinch to zoom, so the bar keeps only the fit button. */
|
||||
@media (max-width: 600px) {
|
||||
.map-top {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.map-toolbar {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.map-toolbar .map-chip {
|
||||
padding: 4px 9px;
|
||||
}
|
||||
|
||||
.zoom-controls .zoom-step,
|
||||
.zoom-level,
|
||||
.map-chip .label-long {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 601px) {
|
||||
.map-chip .label-short {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,7 +488,7 @@
|
||||
"Microcontrollers": ["rtos", "mcu"],
|
||||
"Interfaces & Protocols": ["basic", "wireless", "highSpeed", "industrial", "cellular", "network", "automotive", "display"]
|
||||
},
|
||||
"offMap": ["Don't Know Where to Start!", "Appendix-A: Advanced Topics"],
|
||||
"offMap": ["What is an Embedded System?", "Don't Know Where to Start!"],
|
||||
"readme": {
|
||||
"ADC / DAC": ["ADC", "DAC"],
|
||||
"Buildroot / Yocto": ["Buildroot", "Yocto"],
|
||||
@@ -1,10 +1,11 @@
|
||||
// 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';
|
||||
// priority colors, filters and connections between topics. Laid out by layout.js to fit A4.
|
||||
import { CREDITS, GITHUB_MARK, IMPORTANCE, TITLE, areaDots, areaLabel, h, plural, svg } from './dom.js';
|
||||
import { SPACE, buildLayout } from './layout.js';
|
||||
|
||||
const MIN_ZOOM = 0.6;
|
||||
const MAX_ZOOM = 6;
|
||||
const AREA_RADIUS = 18;
|
||||
|
||||
function intersect(a, b) {
|
||||
const x = Math.max(a.x, b.x);
|
||||
@@ -34,44 +35,77 @@ function splitInTwo(text) {
|
||||
|
||||
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 state = { selected: null, hover: null, 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,
|
||||
// One slim row above the map: a menu of getting-started topics, the active filter and the connections toggle.
|
||||
// Filters are set by clicking the legend at the top of the map.
|
||||
const chip = (label, cls, onclick, title) => h('button', {
|
||||
type: 'button', class: `map-chip ${cls}`, 'aria-pressed': 'false', onclick, title,
|
||||
}, label);
|
||||
const toolbar = h('div', { class: 'map-toolbar', role: 'toolbar', 'aria-label': 'Highlight topics' });
|
||||
const menus = [];
|
||||
function menu(cls, label, ...panel) {
|
||||
const button = h('summary', { class: `map-chip menu-button ${cls}` }, label);
|
||||
const el = h('details', { class: 'map-menu' }, button, h('div', { class: `map-menu-panel ${cls}` }, panel));
|
||||
el.addEventListener('toggle', () => {
|
||||
if (el.open) for (const other of menus) if (other !== el) other.open = false;
|
||||
});
|
||||
el.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Escape' || !el.open) return;
|
||||
e.stopPropagation();
|
||||
el.open = false;
|
||||
button.focus();
|
||||
});
|
||||
menus.push(el);
|
||||
return { el, button };
|
||||
}
|
||||
document.addEventListener('pointerdown', (e) => {
|
||||
for (const m of menus) if (m.open && !m.contains(e.target)) m.open = false;
|
||||
});
|
||||
|
||||
// README topics that the diagram does not cover.
|
||||
const extra = data.root.children.filter((t) => !t.areas.length);
|
||||
const start = menu('start', 'Start here',
|
||||
h('nav', { class: 'menu-list', 'aria-label': 'Getting started topics' }, extra.map((t) => h('button', {
|
||||
type: 'button',
|
||||
class: 'menu-item',
|
||||
onclick: () => {
|
||||
start.el.open = false;
|
||||
onSelect(t);
|
||||
},
|
||||
}, h('span', { class: 'menu-icon', 'aria-hidden': 'true' }, t.icon || '•'), t.title))));
|
||||
|
||||
const clearFilters = () => {
|
||||
state.importance.clear();
|
||||
refresh();
|
||||
};
|
||||
const filterLabel = h('span');
|
||||
const filterPill = h('button', {
|
||||
type: 'button',
|
||||
class: 'map-chip filter-pill',
|
||||
title: 'Clear the highlight',
|
||||
'aria-label': 'Clear the highlight',
|
||||
hidden: true,
|
||||
onclick: clearFilters,
|
||||
}, filterLabel, h('span', { class: 'pill-x', 'aria-hidden': 'true' }, '✕'));
|
||||
const linksChip = chip([h('span', { class: 'label-long' }, 'All connections'), h('span', { class: 'label-short' }, 'Links')], 'links', () => {
|
||||
state.showAll = !state.showAll;
|
||||
refresh();
|
||||
}, 'Show the lines between all related topics');
|
||||
const summary = h('span', { class: 'map-summary', role: 'status' });
|
||||
const toolbar = h('div', { class: 'map-toolbar', role: 'toolbar', 'aria-label': 'Map controls' },
|
||||
start.el, linksChip, filterPill, summary);
|
||||
|
||||
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));
|
||||
linksChip.setAttribute('aria-pressed', String(state.showAll));
|
||||
const names = Object.keys(IMPORTANCE).filter((l) => state.importance.has(l)).map((l) => IMPORTANCE[l]);
|
||||
filterLabel.textContent = names.join(' · ');
|
||||
filterPill.hidden = !names.length;
|
||||
for (const part of legendParts) part.g.setAttribute('aria-pressed', String(part.pressed()));
|
||||
legend.classList.toggle('filtering', names.length > 0);
|
||||
}
|
||||
|
||||
function toggle(set, value) {
|
||||
@@ -80,13 +114,6 @@ export function createMapView({ root, plan, data, build, onSelect }) {
|
||||
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',
|
||||
@@ -106,48 +133,73 @@ export function createMapView({ root, plan, data, build, onSelect }) {
|
||||
|
||||
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.
|
||||
// Title, then one legend row (priority keys, then an info icon and caption), so the diagram also stands on its own when printed.
|
||||
// On screen the header already names the roadmap, so the title only appears in print, PDF and PNG.
|
||||
const legendY = layout.titleY + 22;
|
||||
// On screen the view starts just above the legend row, close to the toolbar.
|
||||
const screenTop = legendY - 17;
|
||||
const title = svg('text', { x: 24, y: layout.titleY, class: 'page-title' }, layers.labels);
|
||||
title.textContent = 'Embedded Systems Engineering Roadmap';
|
||||
title.textContent = TITLE;
|
||||
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 });
|
||||
const info = svg('g', { class: 'legend-info' }, legend);
|
||||
svg('circle', { cx: 7.5, cy: legendY + 6.5, r: 7.5 }, info);
|
||||
svg('path', { d: `M7.5 ${legendY + 6} v4.2` }, info);
|
||||
svg('circle', { cx: 7.5, cy: legendY + 3.2, r: 0.6, class: 'legend-info-dot' }, info);
|
||||
const caption = svg('text', { x: 0, y: legendY + 11, class: 'legend-caption' }, legend);
|
||||
caption.textContent = 'A general guide: priorities vary with your specialty and role';
|
||||
// Areas need no legend: their regions are labeled in the margin. On screen each entry is also a filter button.
|
||||
for (const [level, label] of Object.entries(IMPORTANCE)) {
|
||||
const y = legendY;
|
||||
const pressed = () => state.importance.has(level);
|
||||
const act = () => toggle(state.importance, level);
|
||||
const g = svg('g', {
|
||||
class: 'legend-item', role: 'button', tabindex: '0', 'aria-pressed': 'false', 'aria-label': `Highlight ${label} topics`,
|
||||
}, legend);
|
||||
svg('title', {}, g).textContent = `Highlight ${label} topics`;
|
||||
const hit = rect({ x: 0, y: y - 5, w: 0, h: 23 }, { rx: 6, class: 'legend-hit' }, g);
|
||||
const key = rect({ x: 0, y, w: 22, h: 13 }, { rx: 3, class: `legend-imp ${level}` }, g);
|
||||
const t = svg('text', { x: 0, y: y + 11, class: 'legend-text' }, g);
|
||||
t.textContent = label;
|
||||
g.addEventListener('click', () => {
|
||||
// A drag that starts on the legend pans the map; it must not also filter.
|
||||
if (state.drag && state.drag.moved) return;
|
||||
act();
|
||||
});
|
||||
g.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.preventDefault();
|
||||
act();
|
||||
});
|
||||
legendParts.push({ g, hit, key, t, label, pressed });
|
||||
}
|
||||
|
||||
function layoutLegend() {
|
||||
let x = 26;
|
||||
for (const part of legendParts) {
|
||||
const width = 28 + ((part.t.getComputedTextLength && part.t.getComputedTextLength()) || part.label.length * 6.5);
|
||||
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;
|
||||
part.hit.setAttribute('x', x - 6);
|
||||
part.hit.setAttribute('width', width + 12);
|
||||
x += width + 20;
|
||||
}
|
||||
// The caption follows the keys, set apart by a wider gap.
|
||||
x += 16;
|
||||
info.setAttribute('transform', `translate(${x.toFixed(1)} 0)`);
|
||||
caption.setAttribute('x', (x + 22).toFixed(1));
|
||||
}
|
||||
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(sw, { rx: AREA_RADIUS, class: 'area-fill sw' }, layers.areas);
|
||||
rect(hw, { rx: AREA_RADIUS, class: 'area-fill hw' }, layers.areas);
|
||||
rect(ss, { rx: AREA_RADIUS, 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);
|
||||
rect(sw, { rx: AREA_RADIUS, class: 'area-line sw' }, layers.outlines);
|
||||
rect(hw, { rx: AREA_RADIUS, class: 'area-line hw' }, layers.outlines);
|
||||
rect(ss, { rx: AREA_RADIUS, 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) {
|
||||
@@ -372,16 +424,11 @@ export function createMapView({ root, plan, data, build, onSelect }) {
|
||||
// ---------- 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);
|
||||
return state.importance.has(box.importance);
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
const filtering = state.areas.size > 0 || state.importance.size > 0;
|
||||
const filtering = state.importance.size > 0;
|
||||
chart.classList.toggle('filtering', filtering);
|
||||
chart.classList.toggle('show-all', state.showAll);
|
||||
let count = 0;
|
||||
@@ -412,7 +459,7 @@ export function createMapView({ root, plan, data, build, onSelect }) {
|
||||
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;
|
||||
v.y = slackY ? Math.min(Math.max(v.y, screenTop - 24), 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)}%`;
|
||||
@@ -422,7 +469,7 @@ export function createMapView({ root, plan, data, build, onSelect }) {
|
||||
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 });
|
||||
setView({ x: (page.w - width) / 2, y: screenTop, w: width });
|
||||
fitButton.title = 'Show the whole page';
|
||||
}
|
||||
|
||||
@@ -561,16 +608,24 @@ export function createMapView({ root, plan, data, build, onSelect }) {
|
||||
});
|
||||
|
||||
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) }, '+'),
|
||||
// Drawn as SVG: text glyphs sit on font baselines that differ between browsers.
|
||||
const btnIcon = (d) => {
|
||||
const el = svg('svg', { viewBox: '0 0 16 16', width: 14, height: 14, 'aria-hidden': 'true', class: 'btn-icon' });
|
||||
svg('path', { d }, el);
|
||||
return el;
|
||||
};
|
||||
const fitButton = h('button', { type: 'button', class: 'icon-btn zoom-fit', 'aria-label': 'Toggle between fitting the width and the whole page', title: 'Show the whole page', onclick: toggleFit },
|
||||
btnIcon('M9.5 2.5h4v4M13.5 2.5 9 7M6.5 13.5h-4v-4M2.5 13.5 7 9'));
|
||||
const zoomControls = h('div', { class: 'zoom-controls', role: 'group', 'aria-label': 'Zoom' },
|
||||
h('button', { type: 'button', class: 'icon-btn zoom-step', 'aria-label': 'Zoom out', title: 'Zoom out (Ctrl + scroll)', onclick: () => zoomAt(1 / 1.25) }, btnIcon('M3 8h10')),
|
||||
zoomLevel,
|
||||
h('button', { type: 'button', class: 'icon-btn', 'aria-label': 'Zoom out', title: 'Zoom out (Ctrl + scroll)', onclick: () => zoomAt(1 / 1.25) }, '−'),
|
||||
h('button', { type: 'button', class: 'icon-btn zoom-step', 'aria-label': 'Zoom in', title: 'Zoom in (Ctrl + scroll)', onclick: () => zoomAt(1.25) }, btnIcon('M3 8h10M8 3v10')),
|
||||
fitButton);
|
||||
toolbar.append(zoomControls);
|
||||
|
||||
root.replaceChildren(
|
||||
h('div', { class: 'map-top' }, strip, toolbar),
|
||||
h('div', { class: 'map-canvas' }, chart, zoomControls),
|
||||
h('div', { class: 'map-top' }, toolbar),
|
||||
h('div', { class: 'map-canvas' }, chart),
|
||||
tip,
|
||||
);
|
||||
fitWidth();
|
||||
@@ -1,5 +1,6 @@
|
||||
// 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`.
|
||||
import { TITLE } from './dom.js';
|
||||
|
||||
export const TYPES = {
|
||||
book: { icon: '📘', label: 'Book' },
|
||||
@@ -18,6 +19,8 @@ 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');
|
||||
// A "▶️ [Source](url)" line introduces the blockquote that follows it, a sourced quotation such as a definition.
|
||||
const QUOTE_SOURCE = new RegExp(String.raw`^\s*\u25B6\uFE0F?\s*\[(.+)\]\((${URL_PART})\)\s*$`, 'u');
|
||||
const BULLET = /^\s*[-*]\s+(.+)$/;
|
||||
const SKIP = /^(\s*>|<!--|!\[|\[!\[|_{3,}\s*$|\|)/;
|
||||
|
||||
@@ -87,11 +90,12 @@ function makeNode(fields) {
|
||||
|
||||
export function parseRoadmap(markdown) {
|
||||
const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
|
||||
const root = makeNode({ id: 'root', slug: '', title: 'Embedded Engineering Roadmap' });
|
||||
const root = makeNode({ id: 'root', slug: '', title: TITLE });
|
||||
const groups = [];
|
||||
const stack = [];
|
||||
let target = root;
|
||||
let topic = null;
|
||||
let quote = null;
|
||||
let paragraph = [];
|
||||
|
||||
const flush = () => {
|
||||
@@ -103,6 +107,7 @@ export function parseRoadmap(markdown) {
|
||||
const heading = line.match(HEADING);
|
||||
if (heading) {
|
||||
flush();
|
||||
quote = null;
|
||||
const level = heading[1].length;
|
||||
if (level === 1) continue;
|
||||
const info = headingInfo(heading[2].trim());
|
||||
@@ -127,6 +132,37 @@ export function parseRoadmap(markdown) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const source = line.match(QUOTE_SOURCE);
|
||||
if (source) {
|
||||
flush();
|
||||
quote = null;
|
||||
if (topic) {
|
||||
const label = source[1].replace(/:\s*$/, '');
|
||||
quote = { type: 'quote', source: label, url: source[2], paragraphs: [], gap: false };
|
||||
topic.description.push(quote);
|
||||
topic.resources.push(makeResource(label, source[2]));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (!line.trim()) {
|
||||
// Blank lines may separate the source from its quote; after the quote has started they end it.
|
||||
if (quote.paragraphs.length) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (/^\s*>/.test(line) || quote.paragraphs.length) {
|
||||
const text = line.replace(/^\s*>\s?/, '').trim();
|
||||
if (!text) quote.gap = true;
|
||||
else if (!quote.paragraphs.length || quote.gap) {
|
||||
quote.paragraphs.push(text);
|
||||
quote.gap = false;
|
||||
} else quote.paragraphs[quote.paragraphs.length - 1] += ` ${text}`;
|
||||
continue;
|
||||
}
|
||||
quote = null;
|
||||
}
|
||||
|
||||
const resource = line.match(RESOURCE);
|
||||
if (resource) {
|
||||
flush();
|
||||
@@ -213,7 +249,7 @@ export function parseRoadmap(markdown) {
|
||||
for (const from of topics) {
|
||||
const texts = [
|
||||
...from.resources.map((r) => `${r.title} ${r.note}`),
|
||||
...from.description.flatMap((b) => (b.type === 'p' ? [b.text] : b.items)),
|
||||
...from.description.flatMap((b) => (b.type === 'p' ? [b.text] : b.type === 'quote' ? b.paragraphs : b.items)),
|
||||
];
|
||||
for (const { topic: to, patterns } of matchers) {
|
||||
if (to === from || isLineage(from, to)) continue;
|
||||
@@ -242,7 +278,7 @@ export function parseRoadmap(markdown) {
|
||||
}
|
||||
|
||||
// Topic names too generic to count as a reference when they appear in another topic's text.
|
||||
const ALIAS_STOPLIST = new Set(['Electronics', 'Make', 'Projects', 'Testing', 'Logic', 'Sensors', 'Actuators', 'Search and Ask!']);
|
||||
const ALIAS_STOPLIST = new Set(['Electronics', 'Make', 'Projects', 'Testing', 'Logic', 'Sensors', 'Actuators', 'Search and Ask!', 'Development Environments']);
|
||||
|
||||
function aliases(title) {
|
||||
const found = new Set([title]);
|
||||
@@ -16,7 +16,7 @@ test('builds the topic tree from the README headings', () => {
|
||||
assert.equal(find('1-Wire').parent.title, 'Basic Protocols');
|
||||
assert.equal(find('High-Performance Computing (HPC)').parent.title, 'Multithreading & Parallel Processing');
|
||||
assert.equal(find('History'), undefined);
|
||||
assert.deepEqual(data.groups.map((g) => g.title), ["Don't Know Where to Start!", 'Learning Resources']);
|
||||
assert.deepEqual(data.groups.map((g) => g.title), ['What is an Embedded System?', "Don't Know Where to Start!", 'Learning Resources']);
|
||||
});
|
||||
|
||||
test('gives every topic a unique slug', () => {
|
||||
@@ -34,10 +34,21 @@ test('parses resource markers, bracketed titles and URLs with parentheses', () =
|
||||
});
|
||||
|
||||
test('keeps descriptions and the README introduction', () => {
|
||||
assert.match(data.root.description[0].text, /^This roadmap is designed/);
|
||||
assert.match(data.root.description[0].text, /^This roadmap is \w+ to help beginners/);
|
||||
assert.match(find('Microcontrollers').description[0].text, /^Microcontrollers are integrated circuits/);
|
||||
});
|
||||
|
||||
test('parses sourced quotations as quote blocks and resources', () => {
|
||||
const defs = find('Embedded System Definitions');
|
||||
const quotes = defs.description.filter((b) => b.type === 'quote');
|
||||
assert.equal(quotes.length, 5);
|
||||
assert.equal(quotes[0].source, 'ISO/IEC/IEEE 24765 Standard');
|
||||
assert.equal(quotes[0].paragraphs.length, 2);
|
||||
assert.match(quotes[0].paragraphs[0], /rapid transit system\.$/, 'a lazy continuation line joins its paragraph');
|
||||
assert.deepEqual(defs.resources.map((r) => r.title).slice(0, 2), ['ISO/IEC/IEEE 24765 Standard', '"Making Embedded Systems" Book']);
|
||||
assert.equal(data.root.description.some((b) => b.type === 'quote'), false, 'README callouts are not quotations');
|
||||
});
|
||||
|
||||
test('links topics that share a resource or mention each other', () => {
|
||||
assert.ok(linked(find('Docker'), find('CI/CD Pipelines')).shared.length > 0);
|
||||
assert.ok(linked(find('I2C'), find('Sensors')).mentions.length > 0);
|
||||
@@ -108,7 +108,7 @@ body {
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font: 15px/1.5 var(--font);
|
||||
font: 15px/1.5 var(--font-display);
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(1100px 760px at 12% -12%, var(--glow-a), transparent 62%),
|
||||
@@ -127,6 +127,18 @@ button {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Labels, titles and controls use the roadmap's display face; longer text keeps the body face. */
|
||||
.desc,
|
||||
.res-note,
|
||||
.res-also,
|
||||
.why,
|
||||
.howto,
|
||||
.empty,
|
||||
.result-sub,
|
||||
.tip-hint {
|
||||
font-family: var(--font);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
@@ -172,6 +184,7 @@ button {
|
||||
.brand-text strong {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
@@ -351,16 +364,59 @@ button {
|
||||
background: var(--chip-hover);
|
||||
}
|
||||
|
||||
.text-btn {
|
||||
width: auto;
|
||||
grid-auto-flow: column;
|
||||
gap: 6px;
|
||||
padding: 0 11px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
.download-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.download-menu > summary {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.download-menu > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.download-menu[open] > summary {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.download-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 190px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-solid);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.download-item {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.download-item:hover,
|
||||
.download-item:focus-visible {
|
||||
background: var(--chip-hover);
|
||||
}
|
||||
|
||||
.download-item b {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.download-item span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
|
||||
.layout {
|
||||
@@ -646,6 +702,33 @@ button {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.desc .quote {
|
||||
margin: 0 0 12px;
|
||||
padding-left: 12px;
|
||||
border-left: 3px solid color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
|
||||
.desc .quote blockquote {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.desc .quote figcaption {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.desc .quote figcaption a {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.panel-section .more summary {
|
||||
margin-bottom: 10px;
|
||||
color: var(--accent);
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.desc code {
|
||||
padding: 1px 5px;
|
||||
border-radius: 5px;
|
||||
@@ -967,32 +1050,6 @@ button {
|
||||
font: 12px var(--font);
|
||||
}
|
||||
|
||||
.credits {
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.credits-logo {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.credits-body p {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.credits-body strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.credits-badge {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.sheet-close {
|
||||
display: none;
|
||||
}
|
||||
@@ -1115,11 +1172,14 @@ button {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
/* Below this width the title and the actions no longer fit one row, so the title goes. */
|
||||
@media (max-width: 720px) {
|
||||
.brand-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.actions {
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -1128,14 +1188,6 @@ button {
|
||||
#print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.text-btn {
|
||||
padding: 0 9px;
|
||||
}
|
||||
|
||||
.text-btn svg {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -1,5 +1,5 @@
|
||||
// Warns (never fails) about README sections that have no place on the map yet.
|
||||
// Usage: node site/tools/coverage.mjs Sections listed in map.json "offMap" (titles or group names) are skipped.
|
||||
// Usage: node explorer/tools/coverage.mjs Sections listed in map.json "offMap" (titles or group names) are skipped.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { parseRoadmap } from '../parser.js';
|
||||
import { linkDiagram } from '../diagram.js';
|
||||
@@ -15,6 +15,6 @@ const skipped = new Set(map.offMap || []);
|
||||
const missing = data.root.children.filter((t) => !skipped.has(t.title) && !skipped.has(t.group) && !onMap(t));
|
||||
|
||||
for (const t of missing) {
|
||||
console.log(`::warning file=README.md,title=Not on the roadmap map::"${t.title}" has no box on the map. Add it to site/map.json, or to "offMap" if it should stay off the map (see CONTRIBUTING.md).`);
|
||||
console.log(`::warning file=README.md,title=Not on the roadmap map::"${t.title}" has no box on the map. Add it to explorer/map.json, or to "offMap" if it should stay off the map (see CONTRIBUTING.md).`);
|
||||
}
|
||||
console.log(missing.length ? `${missing.length} README section(s) are not on the map.` : 'Every README section is on the map or listed in "offMap".');
|
||||
@@ -1,13 +1,13 @@
|
||||
// Renders the roadmap map to an A4 PDF and a PNG with headless Chromium, straight from the site.
|
||||
// Usage: node site/tools/render.mjs [rootDir] [pagePath] [outDir]
|
||||
// defaults: repository root, /site/, ./dist (needs: npm install --no-save playwright)
|
||||
// Usage: node explorer/tools/render.mjs [rootDir] [pagePath] [outDir]
|
||||
// defaults: repository root, /explorer/, ./dist (needs: npm install --no-save playwright)
|
||||
import { createServer } from 'node:http';
|
||||
import { mkdir, readFile } from 'node:fs/promises';
|
||||
import { extname, join, resolve, sep } from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const root = resolve(process.argv[2] || '.');
|
||||
const pagePath = process.argv[3] || '/site/';
|
||||
const pagePath = process.argv[3] || '/explorer/';
|
||||
const outDir = resolve(process.argv[4] || 'dist');
|
||||
const NAME = 'Embedded-Engineering-Roadmap';
|
||||
const PNG_SCALE = 2;
|
||||
Reference in New Issue
Block a user