mirror of
https://github.com/m3y54m/Embedded-Engineering-Roadmap.git
synced 2026-10-12 01:08:13 +03:00
feat(explorer): make the map legend the filter; replace Filter menu with an active-filter pill
This commit is contained in:
+1
-1
@@ -312,7 +312,7 @@ function overview(root) {
|
||||
...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', {}, 'Click an entry of the legend above the map to highlight topics by importance or area, including only the Software ∩ Hardware cross-section.'),
|
||||
h('li', {}, 'Hover or select a topic to see solid lines to its related topics. Fainter dashed lines come from resources that share or mention a topic.'),
|
||||
h('li', {}, 'Outline lists every topic of the README; use it to browse topics that are not on the map.'),
|
||||
h('li', {}, 'Press ', h('kbd', {}, '/'), ' to search topics and resources.'))),
|
||||
|
||||
+45
-51
@@ -59,9 +59,19 @@
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.map-chip.clear {
|
||||
border-style: dashed;
|
||||
background: transparent;
|
||||
.filter-pill {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 14%, var(--surface-2));
|
||||
}
|
||||
|
||||
.pill-x {
|
||||
margin-right: -3px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.filter-pill:hover .pill-x {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.map-menu {
|
||||
@@ -93,22 +103,10 @@
|
||||
transform: rotate(-135deg);
|
||||
}
|
||||
|
||||
.map-menu[open] > .menu-button,
|
||||
.menu-button.active {
|
||||
.map-menu[open] > .menu-button {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.menu-count {
|
||||
min-width: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.map-menu-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
@@ -157,47 +155,12 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.menu-heading {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.menu-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.map-menu-panel .map-chip.clear {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -256,6 +219,37 @@
|
||||
.legend-area.ss { stroke: var(--ss); }
|
||||
.legend-hatch { fill: url(#cross-hatch); stroke: var(--border-strong); }
|
||||
|
||||
.legend-hit {
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
/* On screen the legend is the filter: each entry toggles a highlight. */
|
||||
@media not print {
|
||||
.legend-item {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.legend-item:hover .legend-hit {
|
||||
fill: var(--chip-hover);
|
||||
}
|
||||
|
||||
.legend-item:focus-visible .legend-hit {
|
||||
stroke: var(--accent);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.legend-item[aria-pressed="true"] .legend-hit {
|
||||
fill: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
stroke: var(--accent);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.page-legend.filtering .legend-item:not([aria-pressed="true"]) {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
.area-fill {
|
||||
stroke: none;
|
||||
fill-opacity: var(--area-tint);
|
||||
|
||||
+60
-43
@@ -40,7 +40,8 @@ export function createMapView({ root, plan, data, build, onSelect }) {
|
||||
const legendParts = [];
|
||||
|
||||
// ---------- toolbar ----------
|
||||
// One slim row above the map: a menu of getting-started topics, a filter menu and the connections toggle.
|
||||
// One slim row above the map: a menu of getting-started topics, the active filter and the connections toggle.
|
||||
// Filters are set by clicking the legend at the top of the map.
|
||||
const chip = (label, cls, onclick, title) => h('button', {
|
||||
type: 'button', class: `map-chip ${cls}`, 'aria-pressed': 'false', onclick, title,
|
||||
}, label);
|
||||
@@ -78,51 +79,46 @@ export function createMapView({ root, plan, data, build, onSelect }) {
|
||||
|
||||
const isCross = () => state.areas.size === 2 && state.areas.has('SOFTWARE') && state.areas.has('HARDWARE');
|
||||
const isArea = (area) => state.areas.has(area) && !isCross();
|
||||
const areaChips = Object.entries(AREAS).map(([area, info]) => [area, chip(h('span', {}, h('i', { class: `dot ${info.cls}` }), info.label),
|
||||
'', () => {
|
||||
state.areas = isArea(area) ? new Set() : new Set([area]);
|
||||
refresh();
|
||||
}, `Topics in ${info.label}`)]);
|
||||
const crossChip = chip(h('span', {}, h('i', { class: 'dot both' }), 'Software ∩ Hardware'), 'cross', () => {
|
||||
const setArea = (area) => {
|
||||
state.areas = isArea(area) ? new Set() : new Set([area]);
|
||||
refresh();
|
||||
};
|
||||
const setCross = () => {
|
||||
state.areas = isCross() ? new Set() : new Set(['SOFTWARE', 'HARDWARE']);
|
||||
refresh();
|
||||
}, 'Topics that belong to both Software and Hardware');
|
||||
const importanceChips = Object.entries(IMPORTANCE).map(([level, label]) => [level,
|
||||
chip(h('span', {}, h('i', { class: `imp-swatch ${level}` }), label), '', () => toggle(state.importance, level))]);
|
||||
const clearButton = h('button', {
|
||||
};
|
||||
const clearFilters = () => {
|
||||
state.areas.clear();
|
||||
state.importance.clear();
|
||||
refresh();
|
||||
};
|
||||
const filterLabel = h('span');
|
||||
const filterPill = h('button', {
|
||||
type: 'button',
|
||||
class: 'map-chip clear',
|
||||
onclick: () => {
|
||||
state.areas.clear();
|
||||
state.importance.clear();
|
||||
refresh();
|
||||
},
|
||||
}, 'Clear filters');
|
||||
const filterCount = h('span', { class: 'menu-count' });
|
||||
const filter = menu('filter', h('span', {}, 'Filter', filterCount),
|
||||
h('p', { class: 'menu-heading' }, 'Area'),
|
||||
h('div', { class: 'menu-chips' }, areaChips.map(([, el]) => el), crossChip),
|
||||
h('p', { class: 'menu-heading' }, 'Importance'),
|
||||
h('div', { class: 'menu-chips' }, importanceChips.map(([, el]) => el)),
|
||||
clearButton);
|
||||
class: 'map-chip filter-pill',
|
||||
title: 'Clear the highlight',
|
||||
'aria-label': 'Clear the highlight',
|
||||
hidden: true,
|
||||
onclick: clearFilters,
|
||||
}, filterLabel, h('span', { class: 'pill-x', 'aria-hidden': 'true' }, '✕'));
|
||||
const linksChip = chip([h('span', { class: 'label-long' }, 'All connections'), h('span', { class: 'label-short' }, 'Links')], 'links', () => {
|
||||
state.showAll = !state.showAll;
|
||||
refresh();
|
||||
}, 'Show the lines between all related topics');
|
||||
const summary = h('span', { class: 'map-summary', role: 'status' });
|
||||
const toolbar = h('div', { class: 'map-toolbar', role: 'toolbar', 'aria-label': 'Map controls' },
|
||||
start.el, filter.el, linksChip, summary);
|
||||
start.el, linksChip, filterPill, summary);
|
||||
|
||||
function renderToolbar() {
|
||||
for (const [area, el] of areaChips) el.setAttribute('aria-pressed', String(isArea(area)));
|
||||
crossChip.setAttribute('aria-pressed', String(isCross()));
|
||||
for (const [level, el] of importanceChips) el.setAttribute('aria-pressed', String(state.importance.has(level)));
|
||||
linksChip.setAttribute('aria-pressed', String(state.showAll));
|
||||
const active = (state.areas.size ? 1 : 0) + state.importance.size;
|
||||
filterCount.textContent = active ? String(active) : '';
|
||||
filterCount.hidden = !active;
|
||||
filter.button.classList.toggle('active', active > 0);
|
||||
clearButton.hidden = !active;
|
||||
const names = [
|
||||
...(isCross() ? ['Software ∩ Hardware'] : [...state.areas].map((a) => AREAS[a].label)),
|
||||
...Object.keys(IMPORTANCE).filter((l) => state.importance.has(l)).map((l) => IMPORTANCE[l]),
|
||||
];
|
||||
filterLabel.textContent = names.join(' · ');
|
||||
filterPill.hidden = !names.length;
|
||||
for (const part of legendParts) part.g.setAttribute('aria-pressed', String(part.pressed()));
|
||||
legend.classList.toggle('filtering', names.length > 0);
|
||||
}
|
||||
|
||||
function toggle(set, value) {
|
||||
@@ -158,27 +154,48 @@ export function createMapView({ root, plan, data, build, onSelect }) {
|
||||
title.textContent = TITLE;
|
||||
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' },
|
||||
...Object.entries(IMPORTANCE).map(([level, label]) => ({
|
||||
kind: 'imp', cls: level, label, pressed: () => state.importance.has(level), act: () => toggle(state.importance, level),
|
||||
})),
|
||||
{ kind: 'area', cls: 'sw', label: 'Software', pressed: () => isArea('SOFTWARE'), act: () => setArea('SOFTWARE') },
|
||||
{ kind: 'area', cls: 'hw', label: 'Hardware', pressed: () => isArea('HARDWARE'), act: () => setArea('HARDWARE') },
|
||||
{ kind: 'hatch', cls: '', label: 'Software ∩ Hardware', pressed: isCross, act: setCross },
|
||||
{ kind: 'area', cls: 'ss', label: 'Soft skills', pressed: () => isArea('SOFT SKILLS'), act: () => setArea('SOFT SKILLS') },
|
||||
];
|
||||
// On screen every legend entry is also a filter button; print shows the plain legend.
|
||||
for (const item of legendItems) {
|
||||
const y = legendY;
|
||||
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);
|
||||
const g = svg('g', {
|
||||
class: 'legend-item', role: 'button', tabindex: '0', 'aria-pressed': 'false', 'aria-label': `Highlight ${item.label} topics`,
|
||||
}, legend);
|
||||
svg('title', {}, g).textContent = `Highlight ${item.label} topics`;
|
||||
const hit = rect({ x: 0, y: y - 5, w: 0, h: 23 }, { rx: 6, class: 'legend-hit' }, g);
|
||||
const key = rect({ x: 0, y, w: 22, h: 13 }, { rx: item.kind === 'imp' ? 3 : 4, class: cls }, g);
|
||||
const t = svg('text', { x: 0, y: y + 11, class: 'legend-text' }, g);
|
||||
t.textContent = item.label;
|
||||
legendParts.push({ key, t, label: item.label });
|
||||
g.addEventListener('click', () => {
|
||||
// A drag that starts on the legend pans the map; it must not also filter.
|
||||
if (state.drag && state.drag.moved) return;
|
||||
item.act();
|
||||
});
|
||||
g.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.preventDefault();
|
||||
item.act();
|
||||
});
|
||||
legendParts.push({ g, hit, key, t, label: item.label, pressed: item.pressed });
|
||||
}
|
||||
|
||||
function layoutLegend() {
|
||||
let x = 26;
|
||||
for (const part of legendParts) {
|
||||
const width = 28 + ((part.t.getComputedTextLength && part.t.getComputedTextLength()) || part.label.length * 6.5);
|
||||
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;
|
||||
part.hit.setAttribute('x', x - 6);
|
||||
part.hit.setAttribute('width', width + 12);
|
||||
x += width + 20;
|
||||
}
|
||||
}
|
||||
const note = svg('text', { x: 24, y: layout.titleY + 54, class: 'legend-note' }, layers.labels);
|
||||
|
||||
Reference in New Issue
Block a user