Files
m3y54m_Embedded-Engineering…/site/mapview.js
T

640 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 badge = { w: 103, h: 36 };
const logoSize = badge.h;
const iconY = fy + 9;
svg('image', { href: 'assets/logo.svg', x: fr - logoSize, y: iconY, width: logoSize, height: logoSize }, credits);
const badgeX = fr - logoSize - SPACE - badge.w;
svg('image', { href: 'assets/cc-by-sa.svg', x: badgeX, y: iconY, 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 bg = svg('rect', { class: 'header-bg', rx: 6, x: item.x - 6, y: item.y - 1, height: 24 }, g);
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, bg, x: item.x, 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', {}, box ? box.text : 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'),
box && box.related.length ? `${box.related.length} related` : null].filter(Boolean).join(' · ')),
h('div', { class: 'tip-hint' }, box && box.text !== topic.title ? `Click to open “${topic.title}”` : 'Click this topic to open its details'),
].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.reason ? 'curated' : 'evidence'}`,
'stroke-width': connection.reason ? 2.4 : 1.4,
}, 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);
}
}
// The overview of all connections only shows the hand-picked ones.
for (const connection of connections) if (connection.reason) drawConnection(connection, layers.linksAll, 'link-all');
const isWithin = (node, topic) => {
for (let n = node; n; n = n.parent) if (n === topic) return true;
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.reason && !connection.links.some((l) => isWithin(l.source, topic) || isWithin(l.target, topic))) continue;
drawConnection(connection, layers.links, 'link');
lit.add(connection.a);
lit.add(connection.b);
}
}
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';
}
const pageFitWidth = (size) => Math.max(page.w, (page.h + 40) / (size ? size.h / size.w : page.h / page.w)) + 40;
function fitPage() {
const size = canvasSize();
const width = pageFitWidth(size);
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;
// Limits only stop a step that goes further out of range; the whole-page view is always reachable.
const minScale = Math.min((size.w / page.w) * MIN_ZOOM, size.w / pageFitWidth(size), size.w / v.w);
if (factor > 1 ? scale > MAX_ZOOM : scale < minScale) 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 });
}
// Two fingers: zoom around their midpoint and pan with it.
const touches = new Map();
let pinch = null;
const spread = () => {
const [a, b] = [...touches.values()];
return { dist: Math.hypot(a.x - b.x, a.y - b.y), x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
};
function beginPinch() {
pinch = spread();
// The click that ends the gesture must not open a topic.
state.drag = { moved: true, id: null };
chart.classList.add('panning');
hover(null);
}
function movePinch() {
if (touches.size < 2) return;
const now = spread();
if (pinch.dist > 0 && now.dist > 0) zoomAt(now.dist / pinch.dist, now.x, now.y);
const unit = state.view.w / chart.getBoundingClientRect().width;
fitMode = 'custom';
setView({ ...state.view, x: state.view.x - (now.x - pinch.x) * unit, y: state.view.y - (now.y - pinch.y) * unit });
pinch = now;
}
function endTouch(e) {
if (!touches.delete(e.pointerId)) return;
if (pinch && touches.size < 2) {
pinch = null;
chart.classList.remove('panning');
}
if (!touches.size && state.drag && state.drag.id === null) window.setTimeout(() => { state.drag = null; }, 0);
}
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.pointerType === 'touch') touches.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (touches.size === 2) {
beginPinch();
return;
}
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) => {
if (touches.has(e.pointerId)) touches.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pinch) {
movePinch();
return;
}
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) => {
endTouch(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);
// Safari on a Mac sends its own gesture events for trackpad pinches (other browsers send Ctrl + wheel).
// On iOS the same pinch also arrives as touches, which are handled above, so skip it there.
let gestureScale = 1;
chart.addEventListener('gesturestart', (e) => {
e.preventDefault();
gestureScale = 1;
});
chart.addEventListener('gesturechange', (e) => {
e.preventDefault();
if (touches.size) return;
zoomAt(e.scale / gestureScale, e.clientX, e.clientY);
gestureScale = e.scale;
});
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');
}
}
if (el.bg) el.bg.setAttribute('width', (el.text.getComputedTextLength() + 12).toFixed(1));
}
}
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,
};
}