Files
m3y54m_Embedded-Engineering…/site/layout.js
T
Meysam Parvizi fdb9eaba1c feat(site): add interactive roadmap explorer
Static site in site/ (vanilla ES modules, no build step) that reads
README.md at runtime and draws the roadmap map.

- Map view: Software, Software ∩ Hardware, Hardware and Soft skills
  areas, importance colors, area/importance filters, connections
  between topics, pan/zoom, A4 print stylesheet
- Outline view, search and a details panel with descriptions and
  learning resources
- site/map.json is the editable source of the map (bands, clusters,
  importance); layout.js places it on an A4 page with a minimum spacing
- Credits with creator logo, CC BY-SA badge and build revision
- Sofia Sans Semi Condensed bundled under the OFL
- Tests for the README parser, map mapping and layout
2026-10-10 01:15:05 +02:00

168 lines
7.2 KiB
JavaScript

// 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,
};
}