mirror of
https://github.com/m3y54m/Embedded-Engineering-Roadmap.git
synced 2026-10-10 16:28:24 +03:00
feat(site): use curated connections between topics
Connections come from the links list in map.json instead of only from shared or mentioned resources. Curated ones are solid lines with a reason in the details panel; the text-based ones stay as faint dashed evidence. Tests check names, duplicates and that every technical topic is connected.
This commit is contained in:
@@ -30,6 +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.
|
||||
|
||||
## 6. Versioning and Releases
|
||||
|
||||
|
||||
+34
-5
@@ -155,8 +155,33 @@ function topicChips(nodes) {
|
||||
}, swatch(n), n.title, h('span', { class: 'n' }, n.total))));
|
||||
}
|
||||
|
||||
function related(node) {
|
||||
// Hand-picked connections of a topic (from map.json), each with the reason it is listed.
|
||||
function relatedTopics(node) {
|
||||
const seen = new Set();
|
||||
const items = [];
|
||||
for (const box of node.boxes || []) {
|
||||
for (const { box: other, reason } of box.related || []) {
|
||||
if (seen.has(other)) continue;
|
||||
seen.add(other);
|
||||
items.push({ other, reason });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function curatedList(items) {
|
||||
return h('ul', { class: 'related' }, items.map(({ other, reason }) => {
|
||||
const target = other.topics[0];
|
||||
return h('li', {}, h('button', { type: 'button', onclick: () => navigate(target, { reveal: true }) },
|
||||
swatch(target),
|
||||
h('span', {}, other.text),
|
||||
h('span', { class: 'why' }, reason)));
|
||||
}));
|
||||
}
|
||||
|
||||
function related(node, skip = new Set()) {
|
||||
const items = [...node.links]
|
||||
.filter((link) => !skip.has(link.source === node ? link.target : link.source))
|
||||
.sort((a, b) => b.weight - a.weight)
|
||||
.map((link) => {
|
||||
const other = link.source === node ? link.target : link.source;
|
||||
@@ -282,13 +307,13 @@ function overview(root) {
|
||||
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')))),
|
||||
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))),
|
||||
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', {}, '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 ',
|
||||
@@ -325,7 +350,7 @@ function renderPanel(node) {
|
||||
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)));
|
||||
relatedTopics(node).length ? h('span', { class: 'pill' }, `${relatedTopics(node).length} related`) : null)));
|
||||
|
||||
if (node === root) {
|
||||
panel.append(...overview(root));
|
||||
@@ -333,7 +358,11 @@ function renderPanel(node) {
|
||||
}
|
||||
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)));
|
||||
const curated = relatedTopics(node);
|
||||
if (curated.length) panel.append(section('Related topics', curatedList(curated)));
|
||||
const listed = new Set(curated.flatMap(({ other }) => other.topics));
|
||||
const evidence = node.links.filter((link) => !listed.has(link.source === node ? link.target : link.source));
|
||||
if (evidence.length) panel.append(section(curated.length ? 'Also linked by resources' : 'Linked by resources', related(node, listed)));
|
||||
panel.append(resources(node));
|
||||
}
|
||||
|
||||
|
||||
+31
-4
@@ -118,15 +118,41 @@ export function linkDiagram(map, data) {
|
||||
return null;
|
||||
};
|
||||
|
||||
// A connection joins two boxes. `reason` is set for the hand-picked ones in map.json; the rest
|
||||
// are evidence only (a shared resource, or one topic's text naming another).
|
||||
const pairs = new Map();
|
||||
const pairFor = (a, b) => {
|
||||
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, reason: null, weight: 0, shared: 0, links: [] });
|
||||
return pairs.get(id);
|
||||
};
|
||||
|
||||
const topicBoxes = new Map(boxes.filter((b) => !b.header).map((b) => [b.text, b]));
|
||||
const invalidLinks = [];
|
||||
for (const box of boxes) box.related = [];
|
||||
for (const [from, to, reason] of map.links || []) {
|
||||
const a = topicBoxes.get(from);
|
||||
const b = topicBoxes.get(to);
|
||||
if (!a || !b || a === b || typeof reason !== 'string' || !reason.trim()) {
|
||||
invalidLinks.push(`${from} - ${to}`);
|
||||
continue;
|
||||
}
|
||||
const pair = pairFor(a, b);
|
||||
if (pair.reason) {
|
||||
invalidLinks.push(`${from} - ${to} (listed twice)`);
|
||||
continue;
|
||||
}
|
||||
pair.reason = reason.trim();
|
||||
a.related.push({ box: b, reason: pair.reason });
|
||||
b.related.push({ box: a, reason: pair.reason });
|
||||
}
|
||||
|
||||
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);
|
||||
const pair = pairFor(a, b);
|
||||
pair.weight += link.weight;
|
||||
pair.shared += link.shared.length;
|
||||
pair.links.push(link);
|
||||
@@ -139,6 +165,7 @@ export function linkDiagram(map, data) {
|
||||
boxFor,
|
||||
connections: [...pairs.values()],
|
||||
unmatched: boxes.filter((b) => !b.header && !b.topics.length).map((b) => b.text),
|
||||
invalidLinks,
|
||||
invalid: boxes.filter((b) => !b.header && !IMPORTANCE_LEVELS.includes(b.importance)).map((b) => `${b.text}: ${b.importance}`),
|
||||
};
|
||||
}
|
||||
|
||||
+12
-6
@@ -325,15 +325,25 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.link.mention,
|
||||
.link-all.mention {
|
||||
.link.evidence,
|
||||
.link-all.evidence {
|
||||
stroke-dasharray: 5 5;
|
||||
stroke-opacity: 0.45;
|
||||
}
|
||||
|
||||
.link-end {
|
||||
fill: var(--accent);
|
||||
}
|
||||
|
||||
.link-all {
|
||||
stroke-opacity: 0.4;
|
||||
}
|
||||
|
||||
.link-all.curated {
|
||||
stroke-width: 1.2px;
|
||||
stroke-opacity: 0.28;
|
||||
}
|
||||
|
||||
.layer-linksAll {
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s;
|
||||
@@ -347,10 +357,6 @@
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.link-end {
|
||||
fill: var(--accent);
|
||||
}
|
||||
|
||||
.credit-main {
|
||||
fill: var(--text);
|
||||
font-size: 14px;
|
||||
|
||||
+6
-5
@@ -293,7 +293,7 @@ export function createMapView({ root, plan, data, build, onSelect }) {
|
||||
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(' · ')),
|
||||
box && box.related.length ? `${box.related.length} related` : null].filter(Boolean).join(' · ')),
|
||||
h('div', { class: 'tip-hint' }, 'Click this topic to open its details'),
|
||||
].filter(Boolean));
|
||||
tip.hidden = false;
|
||||
@@ -325,8 +325,8 @@ export function createMapView({ root, plan, data, build, onSelect }) {
|
||||
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),
|
||||
class: `${cls} ${connection.reason ? 'curated' : 'evidence'}`,
|
||||
'stroke-width': connection.reason ? 2.4 : 1.4,
|
||||
}, layer);
|
||||
if (cls !== 'link') return;
|
||||
for (const box of [connection.a, connection.b]) {
|
||||
@@ -335,7 +335,8 @@ export function createMapView({ root, plan, data, build, onSelect }) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const connection of connections) drawConnection(connection, layers.linksAll, 'link-all');
|
||||
// The overview of all connections only shows the hand-picked ones.
|
||||
for (const connection of connections) if (connection.reason) drawConnection(connection, layers.linksAll, 'link-all');
|
||||
|
||||
const isWithin = (node, topic) => {
|
||||
for (let n = node; n; n = n.parent) if (n === topic) return true;
|
||||
@@ -353,7 +354,7 @@ export function createMapView({ root, plan, data, build, onSelect }) {
|
||||
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;
|
||||
if (!connection.reason && !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);
|
||||
|
||||
@@ -71,6 +71,17 @@ test('places topics in the map areas and their cross-section', () => {
|
||||
assert.equal(find('Projects').areas.length, 0, 'getting-started topics are not on the map');
|
||||
});
|
||||
|
||||
test('every map topic is connected to related topics, each connection with a reason', () => {
|
||||
const plan = linkDiagram(map, data);
|
||||
assert.deepEqual(plan.invalidLinks, [], 'map.json "links" name existing topics, once, with a reason');
|
||||
// Soft skills describe the engineer, not the technology, so some of them stand alone.
|
||||
const alone = plan.boxes.filter((b) => !b.header && !b.areas.includes('SOFT SKILLS') && !b.related.length);
|
||||
assert.deepEqual(alone.map((b) => b.text), [], 'topics without a connection');
|
||||
const has = (a, b) => plan.connections.some((c) => c.reason && [c.a.text, c.b.text].sort().join('|') === [a, b].sort().join('|'));
|
||||
assert.ok(has('C', 'Memory Management'));
|
||||
assert.ok(has('Rust', 'Memory Management'));
|
||||
});
|
||||
|
||||
test('lays the map out on one A4 page with a minimum spacing between boxes', () => {
|
||||
const layout = buildLayout(linkDiagram(map, data));
|
||||
assert.deepEqual(layout.missing, [], 'every cluster in map.json has a place in layout.js');
|
||||
|
||||
+1
-3
@@ -717,11 +717,9 @@ button {
|
||||
|
||||
.related .why {
|
||||
grid-column: 2;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.res-head {
|
||||
|
||||
Reference in New Issue
Block a user