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:
Meysam Parvizi
2026-10-10 02:39:06 +02:00
committed by Meysam
parent 2e2ca453c9
commit 4c52895477
7 changed files with 96 additions and 23 deletions
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+11
View File
@@ -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
View File
@@ -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 {