From b94fac37854711c6e0e0c74bd6e3e1a3941e4c0a Mon Sep 17 00:00:00 2001 From: Meysam Parvizi Date: Sun, 11 Oct 2026 17:54:53 +0200 Subject: [PATCH] feat(explorer): make the map legend the filter; replace Filter menu with an active-filter pill --- explorer/app.js | 2 +- explorer/map.css | 96 +++++++++++++++++++---------------------- explorer/mapview.js | 103 ++++++++++++++++++++++++++------------------ 3 files changed, 106 insertions(+), 95 deletions(-) diff --git a/explorer/app.js b/explorer/app.js index bbd05f6..ecd8713 100644 --- a/explorer/app.js +++ b/explorer/app.js @@ -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.'))), diff --git a/explorer/map.css b/explorer/map.css index 1487f6d..6619283 100644 --- a/explorer/map.css +++ b/explorer/map.css @@ -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); diff --git a/explorer/mapview.js b/explorer/mapview.js index c8f7e32..6babd6e 100644 --- a/explorer/mapview.js +++ b/explorer/mapview.js @@ -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);