mirror of
https://github.com/m3y54m/Embedded-Engineering-Roadmap.git
synced 2026-10-10 08:18:16 +03:00
Compare commits
502
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7977b7a62b | ||
|
|
81f7e6f7d8 | ||
|
|
44bd12bc7e | ||
|
|
cc34538ad2 | ||
|
|
c9bc31d3fc | ||
|
|
7f1c6e37fe | ||
|
|
263527e9f7 | ||
|
|
0b60717b39 | ||
|
|
fdb9eaba1c | ||
|
|
234f89bb38 | ||
|
|
adca1e955b | ||
|
|
f436b82539 | ||
|
|
52aa4a7efa | ||
|
|
8ba3a7f235 | ||
|
|
0d29726169 | ||
|
|
770423adea | ||
|
|
420a3632fe | ||
|
|
03fba9cb09 | ||
|
|
c1aa63c1aa | ||
|
|
811bf6f779 | ||
|
|
fb6d9c892b | ||
|
|
a6e614bd7b | ||
|
|
718dc4c1a2 | ||
|
|
95798c6c33 | ||
|
|
8d0f027bce | ||
|
|
79949a4123 | ||
|
|
b37dd7ab0c | ||
|
|
0738fcbd8f | ||
|
|
efa2b58a71 | ||
|
|
a9f4eac5c9 | ||
|
|
fbc2bbe074 | ||
|
|
ede541e217 | ||
|
|
3f84cb4946 | ||
|
|
0bc41bf6ac | ||
|
|
37334a10d8 | ||
|
|
c43065728c | ||
|
|
399e9072d8 | ||
|
|
b43ee3b3c9 | ||
|
|
9b0589de9a | ||
|
|
6d2434c093 | ||
|
|
ce19924102 | ||
|
|
e42e802909 | ||
|
|
c6fb798231 | ||
|
|
4d72f70ac1 | ||
|
|
287aba4c37 | ||
|
|
363e2182dc | ||
|
|
a51558de95 | ||
|
|
3243a38153 | ||
|
|
8e983933b6 | ||
|
|
d1265280e0 | ||
|
|
1698faccbf | ||
|
|
10a1359485 | ||
|
|
c341c849cd | ||
|
|
4f4058ca04 | ||
|
|
982dc5e439 | ||
|
|
407857803c | ||
|
|
bfbf6715da | ||
|
|
655c4393f5 | ||
|
|
3ec8d86998 | ||
|
|
09b6b687cc | ||
|
|
8cdcebd74f | ||
|
|
4db7a2607d | ||
|
|
0cbb009e31 | ||
|
|
f9ef67a2f2 | ||
|
|
40cd50535d | ||
|
|
c36274440b | ||
|
|
dd63c2f1f0 | ||
|
|
d8573c8602 | ||
|
|
e733e52cc0 | ||
|
|
2b3afb9077 | ||
|
|
4d43e82a8e | ||
|
|
92344eff24 | ||
|
|
80a11187b7 | ||
|
|
a9689ce9cc | ||
|
|
08fdaa683f | ||
|
|
0c11d5077f | ||
|
|
d2fba71fdb | ||
|
|
4866358427 | ||
|
|
ef2003e1c2 | ||
|
|
f6036f707d | ||
|
|
7a9a4349f0 | ||
|
|
6396c68a9a | ||
|
|
7886954208 | ||
|
|
ccebf9e07e | ||
|
|
bb2bdbf59a | ||
|
|
65ae1f8a4c | ||
|
|
06e65e9636 | ||
|
|
79436daa42 | ||
|
|
f9bda303ab | ||
|
|
7acecc07ea | ||
|
|
3948044dbf | ||
|
|
ce429616af | ||
|
|
bb299c97b8 | ||
|
|
4f57e7322a | ||
|
|
887acbf64d | ||
|
|
dc2738a8cd | ||
|
|
db7f1edbf8 | ||
|
|
73a7e40067 | ||
|
|
9529ffefe4 | ||
|
|
f2bd5403f5 | ||
|
|
faf2d121be | ||
|
|
17022df5e7 | ||
|
|
1bf34b4646 | ||
|
|
1ccad2d532 | ||
|
|
da209028b1 | ||
|
|
9dd36557a5 | ||
|
|
e4afd0f6e9 | ||
|
|
468a3e48e9 | ||
|
|
bb8188ed79 | ||
|
|
144e1b0fa8 | ||
|
|
260fe82159 | ||
|
|
73f33549d5 | ||
|
|
a1d90150e2 | ||
|
|
cb9502e460 | ||
|
|
7c4b025264 | ||
|
|
cf6060c9f5 | ||
|
|
a71d798b6f | ||
|
|
0876545bfa | ||
|
|
bf47fa9812 | ||
|
|
fcc0e6f4da | ||
|
|
675d7ba4a0 | ||
|
|
55f5f21e0a | ||
|
|
8a5db76a73 | ||
|
|
1e4123b58a | ||
|
|
74f9bc837b | ||
|
|
d4e968e341 | ||
|
|
b9648c1a9e | ||
|
|
6b4b93fb95 | ||
|
|
39baabe911 | ||
|
|
f2c0b113a1 | ||
|
|
83d70447a6 | ||
|
|
fb30c35787 | ||
|
|
1f7274bd3b | ||
|
|
16cace6831 | ||
|
|
88556f05dd | ||
|
|
2adfc55547 | ||
|
|
21a28a5628 | ||
|
|
04a6ddc56f | ||
|
|
04666a1778 | ||
|
|
bd4d1fabd1 | ||
|
|
522ac5cd6b | ||
|
|
4b23d44b5b | ||
|
|
6b1b5e37a3 | ||
|
|
b64be6582a | ||
|
|
13e2b8f0e3 | ||
|
|
d534a7981a | ||
|
|
302ad1cf7b | ||
|
|
57ea594a3a | ||
|
|
a046909c32 | ||
|
|
4b90facad3 | ||
|
|
853cad223d | ||
|
|
0bc4ce88bb | ||
|
|
4a9566d917 | ||
|
|
71fdc4b068 | ||
|
|
8fa251fddf | ||
|
|
4bb7b1908f | ||
|
|
63d3632f9d | ||
|
|
b68f6e5902 | ||
|
|
fb4d604bdc | ||
|
|
02b7d51af0 | ||
|
|
f919ec4963 | ||
|
|
42be5a3cff | ||
|
|
7e812ea832 | ||
|
|
8eb6b979d8 | ||
|
|
374bcc818e | ||
|
|
420d375700 | ||
|
|
70d9a7da14 | ||
|
|
2254f0845c | ||
|
|
086066b83a | ||
|
|
52c087ccbd | ||
|
|
42aea2bcf1 | ||
|
|
5f2112a51f | ||
|
|
c0c61ee0ee | ||
|
|
74d1409f73 | ||
|
|
670eaaa6c2 | ||
|
|
18772b293c | ||
|
|
269bcb7595 | ||
|
|
79911f668b | ||
|
|
7bb76db5fa | ||
|
|
33b34ab8c2 | ||
|
|
4fe59cd3a1 | ||
|
|
7415c0d4f3 | ||
|
|
c29d43b8c5 | ||
|
|
36bead8d9c | ||
|
|
13d85a2f45 | ||
|
|
4895841f30 | ||
|
|
f64f741c49 | ||
|
|
e8fbc9cf79 | ||
|
|
05d948f6de | ||
|
|
57f2758660 | ||
|
|
5f47d664bc | ||
|
|
5d9c3c8028 | ||
|
|
ee01886513 | ||
|
|
353baa47f4 | ||
|
|
b3b0ecae1b | ||
|
|
0a5f341b17 | ||
|
|
1786cf3931 | ||
|
|
82e28f1446 | ||
|
|
a9782873c3 | ||
|
|
3dd6b97969 | ||
|
|
86169624df | ||
|
|
2960e57a56 | ||
|
|
87bd904f30 | ||
|
|
c5cae3c044 | ||
|
|
236431f72a | ||
|
|
ee5dc44999 | ||
|
|
cc0bf5622c | ||
|
|
551a74eada | ||
|
|
30c45e1ba2 | ||
|
|
9c29a0fe3f | ||
|
|
5ec4aa7ec3 | ||
|
|
62e7292549 | ||
|
|
1886ea26cb | ||
|
|
42b57bd190 | ||
|
|
abe0259b8f | ||
|
|
9d5d90418c | ||
|
|
7c2fb36823 | ||
|
|
81533b6ba5 | ||
|
|
b7299704cf | ||
|
|
0b77cbc325 | ||
|
|
fac1fa2473 | ||
|
|
5676f51854 | ||
|
|
0a11df94a6 | ||
|
|
f693335b60 | ||
|
|
2c8bd526b0 | ||
|
|
8fe566cf4a | ||
|
|
ee68906275 | ||
|
|
6dd04686e5 | ||
|
|
28d1ed568b | ||
|
|
de59d32539 | ||
|
|
41aaad321c | ||
|
|
a9a9fe2b3b | ||
|
|
555a712bde | ||
|
|
bd93464d91 | ||
|
|
134f8bdca7 | ||
|
|
5fbbb7ca96 | ||
|
|
024624d2e5 | ||
|
|
365dbadc7e | ||
|
|
3d34739037 | ||
|
|
fe82ac1193 | ||
|
|
6543b32216 | ||
|
|
15285e0be4 | ||
|
|
4ce9c26fb1 | ||
|
|
1bd6094cbe | ||
|
|
5ca196ca8c | ||
|
|
8ccca8e12b | ||
|
|
3cd1bcd62d | ||
|
|
25276210a1 | ||
|
|
00089ee209 | ||
|
|
c3481aa490 | ||
|
|
3e905d0cf4 | ||
|
|
5c5174d573 | ||
|
|
07a45ef82e | ||
|
|
d04ed29536 | ||
|
|
127a0d090e | ||
|
|
8284b9dced | ||
|
|
0f0ad015b1 | ||
|
|
91547a59d7 | ||
|
|
40acf885c9 | ||
|
|
800ea9fdf7 | ||
|
|
1f5e15bb92 | ||
|
|
2f7d00da83 | ||
|
|
d3d989456f | ||
|
|
2c77977f38 | ||
|
|
62207b9762 | ||
|
|
092bcbf49c | ||
|
|
3ac8f30ea5 | ||
|
|
d11e0d6098 | ||
|
|
e493fe33fe | ||
|
|
0de7e5b0ff | ||
|
|
8fa54b081f | ||
|
|
6337d25195 | ||
|
|
109142bbde | ||
|
|
3b5e739c9e | ||
|
|
970e80b134 | ||
|
|
c9d150d5c7 | ||
|
|
391d17a873 | ||
|
|
791844b915 | ||
|
|
9eb05d193c | ||
|
|
a9f1d70ebd | ||
|
|
7b44cf62ee | ||
|
|
91625afacd | ||
|
|
7154ce9b80 | ||
|
|
0c98d21c63 | ||
|
|
ea904d1f7b | ||
|
|
dad999d384 | ||
|
|
6b9429aace | ||
|
|
ffe92d3faf | ||
|
|
d4bb415e6c | ||
|
|
411ba4efc2 | ||
|
|
6daa7c32ed | ||
|
|
aa8202438e | ||
|
|
4a208301db | ||
|
|
e28fbb3258 | ||
|
|
5f2594d4fe | ||
|
|
26ec970d62 | ||
|
|
0b712030d8 | ||
|
|
1845ce150f | ||
|
|
8e5dac7e59 | ||
|
|
ee17c54ac8 | ||
|
|
52dae1534d | ||
|
|
cc9ad5d267 | ||
|
|
c55ae0b2ed | ||
|
|
d23c504eb4 | ||
|
|
106de1316e | ||
|
|
b10a3e8ac8 | ||
|
|
236bc95b0a | ||
|
|
d9cd7a23b5 | ||
|
|
8efa40a2e5 | ||
|
|
c9d328df4a | ||
|
|
8f38139a77 | ||
|
|
b6af1949e4 | ||
|
|
4661930a8d | ||
|
|
b8f0a07be3 | ||
|
|
0becbff1e7 | ||
|
|
6766c7a35f | ||
|
|
f92931cd7f | ||
|
|
66f790152f | ||
|
|
a3afd8be59 | ||
|
|
02d6f0e064 | ||
|
|
eec80ce80c | ||
|
|
0c220e1eb8 | ||
|
|
182d258870 | ||
|
|
6808191db9 | ||
|
|
7e38537e05 | ||
|
|
d8700a5775 | ||
|
|
a4b4a045c7 | ||
|
|
2773b42314 | ||
|
|
58292445b1 | ||
|
|
c8fb92948a | ||
|
|
41ff4521e6 | ||
|
|
56840a6664 | ||
|
|
1bff1a0686 | ||
|
|
afcbe99a98 | ||
|
|
5d406bab42 | ||
|
|
fc45b8b444 | ||
|
|
8aa09359cf | ||
|
|
af04df26b8 | ||
|
|
da1ca7233b | ||
|
|
7e84e77ba3 | ||
|
|
19ef9340e3 | ||
|
|
c4d20e4845 | ||
|
|
c069ace61d | ||
|
|
04429f60f4 | ||
|
|
e7eff402db | ||
|
|
c2ea2585c7 | ||
|
|
ffc094f1ce | ||
|
|
2c71dffe3e | ||
|
|
bbf7e9e1db | ||
|
|
b290eb068b | ||
|
|
a614ca5455 | ||
|
|
7897f021e2 | ||
|
|
fddf1a0b4a | ||
|
|
303cb479ab | ||
|
|
127be1e92d | ||
|
|
d3be928502 | ||
|
|
bc37c8f0ab | ||
|
|
c443bab698 | ||
|
|
99ad98a2ee | ||
|
|
740d39a4ff | ||
|
|
d12fed56d4 | ||
|
|
c0281bf33b | ||
|
|
df23e2d927 | ||
|
|
bd956b1743 | ||
|
|
5ae5a7336a | ||
|
|
5137385766 | ||
|
|
ed21862677 | ||
|
|
f884c71825 | ||
|
|
a969b64f4a | ||
|
|
4a921c486c | ||
|
|
24676992a8 | ||
|
|
ef0e2e4a88 | ||
|
|
8a43d03ff5 | ||
|
|
e963933d68 | ||
|
|
a20e4aca35 | ||
|
|
997e6f7b09 | ||
|
|
2539b6f484 | ||
|
|
4ba2d4a1e3 | ||
|
|
20b620c321 | ||
|
|
1c59b3c8f0 | ||
|
|
05e9d836fe | ||
|
|
f84f0320b1 | ||
|
|
73e3af2d57 | ||
|
|
c0434b7d60 | ||
|
|
18bd3a9abe | ||
|
|
8f8f31b9b4 | ||
|
|
0ace66511f | ||
|
|
fb94514cf4 | ||
|
|
73e7edbe82 | ||
|
|
24039054bf | ||
|
|
f25a3e7930 | ||
|
|
3e0f30c886 | ||
|
|
ece884be57 | ||
|
|
7608392b2d | ||
|
|
b97beec23e | ||
|
|
7b7698aede | ||
|
|
249d971baf | ||
|
|
ccc80662ce | ||
|
|
728ee3137c | ||
|
|
c09b48e5f9 | ||
|
|
cef2989c21 | ||
|
|
ae60ba18d3 | ||
|
|
217af4bbbb | ||
|
|
51a62f7f70 | ||
|
|
c33a104269 | ||
|
|
0b656b23dd | ||
|
|
bf4cd0a1aa | ||
|
|
2207d1145a | ||
|
|
9847c8b7b3 | ||
|
|
7b92c127e4 | ||
|
|
d4ab19536c | ||
|
|
2d7b6f2a23 | ||
|
|
94468297c2 | ||
|
|
c7a06d423f | ||
|
|
c00391832c | ||
|
|
eccff74869 | ||
|
|
e094f76eba | ||
|
|
4620038aaa | ||
|
|
274413eab2 | ||
|
|
5e8ebd6a95 | ||
|
|
27478f814d | ||
|
|
b301601e4d | ||
|
|
ab6f8f4530 | ||
|
|
3a84ada859 | ||
|
|
56af45a87f | ||
|
|
4b006348a9 | ||
|
|
fb673ba8f6 | ||
|
|
bed4b9bce6 | ||
|
|
b9a133446f | ||
|
|
cb966fb251 | ||
|
|
20c8f8cc29 | ||
|
|
a59bd2cb43 | ||
|
|
b28b7727dc | ||
|
|
52e857b600 | ||
|
|
5abfa08d9c | ||
|
|
6834f00c8a | ||
|
|
12f2d5f0f0 | ||
|
|
2f3e688e8e | ||
|
|
3aa4cd5554 | ||
|
|
77546b3d26 | ||
|
|
fae8ddcc4a | ||
|
|
4dc83b5e73 | ||
|
|
4c1be44151 | ||
|
|
92228a79a9 | ||
|
|
6dd86addb4 | ||
|
|
69012842fc | ||
|
|
8a00f9f519 | ||
|
|
d64d8fdfb4 | ||
|
|
ca19b1bb7c | ||
|
|
19f2f2b0cb | ||
|
|
7ceee1f7c5 | ||
|
|
140697feb5 | ||
|
|
98c52b4c46 | ||
|
|
f2c659ea95 | ||
|
|
c3ec72d8c7 | ||
|
|
88243467d6 | ||
|
|
f57a1baac6 | ||
|
|
6b210b76f6 | ||
|
|
97885a21c3 | ||
|
|
e962f25474 | ||
|
|
97d0dfb407 | ||
|
|
c16eb7945f | ||
|
|
5c9b3ca37b | ||
|
|
bb125e66ff | ||
|
|
c975a2579c | ||
|
|
33d363bf0b | ||
|
|
b9570fdbf3 | ||
|
|
fe25902b4a | ||
|
|
bd04036cda | ||
|
|
79b6c2427c | ||
|
|
98137ec87c | ||
|
|
ea4ed18fbf | ||
|
|
83143802c9 | ||
|
|
9e58c68f92 | ||
|
|
7f0d0bb032 | ||
|
|
9ae858a12a | ||
|
|
9855357fd2 | ||
|
|
5a61f48526 | ||
|
|
b29ae26ce8 | ||
|
|
60b95f9542 | ||
|
|
3f5a71258a | ||
|
|
205e637110 | ||
|
|
8af3b6dcbc | ||
|
|
e20d634ffa | ||
|
|
c89cf2a40f | ||
|
|
bba97a7664 | ||
|
|
f77f545118 | ||
|
|
e1c3c01126 | ||
|
|
0ba36a7505 | ||
|
|
88fdfe6379 | ||
|
|
1147c50066 | ||
|
|
2348b365a8 | ||
|
|
3f456aea92 | ||
|
|
52b9f41521 | ||
|
|
edcd4a5342 | ||
|
|
2ea0b0aba7 | ||
|
|
e121a8e160 | ||
|
|
0b4f7483d9 | ||
|
|
ecf6afad3a | ||
|
|
c50c0579b1 | ||
|
|
c2dbf403e5 | ||
|
|
cf4c1f4384 |
@@ -0,0 +1 @@
|
||||
custom: ['https://www.buymeacoffee.com/m3y54m']
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Contributing guidelines
|
||||
url: https://github.com/m3y54m/Embedded-Engineering-Roadmap/blob/master/CONTRIBUTING.md
|
||||
about: Read these before suggesting a resource or opening a pull request.
|
||||
@@ -0,0 +1,19 @@
|
||||
name: Report a link problem
|
||||
description: A link is broken, outdated, mislabeled or in the wrong section
|
||||
title: "[Link] "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: input
|
||||
id: entry
|
||||
attributes:
|
||||
label: Which link?
|
||||
description: The title or URL of the entry.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What is wrong, and what should happen to it?
|
||||
description: For example "returns 404, replace with <link>" or "belongs under Debugging".
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Suggest a resource
|
||||
description: Propose a link to add to the roadmap
|
||||
title: "[Resource] "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: input
|
||||
id: url
|
||||
attributes:
|
||||
label: Link
|
||||
placeholder: https://
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: section
|
||||
attributes:
|
||||
label: Where does it belong?
|
||||
description: The README section, for example "Microcontrollers > Interrupts".
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: why
|
||||
attributes:
|
||||
label: Why add it?
|
||||
description: One or two sentences. Mention if it is paid or aimed at beginners.
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,7 @@
|
||||
# What changed
|
||||
|
||||
<!-- Added, moved or removed entries, and the section -->
|
||||
|
||||
- [ ] Entries follow the format and markers described in the README
|
||||
- [ ] Each link works and matches its section
|
||||
- [ ] Each addition has a clear reason (free resources preferred; see [CONTRIBUTING.md](../CONTRIBUTING.md))
|
||||
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: monthly
|
||||
@@ -0,0 +1,28 @@
|
||||
# Many sites in the README (Udemy, Medium, Microchip, ...) answer bots with 403/429 while
|
||||
# working fine in a browser, and some redirect to consent pages, so these count as alive.
|
||||
accept = ["200..=399", "403", "429"]
|
||||
|
||||
exclude = [
|
||||
# Bot challenges or non-standard codes for every request.
|
||||
'^https?://(www\.)?amazon\.',
|
||||
'^https?://a\.co/',
|
||||
'^https?://(www\.)?linkedin\.com/',
|
||||
# Return 404 to non-browser clients although the pages exist.
|
||||
'^https?://(www\.)?nxp\.com/',
|
||||
# Fail HTTP/2 negotiation with the checker's client.
|
||||
'^https?://(www\.|wiki\.)?st\.com/',
|
||||
'^https?://(www\.)?analog\.com/',
|
||||
'^https?://(www\.)?embedded\.com/',
|
||||
'^https?://(www\.)?electronics-tutorials\.ws',
|
||||
'^https?://gemini\.google\.com/',
|
||||
# Broken certificate chain on the server side.
|
||||
'^https?://(www\.)?autosar\.org/',
|
||||
# Repository badge links; GitHub hides these pages from anonymous clients.
|
||||
'^https://github\.com/m3y54m/Embedded-Engineering-Roadmap/(stargazers|watchers|forks|graphs|releases)',
|
||||
]
|
||||
|
||||
max_retries = 3
|
||||
retry_wait_time = 5
|
||||
timeout = 60
|
||||
max_concurrency = 8
|
||||
user_agent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check README.md resource entries against the conventions in CONTRIBUTING.md."""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
README = Path(__file__).resolve().parents[2] / "README.md"
|
||||
|
||||
# Type marker, then optional beginner (👶), then optional invaluable (💎).
|
||||
ENTRY = re.compile(r"^- \[(?:📘|🎞️?|📝|🔗|🎧)👶?💎? .+\]\(https?://\S+\)")
|
||||
LIST_LINK = re.compile(r"^- \[")
|
||||
URL = re.compile(r"\]\((https?://[^)\s]+(?:\([^)\s]*\))?[^)\s]*)\)")
|
||||
BAD_CHARS = {"\ufffd": "U+FFFD replacement character", "\u200b": "zero-width space",
|
||||
"\u200c": "zero-width non-joiner"}
|
||||
HEADING = re.compile(r"^#{2,6} ")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
errors = []
|
||||
section = ""
|
||||
seen = {}
|
||||
for number, line in enumerate(README.read_text(encoding="utf-8").splitlines(), 1):
|
||||
for char, name in BAD_CHARS.items():
|
||||
if char in line:
|
||||
errors.append(f"{number}: {name}")
|
||||
if HEADING.match(line):
|
||||
section = line
|
||||
seen = {}
|
||||
continue
|
||||
if not LIST_LINK.match(line):
|
||||
continue
|
||||
if not ENTRY.match(line):
|
||||
errors.append(f"{number}: entry does not match '- [<type>[👶][💎] Title](url)'")
|
||||
continue
|
||||
match = URL.search(line)
|
||||
if match:
|
||||
url = match.group(1)
|
||||
if url in seen:
|
||||
errors.append(f"{number}: duplicate URL in section, first used on line {seen[url]}")
|
||||
seen[url] = number
|
||||
for error in errors:
|
||||
print(f"README.md:{error}")
|
||||
return 1 if errors else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# Decides the roadmap version for a build.
|
||||
# - Patch releases are automatic: a new one is due when the rendered map differs from the latest release's.
|
||||
# - Minor and major releases only happen when a maintainer asks for them (BUMP=minor|major).
|
||||
# Inputs: FINGERPRINT (sha256 of the map PNG rendered without the revision line), BUMP (auto|minor|major),
|
||||
# GH_REPO and GH_TOKEN for the GitHub API. Outputs (to $GITHUB_OUTPUT): release, version, previous, date.
|
||||
set -euo pipefail
|
||||
: "${FINGERPRINT:?}" "${GH_REPO:?}"
|
||||
BUMP="${BUMP:-auto}"
|
||||
|
||||
tag=v0.0.0 published="" body=""
|
||||
if info="$(gh api "repos/$GH_REPO/releases/latest" --jq '[.tag_name, .published_at[0:10]] | @tsv' 2>/dev/null)"; then
|
||||
IFS=$'\t' read -r tag published <<<"$info"
|
||||
body="$(gh api "repos/$GH_REPO/releases/latest" --jq '.body // ""')"
|
||||
fi
|
||||
previous_fingerprint="$(grep -oE 'map-fingerprint: [0-9a-f]{64}' <<<"$body" | cut -d' ' -f2 || true)"
|
||||
|
||||
if [[ ! $tag =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
||||
echo "::error::The latest release tag '$tag' is not in vMAJOR.MINOR.PATCH form."
|
||||
exit 1
|
||||
fi
|
||||
major=${BASH_REMATCH[1]} minor=${BASH_REMATCH[2]} patch=${BASH_REMATCH[3]}
|
||||
|
||||
release=true
|
||||
if (( major < 2 )); then
|
||||
# Versions restart at 2.0.0 with the roadmap rendered from the site.
|
||||
version=v2.0.0
|
||||
elif [[ $BUMP == major ]]; then
|
||||
version="v$((major + 1)).0.0"
|
||||
elif [[ $BUMP == minor ]]; then
|
||||
version="v$major.$((minor + 1)).0"
|
||||
elif [[ $FINGERPRINT != "$previous_fingerprint" ]]; then
|
||||
version="v$major.$minor.$((patch + 1))"
|
||||
else
|
||||
release=false
|
||||
version=$tag
|
||||
fi
|
||||
date="$([[ $release == true ]] && date -u +%F || echo "$published")"
|
||||
|
||||
echo "Latest release $tag (map ${previous_fingerprint:-unknown}); this map $FINGERPRINT; bump $BUMP"
|
||||
echo "=> release=$release version=$version date=$date"
|
||||
{
|
||||
echo "release=$release"
|
||||
echo "version=$version"
|
||||
echo "previous=$tag"
|
||||
echo "date=$date"
|
||||
} >> "${GITHUB_OUTPUT:-/dev/stdout}"
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Link check
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * 1"
|
||||
pull_request:
|
||||
paths:
|
||||
- "**/*.md"
|
||||
- ".github/lychee.toml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lychee:
|
||||
name: Check links
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Check links
|
||||
id: lychee
|
||||
uses: lycheeverse/lychee-action@v2
|
||||
with:
|
||||
args: --config .github/lychee.toml --no-progress "**/*.md"
|
||||
output: lychee/out.md
|
||||
# Pull requests report broken links without blocking; scheduled runs fail and open an issue.
|
||||
fail: ${{ github.event_name != 'pull_request' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Report broken links
|
||||
if: always() && github.event_name != 'pull_request' && steps.lychee.outputs.exit_code != 0
|
||||
uses: peter-evans/create-issue-from-file@v6
|
||||
with:
|
||||
title: Link check report
|
||||
content-filepath: lychee/out.md
|
||||
labels: broken-links
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Markdown lint
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
markdownlint:
|
||||
name: Markdown lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: DavidAnson/markdownlint-cli2-action@v24
|
||||
with:
|
||||
globs: "**/*.md"
|
||||
|
||||
readme-conventions:
|
||||
name: README conventions
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Check entry format and duplicates
|
||||
run: python3 .github/scripts/check_readme.py
|
||||
@@ -0,0 +1,143 @@
|
||||
name: Roadmap explorer
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- README.md
|
||||
- "site/**"
|
||||
- .github/workflows/pages.yml
|
||||
- .github/scripts/next-version.sh
|
||||
pull_request:
|
||||
paths:
|
||||
- README.md
|
||||
- "site/**"
|
||||
- .github/workflows/pages.yml
|
||||
- .github/scripts/next-version.sh
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: "Version bump: auto releases a patch only if the map changed; minor/major always release"
|
||||
type: choice
|
||||
options: [auto, minor, major]
|
||||
default: auto
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
# Builds on master queue instead of cancelling, so two releases never pick the same version.
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Test and build
|
||||
# Pinned runner and Playwright keep the rendering stable, so the map fingerprint only changes with the map.
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
release: ${{ steps.version.outputs.release }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
previous: ${{ steps.version.outputs.previous }}
|
||||
fingerprint: ${{ steps.fingerprint.outputs.sha256 }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Test the README parser and map layout
|
||||
run: node --test site/parser.test.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/
|
||||
|
||||
- name: Install Chromium
|
||||
run: |
|
||||
npm install --no-save --no-package-lock playwright@1.64.0
|
||||
npx playwright install --with-deps chromium
|
||||
|
||||
- name: Fingerprint the map
|
||||
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
|
||||
echo "sha256=$(sha256sum fingerprint/Embedded-Engineering-Roadmap.png | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Decide the version
|
||||
id: version
|
||||
env:
|
||||
FINGERPRINT: ${{ steps.fingerprint.outputs.sha256 }}
|
||||
BUMP: ${{ inputs.bump || 'auto' }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: .github/scripts/next-version.sh
|
||||
|
||||
- name: Render the roadmap to PDF and PNG
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
DATE: ${{ steps.version.outputs.date }}
|
||||
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
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: roadmap-render
|
||||
path: |
|
||||
_site/Embedded-Engineering-Roadmap.pdf
|
||||
_site/Embedded-Engineering-Roadmap.png
|
||||
if-no-files-found: error
|
||||
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: _site
|
||||
|
||||
release:
|
||||
name: Release ${{ needs.build.outputs.version }}
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/master' && needs.build.outputs.release == 'true'
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: roadmap-render
|
||||
|
||||
- name: Create the release with the PDF and PNG
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
VERSION: ${{ needs.build.outputs.version }}
|
||||
PREVIOUS: ${{ needs.build.outputs.previous }}
|
||||
FINGERPRINT: ${{ needs.build.outputs.fingerprint }}
|
||||
run: |
|
||||
gh api "repos/$GH_REPO/releases/generate-notes" -f tag_name="$VERSION" -f target_commitish="$GITHUB_SHA" \
|
||||
-f previous_tag_name="$PREVIOUS" --jq .body > notes.md
|
||||
# The next build compares its map with this fingerprint to decide whether a new patch release is due.
|
||||
printf '\n<!-- map-fingerprint: %s -->\n' "$FINGERPRINT" >> notes.md
|
||||
gh release create "$VERSION" Embedded-Engineering-Roadmap.pdf Embedded-Engineering-Roadmap.png \
|
||||
--target "$GITHUB_SHA" --title "$VERSION" --notes-file notes.md
|
||||
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
if: >-
|
||||
!cancelled() && github.event_name != 'pull_request' && github.ref == 'refs/heads/master'
|
||||
&& needs.build.result == 'success' && needs.release.result != 'failure'
|
||||
needs: [build, release]
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# Link checker cache
|
||||
.lycheecache
|
||||
|
||||
# Editors and OS
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Local tooling
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"MD026": false,
|
||||
"default": true,
|
||||
"line-length": false
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socioeconomic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our community include:
|
||||
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
- Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
- The use of sexualized language or imagery and unwelcome sexual attention or advances
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or electronic address, without explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [insert contact method here]. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the Contributor Covenant, version 1.4, available at [https://www.contributor-covenant.org/version/1/4/](https://www.contributor-covenant.org/version/1/4/).
|
||||
|
||||
For more details, visit the [Open Source Initiative](https://opensource.org/).
|
||||
@@ -0,0 +1,42 @@
|
||||
|
||||
# Contributing
|
||||
|
||||
Contributions from everyone are welcomed. To keep the roadmap practical, accessible, and high quality, the following guidelines should be followed:
|
||||
|
||||
## 1. Prioritize Beginner-Friendliness
|
||||
|
||||
- Resources and explanations understandable for beginners should be added.
|
||||
- Advanced or specialized topics are welcomed and their inclusion can enrich the roadmap, but they should be explicitly labeled as advanced so beginners are not overwhelmed when choosing resources.
|
||||
|
||||
## 2. Resource Selection Policy
|
||||
|
||||
- Free and open resources are preferred to maximize accessibility.
|
||||
- Paid resources may be included only if they clearly offer more value than existing free options; low-quality paid/free content should not be promoted.
|
||||
- The list should not be spammed with every publication from the same author or creator solely due to their reputation or personal preference. Each resource should be added for a clear reason, with usefulness especially for beginners prioritized.
|
||||
- Resources should be up-to-date, reliable, and organized under relevant headings.
|
||||
|
||||
## 3. Clarity & Structure
|
||||
|
||||
- Clear and direct language should be used. Unnecessary jargon should be avoided. If technical terms are used, simple explanations should be added.
|
||||
- Bullet points and lists should be used for readability and structure.
|
||||
|
||||
## 4. Technical Accuracy
|
||||
|
||||
- The correctness and relevance of all information and links should be verified.
|
||||
- If uncertainty exists, feedback should be requested in the pull request.
|
||||
|
||||
## 5. Roadmap Alignment
|
||||
|
||||
- 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.
|
||||
|
||||
## 6. Versioning and Releases
|
||||
|
||||
The roadmap image is versioned as `vMAJOR.MINOR.PATCH`, starting from `v2.0.0`.
|
||||
|
||||
- **No new version** for changes that do not change the roadmap map, such as new learning resources, descriptions or documentation.
|
||||
- **Patch** (`v2.0.0` → `v2.0.1`) is automatic: when a change merged into `master` changes the rendered map, CI publishes the next patch release with the PDF and PNG attached.
|
||||
- **Minor** (`v2.0.234` → `v2.1.0`) and **major** (`v2.43.57` → `v3.0.0`) are decided by the maintainer: run the *Roadmap explorer* workflow from the Actions tab on `master` and choose `minor` or `major`.
|
||||
|
||||
Releases should not be created by hand; the workflow renders the files and records the map fingerprint that later builds compare against.
|
||||
Binary file not shown.
+668
@@ -0,0 +1,668 @@
|
||||
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';
|
||||
|
||||
const SOURCES = [
|
||||
'README.md',
|
||||
'../README.md',
|
||||
'https://raw.githubusercontent.com/m3y54m/Embedded-Engineering-Roadmap/master/README.md',
|
||||
];
|
||||
const REPO_URL = 'https://github.com/m3y54m/Embedded-Engineering-Roadmap';
|
||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const narrowScreen = window.matchMedia('(max-width: 960px)');
|
||||
|
||||
const els = {
|
||||
diagram: document.getElementById('diagram'),
|
||||
outline: document.getElementById('outline'),
|
||||
panel: document.getElementById('panel'),
|
||||
status: document.getElementById('status'),
|
||||
search: document.getElementById('search'),
|
||||
results: document.getElementById('search-results'),
|
||||
theme: document.getElementById('theme'),
|
||||
tabs: [...document.querySelectorAll('[data-view]')],
|
||||
};
|
||||
|
||||
const state = {
|
||||
data: null,
|
||||
selected: null,
|
||||
includeSub: null,
|
||||
filters: { types: new Set(), beginner: false, gem: false },
|
||||
results: [],
|
||||
active: -1,
|
||||
};
|
||||
|
||||
// ---------- small helpers ----------
|
||||
|
||||
const normalize = (text) => text.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
||||
|
||||
function* descendants(node) {
|
||||
yield node;
|
||||
for (const child of node.children) yield* descendants(child);
|
||||
}
|
||||
|
||||
function store(key, value) {
|
||||
try {
|
||||
if (value === undefined) return localStorage.getItem(key);
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Storage can be unavailable (private mode, blocked cookies); preferences just won't persist.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- 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;
|
||||
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) => {
|
||||
top.index = index;
|
||||
for (const n of descendants(top)) n.top = top;
|
||||
});
|
||||
}
|
||||
|
||||
function colorOf(node) {
|
||||
if (!node.top) return 'var(--accent)';
|
||||
const dark = document.documentElement.dataset.theme !== 'light';
|
||||
const step = Math.min(node.depth - 1, 3);
|
||||
const alt = node.top.index % 2;
|
||||
const light = dark ? [54, 44, 37, 32][step] + alt * 7 : [62, 73, 81, 87][step] - alt * 6;
|
||||
const sat = dark ? [70, 56, 46, 40][step] : [72, 64, 56, 50][step];
|
||||
return `hsl(${node.top.hue.toFixed(1)} ${sat}% ${light}%)`;
|
||||
}
|
||||
|
||||
function swatch(node) {
|
||||
const el = h('span', { class: 'swatch', 'aria-hidden': 'true' });
|
||||
el.style.background = colorOf(node);
|
||||
return el;
|
||||
}
|
||||
|
||||
function truncate(text, max) {
|
||||
if (text.length <= max) return text;
|
||||
return max < 4 ? '' : `${text.slice(0, max - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
// ---------- navigation ----------
|
||||
|
||||
function navigate(node, { push = true, resource = null, reveal = false } = {}) {
|
||||
if (state.selected !== node) state.includeSub = null;
|
||||
state.selected = node;
|
||||
if (resource) resetFilters(false);
|
||||
if (state.mapView) state.mapView.select(node === state.data.root ? null : node);
|
||||
renderPanel(node);
|
||||
syncOutline();
|
||||
if (resource) revealResource(resource);
|
||||
const root = state.data.root;
|
||||
document.title = node === root ? 'Embedded Engineering Roadmap · Explorer' : `${node.title} · Embedded Engineering Roadmap`;
|
||||
if (push) {
|
||||
const hash = node === root ? '#/' : `#/${node.slug}`;
|
||||
if (location.hash !== hash) history.pushState(null, '', hash);
|
||||
}
|
||||
if (reveal && narrowScreen.matches) openSheet(true);
|
||||
}
|
||||
|
||||
function routeFromHash() {
|
||||
const slug = decodeURIComponent(location.hash.replace(/^#\/?/, ''));
|
||||
navigate((slug && state.data.bySlug.get(slug)) || state.data.root, { push: false });
|
||||
}
|
||||
|
||||
// ---------- details panel ----------
|
||||
|
||||
function section(title, ...content) {
|
||||
return h('section', { class: 'panel-section' }, title ? h('h3', {}, title) : null, ...content);
|
||||
}
|
||||
|
||||
function crumbs(node) {
|
||||
const chain = [];
|
||||
for (let n = node.parent; n; n = n.parent) chain.unshift(n);
|
||||
if (!chain.length) return null;
|
||||
const root = state.data.root;
|
||||
return h('nav', { class: 'crumbs', 'aria-label': 'Topic path' }, chain.flatMap((n, i) => [
|
||||
i ? h('span', { 'aria-hidden': 'true' }, '›') : null,
|
||||
h('button', { type: 'button', onclick: () => navigate(n) }, n === root ? 'Overview' : n.title),
|
||||
]));
|
||||
}
|
||||
|
||||
function description(blocks) {
|
||||
const wrap = h('div', { class: 'desc' });
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'p') {
|
||||
const p = h('p');
|
||||
p.innerHTML = renderInline(block.text);
|
||||
wrap.append(p);
|
||||
} else {
|
||||
const ul = h('ul');
|
||||
for (const item of block.items) {
|
||||
const li = h('li');
|
||||
li.innerHTML = renderInline(item);
|
||||
ul.append(li);
|
||||
}
|
||||
wrap.append(ul);
|
||||
}
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function topicChips(nodes) {
|
||||
return h('div', { class: 'chips' }, nodes.map((n) => h('button', {
|
||||
type: 'button',
|
||||
class: 'chip-btn',
|
||||
onclick: () => navigate(n, { reveal: true }),
|
||||
}, swatch(n), n.title, h('span', { class: 'n' }, n.total))));
|
||||
}
|
||||
|
||||
function related(node) {
|
||||
const items = [...node.links]
|
||||
.sort((a, b) => b.weight - a.weight)
|
||||
.map((link) => {
|
||||
const other = link.source === node ? link.target : link.source;
|
||||
const reasons = [];
|
||||
if (link.shared.length) reasons.push(plural(link.shared.length, 'shared resource'));
|
||||
const outgoing = link.mentions.filter((m) => m.topic === node);
|
||||
const incoming = link.mentions.filter((m) => m.topic !== node);
|
||||
if (outgoing.length) reasons.push(`mentioned here: “${truncate(outgoing[0].text, 60)}”`);
|
||||
else if (incoming.length) reasons.push(`mentions this topic: “${truncate(incoming[0].text, 60)}”`);
|
||||
const title = [...link.shared.map((s) => `Shared: ${s}`), ...link.mentions.map((m) => `${m.topic.title}: ${m.text}`)].join('\n');
|
||||
return h('li', {}, h('button', { type: 'button', title, onclick: () => navigate(other, { reveal: true }) },
|
||||
swatch(other),
|
||||
h('span', {}, other.title),
|
||||
h('span', { class: 'why' }, reasons.join(' · '))));
|
||||
});
|
||||
return h('ul', { class: 'related' }, items);
|
||||
}
|
||||
|
||||
function passes(r) {
|
||||
const f = state.filters;
|
||||
return (!f.types.size || f.types.has(r.type)) && (!f.beginner || r.beginner) && (!f.gem || r.gem);
|
||||
}
|
||||
|
||||
function resetFilters(rerender = true) {
|
||||
state.filters = { types: new Set(), beginner: false, gem: false };
|
||||
if (rerender) rerenderPanel();
|
||||
}
|
||||
|
||||
function filterBar(entries) {
|
||||
const f = state.filters;
|
||||
const present = Object.keys(TYPES).filter((type) => entries.some((r) => r.type === type));
|
||||
const button = (label, pressed, onclick) => h('button', { type: 'button', class: 'filter', 'aria-pressed': String(pressed), onclick }, label);
|
||||
const active = f.types.size || f.beginner || f.gem;
|
||||
const hasBeginner = entries.some((r) => r.beginner);
|
||||
const hasGem = entries.some((r) => r.gem);
|
||||
if (present.length < 2 && !hasBeginner && !hasGem && !active) return null;
|
||||
return h('div', { class: 'filters', role: 'group', 'aria-label': 'Filter resources' },
|
||||
present.length > 1 ? present.map((type) => button(`${TYPES[type].icon} ${TYPES[type].label}`, f.types.has(type), () => {
|
||||
if (f.types.has(type)) f.types.delete(type);
|
||||
else f.types.add(type);
|
||||
rerenderPanel();
|
||||
})) : null,
|
||||
hasBeginner ? button('👶 Beginner', f.beginner, () => { f.beginner = !f.beginner; rerenderPanel(); }) : null,
|
||||
hasGem ? button('💎 Essential', f.gem, () => { f.gem = !f.gem; rerenderPanel(); }) : null,
|
||||
active ? h('button', { type: 'button', class: 'filter reset', onclick: () => resetFilters() }, 'Clear filters') : null);
|
||||
}
|
||||
|
||||
function resourceItem(r) {
|
||||
const type = TYPES[r.type];
|
||||
return h('li', { class: 'res', 'data-url': r.url },
|
||||
h('span', { class: 'res-icon', title: type.label, 'aria-label': type.label, role: 'img' }, type.icon),
|
||||
h('div', { class: 'res-body' },
|
||||
h('a', { class: 'res-title', href: r.url, target: '_blank', rel: 'noopener noreferrer' }, r.title),
|
||||
h('div', { class: 'res-meta' },
|
||||
h('span', {}, r.domain),
|
||||
r.beginner ? h('span', { class: 'badge beginner' }, 'Beginner') : null,
|
||||
r.gem ? h('span', { class: 'badge gem' }, 'Essential') : null),
|
||||
r.note ? h('p', { class: 'res-note' }, r.note) : null,
|
||||
r.sharedWith.length ? h('div', { class: 'res-also' }, 'Also listed in ', r.sharedWith.flatMap((t, i) => [
|
||||
i ? ', ' : null,
|
||||
h('button', { type: 'button', class: 'link-btn', onclick: () => navigate(t, { reveal: true }) }, t.title),
|
||||
])) : null));
|
||||
}
|
||||
|
||||
function resources(node) {
|
||||
const hasSub = node.children.length > 0 && node.total > node.resources.length;
|
||||
const include = hasSub && (state.includeSub ?? node.resources.length === 0);
|
||||
const groups = include
|
||||
? [...descendants(node)].filter((n) => n.resources.length).map((n) => ({ node: n, list: n.resources }))
|
||||
: [{ node, list: node.resources }];
|
||||
const all = groups.flatMap((g) => g.list);
|
||||
const shown = all.filter(passes).length;
|
||||
|
||||
const sec = h('section', { class: 'panel-section' },
|
||||
h('div', { class: 'res-head' },
|
||||
h('h3', {}, 'Resources'),
|
||||
h('span', { class: 'count' }, shown === all.length ? plural(all.length, 'item') : `${shown} of ${all.length}`)));
|
||||
if (hasSub) {
|
||||
const box = h('input', { type: 'checkbox', checked: include });
|
||||
box.addEventListener('change', () => {
|
||||
state.includeSub = box.checked;
|
||||
rerenderPanel();
|
||||
});
|
||||
sec.append(h('label', { class: 'toggle' }, box, `Include subtopics (${plural(node.total, 'resource')})`));
|
||||
}
|
||||
if (!all.length) {
|
||||
sec.append(h('p', { class: 'empty' }, 'This topic only groups its subtopics. Pick one above.'));
|
||||
return sec;
|
||||
}
|
||||
const filters = filterBar(all);
|
||||
if (filters) sec.append(filters);
|
||||
if (!shown) {
|
||||
sec.append(h('div', { class: 'empty' }, 'No resources match these filters. ',
|
||||
h('button', { type: 'button', class: 'link-btn', onclick: () => resetFilters() }, 'Clear filters')));
|
||||
return sec;
|
||||
}
|
||||
for (const group of groups) {
|
||||
const list = group.list.filter(passes);
|
||||
if (!list.length) continue;
|
||||
if (include) {
|
||||
sec.append(h('div', { class: 'res-group' },
|
||||
h('button', { type: 'button', onclick: () => navigate(group.node, { reveal: true }) }, group.node.title)));
|
||||
}
|
||||
sec.append(h('ul', { class: 'res-list' }, list.map(resourceItem)));
|
||||
}
|
||||
return sec;
|
||||
}
|
||||
|
||||
function areaSummary() {
|
||||
const boxes = state.plan ? state.plan.boxes.filter((b) => !b.header) : [];
|
||||
const count = (test) => boxes.filter(test).length;
|
||||
const tile = (areas, n, label) => h('div', { class: 'stat' }, areaDots(areas), h('b', {}, n), h('span', {}, label));
|
||||
return h('div', { class: 'stats areas' },
|
||||
tile(['SOFTWARE'], count((b) => b.areas.length === 1 && b.areas[0] === 'SOFTWARE'), 'Software only'),
|
||||
tile(['SOFTWARE', 'HARDWARE'], count((b) => b.areas.includes('SOFTWARE') && b.areas.includes('HARDWARE') && !b.areas.includes('SOFT SKILLS')), 'Software ∩ Hardware'),
|
||||
tile(['HARDWARE'], count((b) => b.areas.length === 1 && b.areas[0] === 'HARDWARE'), 'Hardware only'));
|
||||
}
|
||||
|
||||
function overview(root) {
|
||||
const { stats, groups } = state.data;
|
||||
return [
|
||||
section(null, description(root.description)),
|
||||
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')))),
|
||||
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', {}, '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;
|
||||
panel.replaceChildren();
|
||||
panel.append(h('header', { class: 'panel-head' },
|
||||
h('button', { type: 'button', class: 'icon-btn sheet-close', 'aria-label': 'Close details', onclick: () => openSheet(false) }, '✕'),
|
||||
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 ? 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,
|
||||
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)));
|
||||
|
||||
if (node === root) {
|
||||
panel.append(...overview(root));
|
||||
return;
|
||||
}
|
||||
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)));
|
||||
panel.append(resources(node));
|
||||
}
|
||||
|
||||
function rerenderPanel() {
|
||||
const top = els.panel.scrollTop;
|
||||
renderPanel(state.selected);
|
||||
els.panel.scrollTop = top;
|
||||
}
|
||||
|
||||
function revealResource(resource) {
|
||||
const item = [...els.panel.querySelectorAll('.res')].find((li) => li.dataset.url === resource.url);
|
||||
if (!item) return;
|
||||
item.scrollIntoView({ block: 'center', behavior: reducedMotion.matches ? 'auto' : 'smooth' });
|
||||
item.classList.remove('flash');
|
||||
void item.offsetWidth;
|
||||
item.classList.add('flash');
|
||||
}
|
||||
|
||||
function openSheet(open) {
|
||||
els.panel.classList.toggle('open', open);
|
||||
if (open) els.panel.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
// ---------- outline view ----------
|
||||
|
||||
function buildOutline() {
|
||||
const { groups, stats } = state.data;
|
||||
const item = (node) => {
|
||||
const button = h('button', {
|
||||
type: 'button',
|
||||
class: 'tree-btn',
|
||||
'data-id': node.id,
|
||||
onclick: (e) => {
|
||||
e.preventDefault();
|
||||
navigate(node, { reveal: true });
|
||||
},
|
||||
}, swatch(node), h('span', { class: 'tree-title' }, node.title),
|
||||
node.areas && node.areas.length ? areaDots(node.areas) : null,
|
||||
h('span', { class: 'tree-count' }, node.total));
|
||||
if (!node.children.length) return h('li', {}, button);
|
||||
const details = h('details', {}, h('summary', {}, button), h('ul', { class: 'tree' }, node.children.map(item)));
|
||||
return h('li', {}, details);
|
||||
};
|
||||
els.outline.replaceChildren(
|
||||
h('h2', {}, 'All topics'),
|
||||
h('p', { class: 'res-note' }, `${stats.topics} topics and ${stats.resources} resources. Select a topic to see its details; use the arrows to expand.`),
|
||||
...groups.map((g) => h('section', { class: 'outline-group' }, h('h3', {}, g.title), h('ul', { class: 'tree' }, g.topics.map(item)))),
|
||||
);
|
||||
}
|
||||
|
||||
function syncOutline() {
|
||||
for (const btn of els.outline.querySelectorAll('.tree-btn[aria-current]')) btn.removeAttribute('aria-current');
|
||||
const btn = state.selected && els.outline.querySelector(`.tree-btn[data-id="${CSS.escape(state.selected.id)}"]`);
|
||||
if (!btn) return;
|
||||
btn.setAttribute('aria-current', 'true');
|
||||
for (let el = btn.parentElement; el && el !== els.outline; el = el.parentElement) {
|
||||
if (el.tagName === 'DETAILS') el.open = true;
|
||||
}
|
||||
}
|
||||
|
||||
const VIEWS = ['map', 'list'];
|
||||
|
||||
function setView(view) {
|
||||
if (!VIEWS.includes(view) || (view === 'map' && !state.mapView)) view = state.mapView ? 'map' : 'list';
|
||||
els.diagram.hidden = view !== 'map';
|
||||
els.outline.hidden = view !== 'list';
|
||||
for (const tab of els.tabs) {
|
||||
tab.setAttribute('aria-selected', String(tab.dataset.view === view));
|
||||
if (tab.dataset.view === 'map') tab.hidden = !state.mapView;
|
||||
}
|
||||
store('roadmap-view', view);
|
||||
if (view === 'map') state.mapView.show();
|
||||
if (view === 'list') {
|
||||
syncOutline();
|
||||
const current = els.outline.querySelector('.tree-btn[aria-current]');
|
||||
if (current) current.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- search ----------
|
||||
|
||||
function buildIndex() {
|
||||
state.index = {
|
||||
topics: state.data.topics.map((node) => ({
|
||||
node,
|
||||
title: normalize(node.title),
|
||||
text: normalize([node.title, ...node.path].join(' ')),
|
||||
})),
|
||||
resources: state.data.topics.flatMap((node) => node.resources.map((r) => ({
|
||||
node,
|
||||
r,
|
||||
text: normalize(`${r.title} ${r.domain}`),
|
||||
}))),
|
||||
};
|
||||
}
|
||||
|
||||
function runSearch(query) {
|
||||
const tokens = normalize(query).split(/\s+/).filter(Boolean);
|
||||
if (!tokens.length) return [];
|
||||
const match = (entry) => tokens.every((t) => entry.text.includes(t));
|
||||
const topics = state.index.topics
|
||||
.filter(match)
|
||||
.map((e) => ({ ...e, score: e.title.startsWith(tokens[0]) ? 0 : e.title.includes(tokens[0]) ? 1 : 2 }))
|
||||
.sort((a, b) => a.score - b.score || a.node.depth - b.node.depth)
|
||||
.slice(0, 6)
|
||||
.map((e) => ({ kind: 'topic', node: e.node }));
|
||||
const seen = new Set();
|
||||
const resourceHits = state.index.resources
|
||||
.filter((e) => match(e) && !seen.has(e.r.url) && seen.add(e.r.url))
|
||||
.slice(0, 8)
|
||||
.map((e) => ({ kind: 'resource', node: e.node, r: e.r }));
|
||||
return [...topics, ...resourceHits];
|
||||
}
|
||||
|
||||
function renderResults() {
|
||||
const box = els.results;
|
||||
const query = els.search.value.trim();
|
||||
box.replaceChildren();
|
||||
if (!query) {
|
||||
closeResults();
|
||||
return;
|
||||
}
|
||||
const results = state.results;
|
||||
if (!results.length) {
|
||||
box.append(h('div', { class: 'result-empty' }, `No topics or resources match “${query}”.`));
|
||||
}
|
||||
let lastKind = '';
|
||||
results.forEach((res, i) => {
|
||||
if (res.kind !== lastKind) {
|
||||
box.append(h('div', { class: 'result-group', role: 'presentation' }, res.kind === 'topic' ? 'Topics' : 'Resources'));
|
||||
lastKind = res.kind;
|
||||
}
|
||||
const isTopic = res.kind === 'topic';
|
||||
const option = h('div', {
|
||||
id: `result-${i}`,
|
||||
class: 'result',
|
||||
role: 'option',
|
||||
'aria-selected': String(i === state.active),
|
||||
},
|
||||
isTopic ? swatch(res.node) : h('span', { 'aria-hidden': 'true' }, TYPES[res.r.type].icon),
|
||||
h('div', { class: 'result-main' },
|
||||
h('div', { class: 'result-title' }, isTopic ? res.node.title : res.r.title, isTopic && res.node.areas && res.node.areas.length ? areaDots(res.node.areas) : null),
|
||||
h('div', { class: 'result-sub' }, isTopic
|
||||
? (res.node.path.length ? res.node.path.join(' › ') : plural(res.node.total, 'resource'))
|
||||
: `${res.r.domain} · ${res.node.title}`)));
|
||||
option.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault();
|
||||
choose(i);
|
||||
});
|
||||
box.append(option);
|
||||
});
|
||||
box.hidden = false;
|
||||
els.search.setAttribute('aria-expanded', 'true');
|
||||
els.search.setAttribute('aria-activedescendant', state.active >= 0 ? `result-${state.active}` : '');
|
||||
}
|
||||
|
||||
function closeResults() {
|
||||
els.results.hidden = true;
|
||||
els.search.setAttribute('aria-expanded', 'false');
|
||||
els.search.removeAttribute('aria-activedescendant');
|
||||
state.active = -1;
|
||||
}
|
||||
|
||||
function choose(index) {
|
||||
const res = state.results[index];
|
||||
if (!res) return;
|
||||
closeResults();
|
||||
els.search.value = '';
|
||||
els.search.blur();
|
||||
if (res.kind === 'topic') navigate(res.node, { reveal: true });
|
||||
else navigate(res.node, { resource: res.r, reveal: true });
|
||||
}
|
||||
|
||||
function wireSearch() {
|
||||
els.search.addEventListener('input', () => {
|
||||
state.results = runSearch(els.search.value);
|
||||
state.active = state.results.length ? 0 : -1;
|
||||
renderResults();
|
||||
});
|
||||
els.search.addEventListener('keydown', (e) => {
|
||||
const count = state.results.length;
|
||||
if (e.key === 'ArrowDown' && count) {
|
||||
e.preventDefault();
|
||||
state.active = (state.active + 1) % count;
|
||||
renderResults();
|
||||
document.getElementById(`result-${state.active}`)?.scrollIntoView({ block: 'nearest' });
|
||||
} else if (e.key === 'ArrowUp' && count) {
|
||||
e.preventDefault();
|
||||
state.active = (state.active - 1 + count) % count;
|
||||
renderResults();
|
||||
document.getElementById(`result-${state.active}`)?.scrollIntoView({ block: 'nearest' });
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
choose(state.active >= 0 ? state.active : 0);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
if (els.search.value) {
|
||||
els.search.value = '';
|
||||
closeResults();
|
||||
} else {
|
||||
els.search.blur();
|
||||
}
|
||||
}
|
||||
});
|
||||
els.search.addEventListener('focus', () => {
|
||||
if (els.search.value.trim()) renderResults();
|
||||
});
|
||||
els.search.addEventListener('blur', () => window.setTimeout(closeResults, 120));
|
||||
}
|
||||
|
||||
// ---------- theme, loading, startup ----------
|
||||
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
if (!state.data) return;
|
||||
buildOutline();
|
||||
syncOutline();
|
||||
rerenderPanel();
|
||||
}
|
||||
|
||||
async function loadMarkdown() {
|
||||
for (const source of SOURCES) {
|
||||
try {
|
||||
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;
|
||||
} catch {
|
||||
// Try the next source.
|
||||
}
|
||||
}
|
||||
throw new Error('README.md could not be loaded');
|
||||
}
|
||||
|
||||
async function loadMap() {
|
||||
try {
|
||||
const response = await fetch('map.json', { cache: 'no-cache' });
|
||||
return response.ok ? await response.json() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Revision and date, written by CI next to the deployed site; absent in local previews.
|
||||
async function loadBuild() {
|
||||
try {
|
||||
const response = await fetch('build.json', { cache: 'no-cache' });
|
||||
return response.ok ? await response.json() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isTyping(el) {
|
||||
return el instanceof HTMLElement && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName));
|
||||
}
|
||||
|
||||
function wireGlobal() {
|
||||
els.theme.addEventListener('click', () => {
|
||||
const next = document.documentElement.dataset.theme === 'light' ? 'dark' : 'light';
|
||||
store('roadmap-theme', next);
|
||||
applyTheme(next);
|
||||
});
|
||||
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]')) {
|
||||
fetch(link.getAttribute('href'), { method: 'HEAD', cache: 'no-cache' })
|
||||
.then((response) => { link.hidden = !response.ok; }, () => {});
|
||||
}
|
||||
// Printing always produces the diagram, whatever view is open.
|
||||
let viewBeforePrint = null;
|
||||
window.addEventListener('beforeprint', () => {
|
||||
if (!state.mapView) return;
|
||||
viewBeforePrint = store('roadmap-view');
|
||||
setView('map');
|
||||
state.mapView.prepareForPrint();
|
||||
});
|
||||
window.addEventListener('afterprint', () => {
|
||||
if (state.mapView) state.mapView.afterPrint();
|
||||
if (viewBeforePrint && viewBeforePrint !== 'map') setView(viewBeforePrint);
|
||||
viewBeforePrint = null;
|
||||
});
|
||||
window.addEventListener('popstate', () => routeFromHash());
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (isTyping(e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
if (e.key === '/') {
|
||||
e.preventDefault();
|
||||
els.search.focus();
|
||||
} else if (e.key === 'Escape') {
|
||||
if (els.panel.classList.contains('open')) openSheet(false);
|
||||
else if (state.selected && state.selected.parent) navigate(state.selected.parent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
applyTheme(store('roadmap-theme') || (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'));
|
||||
let markdown;
|
||||
const diagramRequest = loadMap();
|
||||
const buildRequest = loadBuild();
|
||||
try {
|
||||
markdown = await loadMarkdown();
|
||||
} catch {
|
||||
els.status.replaceChildren('The roadmap could not be loaded. ',
|
||||
h('a', { href: `${REPO_URL}#readme` }, 'Read it on GitHub instead'), '.');
|
||||
return;
|
||||
}
|
||||
state.data = parseRoadmap(markdown);
|
||||
assignHues(state.data);
|
||||
const diagram = await diagramRequest;
|
||||
state.build = await buildRequest;
|
||||
if (diagram) {
|
||||
state.plan = linkDiagram(diagram, state.data);
|
||||
state.mapView = createMapView({
|
||||
root: els.diagram,
|
||||
plan: state.plan,
|
||||
data: state.data,
|
||||
build: state.build,
|
||||
onSelect: (topic) => navigate(topic, { reveal: true }),
|
||||
});
|
||||
}
|
||||
buildOutline();
|
||||
buildIndex();
|
||||
wireSearch();
|
||||
wireGlobal();
|
||||
els.status.hidden = true;
|
||||
routeFromHash();
|
||||
setView(store('roadmap-view') || 'map');
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,199 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://web.resource.org/cc/"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="120"
|
||||
height="42"
|
||||
id="svg2759"
|
||||
sodipodi:version="0.32"
|
||||
inkscape:version="0.45+devel"
|
||||
version="1.0"
|
||||
sodipodi:docname="by-sa.svg"
|
||||
inkscape:output_extension="org.inkscape.output.svg.inkscape">
|
||||
<defs
|
||||
id="defs2761" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#8b8b8b"
|
||||
borderopacity="1"
|
||||
gridtolerance="10000"
|
||||
guidetolerance="10"
|
||||
objecttolerance="10"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="1"
|
||||
inkscape:cx="179"
|
||||
inkscape:cy="89.569904"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
width="120px"
|
||||
height="42px"
|
||||
inkscape:showpageshadow="false"
|
||||
inkscape:window-width="1198"
|
||||
inkscape:window-height="624"
|
||||
inkscape:window-x="488"
|
||||
inkscape:window-y="401" />
|
||||
<metadata
|
||||
id="metadata2764">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1">
|
||||
<g
|
||||
transform="matrix(0.9937807,0,0,0.9936694,-177.69409,-74.436409)"
|
||||
id="g287"
|
||||
inkscape:export-filename="/mnt/hgfs/Bov/Documents/Work/2007/cc/identity/srr buttons/big/by-sa.png"
|
||||
inkscape:export-xdpi="300.23013"
|
||||
inkscape:export-ydpi="300.23013">
|
||||
<path
|
||||
id="path3817_2_"
|
||||
nodetypes="ccccccc"
|
||||
d="M 182.23532,75.39014 L 296.29928,75.59326 C 297.89303,75.59326 299.31686,75.35644 299.31686,78.77344 L 299.17721,116.34033 L 179.3569,116.34033 L 179.3569,78.63379 C 179.3569,76.94922 179.51999,75.39014 182.23532,75.39014 z"
|
||||
style="fill:#aab2ab" />
|
||||
|
||||
<g
|
||||
id="g5908_2_"
|
||||
transform="matrix(0.872921,0,0,0.872921,50.12536,143.2144)">
|
||||
|
||||
<path
|
||||
id="path5906_2_"
|
||||
cx="296.35416"
|
||||
ry="22.939548"
|
||||
cy="264.3577"
|
||||
type="arc"
|
||||
rx="22.939548"
|
||||
d="M 187.20944,-55.6792 C 187.21502,-46.99896 180.18158,-39.95825 171.50134,-39.95212 C 162.82113,-39.94708 155.77929,-46.97998 155.77426,-55.66016 C 155.77426,-55.66687 155.77426,-55.67249 155.77426,-55.6792 C 155.76922,-64.36054 162.80209,-71.40125 171.48233,-71.40631 C 180.16367,-71.41193 187.20441,-64.37842 187.20944,-55.69824 C 187.20944,-55.69263 187.20944,-55.68591 187.20944,-55.6792 z"
|
||||
style="fill:#ffffff" />
|
||||
|
||||
<g
|
||||
id="g5706_2_"
|
||||
transform="translate(-289.6157,99.0653)">
|
||||
<path
|
||||
id="path5708_2_"
|
||||
d="M 473.88455,-167.54724 C 477.36996,-164.06128 479.11294,-159.79333 479.11294,-154.74451 C 479.11294,-149.69513 477.40014,-145.47303 473.9746,-142.07715 C 470.33929,-138.50055 466.04281,-136.71283 461.08513,-136.71283 C 456.18736,-136.71283 451.96526,-138.48544 448.42003,-142.03238 C 444.87419,-145.57819 443.10158,-149.81537 443.10158,-154.74451 C 443.10158,-159.6731 444.87419,-163.94049 448.42003,-167.54724 C 451.87523,-171.03375 456.09728,-172.77618 461.08513,-172.77618 C 466.13342,-172.77618 470.39914,-171.03375 473.88455,-167.54724 z M 450.76657,-165.20239 C 447.81982,-162.22601 446.34701,-158.7395 446.34701,-154.74005 C 446.34701,-150.7417 447.80529,-147.28485 450.72125,-144.36938 C 453.63778,-141.45288 457.10974,-139.99462 461.1383,-139.99462 C 465.16683,-139.99462 468.66848,-141.46743 471.64486,-144.41363 C 474.47076,-147.14947 475.88427,-150.59069 475.88427,-154.74005 C 475.88427,-158.85809 474.44781,-162.35297 471.57659,-165.22479 C 468.70595,-168.09546 465.22671,-169.53131 461.1383,-169.53131 C 457.04993,-169.53131 453.59192,-168.08813 450.76657,-165.20239 z M 458.52106,-156.49927 C 458.07074,-157.4809 457.39673,-157.9715 456.49781,-157.9715 C 454.90867,-157.9715 454.11439,-156.90198 454.11439,-154.763 C 454.11439,-152.62341 454.90867,-151.55389 456.49781,-151.55389 C 457.54719,-151.55389 458.29676,-152.07519 458.74647,-153.11901 L 460.94923,-151.94598 C 459.8993,-150.0805 458.32417,-149.14697 456.22374,-149.14697 C 454.60384,-149.14697 453.30611,-149.64367 452.33168,-150.63653 C 451.35561,-151.62994 450.86894,-152.99926 450.86894,-154.7445 C 450.86894,-156.46008 451.37123,-157.82159 452.37642,-158.83013 C 453.38161,-159.83806 454.63347,-160.34264 456.13423,-160.34264 C 458.35435,-160.34264 459.94407,-159.46776 460.90504,-157.71978 L 458.52106,-156.49927 z M 468.8844,-156.49927 C 468.43353,-157.4809 467.77292,-157.9715 466.90201,-157.9715 C 465.28095,-157.9715 464.46988,-156.90198 464.46988,-154.763 C 464.46988,-152.62341 465.28095,-151.55389 466.90201,-151.55389 C 467.95304,-151.55389 468.68918,-152.07519 469.10925,-153.11901 L 471.36126,-151.94598 C 470.31301,-150.0805 468.74007,-149.14697 466.64358,-149.14697 C 465.02587,-149.14697 463.73095,-149.64367 462.75711,-150.63653 C 461.78494,-151.62994 461.29773,-152.99926 461.29773,-154.7445 C 461.29773,-156.46008 461.79221,-157.82159 462.78061,-158.83013 C 463.76843,-159.83806 465.02588,-160.34264 466.55408,-160.34264 C 468.77027,-160.34264 470.35776,-159.46776 471.3154,-157.71978 L 468.8844,-156.49927 z" />
|
||||
|
||||
</g>
|
||||
|
||||
</g>
|
||||
|
||||
<path
|
||||
d="M 297.29639,74.91064 L 181.06688,74.91064 C 179.8203,74.91064 178.80614,75.92529 178.80614,77.17187 L 178.80614,116.66748 C 178.80614,116.94922 179.03466,117.17822 179.31639,117.17822 L 299.04639,117.17822 C 299.32813,117.17822 299.55713,116.94922 299.55713,116.66748 L 299.55713,77.17188 C 299.55713,75.92529 298.54297,74.91064 297.29639,74.91064 z M 181.06688,75.93213 L 297.29639,75.93213 C 297.97998,75.93213 298.53565,76.48828 298.53565,77.17188 C 298.53565,77.17188 298.53565,93.09131 298.53565,104.59034 L 215.4619,104.59034 C 212.41698,110.09571 206.55077,113.83399 199.81835,113.83399 C 193.083,113.83399 187.21825,110.09913 184.1748,104.59034 L 179.82666,104.59034 C 179.82666,93.09132 179.82666,77.17188 179.82666,77.17188 C 179.82664,76.48828 180.38329,75.93213 181.06688,75.93213 z"
|
||||
id="path294" />
|
||||
|
||||
<g
|
||||
enable-background="new "
|
||||
id="g296">
|
||||
<path
|
||||
d="M 265.60986,112.8833 C 265.68994,113.03906 265.79736,113.16504 265.93115,113.26172 C 266.06494,113.35791 266.22119,113.42969 266.40088,113.47608 C 266.58154,113.52296 266.76807,113.54639 266.96045,113.54639 C 267.09033,113.54639 267.22998,113.53565 267.3794,113.51368 C 267.52784,113.4922 267.66749,113.44972 267.79835,113.3877 C 267.92823,113.32569 268.03761,113.23975 268.12355,113.13086 C 268.21144,113.02197 268.25441,112.88379 268.25441,112.71533 C 268.25441,112.53515 268.19679,112.38916 268.08156,112.27685 C 267.9673,112.16455 267.81594,112.07177 267.62941,111.99658 C 267.44386,111.92236 267.23195,111.85693 266.9966,111.80078 C 266.76027,111.74463 266.52101,111.68262 266.27883,111.61377 C 266.02981,111.55176 265.78762,111.47559 265.55129,111.38525 C 265.31594,111.29541 265.10402,111.17822 264.9175,111.03515 C 264.73098,110.89208 264.58059,110.71337 264.46535,110.49853 C 264.35109,110.28369 264.29347,110.02392 264.29347,109.71923 C 264.29347,109.37646 264.36671,109.07958 264.51222,108.82763 C 264.6587,108.57568 264.85011,108.36572 265.08644,108.19726 C 265.32179,108.02929 265.58937,107.90478 265.8882,107.82372 C 266.18605,107.74315 266.48488,107.70263 266.78273,107.70263 C 267.13136,107.70263 267.46535,107.74169 267.78566,107.81982 C 268.105,107.89746 268.39015,108.02392 268.6382,108.19824 C 268.88722,108.37256 269.08449,108.59521 269.23097,108.86621 C 269.37648,109.13721 269.44972,109.46582 269.44972,109.85156 L 268.02784,109.85156 C 268.01514,109.65234 267.97315,109.4873 267.90284,109.35693 C 267.83155,109.22607 267.73682,109.12353 267.61964,109.04834 C 267.50148,108.97412 267.36671,108.9209 267.21534,108.89014 C 267.063,108.85889 266.89796,108.84326 266.71827,108.84326 C 266.60108,108.84326 266.48292,108.85596 266.36573,108.88037 C 266.24757,108.90576 266.14112,108.94922 266.04542,109.01123 C 265.94874,109.07373 265.86964,109.15137 265.80812,109.24463 C 265.7466,109.33838 265.71535,109.45654 265.71535,109.59961 C 265.71535,109.73047 265.73976,109.83643 265.78957,109.91699 C 265.83937,109.99804 265.93801,110.07275 266.08352,110.14111 C 266.22903,110.20947 266.43118,110.27832 266.68899,110.34668 C 266.9468,110.41504 267.28372,110.50244 267.70071,110.60791 C 267.82473,110.63281 267.99661,110.67822 268.21731,110.74365 C 268.43801,110.80908 268.65676,110.91308 268.87454,111.05615 C 269.09231,111.1997 269.27981,111.39111 269.43899,111.63037 C 269.59719,111.87012 269.67629,112.17676 269.67629,112.55029 C 269.67629,112.85547 269.61672,113.13867 269.49856,113.3999 C 269.3804,113.66162 269.20461,113.8872 268.97122,114.07666 C 268.73782,114.26709 268.44876,114.41455 268.10403,114.52051 C 267.75833,114.62647 267.35794,114.6792 266.90481,114.6792 C 266.53762,114.6792 266.18118,114.63379 265.83547,114.54346 C 265.49074,114.45313 265.18508,114.31104 264.92043,114.11768 C 264.65676,113.92432 264.4468,113.67774 264.29055,113.37891 C 264.13528,113.07959 264.06106,112.7251 264.06692,112.31397 L 265.4888,112.31397 C 265.48877,112.53809 265.52881,112.72803 265.60986,112.8833 z"
|
||||
id="path298"
|
||||
style="fill:#ffffff" />
|
||||
|
||||
<path
|
||||
d="M 273.8667,107.8667 L 276.35986,114.53076 L 274.8374,114.53076 L 274.33349,113.04638 L 271.84033,113.04638 L 271.31787,114.53076 L 269.84326,114.53076 L 272.36377,107.8667 L 273.8667,107.8667 z M 273.95068,111.95264 L 273.11084,109.50928 L 273.09229,109.50928 L 272.22315,111.95264 L 273.95068,111.95264 z"
|
||||
id="path300"
|
||||
style="fill:#ffffff" />
|
||||
|
||||
</g>
|
||||
|
||||
<g
|
||||
enable-background="new "
|
||||
id="g302">
|
||||
<path
|
||||
d="M 239.17821,107.8667 C 239.49559,107.8667 239.78563,107.89502 240.04735,107.95068 C 240.30907,108.00683 240.53368,108.09863 240.72118,108.22607 C 240.9077,108.35351 241.05321,108.52295 241.15575,108.73437 C 241.25829,108.94579 241.31005,109.20703 241.31005,109.51806 C 241.31005,109.854 241.23388,110.13329 241.08056,110.35742 C 240.92822,110.58154 240.70165,110.76465 240.40283,110.90771 C 240.81494,111.02587 241.12256,111.23291 241.32568,111.5288 C 241.5288,111.82469 241.63037,112.18114 241.63037,112.59814 C 241.63037,112.93408 241.56494,113.22509 241.43408,113.47119 C 241.30322,113.7168 241.12646,113.91748 240.90576,114.07324 C 240.68408,114.229 240.43115,114.34424 240.14795,114.41845 C 239.86377,114.49365 239.57275,114.53075 239.27295,114.53075 L 236.03662,114.53075 L 236.03662,107.86669 L 239.17821,107.86669 L 239.17821,107.8667 z M 238.99071,110.56201 C 239.25243,110.56201 239.46727,110.5 239.63622,110.37597 C 239.80419,110.25146 239.88817,110.05029 239.88817,109.77099 C 239.88817,109.61572 239.85985,109.48828 239.80419,109.38915 C 239.74755,109.28954 239.67333,109.21239 239.57958,109.15624 C 239.48583,109.10058 239.37841,109.06151 239.25731,109.04003 C 239.13524,109.01806 239.00926,109.00732 238.8784,109.00732 L 237.50535,109.00732 L 237.50535,110.56201 L 238.99071,110.56201 z M 239.07664,113.39014 C 239.22019,113.39014 239.35691,113.37647 239.48777,113.34815 C 239.61863,113.32032 239.73484,113.27344 239.83445,113.2085 C 239.93406,113.14307 240.01316,113.0542 240.07273,112.94239 C 240.1323,112.83058 240.1616,112.68751 240.1616,112.51319 C 240.1616,112.17139 240.06492,111.92725 239.87156,111.78126 C 239.6782,111.63527 239.42234,111.56202 239.10496,111.56202 L 237.50535,111.56202 L 237.50535,113.39014 L 239.07664,113.39014 z"
|
||||
id="path304"
|
||||
style="fill:#ffffff" />
|
||||
|
||||
<path
|
||||
d="M 241.88914,107.8667 L 243.53269,107.8667 L 245.09324,110.49854 L 246.64402,107.8667 L 248.27781,107.8667 L 245.80418,111.97315 L 245.80418,114.53077 L 244.33543,114.53077 L 244.33543,111.93604 L 241.88914,107.8667 z"
|
||||
id="path306"
|
||||
style="fill:#ffffff" />
|
||||
|
||||
</g>
|
||||
|
||||
<g
|
||||
id="g6316_1_"
|
||||
transform="matrix(0.624995,0,0,0.624995,391.2294,176.9332)">
|
||||
|
||||
<path
|
||||
id="path6318_1_"
|
||||
cx="475.97119"
|
||||
ry="29.209877"
|
||||
cy="252.08646"
|
||||
type="arc"
|
||||
rx="29.209877"
|
||||
d="M -175.0083,-139.1153 C -175.00204,-129.7035 -182.62555,-122.06751 -192.03812,-122.06049 C -201.44913,-122.05341 -209.08512,-129.67774 -209.09293,-139.09028 C -209.09293,-139.09809 -209.09293,-139.10749 -209.09293,-139.1153 C -209.09919,-148.52784 -201.47413,-156.1623 -192.06311,-156.17011 C -182.65054,-156.17713 -175.01456,-148.55207 -175.0083,-139.14026 C -175.0083,-139.13092 -175.0083,-139.1239 -175.0083,-139.1153 z"
|
||||
style="fill:#ffffff" />
|
||||
|
||||
<g
|
||||
id="g6320_1_"
|
||||
transform="translate(-23.9521,-89.72962)">
|
||||
<path
|
||||
id="path6322_1_"
|
||||
d="M -168.2204,-68.05536 C -173.39234,-68.05536 -177.76892,-66.25067 -181.35175,-62.64203 C -185.02836,-58.90759 -186.86588,-54.48883 -186.86588,-49.38568 C -186.86588,-44.28253 -185.02836,-39.89416 -181.35175,-36.22308 C -177.67673,-32.55114 -173.29859,-30.71521 -168.2204,-30.71521 C -163.07974,-30.71521 -158.62503,-32.56677 -154.85312,-36.26996 C -151.30307,-39.78558 -149.52652,-44.15827 -149.52652,-49.38568 C -149.52652,-54.6123 -151.33432,-59.03265 -154.94843,-62.64203 C -158.5625,-66.25067 -162.98599,-68.05536 -168.2204,-68.05536 z M -168.17352,-64.69519 C -163.936,-64.69519 -160.33752,-63.20221 -157.37655,-60.21466 C -154.38748,-57.25836 -152.89214,-53.64899 -152.89214,-49.38568 C -152.89214,-45.09186 -154.35466,-41.52856 -157.28438,-38.69653 C -160.36876,-35.64727 -163.99849,-34.12304 -168.17351,-34.12304 C -172.34856,-34.12304 -175.94701,-35.63244 -178.96892,-38.64965 C -181.9908,-41.66918 -183.50176,-45.24657 -183.50176,-49.38567 C -183.50176,-53.52398 -181.97518,-57.13414 -178.92205,-60.21465 C -175.9939,-63.20221 -172.41107,-64.69519 -168.17352,-64.69519 z" />
|
||||
|
||||
<path
|
||||
id="path6324_1_"
|
||||
d="M -176.49548,-52.02087 C -175.75171,-56.71856 -172.44387,-59.22949 -168.30008,-59.22949 C -162.33911,-59.22949 -158.70783,-54.90448 -158.70783,-49.1372 C -158.70783,-43.50982 -162.57194,-39.13793 -168.39383,-39.13793 C -172.39856,-39.13793 -175.98297,-41.60277 -176.63611,-46.43877 L -171.93292,-46.43877 C -171.7923,-43.92778 -170.1626,-43.04418 -167.83447,-43.04418 C -165.1813,-43.04418 -163.4563,-45.50908 -163.4563,-49.27709 C -163.4563,-53.22942 -164.94693,-55.32244 -167.74228,-55.32244 C -169.79074,-55.32244 -171.55948,-54.57787 -171.93292,-52.02087 L -170.56418,-52.02789 L -174.26734,-48.32629 L -177.96894,-52.02789 L -176.49548,-52.02087 z" />
|
||||
|
||||
</g>
|
||||
|
||||
</g>
|
||||
|
||||
<g
|
||||
id="g313">
|
||||
<circle
|
||||
cx="242.56226"
|
||||
cy="90.224609"
|
||||
r="10.8064"
|
||||
id="circle315"
|
||||
sodipodi:cx="242.56226"
|
||||
sodipodi:cy="90.224609"
|
||||
sodipodi:rx="10.8064"
|
||||
sodipodi:ry="10.8064"
|
||||
style="fill:#ffffff" />
|
||||
|
||||
<g
|
||||
id="g317">
|
||||
<path
|
||||
d="M 245.68994,87.09766 C 245.68994,86.68116 245.35205,86.34424 244.93603,86.34424 L 240.16357,86.34424 C 239.74755,86.34424 239.40966,86.68115 239.40966,87.09766 L 239.40966,91.87061 L 240.74071,91.87061 L 240.74071,97.52295 L 244.3579,97.52295 L 244.3579,91.87061 L 245.68993,91.87061 L 245.68993,87.09766 L 245.68994,87.09766 z"
|
||||
id="path319" />
|
||||
|
||||
<circle
|
||||
cx="242.5498"
|
||||
cy="84.083008"
|
||||
r="1.63232"
|
||||
id="circle321"
|
||||
sodipodi:cx="242.5498"
|
||||
sodipodi:cy="84.083008"
|
||||
sodipodi:rx="1.63232"
|
||||
sodipodi:ry="1.63232" />
|
||||
|
||||
</g>
|
||||
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M 242.53467,78.31836 C 239.30322,78.31836 236.56641,79.4458 234.32715,81.70215 C 232.0293,84.03516 230.88086,86.79736 230.88086,89.98633 C 230.88086,93.1753 232.0293,95.91846 234.32715,98.21338 C 236.625,100.50781 239.36133,101.65527 242.53467,101.65527 C 245.74756,101.65527 248.53272,100.49853 250.88819,98.18359 C 253.10889,95.98681 254.21827,93.2539 254.21827,89.98632 C 254.21827,86.71874 253.08936,83.95751 250.83057,81.70214 C 248.57178,79.4458 245.80615,78.31836 242.53467,78.31836 z M 242.56396,80.41797 C 245.2124,80.41797 247.46142,81.35156 249.31103,83.21875 C 251.18115,85.06592 252.11572,87.32227 252.11572,89.98633 C 252.11572,92.66992 251.20068,94.89746 249.36963,96.66699 C 247.4419,98.57275 245.17334,99.52539 242.56397,99.52539 C 239.9546,99.52539 237.70557,98.58252 235.81739,96.6958 C 233.92774,94.80957 232.98389,92.57324 232.98389,89.98633 C 232.98389,87.3999 233.93799,85.14404 235.84619,83.21875 C 237.67676,81.35156 239.9165,80.41797 242.56396,80.41797 z"
|
||||
id="path323"
|
||||
style="fill-rule:evenodd" />
|
||||
|
||||
</g>
|
||||
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300" width="300" height="300">
|
||||
<rect width="300" height="300" fill="#afd074"/>
|
||||
<g fill="#000">
|
||||
<rect x="135" y="65" width="30" height="30"/><rect x="170" y="65" width="30" height="30"/><rect x="205" y="65" width="30" height="30"/>
|
||||
<rect x="135" y="100" width="30" height="30"/><rect x="205" y="100" width="30" height="30"/>
|
||||
<rect x="65" y="135" width="30" height="30"/><rect x="100" y="135" width="30" height="30"/><rect x="135" y="135" width="30" height="30"/><rect x="170" y="135" width="30" height="30"/><rect x="205" y="135" width="30" height="30"/>
|
||||
<rect x="65" y="170" width="30" height="30"/><rect x="135" y="170" width="30" height="30"/>
|
||||
<rect x="65" y="205" width="30" height="30"/><rect x="100" y="205" width="30" height="30"/><rect x="135" y="205" width="30" height="30"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 879 B |
+144
@@ -0,0 +1,144 @@
|
||||
// Links the topics drawn on the map (map.json) to README topics, so every topic knows
|
||||
// which areas (Software, Hardware, Soft skills) it sits in and how important the map marks it.
|
||||
|
||||
// Map labels whose README heading is worded differently.
|
||||
const ALIASES = {
|
||||
'ADC / DAC': ['ADC', 'DAC'],
|
||||
'Buildroot / Yocto': ['Buildroot', 'Yocto'],
|
||||
'TDD & Unit Testing': ['Test Driven Development (TDD)', 'Unit Testing'],
|
||||
'Threading / Parallelism': ['Multithreading & Parallel Processing'],
|
||||
'Device Drivers': ['Linux Device Drivers'],
|
||||
'Real-Time OS': ['Real-Time Operating Systems'],
|
||||
'Interfaces & Protocols': ['Interfaces, Protocols & Communication Technologies'],
|
||||
Basic: ['Basic Protocols'],
|
||||
'High-Speed': ['High-Speed Protocols'],
|
||||
Wireless: ['Wireless Protocols'],
|
||||
Industrial: ['Industrial Protocols'],
|
||||
Automotive: ['Automotive Protocols'],
|
||||
Network: ['Network Protocols / Socket Programming'],
|
||||
'TCP/IP': ['Network Protocols / Socket Programming'],
|
||||
UDP: ['Network Protocols / Socket Programming'],
|
||||
Cellular: ['Cellular Communication'],
|
||||
MQTT: ['CoAP & MQTT'],
|
||||
CoAP: ['CoAP & MQTT'],
|
||||
'LTE-M / 5G': ['LTE-M & NB-IoT'],
|
||||
'NB-IoT': ['LTE-M & NB-IoT'],
|
||||
'Basic Math & Calculus': ['Basic Calculus'],
|
||||
'SDLC Models': ['Software Development Life Cycle (SDLC) Models'],
|
||||
'Version Control': ['Version Control Systems'],
|
||||
AUTOSAR: ['AUTOSAR Architecture'],
|
||||
// Drawn on the map but without their own README section: open the protocol family instead.
|
||||
Profinet: ['Industrial Protocols'],
|
||||
LIN: ['Automotive Protocols'],
|
||||
MOST: ['Automotive Protocols'],
|
||||
FlexRay: ['Automotive Protocols'],
|
||||
UWB: ['Wireless Protocols'],
|
||||
};
|
||||
|
||||
export const AREA_ORDER = ['SOFTWARE', 'HARDWARE', 'SOFT SKILLS'];
|
||||
export const IMPORTANCE_LEVELS = ['required', 'recommended', 'possible'];
|
||||
const RANK = { required: 3, recommended: 2, possible: 1 };
|
||||
|
||||
const key = (text) => text.normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim();
|
||||
|
||||
function topicIndex(topics) {
|
||||
const index = new Map();
|
||||
const add = (k, topic) => {
|
||||
if (k && !index.has(k)) index.set(k, topic);
|
||||
};
|
||||
const byDepth = [...topics].sort((a, b) => a.depth - b.depth);
|
||||
for (const t of byDepth) add(key(t.title), t);
|
||||
for (const t of byDepth) {
|
||||
const base = t.title.replace(/\s*\([^)]*\)\s*/g, ' ').trim();
|
||||
add(key(base), t);
|
||||
const inner = t.title.match(/\(([^)]+)\)/);
|
||||
if (inner && /^[A-Z0-9-]{2,6}$/.test(inner[1])) add(key(inner[1]), t);
|
||||
add(key(base.split(' / ')[0]), t);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
export function linkDiagram(map, data) {
|
||||
const index = topicIndex(data.topics);
|
||||
const softSkills = data.topics.find((t) => t.depth === 1 && key(t.title) === 'soft skills');
|
||||
const resolve = (text, areas = []) => {
|
||||
if (ALIASES[text]) return ALIASES[text].map((title) => index.get(key(title))).filter(Boolean);
|
||||
const topic = index.get(key(text));
|
||||
if (topic) return [topic];
|
||||
if (softSkills && areas.includes('SOFT SKILLS')) return [softSkills];
|
||||
return [];
|
||||
};
|
||||
|
||||
// A cluster's areas are those of the band it sits in; a group's are the areas all its clusters share.
|
||||
const clusterAreas = new Map();
|
||||
const clusters = {};
|
||||
const boxes = [];
|
||||
for (const band of map.bands) {
|
||||
for (const [cluster, spec] of Object.entries(band.clusters)) {
|
||||
clusterAreas.set(cluster, band.areas);
|
||||
clusters[cluster] = { header: spec.header, caption: spec.caption, per: spec.per };
|
||||
if (spec.header) boxes.push({ text: spec.header, importance: null, areas: band.areas, cluster, header: true });
|
||||
for (const [text, importance] of spec.topics) boxes.push({ text, importance, areas: band.areas, cluster, header: false });
|
||||
}
|
||||
}
|
||||
boxes.forEach((b, i) => Object.assign(b, { id: `box-${i}`, topics: resolve(b.text, b.areas) }));
|
||||
const regions = Object.entries(map.groups).map(([name, clusters]) => {
|
||||
const areas = AREA_ORDER.filter((a) => clusters.every((c) => (clusterAreas.get(c) || []).includes(a)));
|
||||
return { name, clusters, areas, topics: resolve(name) };
|
||||
});
|
||||
|
||||
const boxOf = new Map();
|
||||
for (const t of data.topics) {
|
||||
t.areas = [];
|
||||
t.importance = null;
|
||||
t.boxes = [];
|
||||
}
|
||||
for (const box of boxes) {
|
||||
for (const t of box.topics) {
|
||||
t.boxes.push(box);
|
||||
if (!boxOf.has(t)) boxOf.set(t, box);
|
||||
for (const area of box.areas) if (!t.areas.includes(area)) t.areas.push(area);
|
||||
if (box.importance && (!t.importance || RANK[box.importance] > RANK[t.importance])) t.importance = box.importance;
|
||||
}
|
||||
}
|
||||
for (const region of regions) {
|
||||
for (const t of region.topics) {
|
||||
for (const area of region.areas) if (!t.areas.includes(area)) t.areas.push(area);
|
||||
}
|
||||
}
|
||||
// Subtopics that are not drawn in the diagram inherit the areas of their closest drawn ancestor.
|
||||
for (const t of data.topics) {
|
||||
if (!t.areas.length) for (let p = t.parent; p && !t.areas.length; p = p.parent) t.areas = [...(p.areas || [])];
|
||||
t.areas.sort((a, b) => AREA_ORDER.indexOf(a) - AREA_ORDER.indexOf(b));
|
||||
}
|
||||
|
||||
// The box that stands for a topic on the map: its own, or the closest ancestor's.
|
||||
const boxFor = (topic) => {
|
||||
for (let t = topic; t; t = t.parent) if (boxOf.has(t)) return boxOf.get(t);
|
||||
return null;
|
||||
};
|
||||
|
||||
const pairs = new Map();
|
||||
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);
|
||||
pair.weight += link.weight;
|
||||
pair.shared += link.shared.length;
|
||||
pair.links.push(link);
|
||||
}
|
||||
|
||||
return {
|
||||
regions,
|
||||
clusters,
|
||||
boxes,
|
||||
boxFor,
|
||||
connections: [...pairs.values()],
|
||||
unmatched: boxes.filter((b) => !b.header && !b.topics.length).map((b) => b.text),
|
||||
invalid: boxes.filter((b) => !b.header && !IMPORTANCE_LEVELS.includes(b.importance)).map((b) => `${b.text}: ${b.importance}`),
|
||||
};
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
// DOM helpers shared by the views.
|
||||
|
||||
export const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
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/',
|
||||
};
|
||||
|
||||
export const GITHUB_MARK = 'M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z';
|
||||
|
||||
export function h(tag, props = {}, ...children) {
|
||||
const el = document.createElement(tag);
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if (value == null || value === false) continue;
|
||||
if (key === 'class') el.className = value;
|
||||
else if (key.startsWith('on')) el.addEventListener(key.slice(2), value);
|
||||
else el.setAttribute(key, value === true ? '' : value);
|
||||
}
|
||||
for (const child of children.flat(Infinity)) {
|
||||
if (child == null || child === false || child === '') continue;
|
||||
el.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
export function svg(tag, attrs = {}, parent = null) {
|
||||
const el = document.createElementNS(SVG_NS, tag);
|
||||
for (const [key, value] of Object.entries(attrs)) if (value != null) el.setAttribute(key, value);
|
||||
if (parent) parent.append(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
export const AREAS = {
|
||||
SOFTWARE: { label: 'Software', cls: 'sw' },
|
||||
HARDWARE: { label: 'Hardware', cls: 'hw' },
|
||||
'SOFT SKILLS': { label: 'Soft skills', cls: 'ss' },
|
||||
};
|
||||
|
||||
export const IMPORTANCE = {
|
||||
required: 'Required',
|
||||
recommended: 'Recommended',
|
||||
possible: 'Possibility',
|
||||
};
|
||||
|
||||
export const areaClass = (area) => (AREAS[area] ? AREAS[area].cls : 'xx');
|
||||
export const areaLabel = (areas) => areas.map((a) => (AREAS[a] ? AREAS[a].label : a)).join(' ∩ ');
|
||||
|
||||
export function areaDots(areas) {
|
||||
return h('span', { class: 'area-dots', 'aria-hidden': 'true' }, areas.map((a) => h('i', { class: `dot ${areaClass(a)}` })));
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2019 The Sofia Sans Project Authors (https://github.com/lettersoup/Sofia-Sans)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
@@ -0,0 +1,70 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<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>
|
||||
<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">
|
||||
<link rel="stylesheet" href="map.css">
|
||||
<script type="module" src="app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<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>
|
||||
<span>Interactive explorer</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<div class="search" role="search">
|
||||
<svg class="search-icon" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2"/><path d="m20 20-4.2-4.2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
<input id="search" type="search" placeholder="Search topics and resources" autocomplete="off" spellcheck="false"
|
||||
role="combobox" aria-label="Search topics and resources" aria-expanded="false" aria-controls="search-results" aria-autocomplete="list">
|
||||
<kbd aria-hidden="true">/</kbd>
|
||||
<div id="search-results" class="search-results" role="listbox" aria-label="Search results" hidden></div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<div class="segmented" role="tablist" aria-label="View">
|
||||
<button type="button" role="tab" data-view="map" aria-selected="true" aria-controls="diagram">Map</button>
|
||||
<button type="button" role="tab" data-view="list" aria-selected="false" aria-controls="outline">Outline</button>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
<a class="icon-btn" href="https://github.com/m3y54m/Embedded-Engineering-Roadmap" target="_blank" rel="noopener noreferrer" aria-label="Repository on GitHub" title="Repository on GitHub">
|
||||
<svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<section class="stage" aria-label="Roadmap">
|
||||
<div id="diagram" class="map-view" role="tabpanel" hidden></div>
|
||||
<div id="outline" class="outline" role="tabpanel" hidden></div>
|
||||
<div id="status" class="status" role="status">
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
<span>Loading roadmap…</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside id="panel" class="panel" aria-label="Topic details" tabindex="-1"></aside>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
+516
@@ -0,0 +1,516 @@
|
||||
/* Map view: the roadmap diagram. */
|
||||
|
||||
.map-view {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.map-top {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px 16px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.start-strip,
|
||||
.map-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.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;
|
||||
gap: 6px;
|
||||
min-height: 30px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.start-chip:hover,
|
||||
.map-chip:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--chip-hover);
|
||||
}
|
||||
|
||||
.map-chip > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.map-chip[aria-pressed="true"] {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.map-chip.clear {
|
||||
border-style: dashed;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.map-toolbar-sep {
|
||||
width: 1px;
|
||||
height: 22px;
|
||||
margin: 0 4px;
|
||||
background: var(--border-strong);
|
||||
}
|
||||
|
||||
.map-summary {
|
||||
margin-left: 4px;
|
||||
color: var(--muted);
|
||||
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;
|
||||
}
|
||||
|
||||
.diagram {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: var(--font-display);
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.diagram.panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.page-bg {
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
fill: var(--text);
|
||||
font-size: 34px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.legend-text {
|
||||
fill: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.legend-note {
|
||||
fill: var(--muted);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.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); }
|
||||
|
||||
.area-fill {
|
||||
stroke: none;
|
||||
fill-opacity: var(--area-tint);
|
||||
}
|
||||
|
||||
.area-fill.sw { fill: var(--sw); }
|
||||
.area-fill.hw { fill: var(--hw); }
|
||||
.area-fill.ss { fill: var(--ss); fill-opacity: calc(var(--area-tint) * 1.6); }
|
||||
|
||||
.area-line {
|
||||
fill: none;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.area-line.sw { stroke: var(--sw); }
|
||||
.area-line.hw { stroke: var(--hw); }
|
||||
.area-line.ss { stroke: var(--ss); stroke-width: 2.5; }
|
||||
|
||||
.overlap-fill {
|
||||
fill: url(#cross-hatch);
|
||||
}
|
||||
|
||||
.hatch-a,
|
||||
.hatch-b {
|
||||
stroke-width: 2.4;
|
||||
stroke-opacity: 0.2;
|
||||
}
|
||||
|
||||
.hatch-a { stroke: var(--sw); }
|
||||
.hatch-b { stroke: var(--hw); }
|
||||
|
||||
.band-label {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.22em;
|
||||
}
|
||||
|
||||
.band-label.sw { fill: var(--sw-text); }
|
||||
.band-label.hw { fill: var(--hw-text); }
|
||||
.band-label.both { fill: var(--text); }
|
||||
.band-label.ss { fill: var(--ss-text); }
|
||||
|
||||
.group-line {
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.group-line.g-os { stroke: var(--g-os); }
|
||||
.group-line.g-mcu { stroke: var(--g-mcu); }
|
||||
.group-line.g-ifc { stroke: var(--g-ifc); }
|
||||
|
||||
.tag-bg {
|
||||
fill: var(--surface-solid);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.tag-text {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.group-tag.g-os .tag-bg { stroke: var(--g-os); }
|
||||
.group-tag.g-mcu .tag-bg { stroke: var(--g-mcu); }
|
||||
.group-tag.g-ifc .tag-bg { stroke: var(--g-ifc); }
|
||||
.group-tag.g-os .tag-text { fill: var(--g-os); }
|
||||
.group-tag.g-mcu .tag-text { fill: var(--g-mcu); }
|
||||
.group-tag.g-ifc .tag-text { fill: var(--g-ifc); }
|
||||
|
||||
.cluster-caption {
|
||||
fill: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.box .box-bg {
|
||||
stroke: var(--box-edge);
|
||||
stroke-width: 1;
|
||||
transition: filter 0.15s, stroke 0.15s;
|
||||
}
|
||||
|
||||
.box.required .box-bg { fill: var(--imp-required); }
|
||||
.box.recommended .box-bg { fill: var(--imp-recommended); }
|
||||
.box.possible .box-bg { fill: var(--imp-possible); stroke: var(--border-strong); }
|
||||
|
||||
.box-text {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.box.required .box-text { fill: var(--imp-required-text); }
|
||||
.box.recommended .box-text { fill: var(--imp-recommended-text); }
|
||||
.box.possible .box-text { fill: var(--imp-possible-text); }
|
||||
|
||||
.box .header-text {
|
||||
fill: var(--text);
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.target {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.box.target:hover .box-bg,
|
||||
.box.lit .box-bg {
|
||||
filter: brightness(1.06);
|
||||
stroke: var(--accent);
|
||||
stroke-width: 2.2;
|
||||
}
|
||||
|
||||
.box.header.target:hover .header-text,
|
||||
.box.header.lit .header-text,
|
||||
.group-tag.target:hover .tag-text {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.group-tag.target:hover .tag-bg {
|
||||
fill: var(--chip-hover);
|
||||
}
|
||||
|
||||
.box.selected .box-bg {
|
||||
stroke: var(--text);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.box.header.selected .header-text {
|
||||
fill: var(--accent);
|
||||
}
|
||||
|
||||
.box.target:focus-visible .box-bg,
|
||||
.group-tag.target:focus-visible .tag-bg {
|
||||
stroke: var(--accent);
|
||||
stroke-width: 3.5;
|
||||
}
|
||||
|
||||
.box.header.target:focus-visible .header-text {
|
||||
fill: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.layer-boxes .box {
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.diagram.filtering .box:not(.match, .header) {
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
.diagram.has-focus:not(.filtering) .box:not(.lit, .header) {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.link,
|
||||
.link-all {
|
||||
fill: none;
|
||||
stroke: var(--accent);
|
||||
stroke-linecap: round;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.link.mention,
|
||||
.link-all.mention {
|
||||
stroke-dasharray: 5 5;
|
||||
}
|
||||
|
||||
.link-all {
|
||||
stroke-opacity: 0.4;
|
||||
}
|
||||
|
||||
.layer-linksAll {
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
|
||||
.diagram.show-all .layer-linksAll {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.diagram.show-all.has-focus .layer-linksAll {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.link-end {
|
||||
fill: var(--accent);
|
||||
}
|
||||
|
||||
.credit-main {
|
||||
fill: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.credit-text {
|
||||
fill: var(--muted);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.credit-strong {
|
||||
fill: var(--text);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.credit-icon {
|
||||
fill: var(--text);
|
||||
}
|
||||
|
||||
.page-credits a:hover text {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.zoom-controls .icon-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.zoom-level {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.map-top {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.start-strip,
|
||||
.map-toolbar {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.start-chip,
|
||||
.map-chip,
|
||||
.map-toolbar-label,
|
||||
.map-summary {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Printing: the diagram alone on an A4 page, in print colors. */
|
||||
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 8mm;
|
||||
}
|
||||
|
||||
@media print {
|
||||
html[data-theme] {
|
||||
color-scheme: light;
|
||||
--bg: #ffffff;
|
||||
--text: #0f172a;
|
||||
--muted: #475569;
|
||||
--surface-solid: #ffffff;
|
||||
--border-strong: rgba(15, 23, 42, 0.35);
|
||||
--chip-hover: #ffffff;
|
||||
--accent: #3b4fd8;
|
||||
--sw: #2b9fbf;
|
||||
--hw: #e8a400;
|
||||
--ss: #12b85a;
|
||||
--sw-text: #0b6a85;
|
||||
--hw-text: #8a5b00;
|
||||
--ss-text: #0b7a3b;
|
||||
--g-os: #6d4fb0;
|
||||
--g-mcu: #b2453c;
|
||||
--g-ifc: #4b5567;
|
||||
--area-tint: 0.06;
|
||||
--imp-required: #fff04a;
|
||||
--imp-required-text: #1d1a00;
|
||||
--imp-recommended: #fbd7bb;
|
||||
--imp-recommended-text: #2b1407;
|
||||
--imp-possible: #f1f3f6;
|
||||
--imp-possible-text: #1f2937;
|
||||
--box-edge: rgba(15, 23, 42, 0.75);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
background: #ffffff;
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.panel,
|
||||
.map-top,
|
||||
.zoom-controls,
|
||||
.tooltip,
|
||||
#outline,
|
||||
.status {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.app,
|
||||
.layout,
|
||||
.stage,
|
||||
.map-view,
|
||||
.map-canvas {
|
||||
display: block !important;
|
||||
position: static !important;
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.diagram {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 280mm;
|
||||
}
|
||||
|
||||
.diagram .box,
|
||||
.diagram .box-bg {
|
||||
opacity: 1 !important;
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
.layer-links,
|
||||
.layer-linksAll {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
{
|
||||
"bands": [
|
||||
{
|
||||
"label": "SOFTWARE",
|
||||
"areas": ["SOFTWARE"],
|
||||
"clusters": {
|
||||
"languages": {
|
||||
"header": "Programming Languages",
|
||||
"per": 2,
|
||||
"topics": [
|
||||
["C", "required"],
|
||||
["C++", "recommended"],
|
||||
["Python", "recommended"],
|
||||
["Assembly", "possible"],
|
||||
["Rust", "possible"]
|
||||
]
|
||||
},
|
||||
"fundamentals": {
|
||||
"header": "Programming Fundamentals",
|
||||
"topics": [
|
||||
["Algorithms & Data Structures", "required"],
|
||||
["Design Patterns", "required"],
|
||||
["State Machines", "required"],
|
||||
["Memory Management", "required"]
|
||||
]
|
||||
},
|
||||
"debugging": {
|
||||
"header": "Debugging",
|
||||
"topics": [
|
||||
["JTAG / SWD", "required"],
|
||||
["GDB", "required"],
|
||||
["OpenOCD", "possible"]
|
||||
]
|
||||
},
|
||||
"sdlc": {
|
||||
"header": "SDLC Models",
|
||||
"per": 2,
|
||||
"topics": [
|
||||
["Agile / SCRUM", "recommended"],
|
||||
["V-Model", "recommended"]
|
||||
]
|
||||
},
|
||||
"vcs": {
|
||||
"header": "Version Control",
|
||||
"per": 2,
|
||||
"topics": [
|
||||
["Git", "required"],
|
||||
["SVN", "possible"]
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"header": "Build System",
|
||||
"topics": [
|
||||
["Compilers / GCC", "required"],
|
||||
["Make / CMake", "required"],
|
||||
["Bash Scripting", "recommended"],
|
||||
["Docker", "possible"]
|
||||
]
|
||||
},
|
||||
"testing": {
|
||||
"header": "Testing",
|
||||
"topics": [
|
||||
["TDD & Unit Testing", "required"],
|
||||
["CI/CD Pipelines", "recommended"],
|
||||
["SIL / HIL Testing", "possible"],
|
||||
["Standards & Certifications", "possible"]
|
||||
]
|
||||
},
|
||||
"specialized": {
|
||||
"caption": "Specializations",
|
||||
"topics": [
|
||||
["Embedded Security", "possible"],
|
||||
["Embedded GUI", "possible"],
|
||||
["IoT", "possible"],
|
||||
["Edge AI", "possible"],
|
||||
["AUTOSAR", "possible"]
|
||||
]
|
||||
},
|
||||
"osBase": {
|
||||
"topics": [
|
||||
["Operating System Fundamentals", "recommended"]
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"header": "Embedded Linux",
|
||||
"per": 2,
|
||||
"topics": [
|
||||
["Linux Kernel", "recommended"],
|
||||
["Device Drivers", "recommended"],
|
||||
["U-Boot", "possible"],
|
||||
["Buildroot / Yocto", "possible"],
|
||||
["Threading / Parallelism", "possible"],
|
||||
["IPC", "possible"],
|
||||
["Qt Framework", "possible"]
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "SOFTWARE ∩ HARDWARE",
|
||||
"areas": ["SOFTWARE", "HARDWARE"],
|
||||
"clusters": {
|
||||
"rtos": {
|
||||
"header": "Real-Time OS",
|
||||
"per": 2,
|
||||
"topics": [
|
||||
["RTOS Basics", "required"],
|
||||
["FreeRTOS", "recommended"],
|
||||
["Zephyr", "recommended"],
|
||||
["QNX", "possible"],
|
||||
["µC/OS", "possible"],
|
||||
["RT-Thread", "possible"]
|
||||
]
|
||||
},
|
||||
"mcu": {
|
||||
"per": 2,
|
||||
"topics": [
|
||||
["GPIO", "required"],
|
||||
["ADC / DAC", "required"],
|
||||
["Timers / Counters", "required"],
|
||||
["PWM", "required"],
|
||||
["Watchdog", "required"],
|
||||
["Interrupts", "required"],
|
||||
["DMA", "required"],
|
||||
["Clock Management", "required"],
|
||||
["Power Management", "recommended"],
|
||||
["Bootloader / DFU", "recommended"]
|
||||
]
|
||||
},
|
||||
"bridge": {
|
||||
"caption": "Hardware-facing software",
|
||||
"topics": [
|
||||
["Memory Technologies & File Systems", "recommended"],
|
||||
["Hardware Simulation / Emulation", "recommended"],
|
||||
["Sensors & Actuators", "possible"],
|
||||
["Digital Signal Processing", "possible"],
|
||||
["Control Theory", "possible"]
|
||||
]
|
||||
},
|
||||
"basic": {
|
||||
"header": "Basic",
|
||||
"topics": [
|
||||
["UART", "required"],
|
||||
["I2C", "required"],
|
||||
["SPI", "required"]
|
||||
]
|
||||
},
|
||||
"wireless": {
|
||||
"header": "Wireless",
|
||||
"topics": [
|
||||
["Bluetooth", "recommended"],
|
||||
["Wi-Fi", "recommended"],
|
||||
["LoRa", "possible"],
|
||||
["Zigbee", "possible"],
|
||||
["Thread", "possible"],
|
||||
["Matter", "possible"],
|
||||
["UWB", "possible"]
|
||||
]
|
||||
},
|
||||
"highSpeed": {
|
||||
"header": "High-Speed",
|
||||
"topics": [
|
||||
["Ethernet", "recommended"],
|
||||
["USB", "recommended"],
|
||||
["PCIe", "possible"]
|
||||
]
|
||||
},
|
||||
"industrial": {
|
||||
"header": "Industrial",
|
||||
"topics": [
|
||||
["Modbus", "recommended"],
|
||||
["Profinet", "possible"],
|
||||
["EtherCAT", "possible"]
|
||||
]
|
||||
},
|
||||
"cellular": {
|
||||
"header": "Cellular",
|
||||
"topics": [
|
||||
["GSM / LTE", "possible"],
|
||||
["LTE-M / 5G", "possible"],
|
||||
["NB-IoT", "possible"]
|
||||
]
|
||||
},
|
||||
"network": {
|
||||
"header": "Network",
|
||||
"topics": [
|
||||
["MQTT", "recommended"],
|
||||
["CoAP", "possible"],
|
||||
["TCP/IP", "possible"],
|
||||
["UDP", "possible"]
|
||||
]
|
||||
},
|
||||
"automotive": {
|
||||
"header": "Automotive",
|
||||
"topics": [
|
||||
["CAN", "recommended"],
|
||||
["LIN", "possible"],
|
||||
["MOST", "possible"],
|
||||
["FlexRay", "possible"]
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "HARDWARE",
|
||||
"areas": ["HARDWARE"],
|
||||
"clusters": {
|
||||
"electronics": {
|
||||
"header": "Electronics",
|
||||
"per": 2,
|
||||
"topics": [
|
||||
["Basic Math & Calculus", "required"],
|
||||
["Principles of Electric Circuits", "required"],
|
||||
["Electronics Fundamentals", "required"],
|
||||
["Digital Design", "required"],
|
||||
["Computer Architecture", "required"]
|
||||
]
|
||||
},
|
||||
"equipment": {
|
||||
"header": "Using Test Equipment",
|
||||
"topics": [
|
||||
["Multimeter", "required"],
|
||||
["Logic / Protocol Analyzer", "required"],
|
||||
["Oscilloscope", "recommended"]
|
||||
]
|
||||
},
|
||||
"prototyping": {
|
||||
"header": "Prototyping Skills",
|
||||
"topics": [
|
||||
["Breadboarding", "recommended"],
|
||||
["Hardware Design Basics", "recommended"],
|
||||
["PCB Design / EMC", "recommended"],
|
||||
["Soldering / Rework", "recommended"]
|
||||
]
|
||||
},
|
||||
"fpga": {
|
||||
"topics": [
|
||||
["FPGA Development", "possible"]
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "SOFT SKILLS",
|
||||
"areas": ["SOFT SKILLS"],
|
||||
"clusters": {
|
||||
"soft": {
|
||||
"per": 3,
|
||||
"topics": [
|
||||
["Communication Skills", "required"],
|
||||
["Problem-Solving Skills & Critical Thinking", "required"],
|
||||
["Teamwork & Collaborative Abilities", "required"],
|
||||
["Organizational & Time Management Skills", "required"],
|
||||
["Being Self-Driven and Independent", "required"],
|
||||
["Adaptability & Patience", "required"]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"groups": {
|
||||
"Operating Systems": ["osBase", "linux", "rtos"],
|
||||
"Microcontrollers": ["rtos", "mcu"],
|
||||
"Interfaces & Protocols": ["basic", "wireless", "highSpeed", "industrial", "cellular", "network", "automotive"]
|
||||
}
|
||||
}
|
||||
+572
@@ -0,0 +1,572 @@
|
||||
// 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';
|
||||
import { SPACE, buildLayout } from './layout.js';
|
||||
|
||||
const MIN_ZOOM = 0.6;
|
||||
const MAX_ZOOM = 6;
|
||||
|
||||
function intersect(a, b) {
|
||||
const x = Math.max(a.x, b.x);
|
||||
const y = Math.max(a.y, b.y);
|
||||
const w = Math.min(a.x + a.w, b.x + b.w) - x;
|
||||
const hh = Math.min(a.y + a.h, b.y + b.h) - y;
|
||||
return w > 0 && hh > 0 ? { x, y, w, h: hh } : null;
|
||||
}
|
||||
|
||||
const center = (r) => [r.x + r.w / 2, r.y + r.h / 2];
|
||||
const rect = (r, attrs, parent) => svg('rect', { x: r.x, y: r.y, width: r.w, height: r.h, ...attrs }, parent);
|
||||
|
||||
function splitInTwo(text) {
|
||||
const words = text.split(' ');
|
||||
let best = [text];
|
||||
let score = Infinity;
|
||||
for (let i = 1; i < words.length; i += 1) {
|
||||
const a = words.slice(0, i).join(' ');
|
||||
const b = words.slice(i).join(' ');
|
||||
if (Math.max(a.length, b.length) < score) {
|
||||
score = Math.max(a.length, b.length);
|
||||
best = [a, b];
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
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 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,
|
||||
}, label);
|
||||
const toolbar = h('div', { class: 'map-toolbar', role: 'toolbar', 'aria-label': 'Highlight topics' });
|
||||
const summary = h('span', { class: 'map-summary', role: 'status' });
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
function toggle(set, value) {
|
||||
if (set.has(value)) set.delete(value);
|
||||
else set.add(value);
|
||||
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',
|
||||
role: 'group',
|
||||
'aria-label': 'Roadmap diagram. Tab moves between topics, Enter opens one.',
|
||||
preserveAspectRatio: 'xMidYMid meet',
|
||||
});
|
||||
const defs = svg('defs', {}, chart);
|
||||
const hatch = svg('pattern', { id: 'cross-hatch', width: 10, height: 10, patternUnits: 'userSpaceOnUse', patternTransform: 'rotate(45)' }, defs);
|
||||
svg('line', { x1: 0, y1: 0, x2: 0, y2: 10, class: 'hatch-a' }, hatch);
|
||||
svg('line', { x1: 5, y1: 0, x2: 5, y2: 10, class: 'hatch-b' }, hatch);
|
||||
|
||||
const layers = {};
|
||||
for (const name of ['page', 'areas', 'overlap', 'outlines', 'groups', 'linksAll', 'links', 'boxes', 'labels']) {
|
||||
layers[name] = svg('g', { class: `layer-${name}` }, chart);
|
||||
}
|
||||
|
||||
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.
|
||||
const title = svg('text', { x: 24, y: layout.titleY, class: 'page-title' }, layers.labels);
|
||||
title.textContent = 'Embedded Systems Engineering Roadmap';
|
||||
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 });
|
||||
}
|
||||
|
||||
function layoutLegend() {
|
||||
let x = 26;
|
||||
for (const part of legendParts) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
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(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);
|
||||
|
||||
// Band names run down the left margin; every band reads as one area or a cross-section.
|
||||
for (const band of layout.bands) {
|
||||
const cy = (band.y0 + band.y1) / 2;
|
||||
const t = svg('text', {
|
||||
x: 0, y: 0, class: `band-label ${band.cls}`, 'text-anchor': 'middle', 'dominant-baseline': 'central',
|
||||
transform: `translate(${sw.x + 18} ${cy.toFixed(1)}) rotate(-90)`,
|
||||
}, layers.labels);
|
||||
t.textContent = band.label;
|
||||
}
|
||||
|
||||
const tags = [];
|
||||
function tag(text, cls, r, topic = null) {
|
||||
const g = svg('g', { class: `tag ${cls}` }, layers.labels);
|
||||
const bg = svg('rect', { class: 'tag-bg', height: 22, rx: 11 }, g);
|
||||
const label = svg('text', { class: 'tag-text', 'dominant-baseline': 'central' }, g);
|
||||
label.textContent = text;
|
||||
const entry = { g, bg, label, text, r };
|
||||
tags.push(entry);
|
||||
if (topic) wireTarget(g, topic, null, `${text} group`);
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Group labels all sit on the top-left of their own border.
|
||||
for (const group of layout.groups) {
|
||||
rect(group.rect, { rx: 16, class: `group-line ${group.cls}` }, layers.groups);
|
||||
tag(group.name, `group-tag ${group.cls}`, group.rect, group.topics[0] || null);
|
||||
}
|
||||
|
||||
function layoutTags() {
|
||||
for (const t of tags) {
|
||||
const width = (t.label.getComputedTextLength && t.label.getComputedTextLength()) || t.text.length * 7;
|
||||
const x = t.r.x + 22;
|
||||
const y = t.r.y - 11;
|
||||
t.bg.setAttribute('x', x.toFixed(1));
|
||||
t.bg.setAttribute('y', y.toFixed(1));
|
||||
t.bg.setAttribute('width', (width + 22).toFixed(1));
|
||||
t.label.setAttribute('x', (x + 11).toFixed(1));
|
||||
t.label.setAttribute('y', (y + 11).toFixed(1));
|
||||
}
|
||||
}
|
||||
|
||||
// Credits: where the resources are, who maintains the roadmap, its license and revision.
|
||||
const { x: fx, y: fy, w: fw } = layout.footer;
|
||||
const fr = fx + fw;
|
||||
const credits = svg('g', { class: 'page-credits' }, layers.labels);
|
||||
const link = (href) => svg('a', { href, target: '_blank', rel: 'noopener noreferrer' }, credits);
|
||||
const icon = svg('svg', { x: fx, y: fy + 2, width: 16, height: 16, viewBox: '0 0 16 16' }, credits);
|
||||
svg('path', { d: GITHUB_MARK, class: 'credit-icon' }, icon);
|
||||
const repo = svg('text', { x: fx + 24, y: fy + 15, class: 'credit-main' }, link(`https://${CREDITS.repo}`));
|
||||
repo.textContent = `Learning resources for every topic: ${CREDITS.repo}`;
|
||||
const creditLine = (y, label, value) => {
|
||||
const t = svg('text', { x: fx, y, class: 'credit-text' }, credits);
|
||||
t.append(label);
|
||||
svg('tspan', { class: 'credit-strong' }, t).textContent = value;
|
||||
};
|
||||
creditLine(fy + 35, 'Creator & Maintainer: ', CREDITS.author);
|
||||
if (build) creditLine(fy + 53, 'Revision: ', `${build.version} · Last update: ${build.date}`);
|
||||
const logoSize = 52;
|
||||
const badge = { w: 103, h: 36 };
|
||||
svg('image', { href: 'assets/logo.svg', x: fr - logoSize, y: fy, width: logoSize, height: logoSize }, credits);
|
||||
const badgeX = fr - logoSize - SPACE - badge.w;
|
||||
svg('image', { href: 'assets/cc-by-sa.svg', x: badgeX, y: fy + (logoSize - badge.h) / 2, width: badge.w, height: badge.h }, link(CREDITS.licenseUrl));
|
||||
const licenseText = svg('text', { x: badgeX - 14, y: fy + 22, 'text-anchor': 'end', class: 'credit-text' }, link(CREDITS.licenseUrl));
|
||||
licenseText.textContent = 'This work is licensed under the Creative Commons';
|
||||
const licenseText2 = svg('text', { x: badgeX - 14, y: fy + 40, 'text-anchor': 'end', class: 'credit-text' }, link(CREDITS.licenseUrl));
|
||||
licenseText2.textContent = 'Attribution-ShareAlike 4.0 International License.';
|
||||
|
||||
// ---------- topics ----------
|
||||
for (const item of layout.items) {
|
||||
const box = item.box;
|
||||
if (item.kind === 'caption') {
|
||||
const t = svg('text', { x: item.x, y: item.y + 15, class: 'cluster-caption' }, layers.boxes);
|
||||
t.textContent = item.text.toUpperCase();
|
||||
continue;
|
||||
}
|
||||
const topic = box.topics[0] || null;
|
||||
const g = svg('g', { class: `box ${item.kind === 'header' ? 'header' : box.importance}`, 'data-box': box.id }, layers.boxes);
|
||||
rectOf.set(box, item);
|
||||
if (item.kind === 'header') {
|
||||
const t = svg('text', { x: item.x, y: item.y + 16, class: 'box-text header-text' }, g);
|
||||
t.textContent = item.text;
|
||||
boxEls.set(box, { g, text: t, width: item.w, header: true });
|
||||
} else {
|
||||
rect(item, { rx: 5, class: 'box-bg' }, g);
|
||||
const lines = item.h >= 36 ? splitInTwo(item.text) : [item.text];
|
||||
const t = svg('text', { x: item.x + item.w / 2, y: item.y + item.h / 2, 'text-anchor': 'middle', class: 'box-text' }, g);
|
||||
lines.forEach((line, i) => {
|
||||
const span = svg('tspan', { x: item.x + item.w / 2, dy: i === 0 ? `${0.36 - (lines.length - 1) * 0.55}em` : '1.1em' }, t);
|
||||
span.textContent = line;
|
||||
});
|
||||
boxEls.set(box, { g, text: t, width: item.w - 10 });
|
||||
}
|
||||
for (const t of box.topics) {
|
||||
if (!t.layoutBoxes) t.layoutBoxes = [];
|
||||
t.layoutBoxes.push(box);
|
||||
}
|
||||
if (topic) wireTarget(g, topic, box);
|
||||
}
|
||||
|
||||
function describe(topic, box) {
|
||||
const parts = [box ? box.text : topic.title];
|
||||
if (topic.areas.length) parts.push(areaLabel(topic.areas));
|
||||
const importance = (box && box.importance) || topic.importance;
|
||||
if (importance) parts.push(IMPORTANCE[importance]);
|
||||
parts.push(plural(topic.total, 'resource'));
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
function wireTarget(el, topic, box = null, label = null) {
|
||||
el.setAttribute('tabindex', '0');
|
||||
el.setAttribute('role', 'button');
|
||||
el.setAttribute('aria-label', label ? `${label}, ${plural(topic.total, 'resource')}` : describe(topic, box));
|
||||
el.classList.add('target');
|
||||
el.addEventListener('pointerenter', (e) => hover(topic, box, e));
|
||||
el.addEventListener('pointermove', moveTip);
|
||||
el.addEventListener('pointerleave', () => hover(null));
|
||||
el.addEventListener('focus', () => hover(topic, box));
|
||||
el.addEventListener('blur', () => hover(null));
|
||||
el.addEventListener('click', () => {
|
||||
if (state.drag && state.drag.moved) return;
|
||||
onSelect(topic);
|
||||
});
|
||||
el.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelect(topic);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- tooltip ----------
|
||||
const tip = h('div', { class: 'tooltip', hidden: true });
|
||||
function hover(topic, box = null, e = null) {
|
||||
state.hover = topic;
|
||||
drawLinks();
|
||||
if (!topic || !e) {
|
||||
tip.hidden = true;
|
||||
return;
|
||||
}
|
||||
const importance = (box && box.importance) || topic.importance;
|
||||
tip.replaceChildren(...[
|
||||
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(' · ')),
|
||||
h('div', { class: 'tip-hint' }, 'Click for details and resources'),
|
||||
].filter(Boolean));
|
||||
tip.hidden = false;
|
||||
moveTip(e);
|
||||
}
|
||||
|
||||
function moveTip(e) {
|
||||
if (tip.hidden) return;
|
||||
const bounds = root.getBoundingClientRect();
|
||||
let x = e.clientX - bounds.left + 16;
|
||||
let y = e.clientY - bounds.top + 16;
|
||||
if (x + tip.offsetWidth > bounds.width - 8) x = e.clientX - bounds.left - tip.offsetWidth - 16;
|
||||
if (y + tip.offsetHeight > bounds.height - 8) y = e.clientY - bounds.top - tip.offsetHeight - 16;
|
||||
tip.style.transform = `translate(${Math.max(8, x)}px, ${Math.max(8, y)}px)`;
|
||||
}
|
||||
|
||||
// ---------- connections ----------
|
||||
const connections = plan.connections.filter((c) => rectOf.has(c.a) && rectOf.has(c.b));
|
||||
|
||||
function curve(a, b) {
|
||||
const [x1, y1] = center(rectOf.get(a));
|
||||
const [x2, y2] = center(rectOf.get(b));
|
||||
const mx = (x1 + x2) / 2;
|
||||
const my = (y1 + y2) / 2;
|
||||
const bend = 0.2;
|
||||
return `M${x1.toFixed(1)} ${y1.toFixed(1)}Q${(mx - (y2 - y1) * bend).toFixed(1)} ${(my + (x2 - x1) * bend).toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`;
|
||||
}
|
||||
|
||||
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),
|
||||
}, layer);
|
||||
if (cls !== 'link') return;
|
||||
for (const box of [connection.a, connection.b]) {
|
||||
const [cx, cy] = center(rectOf.get(box));
|
||||
svg('circle', { cx: cx.toFixed(1), cy: cy.toFixed(1), r: 3, class: 'link-end' }, layer);
|
||||
}
|
||||
}
|
||||
|
||||
for (const connection of connections) drawConnection(connection, layers.linksAll, 'link-all');
|
||||
|
||||
const isWithin = (node, topic) => {
|
||||
for (let n = node; n; n = n.parent) if (n === topic) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
function drawLinks() {
|
||||
layers.links.replaceChildren();
|
||||
const topic = state.hover || state.selected;
|
||||
const lit = new Set();
|
||||
if (topic) {
|
||||
const own = new Set(plan.boxes.filter((b) => rectOf.has(b) && b.topics.some((t) => isWithin(t, topic))));
|
||||
const fallback = plan.boxFor(topic);
|
||||
if (fallback && rectOf.has(fallback)) own.add(fallback);
|
||||
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;
|
||||
drawConnection(connection, layers.links, 'link');
|
||||
lit.add(connection.a);
|
||||
lit.add(connection.b);
|
||||
}
|
||||
}
|
||||
chart.classList.toggle('has-focus', Boolean(state.hover));
|
||||
for (const [box, el] of boxEls) {
|
||||
el.g.classList.toggle('lit', lit.has(box));
|
||||
el.g.classList.toggle('selected', Boolean(state.selected) && box.topics.includes(state.selected));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 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);
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
const filtering = state.areas.size > 0 || state.importance.size > 0;
|
||||
chart.classList.toggle('filtering', filtering);
|
||||
chart.classList.toggle('show-all', state.showAll);
|
||||
let count = 0;
|
||||
for (const [box, el] of boxEls) {
|
||||
const match = filtering && matches(box);
|
||||
if (match) count += 1;
|
||||
el.g.classList.toggle('match', match);
|
||||
}
|
||||
summary.textContent = filtering ? `${plural(count, 'topic')}` : '';
|
||||
renderToolbar();
|
||||
}
|
||||
|
||||
// ---------- pan and zoom ----------
|
||||
const page = { w: layout.width, h: layout.height };
|
||||
const MAX_SCALE = 1.5;
|
||||
let fitMode = 'width';
|
||||
|
||||
function canvasSize() {
|
||||
const box = chart.getBoundingClientRect();
|
||||
return box.width > 0 && box.height > 0 ? { w: box.width, h: box.height } : null;
|
||||
}
|
||||
|
||||
// The view always has the canvas' aspect ratio, so the drawing fills it without letterboxing.
|
||||
function setView(view) {
|
||||
const size = canvasSize();
|
||||
const v = { ...view };
|
||||
v.h = size ? v.w * (size.h / size.w) : v.h || v.w * (page.h / page.w);
|
||||
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;
|
||||
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)}%`;
|
||||
}
|
||||
|
||||
function fitWidth() {
|
||||
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 });
|
||||
fitButton.title = 'Show the whole page';
|
||||
}
|
||||
|
||||
function fitPage() {
|
||||
const size = canvasSize();
|
||||
const aspect = size ? size.h / size.w : page.h / page.w;
|
||||
const width = Math.max(page.w, (page.h + 40) / aspect) + 40;
|
||||
fitMode = 'page';
|
||||
setView({ x: (page.w - width) / 2, y: 0, w: width });
|
||||
fitButton.title = 'Fit the width';
|
||||
}
|
||||
|
||||
const toggleFit = () => (fitMode === 'width' ? fitPage() : fitWidth());
|
||||
|
||||
function toChart(clientX, clientY) {
|
||||
const matrix = chart.getScreenCTM();
|
||||
if (!matrix) return [0, 0];
|
||||
const p = new DOMPoint(clientX, clientY).matrixTransform(matrix.inverse());
|
||||
return [p.x, p.y];
|
||||
}
|
||||
|
||||
function zoomAt(factor, clientX, clientY) {
|
||||
const v = state.view;
|
||||
const size = canvasSize();
|
||||
if (!size) return;
|
||||
const scale = (size.w / v.w) * factor;
|
||||
if (scale < (size.w / page.w) * MIN_ZOOM || scale > MAX_ZOOM) return;
|
||||
const [px, py] = clientX == null ? [v.x + v.w / 2, v.y + v.h / 2] : toChart(clientX, clientY);
|
||||
fitMode = 'custom';
|
||||
setView({ x: px - (px - v.x) / factor, y: py - (py - v.y) / factor, w: v.w / factor });
|
||||
}
|
||||
|
||||
chart.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
zoomAt(Math.exp(-e.deltaY * 0.0025), e.clientX, e.clientY);
|
||||
return;
|
||||
}
|
||||
const size = canvasSize();
|
||||
if (!size) return;
|
||||
const unit = state.view.w / size.w;
|
||||
const dx = e.shiftKey && !e.deltaX ? e.deltaY : e.deltaX;
|
||||
const dy = e.shiftKey && !e.deltaX ? 0 : e.deltaY;
|
||||
setView({ ...state.view, x: state.view.x + dx * unit, y: state.view.y + dy * unit });
|
||||
}, { passive: false });
|
||||
chart.addEventListener('pointerdown', (e) => {
|
||||
if (e.button !== 0) return;
|
||||
state.drag = { x: e.clientX, y: e.clientY, view: { ...state.view }, moved: false, id: e.pointerId };
|
||||
});
|
||||
chart.addEventListener('pointermove', (e) => {
|
||||
const drag = state.drag;
|
||||
if (!drag || drag.id !== e.pointerId) return;
|
||||
const dx = e.clientX - drag.x;
|
||||
const dy = e.clientY - drag.y;
|
||||
if (!drag.moved && Math.hypot(dx, dy) < 5) return;
|
||||
if (!drag.moved) {
|
||||
drag.moved = true;
|
||||
chart.setPointerCapture(e.pointerId);
|
||||
chart.classList.add('panning');
|
||||
}
|
||||
const scale = drag.view.w / chart.getBoundingClientRect().width;
|
||||
fitMode = 'custom';
|
||||
setView({ ...drag.view, x: drag.view.x - dx * scale, y: drag.view.y - dy * scale });
|
||||
});
|
||||
const endDrag = (e) => {
|
||||
if (!state.drag || state.drag.id !== e.pointerId) return;
|
||||
chart.classList.remove('panning');
|
||||
// The click that follows a drag still needs to see `moved`.
|
||||
window.setTimeout(() => { state.drag = null; }, 0);
|
||||
};
|
||||
chart.addEventListener('pointerup', endDrag);
|
||||
chart.addEventListener('pointercancel', endDrag);
|
||||
chart.addEventListener('dblclick', toggleFit);
|
||||
|
||||
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) }, '+'),
|
||||
zoomLevel,
|
||||
h('button', { type: 'button', class: 'icon-btn', 'aria-label': 'Zoom out', title: 'Zoom out (Ctrl + scroll)', onclick: () => zoomAt(1 / 1.25) }, '−'),
|
||||
fitButton);
|
||||
|
||||
root.replaceChildren(
|
||||
h('div', { class: 'map-top' }, strip, toolbar),
|
||||
h('div', { class: 'map-canvas' }, chart, zoomControls),
|
||||
tip,
|
||||
);
|
||||
fitWidth();
|
||||
refresh();
|
||||
layoutLegend();
|
||||
layoutTags();
|
||||
// Tag and legend widths depend on the web font, so measure again once it has loaded.
|
||||
document.fonts.load('800 14px "Sofia Sans Semi Condensed"').then(() => requestAnimationFrame(relayout), () => {});
|
||||
|
||||
// Keep the same part of the diagram in view when the window or panel changes size.
|
||||
new ResizeObserver(() => {
|
||||
if (!canvasSize() || state.beforePrint) return;
|
||||
if (fitMode === 'width') fitWidth();
|
||||
else if (fitMode === 'page') fitPage();
|
||||
else setView(state.view);
|
||||
}).observe(chart);
|
||||
|
||||
// Long labels are squeezed to fit their box once real text widths are known.
|
||||
function fitText() {
|
||||
for (const [, el] of boxEls) {
|
||||
const spans = el.header ? [el.text] : [...el.text.querySelectorAll('tspan')];
|
||||
for (const span of spans) {
|
||||
span.removeAttribute('textLength');
|
||||
span.removeAttribute('lengthAdjust');
|
||||
if (span.getComputedTextLength() > el.width) {
|
||||
span.setAttribute('textLength', el.width.toFixed(1));
|
||||
span.setAttribute('lengthAdjust', 'spacingAndGlyphs');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function relayout() {
|
||||
layoutLegend();
|
||||
layoutTags();
|
||||
fitText();
|
||||
}
|
||||
|
||||
return {
|
||||
select(topic) {
|
||||
state.selected = topic;
|
||||
drawLinks();
|
||||
},
|
||||
show() {
|
||||
if (fitMode === 'width') fitWidth();
|
||||
relayout();
|
||||
},
|
||||
prepareForPrint() {
|
||||
state.hover = null;
|
||||
tip.hidden = true;
|
||||
state.beforePrint = { view: { ...state.view }, fitMode };
|
||||
state.view = { x: 0, y: 0, w: page.w, h: page.h };
|
||||
chart.setAttribute('viewBox', `0 0 ${page.w} ${page.h}`);
|
||||
relayout();
|
||||
drawLinks();
|
||||
},
|
||||
afterPrint() {
|
||||
if (!state.beforePrint) return;
|
||||
fitMode = state.beforePrint.fitMode;
|
||||
setView(state.beforePrint.view);
|
||||
state.beforePrint = null;
|
||||
},
|
||||
layout,
|
||||
};
|
||||
}
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
// 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`.
|
||||
|
||||
export const TYPES = {
|
||||
book: { icon: '📘', label: 'Book' },
|
||||
video: { icon: '🎞️', label: 'Video' },
|
||||
article: { icon: '📝', label: 'Article' },
|
||||
link: { icon: '🔗', label: 'Link' },
|
||||
audio: { icon: '🎧', label: 'Audio book' },
|
||||
};
|
||||
|
||||
const TYPE_BY_SYMBOL = { '📘': 'book', '🎞': 'video', '📝': 'article', '🔗': 'link', '🎧': 'audio' };
|
||||
// Heading prefixes the README uses only to mark the heading level.
|
||||
const LEVEL_MARKERS = new Set(['✳', '🔵', '🔶', '🔸']);
|
||||
const SYMBOLS = /^[\p{Extended_Pictographic}\uFE0F\u200D\u200C\u20E3\s]+/u;
|
||||
const INVISIBLE = /[\uFE0F\u200D\u200C\u20E3\s]/u;
|
||||
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');
|
||||
const BULLET = /^\s*[-*]\s+(.+)$/;
|
||||
const SKIP = /^(\s*>|<!--|!\[|\[!\[|_{3,}\s*$|\|)/;
|
||||
|
||||
function splitSymbols(text) {
|
||||
const match = text.match(SYMBOLS);
|
||||
const prefix = match ? match[0] : '';
|
||||
return {
|
||||
prefix: prefix.trim(),
|
||||
symbols: [...prefix].filter((c) => !INVISIBLE.test(c)),
|
||||
rest: text.slice(prefix.length).replace(/\s+/g, ' ').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function headingInfo(text) {
|
||||
const link = text.match(HEADING_LINK);
|
||||
const { prefix, symbols, rest } = splitSymbols(link ? link[1] : text);
|
||||
const generic = symbols.every((s) => LEVEL_MARKERS.has(s));
|
||||
return { title: rest, icon: generic ? '' : prefix, link: link ? link[2] : null, label: link ? link[1] : null };
|
||||
}
|
||||
|
||||
function makeResource(label, url, trailing = '') {
|
||||
const { symbols, rest } = splitSymbols(label);
|
||||
let type = 'link';
|
||||
for (const s of symbols) if (TYPE_BY_SYMBOL[s]) type = TYPE_BY_SYMBOL[s];
|
||||
let domain = '';
|
||||
try {
|
||||
domain = new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
domain = '';
|
||||
}
|
||||
return {
|
||||
title: rest,
|
||||
url,
|
||||
domain,
|
||||
type,
|
||||
beginner: symbols.includes('👶'),
|
||||
gem: symbols.includes('💎'),
|
||||
note: trailing.replace(/^\s*[-–—:]\s*/, '').trim(),
|
||||
topic: null,
|
||||
sharedWith: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function slugify(text) {
|
||||
return text
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/\+\+/g, 'pp')
|
||||
.replace(/#/g, 'sharp')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function normalizeUrl(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return `${u.hostname.replace(/^www\./, '').toLowerCase()}${u.pathname.replace(/\/+$/, '')}${u.search}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function makeNode(fields) {
|
||||
return { title: '', icon: '', depth: 0, group: '', parent: null, children: [], resources: [], description: [], ...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 groups = [];
|
||||
const stack = [];
|
||||
let target = root;
|
||||
let topic = null;
|
||||
let paragraph = [];
|
||||
|
||||
const flush = () => {
|
||||
if (paragraph.length && target) target.description.push({ type: 'p', text: paragraph.join(' ') });
|
||||
paragraph = [];
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const heading = line.match(HEADING);
|
||||
if (heading) {
|
||||
flush();
|
||||
const level = heading[1].length;
|
||||
if (level === 1) continue;
|
||||
const info = headingInfo(heading[2].trim());
|
||||
if (level === 2) {
|
||||
const group = makeNode({ title: info.title, icon: info.icon, topics: [] });
|
||||
groups.push(group);
|
||||
stack.length = 0;
|
||||
target = group;
|
||||
topic = null;
|
||||
continue;
|
||||
}
|
||||
while (stack.length && stack[stack.length - 1].level >= level) stack.pop();
|
||||
const parent = stack.length ? stack[stack.length - 1].node : root;
|
||||
const group = groups[groups.length - 1];
|
||||
const node = makeNode({ title: info.title, icon: info.icon, depth: parent.depth + 1, group: group ? group.title : '', parent });
|
||||
if (info.link) node.resources.push(makeResource(info.label, info.link));
|
||||
parent.children.push(node);
|
||||
if (group && parent === root) group.topics.push(node);
|
||||
stack.push({ level, node });
|
||||
target = node;
|
||||
topic = node;
|
||||
continue;
|
||||
}
|
||||
|
||||
const resource = line.match(RESOURCE);
|
||||
if (resource) {
|
||||
flush();
|
||||
if (topic) topic.resources.push(makeResource(resource[1], resource[2], resource[3]));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (SKIP.test(line)) {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
|
||||
const bullet = line.match(BULLET);
|
||||
if (bullet) {
|
||||
flush();
|
||||
if (!target) continue;
|
||||
const last = target.description[target.description.length - 1];
|
||||
if (last && last.type === 'ul') last.items.push(bullet[1].trim());
|
||||
else target.description.push({ type: 'ul', items: [bullet[1].trim()] });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.trim()) {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
// Text before the first "##" heading after the badges is the README introduction.
|
||||
if (target === root && root.description.length >= 2) continue;
|
||||
paragraph.push(line.trim());
|
||||
}
|
||||
flush();
|
||||
|
||||
const topics = [];
|
||||
const used = new Set(['']);
|
||||
const walk = (node, path) => {
|
||||
for (const child of node.children) {
|
||||
let slug = slugify(child.title) || 'topic';
|
||||
if (used.has(slug)) slug = `${slug}-${slugify(node.title)}`;
|
||||
for (let i = 2; used.has(slug); i += 1) slug = `${slugify(child.title)}-${i}`;
|
||||
used.add(slug);
|
||||
child.slug = slug;
|
||||
child.id = slug;
|
||||
child.path = path;
|
||||
for (const r of child.resources) r.topic = child;
|
||||
topics.push(child);
|
||||
walk(child, [...path, child.title]);
|
||||
}
|
||||
};
|
||||
walk(root, []);
|
||||
|
||||
const total = (node) => {
|
||||
node.total = node.resources.length + node.children.reduce((sum, c) => sum + total(c), 0);
|
||||
return node.total;
|
||||
};
|
||||
total(root);
|
||||
|
||||
const order = new Map(topics.map((t, i) => [t, i]));
|
||||
const linkMap = new Map();
|
||||
const linkFor = (a, b) => {
|
||||
const [source, target] = order.get(a) <= order.get(b) ? [a, b] : [b, a];
|
||||
const key = `${source.id}|${target.id}`;
|
||||
if (!linkMap.has(key)) linkMap.set(key, { source, target, shared: [], mentions: [] });
|
||||
return linkMap.get(key);
|
||||
};
|
||||
|
||||
const byUrl = new Map();
|
||||
for (const node of topics) {
|
||||
for (const r of node.resources) {
|
||||
const key = normalizeUrl(r.url);
|
||||
if (!byUrl.has(key)) byUrl.set(key, []);
|
||||
byUrl.get(key).push(r);
|
||||
}
|
||||
}
|
||||
for (const list of byUrl.values()) {
|
||||
const owners = [...new Set(list.map((r) => r.topic))];
|
||||
if (owners.length < 2) continue;
|
||||
for (const r of list) r.sharedWith = owners.filter((o) => o !== r.topic);
|
||||
for (let i = 0; i < owners.length; i += 1) {
|
||||
for (let j = i + 1; j < owners.length; j += 1) linkFor(owners[i], owners[j]).shared.push(list[0].title);
|
||||
}
|
||||
}
|
||||
|
||||
const matchers = topics.map((t) => ({ topic: t, patterns: aliases(t.title).map(aliasPattern) }));
|
||||
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)),
|
||||
];
|
||||
for (const { topic: to, patterns } of matchers) {
|
||||
if (to === from || isLineage(from, to)) continue;
|
||||
for (const text of texts) {
|
||||
if (patterns.some((p) => p.test(text))) linkFor(from, to).mentions.push({ topic: from, text: plainText(text) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const links = [...linkMap.values()];
|
||||
for (const link of links) link.weight = link.shared.length * 2 + link.mentions.length;
|
||||
for (const t of topics) t.links = [];
|
||||
for (const link of links) {
|
||||
link.source.links.push(link);
|
||||
link.target.links.push(link);
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
topics,
|
||||
groups: groups.filter((g) => g.topics.length),
|
||||
links,
|
||||
bySlug: new Map(topics.map((t) => [t.slug, t])),
|
||||
stats: { topics: topics.length, resources: byUrl.size, links: links.length },
|
||||
};
|
||||
}
|
||||
|
||||
// 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!']);
|
||||
|
||||
function aliases(title) {
|
||||
const found = new Set([title]);
|
||||
const base = title.replace(/\s*\(([^)]*)\)\s*/g, (m, inner) => {
|
||||
found.add(inner.trim());
|
||||
return ' ';
|
||||
}).trim();
|
||||
found.add(base);
|
||||
for (const part of base.split(' / ')) found.add(part.trim());
|
||||
return [...found].filter((a) => !ALIAS_STOPLIST.has(a) && (a.length >= 3 || /^[A-Z0-9]{2,}$/.test(a)));
|
||||
}
|
||||
|
||||
function aliasPattern(alias) {
|
||||
const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return new RegExp(`(?<![\\w+#-])${escaped}(?![\\w+#-])`, 'u');
|
||||
}
|
||||
|
||||
function isLineage(a, b) {
|
||||
for (let n = a; n; n = n.parent) if (n === b) return true;
|
||||
for (let n = b; n; n = n.parent) if (n === a) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function plainText(markdown) {
|
||||
return markdown.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/[*_`$]/g, '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
const ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
const GREEK = { Pi: 'Π', pi: 'π', lambda: 'λ' };
|
||||
|
||||
export function escapeHtml(text) {
|
||||
return text.replace(/[&<>"']/g, (c) => ESCAPES[c]);
|
||||
}
|
||||
|
||||
function formatText(escaped) {
|
||||
return escaped
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/(^|[\s(])_(.+?)_(?=[\s.,;:)!?]|$)/g, '$1<em>$2</em>')
|
||||
.replace(/\$\$\\(\w+)\$\$/g, (m, name) => GREEK[name] || name);
|
||||
}
|
||||
|
||||
// Renders README inline markdown to HTML. Input is escaped first; only http(s) links become anchors.
|
||||
export function renderInline(markdown) {
|
||||
const link = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
|
||||
let html = '';
|
||||
let last = 0;
|
||||
for (const m of markdown.matchAll(link)) {
|
||||
html += formatText(escapeHtml(markdown.slice(last, m.index)));
|
||||
html += `<a href="${escapeHtml(m[2])}" target="_blank" rel="noopener noreferrer">${formatText(escapeHtml(m[1]))}</a>`;
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
return html + formatText(escapeHtml(markdown.slice(last)));
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { parseRoadmap, renderInline } from './parser.js';
|
||||
import { linkDiagram } from './diagram.js';
|
||||
import { SPACE, buildLayout } from './layout.js';
|
||||
|
||||
const data = parseRoadmap(readFileSync(new URL('../README.md', import.meta.url), 'utf8'));
|
||||
const find = (title) => data.topics.find((t) => t.title === title);
|
||||
const linked = (a, b) => data.links.find((l) => (l.source === a && l.target === b) || (l.source === b && l.target === a));
|
||||
|
||||
test('builds the topic tree from the README headings', () => {
|
||||
assert.ok(data.topics.length > 100);
|
||||
assert.equal(find('Microcontrollers').depth, 1);
|
||||
assert.ok(find('Microcontrollers').children.some((c) => c.title === 'GPIO'));
|
||||
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']);
|
||||
});
|
||||
|
||||
test('gives every topic a unique slug', () => {
|
||||
assert.equal(new Set(data.topics.map((t) => t.slug)).size, data.topics.length);
|
||||
});
|
||||
|
||||
test('parses resource markers, bracketed titles and URLs with parentheses', () => {
|
||||
const beej = find('C').resources.find((r) => r.title.startsWith("Beej's Guide"));
|
||||
assert.deepEqual([beej.type, beej.beginner, beej.gem], ['book', true, false]);
|
||||
const j1939 = find('CAN').resources.find((r) => r.title.startsWith('J1939'));
|
||||
assert.equal(j1939.title, 'J1939 Explained - A Simple Intro [v2.0 | 2021]');
|
||||
assert.match(find('PCM').resources[0].url, /_04\(1\)\.pdf$/);
|
||||
assert.equal(find('GoogleTest - Google Testing and Mocking Framework').resources[0].url, 'https://github.com/google/googletest');
|
||||
assert.match(find('Career Development').resources[0].note, /job board/);
|
||||
});
|
||||
|
||||
test('keeps descriptions and the README introduction', () => {
|
||||
assert.match(data.root.description[0].text, /^This roadmap is designed/);
|
||||
assert.match(find('Microcontrollers').description[0].text, /^Microcontrollers are integrated circuits/);
|
||||
});
|
||||
|
||||
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);
|
||||
assert.equal(linked(find('Microcontrollers'), find('GPIO')), undefined, 'parent and child are not cross-linked');
|
||||
assert.equal(linked(find('Thread'), find('RT-Thread')), undefined, 'RT-Thread is not a mention of Thread');
|
||||
});
|
||||
|
||||
test('renderInline escapes HTML and only links http(s) URLs', () => {
|
||||
assert.equal(
|
||||
renderInline('<b>x</b> **y** [a](https://e.com/?q=1&r=2) [j](javascript:alert(1))'),
|
||||
'<b>x</b> <strong>y</strong> <a href="https://e.com/?q=1&r=2" target="_blank" rel="noopener noreferrer">a</a> [j](javascript:alert(1))',
|
||||
);
|
||||
});
|
||||
|
||||
const map = JSON.parse(readFileSync(new URL('./map.json', import.meta.url), 'utf8'));
|
||||
|
||||
test('places topics in the map areas and their cross-section', () => {
|
||||
const plan = linkDiagram(map, data);
|
||||
assert.deepEqual(plan.unmatched, [], 'every map topic matches a README heading (or an alias in diagram.js)');
|
||||
assert.deepEqual(plan.invalid, [], 'importance is required, recommended or possible');
|
||||
assert.deepEqual(find('GPIO').areas, ['SOFTWARE', 'HARDWARE']);
|
||||
assert.deepEqual(find('I2C').areas, ['SOFTWARE', 'HARDWARE']);
|
||||
assert.deepEqual(find('Sensors & Actuators').areas, ['SOFTWARE', 'HARDWARE']);
|
||||
assert.deepEqual(find('GDB').areas, ['SOFTWARE']);
|
||||
assert.deepEqual(find('Oscilloscope').areas, ['HARDWARE']);
|
||||
assert.deepEqual(find('Soft Skills').areas, ['SOFT SKILLS']);
|
||||
assert.deepEqual(find('Microcontrollers').areas, ['SOFTWARE', 'HARDWARE']);
|
||||
assert.equal(find('RTOS Basics').importance, 'required');
|
||||
assert.equal(find('Rust').importance, 'possible');
|
||||
assert.deepEqual(find('Linux Device Drivers').areas, ['SOFTWARE']);
|
||||
assert.equal(find('Projects').areas.length, 0, 'getting-started topics are not on the map');
|
||||
});
|
||||
|
||||
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');
|
||||
assert.ok(layout.height / layout.width <= Math.SQRT2, 'fits A4 portrait');
|
||||
const boxes = layout.items.filter((i) => i.kind === 'box');
|
||||
const inside = (a, b) => a.x >= b.x && a.y >= b.y && a.x + a.w <= b.x + b.w && a.y + a.h <= b.y + b.h;
|
||||
const gap = (a, b) => Math.max(b.x - a.x - a.w, a.x - b.x - b.w, b.y - a.y - a.h, a.y - b.y - b.h);
|
||||
for (const [i, a] of boxes.entries()) {
|
||||
for (const b of boxes.slice(i + 1)) assert.ok(gap(a, b) >= SPACE - 0.01, `${a.text} / ${b.text}`);
|
||||
for (const g of layout.groups) {
|
||||
if (!inside(a, g.rect)) continue;
|
||||
const r = g.rect;
|
||||
const d = Math.min(a.x - r.x, a.y - r.y, r.x + r.w - a.x - a.w, r.y + r.h - a.y - a.h);
|
||||
assert.ok(d >= SPACE - 0.01, `${a.text} too close to the ${g.name} outline`);
|
||||
}
|
||||
}
|
||||
});
|
||||
+1148
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
// 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)
|
||||
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 outDir = resolve(process.argv[4] || 'dist');
|
||||
const NAME = 'Embedded-Engineering-Roadmap';
|
||||
const PNG_SCALE = 2;
|
||||
|
||||
const TYPES = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json',
|
||||
'.md': 'text/markdown; charset=utf-8',
|
||||
'.woff2': 'font/woff2',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
};
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const file = resolve(join(root, decodeURIComponent(url.pathname), url.pathname.endsWith('/') ? 'index.html' : ''));
|
||||
if (file !== root && !file.startsWith(root + sep)) {
|
||||
res.writeHead(403).end();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = await readFile(file);
|
||||
res.writeHead(200, { 'content-type': TYPES[extname(file)] || 'application/octet-stream' }).end(body);
|
||||
} catch {
|
||||
res.writeHead(404).end();
|
||||
}
|
||||
});
|
||||
await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
|
||||
const url = `http://127.0.0.1:${server.address().port}${pagePath}`;
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
// bypassCSP lets the PNG step add one style rule; the site itself only allows its own stylesheets.
|
||||
const context = await browser.newContext({ viewport: { width: 1400, height: 1000 }, deviceScaleFactor: PNG_SCALE, bypassCSP: true });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
await page.waitForSelector('.diagram .box');
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
await new Promise((ok) => requestAnimationFrame(() => requestAnimationFrame(ok)));
|
||||
});
|
||||
if (errors.length) throw new Error(`The page reported errors:\n${errors.join('\n')}`);
|
||||
|
||||
await mkdir(outDir, { recursive: true });
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('beforeprint')));
|
||||
await page.emulateMedia({ media: 'print' });
|
||||
await page.pdf({ path: join(outDir, `${NAME}.pdf`), format: 'A4', printBackground: true, preferCSSPageSize: true });
|
||||
|
||||
// PNG: the same print rendering, at the diagram's own size instead of the page's.
|
||||
// page.pdf() fires afterprint, which restores the screen view, so prepare for print again.
|
||||
const { w, h } = await page.evaluate(() => {
|
||||
const bg = document.querySelector('.diagram .page-bg');
|
||||
return { w: Number(bg.getAttribute('width')), h: Number(bg.getAttribute('height')) };
|
||||
});
|
||||
await page.setViewportSize({ width: Math.ceil(w) + 40, height: Math.ceil(h) + 40 });
|
||||
await page.addStyleTag({ content: `.diagram { width: ${w}px !important; height: ${h}px !important; max-height: none !important; }` });
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('beforeprint')));
|
||||
await page.locator('.diagram').screenshot({ path: join(outDir, `${NAME}.png`) });
|
||||
|
||||
console.log(`Wrote ${NAME}.pdf and ${NAME}.png (${Math.round(w * PNG_SCALE)}x${Math.round(h * PNG_SCALE)}) to ${outDir}`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.close();
|
||||
}
|
||||
Reference in New Issue
Block a user