diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 936d024..994ca97 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/site/app.js b/site/app.js index 149a017..4bd2a51 100644 --- a/site/app.js +++ b/site/app.js @@ -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)); } diff --git a/site/diagram.js b/site/diagram.js index 63980b8..365e315 100644 --- a/site/diagram.js +++ b/site/diagram.js @@ -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}`), }; } diff --git a/site/map.css b/site/map.css index b4c3901..7a252e6 100644 --- a/site/map.css +++ b/site/map.css @@ -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; diff --git a/site/mapview.js b/site/mapview.js index 5bdd21a..652ac6f 100644 --- a/site/mapview.js +++ b/site/mapview.js @@ -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); diff --git a/site/parser.test.mjs b/site/parser.test.mjs index 0919ae4..c8426cf 100644 --- a/site/parser.test.mjs +++ b/site/parser.test.mjs @@ -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'); diff --git a/site/styles.css b/site/styles.css index 2ef0ec8..536f786 100644 --- a/site/styles.css +++ b/site/styles.css @@ -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 {