// Reused from the InferenceNet Challenge website; local adaptation adds Chinese labels. /* Mountain-climb leaderboard: every entry is a climber whose altitude is its score, * the flag marks the unconquered summit (score = max). ES module, zero dependencies. * * Markup: *
* *
*
* * * The module mounts every [data-mountain] element once the DOM is ready; calling * mountMountains() again (or mountain(el, data) twice) re-renders in place. */ const NS = "http://www.w3.org/2000/svg"; const DEFAULTS = { compactBreakpoint: 700, // px; below this the pills move under the drawing headingLevel: 3, table: true, // emit a ranked when the page has none minGap: 28, // px; minimum centre distance between two markers markerRadius: 11, leaderRadius: 15, aspect: 0.6, // drawing height / width (1200 × 720) compactAspect: 0.75 // 4:3 on small screens }; /* Terrain in unit coordinates: [x / width, altitude above the base line, 0..1 = summit]. */ const PEAK = [[-0.06, 0], [0.02, 0.06], [0.07, 0.17], [0.11, 0.14], [0.17, 0.29], [0.21, 0.26], [0.26, 0.42], [0.30, 0.39], [0.345, 0.55], [0.375, 0.53], [0.42, 0.73], [0.445, 0.71], [0.475, 0.88], [0.5, 1], [0.525, 0.87], [0.555, 0.84], [0.59, 0.72], [0.62, 0.69], [0.665, 0.52], [0.70, 0.47], [0.75, 0.34], [0.79, 0.30], [0.84, 0.18], [0.885, 0.15], [0.94, 0.06], [1.06, 0]]; const SNOW = [[0.42, 0.73], [0.445, 0.71], [0.475, 0.88], [0.5, 1], [0.525, 0.87], [0.555, 0.84], [0.59, 0.72], [0.60, 0.69], [0.592, 0.63], [0.578, 0.66], [0.567, 0.585], [0.552, 0.63], [0.541, 0.555], [0.531, 0.60], [0.52, 0.505], [0.506, 0.57], [0.495, 0.495], [0.484, 0.56], [0.471, 0.525], [0.461, 0.60], [0.451, 0.565], [0.441, 0.63], [0.431, 0.61], [0.425, 0.68]]; const SNOW_TONGUES = [ [[0.462, 0.605], [0.482, 0.575], [0.478, 0.46], [0.470, 0.43], [0.464, 0.47]], [[0.512, 0.545], [0.531, 0.565], [0.535, 0.43], [0.526, 0.37], [0.519, 0.44]], [[0.556, 0.61], [0.573, 0.585], [0.592, 0.50], [0.585, 0.465], [0.571, 0.53]], [[0.388, 0.52], [0.404, 0.565], [0.416, 0.535], [0.412, 0.485], [0.398, 0.475]], [[0.628, 0.60], [0.646, 0.585], [0.662, 0.525], [0.652, 0.50], [0.636, 0.535]] ]; const SHADE = [[0.5, 1], [0.515, 0.80], [0.53, 0.62], [0.56, 0.45], [0.585, 0.28], [0.62, 0.12], [0.66, 0], [1.06, 0], [0.94, 0.06], [0.885, 0.15], [0.84, 0.18], [0.79, 0.30], [0.75, 0.34], [0.70, 0.47], [0.665, 0.52], [0.62, 0.69], [0.59, 0.72], [0.555, 0.84], [0.525, 0.87]]; const STRIAE = [ [[0.36, 0.53], [0.33, 0.40], [0.29, 0.30], [0.23, 0.16]], [[0.41, 0.66], [0.39, 0.50], [0.35, 0.36], [0.31, 0.20]], [[0.66, 0.50], [0.69, 0.36], [0.74, 0.22], [0.80, 0.10]], [[0.60, 0.66], [0.64, 0.48], [0.68, 0.30], [0.71, 0.14]] ]; const FAR = [[-0.06, 0], [0.0, 0.22], [0.05, 0.18], [0.10, 0.36], [0.15, 0.30], [0.20, 0.44], [0.25, 0.40], [0.31, 0.52], [0.36, 0.48], [0.42, 0.56], [0.5, 0.6], [0.58, 0.55], [0.64, 0.58], [0.70, 0.48], [0.75, 0.52], [0.80, 0.40], [0.86, 0.44], [0.92, 0.28], [0.97, 0.32], [1.06, 0.16], [1.06, 0]]; const MID = [[-0.06, 0], [0.0, 0.10], [0.06, 0.20], [0.10, 0.18], [0.16, 0.36], [0.20, 0.30], [0.24, 0.33], [0.29, 0.24], [0.34, 0.28], [0.38, 0.18], [0.42, 0.24], [0.47, 0.12], [0.53, 0.14], [0.58, 0.08], [0.63, 0.16], [0.68, 0.22], [0.72, 0.20], [0.77, 0.34], [0.82, 0.42], [0.86, 0.32], [0.90, 0.36], [0.95, 0.22], [1.0, 0.28], [1.06, 0.12], [1.06, 0]]; const MID_CAPS = [ [[0.135, 0.29], [0.16, 0.36], [0.185, 0.31], [0.175, 0.30], [0.16, 0.32], [0.148, 0.30]], [[0.795, 0.37], [0.82, 0.42], [0.845, 0.36], [0.835, 0.35], [0.82, 0.37], [0.807, 0.355]] ]; const FOOT = [[-0.06, 0], [0.04, 0.05], [0.12, 0.03], [0.20, 0.075], [0.28, 0.04], [0.36, 0.06], [0.45, 0.02], [0.55, 0.05], [0.64, 0.03], [0.73, 0.08], [0.82, 0.045], [0.9, 0.07], [1.06, 0.03], [1.06, 0]]; const ROUTE = [[0.34, 0], [0.30, 0.10], [0.39, 0.20], [0.33, 0.33], [0.43, 0.44], [0.40, 0.56], [0.47, 0.68], [0.455, 0.80], [0.49, 0.92], [0.5, 1]]; /* Horizontal spread sequence for markers of one side, indexed by order within that side. */ const SPREAD = [0.45, 0.78, 0.22, 0.6, 0.92, 0.35, 0.7, 0.12, 0.52, 0.85, 0.3, 0.65]; let counter = 0; const instances = new WeakMap(); /* ---------- small helpers ---------- */ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); const num = (v) => (Math.round(v * 10) / 10).toFixed(1).replace(/\.0$/, ""); const fmt = (v, unit) => { const s = num(Number(v) || 0); return unit === "%" ? `${s}%` : unit ? `${s} ${unit}` : s; }; function h(tag, attrs, parent, text) { const n = document.createElement(tag); for (const k in attrs) if (attrs[k] != null) n.setAttribute(k, attrs[k]); if (text != null) n.textContent = text; if (parent) parent.appendChild(n); return n; } function s(tag, attrs, parent, text) { const n = document.createElementNS(NS, tag); for (const k in attrs) if (attrs[k] != null) n.setAttribute(k, attrs[k]); if (text != null) n.textContent = text; if (parent) parent.appendChild(n); return n; } const px = (v) => (Math.round(v * 10) / 10).toString(); function frame(W, compact, o) { const aspect = compact ? o.compactAspect : W < 1000 ? Math.max(o.aspect, 0.7) : o.aspect; const H = Math.round(W * aspect); const base = H - (compact ? 26 : 30); const summit = Math.round(H * (compact ? 0.27 : 0.13)); return { W, H, base, summit, range: base - summit, cx: W / 2, compact }; } const pt = (f, p) => [p[0] * f.W, f.base - p[1] * f.range]; const pts = (f, list) => list.map((p) => pt(f, p).map(px).join(",")).join(" "); /* Catmull-Rom spline through the points, as a cubic path. */ function smoothPath(list) { let d = `M${px(list[0][0])},${px(list[0][1])}`; for (let i = 0; i < list.length - 1; i++) { const p0 = list[i - 1] || list[i], p1 = list[i], p2 = list[i + 1], p3 = list[i + 2] || p2; const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6]; const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6]; d += ` C${px(c1[0])},${px(c1[1])} ${px(c2[0])},${px(c2[1])} ${px(p2[0])},${px(p2[1])}`; } return d; } /* Horizontal extent of a closed polygon (pixel coordinates) along the line y. */ function extentAt(poly, y) { let lo = Infinity, hi = -Infinity; for (let i = 0; i < poly.length; i++) { const a = poly[i], b = poly[(i + 1) % poly.length]; if ((a[1] <= y && b[1] > y) || (b[1] <= y && a[1] > y)) { const x = a[0] + ((y - a[1]) * (b[0] - a[0])) / (b[1] - a[1]); lo = Math.min(lo, x); hi = Math.max(hi, x); } } return lo <= hi ? [lo, hi] : null; } /* Deterministic collision resolution: sweep x outward in 6px steps inside [lo, hi], * then allow small vertical nudges; never places two markers closer than gap. */ function resolve(x, y, lo, hi, placed, gap) { const free = (X, Y) => placed.every((p) => Math.hypot(p.x - X, p.y - Y) >= gap); const dys = [0, -3, 3, -6, 6, -9, 9, -12, 12]; for (const dy of dys) { for (let d = 0; d <= Math.max(hi - lo, 1) + 6; d += 6) { for (const X of d === 0 ? [x] : [x + d, x - d]) { if (X < lo - 0.5 || X > hi + 0.5) continue; if (free(X, y + dy)) return { x: X, y: y + dy }; } } } let best = { x, y }, score = -1; // last resort: the least crowded spot for (let X = lo; X <= hi; X += 4) { const m = Math.min(...placed.map((p) => Math.hypot(p.x - X, p.y - y)), Infinity); if (m > score) { score = m; best = { x: X, y }; } } return best; } function normalise(data) { const d = Object.assign({ unit: "", max: 100, items: [], trails: [] }, data || {}); const max = Number(d.max) > 0 ? Number(d.max) : 100; const items = (d.items || []).map((it) => ({ label: String(it.label ?? it.name ?? ""), value: Number(it.value) || 0, group: it.group ?? "", kind: it.kind || "public", rank: it.rank })); items.sort((a, b) => (b.value - a.value) || String(a.label).localeCompare(String(b.label))); items.forEach((it, i) => { if (!Number.isFinite(Number(it.rank))) it.rank = i + 1; }); items.sort((a, b) => a.rank - b.rank); return { ...d, max, items, trails: Array.isArray(d.trails) ? d.trails : [] }; } /* ---------- component ---------- */ export function mountain(el, data, options = {}) { let inst = instances.get(el); if (inst) { inst.update(data, options); return inst; } const o = Object.assign({}, DEFAULTS, options); const id = el.id || `mountain-${++counter}`; let d = normalise(data); let canvas = el.querySelector(":scope > .mountain-canvas") || h("div", { class: "mountain-canvas" }, el); let lastWidth = -1, raf = 0, ro = null; el.classList.add("mountain"); function render() { el.classList.add("mountain--ready"); // reveals the canvas before it is measured const width = canvas.clientWidth; if (!width) return; // hidden (closed tab, display:none): the ResizeObserver re-schedules when visible lastWidth = width; const compact = width < o.compactBreakpoint; el.classList.toggle("mountain--compact", compact); el.classList.toggle("mountain--narrow", !compact && width < 1000); // shorter pills keep the drawing wide canvas.replaceChildren(); /* Header */ const head = h("div", { class: "mountain-head" }, canvas); const headText = h("div", { class: "mountain-head-text" }, head); if (d.title) h(`h${o.headingLevel}`, { class: "mountain-title" }, headText, d.title); if (d.subtitle) h("p", { class: "mountain-subtitle" }, headText, d.subtitle); if (compact && d.date) h("p", { class: "mountain-stamp" }, head, d.date); /* Stage: drawing + pills */ const stage = h("div", { class: "mountain-stage" }, canvas); /* Trails need vertical room on phones: a taller compact drawing keeps labels off the markers. */ const f = frame(width, compact, d.trails.length ? { ...o, compactAspect: Math.max(o.compactAspect, 0.9) } : o); const svg = s("svg", { class: "mountain-svg", viewBox: `0 0 ${f.W} ${f.H}`, width: f.W, height: f.H, role: "img", "aria-labelledby": `${id}-title ${id}-desc`, focusable: "false" }, stage); const leader = d.items[0]; const isZh = d.locale === "zh"; const n = d.items.length; s("title", { id: `${id}-title` }, svg, `${d.title || "Leaderboard"}: ${n} entries climbing by ${d.metric || "score"}.`); s("desc", { id: `${id}-desc` }, svg, leader ? `Altitude is ${d.metric || "score"}; the summit flag marks ${fmt(d.max, d.unit)}. Leader: ${leader.label} at ${fmt(leader.value, d.unit)}. Full ranking in the table below.` : "No entries yet."); svg.setAttribute("aria-label", `${d.title || "Leaderboard"}: ${n} entries climbing by ${d.metric || "score"}${leader ? `; leader ${leader.label} at ${fmt(leader.value, d.unit)}` : ""}.`); const pills = h("ol", { class: "mountain-pills", "aria-label": `Ranked entries by ${d.metric || "score"}` }, stage); const pillOf = new Map(); d.items.forEach((it) => { const right = it.rank % 2 === 1; const li = h("li", { class: `mountain-pill mountain-pill--${right ? "right" : "left"}${it.kind === "internal" ? " mountain-pill--internal" : ""}`, "data-rank": it.rank, "aria-describedby": `${id}-d${it.rank}` }, pills); const badge = h("span", { class: `mountain-badge${it.rank <= 3 ? ` mountain-badge--${["gold", "silver", "bronze"][it.rank - 1]}` : ""}`, "aria-hidden": "true" }, li, String(it.rank)); badge.dataset.rank = it.rank; h("span", { class: "mountain-vh" }, li, `Rank ${it.rank}: `); h("span", { class: "mountain-pill-label" }, li, it.label); h("span", { class: "mountain-pill-value" }, li, fmt(it.value, d.unit)); pillOf.set(it.rank, li); }); /* Column geometry from the measured pills (desktop only). */ const gutter = 16; let leftEdge = 0, rightEdge = f.W, maxL = 0, maxR = 0, nL = 0, nR = 0; if (!compact) { pillOf.forEach((li, rank) => { const w = li.offsetWidth; if (rank % 2 === 1) { maxR = Math.max(maxR, w); nR++; } else { maxL = Math.max(maxL, w); nL++; } }); leftEdge = gutter + maxL; rightEdge = f.W - gutter - maxR; } /* Marker placement. */ const peakPx = PEAK.map((p) => pt(f, p)); const r0 = compact ? Math.max(9, o.markerRadius - 2) : o.markerRadius; const rLead = compact ? Math.max(12, o.leaderRadius - 2) : o.leaderRadius; const placed = []; const byLabel = new Map(); const trailTo = new Map(d.trails.map((t) => [t.from, t.to])); const sideCount = { 1: 0, "-1": 0 }; d.items.forEach((it) => { const side = it.rank % 2 === 1 ? 1 : -1; const k = sideCount[side]++; const r = it.rank === 1 ? rLead : r0; const y = Math.max(f.summit + 1, f.base - clamp(it.value / d.max, 0, 1) * f.range); const ext = extentAt(peakPx, y) || [f.cx - r, f.cx + r]; let lo, hi; if (side > 0) { lo = Math.max(f.cx + 0.03 * f.W, ext[0] + r + 4); hi = Math.min(ext[1] - r - 4, rightEdge - r - 14, f.W - r - 6); } else { hi = Math.min(f.cx - 0.03 * f.W, ext[1] - r - 4); lo = Math.max(ext[0] + r + 4, leftEdge + r + 14, r + 6); } if (lo > hi) { const m = clamp((lo + hi) / 2, leftEdge + r + 14, rightEdge - r - 14); lo = hi = m; } let x = lo + SPREAD[k % SPREAD.length] * (hi - lo); const to = trailTo.get(it.label); if (to && byLabel.has(to)) x = clamp(byLabel.get(to).x + side * 0.07 * f.W, lo, hi); const p = resolve(x, y, lo, hi, placed, o.minGap); const m = { ...p, r, item: it, side }; placed.push(m); byLabel.set(it.label, m); }); /* Trail endpoints that are not ranked entries become unlabeled waypoints. */ d.trails.forEach((t) => { [["from", t.fromValue], ["to", t.toValue]].forEach(([key, v]) => { const label = t[key]; if (byLabel.has(label) || !Number.isFinite(Number(v))) return; const other = byLabel.get(t[key === "from" ? "to" : "from"]); const side = other ? other.side : 1; const y = Math.max(f.summit + 1, f.base - clamp(Number(v) / d.max, 0, 1) * f.range); const ext = extentAt(peakPx, y) || [f.cx - r0, f.cx + r0]; const lo = Math.max(ext[0] + r0 + 4, leftEdge + r0 + 14), hi = Math.min(ext[1] - r0 - 4, rightEdge - r0 - 14); const x0 = other ? clamp(other.x + side * 0.08 * f.W, lo, hi) : f.cx; const p = resolve(x0, y, Math.min(lo, hi), Math.max(lo, hi), placed, o.minGap); const m = { ...p, r: r0 - 2, item: { label, value: Number(v), kind: t.kind || "internal", rank: null }, side, synthetic: true }; placed.push(m); byLabel.set(label, m); }); }); /* Pill positions: bottom-aligned stacks on both sides, ranks ascending downwards. */ const pillH = 28; const cols = { 1: [], "-1": [] }; if (!compact) { const nSide = Math.max(nL, nR, 1); const bottom = f.base - 6; const pitch = clamp(Math.floor((bottom - f.H * 0.38) / nSide), 30, 34); const top = bottom - nSide * pitch; const idx = { 1: 0, "-1": 0 }; d.items.forEach((it) => { const side = it.rank % 2 === 1 ? 1 : -1; const li = pillOf.get(it.rank); const w = li.offsetWidth; const yTop = top + idx[side]++ * pitch + (pitch - pillH) / 2; li.style.top = `${px(yTop)}px`; li.style.left = side > 0 ? `${px(f.W - gutter - w)}px` : `${px(gutter)}px`; cols[side].push({ rank: it.rank, cy: yTop + pillH / 2, inner: side > 0 ? f.W - gutter - w - 3 : gutter + w + 3, top: yTop, bottom: yTop + pillH }); }); } /* ---------- drawing ---------- */ const defs = s("defs", {}, svg); const sky = s("linearGradient", { id: `${id}-sky`, x1: "0", y1: "0", x2: "0", y2: "1" }, defs); s("stop", { offset: "0", class: "mtn-sky-top" }, sky); s("stop", { offset: "1", class: "mtn-sky-low" }, sky); const haze = s("linearGradient", { id: `${id}-haze`, x1: "0", y1: "0", x2: "0", y2: "1" }, defs); s("stop", { offset: "0", class: "mtn-haze-top" }, haze); s("stop", { offset: "1", class: "mtn-haze-low" }, haze); s("rect", { class: "mtn-sky", x: 0, y: 0, width: f.W, height: f.base, fill: `url(#${id}-sky)` }, svg); const scene = s("g", { class: "mtn-scene", "aria-hidden": "true" }, svg); s("polygon", { class: "mtn-far", points: pts(f, FAR) }, scene); s("polygon", { class: "mtn-mid", points: pts(f, MID) }, scene); MID_CAPS.forEach((c) => s("polygon", { class: "mtn-cap", points: pts(f, c) }, scene)); s("rect", { class: "mtn-haze", x: 0, y: f.base - f.range * 0.42, width: f.W, height: f.range * 0.42 + 1, fill: `url(#${id}-haze)` }, scene); s("polygon", { class: "mtn-near", points: pts(f, PEAK) }, scene); s("polygon", { class: "mtn-snow", points: pts(f, SNOW) }, scene); SNOW_TONGUES.forEach((t) => s("polygon", { class: "mtn-snow", points: pts(f, t) }, scene)); s("polygon", { class: "mtn-shade", points: pts(f, SHADE) }, scene); STRIAE.forEach((l) => s("polyline", { class: "mtn-stria", points: pts(f, l) }, scene)); s("polygon", { class: "mtn-foot", points: pts(f, FOOT) }, scene); s("polygon", { class: "mtn-outline", points: pts(f, PEAK) }, scene); s("polygon", { class: "mtn-outline mtn-outline--foot", points: pts(f, FOOT) }, scene); /* Contour lines every 25 % with a label where the pill columns leave room. */ const contours = s("g", { class: "mtn-contours" }, scene); const labelFont = 12; [0.25, 0.5, 0.75].forEach((a) => { const y = f.base - a * f.range; s("line", { class: "mtn-contour", x1: 0, y1: px(y), x2: f.W, y2: px(y) }, contours); const text = `${num(a * d.max)}${d.unit === "%" ? "%" : ""}`; const collides = (col) => col.some((c) => y - labelFont - 4 < c.bottom && y + 2 > c.top - 4); if (!collides(cols[1])) s("text", { class: "mtn-contour-label", x: f.W - 10, y: px(y - 5), "text-anchor": "end" }, contours, text); else if (!collides(cols["-1"])) s("text", { class: "mtn-contour-label", x: 10, y: px(y - 5) }, contours, text); }); s("line", { class: "mtn-base", x1: 0, y1: f.base + 0.5, x2: f.W, y2: f.base + 0.5 }, scene); s("text", { class: "mtn-contour-label", x: f.W - 10, y: f.base + 16, "text-anchor": "end" }, scene, `0${d.unit === "%" ? "%" : ""} · ${isZh ? "起点" : "base camp"}`); /* Route and flag. */ s("path", { class: "mtn-route", d: smoothPath(ROUTE.map((p) => pt(f, p))), fill: "none" }, scene); const top = pt(f, [0.5, 1]); const poleH = compact ? Math.min(34, Math.max(26, f.summit - 30)) : Math.max(34, f.H * 0.075); s("line", { class: "mtn-pole", x1: px(top[0]), y1: px(top[1]), x2: px(top[0]), y2: px(top[1] - poleH) }, scene); const fw = Math.max(22, f.W * 0.028), fh = fw * 0.62, fx = top[0] + 1, fy = top[1] - poleH; s("path", { class: "mtn-flag", d: `M${px(fx)},${px(fy)} Q${px(fx + fw * 0.55)},${px(fy + fh * 0.2)} ${px(fx + fw)},${px(fy + fh * 0.5)} Q${px(fx + fw * 0.55)},${px(fy + fh * 0.8)} ${px(fx)},${px(fy + fh)} Z` }, scene); if (d.peakLabel) { if (compact) s("text", { class: "mtn-peak-label", x: px(f.cx), y: 18, "text-anchor": "middle" }, scene, d.peakLabel); else s("text", { class: "mtn-peak-label", x: px(fx + fw + 10), y: px(fy + 12) }, scene, d.peakLabel); } if (!compact && d.date) s("text", { class: "mtn-stamp", x: f.W - 16, y: 26, "text-anchor": "end" }, scene, d.date); /* Connectors (pill → marker); straight, from the pill's inner edge, so they never cross a pill. */ const connectors = s("g", { class: "mtn-connectors" }, scene); const connOf = new Map(); if (!compact) { [1, -1].forEach((side) => cols[side].forEach((c) => { const m = placed.find((p) => p.item.rank === c.rank); if (!m) return; const dx = m.x - c.inner, dy = m.y - c.cy, len = Math.hypot(dx, dy) || 1; const ex = m.x - (dx / len) * (m.r + 3), ey = m.y - (dy / len) * (m.r + 3); connOf.set(c.rank, s("line", { class: "mtn-connector", x1: px(c.inner), y1: px(c.cy), x2: px(ex), y2: px(ey) }, connectors)); })); } /* Trails: dashed climbing arrow from the baseline marker to the improved one. */ const trails = s("g", { class: "mtn-trails" }, scene); d.trails.forEach((t) => { const a = byLabel.get(t.from), b = byLabel.get(t.to); if (!a || !b) return; const dx = b.x - a.x, dy = b.y - a.y, len = Math.hypot(dx, dy) || 1; let nx = -dy / len, ny = dx / len; const mid = [(a.x + b.x) / 2, (a.y + b.y) / 2]; const bow = Math.min(0.22 * len, 60); /* Bow (and label) on the outer side of the trail: for a steep trail that is the side away from the summit line. */ const side = a.side || 1; const inward = Math.abs(nx) >= 0.45 ? nx * side < 0 : ((mid[0] + nx * bow) - f.cx) * side < 0; if (inward) { nx = -nx; ny = -ny; } const c = [mid[0] + nx * bow, mid[1] + ny * bow]; const sa = [c[0] - a.x, c[1] - a.y], la = Math.hypot(...sa) || 1; const sb = [c[0] - b.x, c[1] - b.y], lb = Math.hypot(...sb) || 1; const p0 = [a.x + (sa[0] / la) * (a.r + 5), a.y + (sa[1] / la) * (a.r + 5)]; const p1 = [b.x + (sb[0] / lb) * (b.r + 3.5), b.y + (sb[1] / lb) * (b.r + 3.5)]; // arrowhead touches the ring const g = s("g", { class: "mtn-trail" }, trails); s("path", { class: "mtn-trail-path", d: `M${px(p0[0])},${px(p0[1])} Q${px(c[0])},${px(c[1])} ${px(p1[0])},${px(p1[1])}`, fill: "none" }, g); const tx = p1[0] - c[0], ty = p1[1] - c[1], tl = Math.hypot(tx, ty) || 1, ux = tx / tl, uy = ty / tl; const tip = p1, back = [tip[0] - ux * 9, tip[1] - uy * 9]; s("polygon", { class: "mtn-trail-head", points: `${px(tip[0])},${px(tip[1])} ${px(back[0] - uy * 4)},${px(back[1] + ux * 4)} ${px(back[0] + uy * 4)},${px(back[1] - ux * 4)}` }, g); if (t.label) { const q = [0.25 * p0[0] + 0.5 * c[0] + 0.25 * p1[0], 0.25 * p0[1] + 0.5 * c[1] + 0.25 * p1[1]]; /* On a steep trail the label hangs off its outer side (anchored at the inner edge) so it never crosses back over the trail or the markers. */ const steep = Math.abs(nx) >= 0.45, off = steep ? 9 : 14; s("text", { class: "mtn-trail-label", x: px(q[0] + nx * off), y: px(q[1] + ny * off + 4), "text-anchor": steep ? (nx > 0 ? "start" : "end") : "middle" }, g, t.label); } }); /* Markers. Drawn last so they sit above route, trails and snow. */ const markers = s("g", { class: "mtn-markers" }, svg); const markerOf = new Map(); placed.forEach((m) => { const it = m.item; const cls = ["mtn-marker"]; if (it.rank === 1) cls.push("mtn-marker--leader"); if (it.rank === 2) cls.push("mtn-marker--silver"); if (it.rank === 3) cls.push("mtn-marker--bronze"); if (it.kind === "internal") cls.push("mtn-marker--internal"); if (m.synthetic) cls.push("mtn-marker--waypoint"); const g = s("g", { class: cls.join(" "), transform: `translate(${px(m.x)} ${px(m.y)})` }, markers); s("circle", { class: "mtn-marker-halo", r: m.r + 6 }, g); if (it.kind === "internal") s("circle", { class: "mtn-marker-ring", r: m.r + 4.5 }, g); s("circle", { class: "mtn-marker-dot", r: m.r }, g); if (!m.synthetic) { s("text", { class: "mtn-marker-num", "text-anchor": "middle", dy: "0.36em" }, g, String(it.rank)); s("text", { class: "mtn-marker-val", "text-anchor": "middle", y: -(m.r + 8) }, g, fmt(it.value, d.unit)); s("desc", { id: `${id}-d${it.rank}` }, g, `${fmt(it.value, d.unit)} ${d.metric || ""}; rank ${it.rank} of ${n}${it.group ? `; ${it.group}` : ""}${it.kind === "internal" ? "; internal study" : ""}.`); markerOf.set(it.rank, g); } else { s("title", {}, g, `${it.label}: ${fmt(it.value, d.unit)}`); } }); /* Hover / focus linking between pills and markers. */ pillOf.forEach((li, rank) => { const g = markerOf.get(rank), line = connOf.get(rank); const set = (on) => { li.classList.toggle("is-active", on); g && g.classList.toggle("is-active", on); line && line.classList.toggle("is-active", on); }; li.addEventListener("mouseenter", () => set(true)); li.addEventListener("mouseleave", () => set(false)); if (g) { g.addEventListener("mouseenter", () => set(true)); g.addEventListener("mouseleave", () => set(false)); } }); /* Caption + legend. */ const caption = h("div", { class: "mountain-caption" }, canvas); const noteParts = []; if (d.metric) noteParts.push(isZh ? `高度 = ${d.metric}${d.unit ? `(${d.unit})` : ""};顶峰 = ${fmt(d.max, d.unit)}` : `Altitude = ${d.metric}${d.unit ? ` (${d.unit})` : ""}; summit = ${fmt(d.max, d.unit)}`); if (d.date) noteParts.push(d.date); if (d.caption) noteParts.push(d.caption); h("p", { class: "mountain-note" }, caption, noteParts.join(" · ")); const legend = h("ul", { class: "mountain-legend", "aria-label": "Legend" }, caption); const key = (cls, text) => { const li = h("li", {}, legend); h("i", { class: `mtn-key ${cls}`, "aria-hidden": "true" }, li); li.appendChild(document.createTextNode(text)); }; if (n) key("mtn-key--gold", "rank 1"); if (d.items.some((i) => i.kind !== "internal")) key("mtn-key--public", "public result"); if (d.items.some((i) => i.kind === "internal") || d.trails.length) key("mtn-key--internal", "internal study"); if (d.trails.length) key("mtn-key--trail", d.trailLegend || "gain with harness"); /* Ranked table for no-JS and screen readers: keep the page's own, or emit one. */ let table = el.querySelector(":scope > .mountain-table, :scope > .mountain-details .mountain-table, :scope > .mountain-table-wrap .mountain-table"); if (!table && o.table) { table = h("table", { class: "mountain-table" }); h("caption", {}, table, `${d.title || "Leaderboard"} — ranked by ${d.metric || "score"}${d.date ? ` (${d.date})` : ""}.`); const tr = h("tr", {}, h("thead", {}, table)); h("th", { scope: "col" }, tr, "#"); h("th", { scope: "col" }, tr, "Entry"); if (d.items.some((i) => i.group)) h("th", { scope: "col" }, tr, "Group"); h("th", { scope: "col" }, tr, d.metric || "Score"); const tb = h("tbody", {}, table); d.items.forEach((it) => { const row = h("tr", {}, tb); h("td", {}, row, String(it.rank)); h("th", { scope: "row" }, row, it.label + (it.kind === "internal" ? " (internal study)" : "")); if (d.items.some((i) => i.group)) h("td", {}, row, it.group); h("td", {}, row, fmt(it.value, d.unit)); }); el.appendChild(table); } if (table && !table.closest(".mountain-details")) { const details = h("details", { class: "mountain-details" }); h("summary", {}, details, `Ranked list · ${n} ${n === 1 ? "entry" : "entries"}`); table.replaceWith(details); details.appendChild(table); } } function schedule() { cancelAnimationFrame(raf); raf = requestAnimationFrame(() => { if (canvas.clientWidth !== lastWidth) render(); }); } inst = { el, render, update(next, opts) { if (next) d = normalise(next); if (opts) Object.assign(o, opts); lastWidth = -1; render(); }, destroy() { ro && ro.disconnect(); cancelAnimationFrame(raf); instances.delete(el); el.classList.remove("mountain--ready", "mountain--compact", "mountain--narrow"); } }; instances.set(el, inst); render(); if (typeof ResizeObserver === "function") { ro = new ResizeObserver(schedule); ro.observe(canvas); } else window.addEventListener("resize", schedule); if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => { lastWidth = -1; schedule(); }).catch(() => {}); return inst; } export function mountMountains(root = document) { const out = []; root.querySelectorAll("[data-mountain]").forEach((el) => { const script = el.querySelector(':scope > script[type="application/json"]'); let data = null; try { data = script ? JSON.parse(script.textContent) : null; } catch (err) { el.classList.add("mountain--error"); return; } if (!data) return; const opts = {}; if (el.dataset.headingLevel) opts.headingLevel = Number(el.dataset.headingLevel); if (el.dataset.compactBreakpoint) opts.compactBreakpoint = Number(el.dataset.compactBreakpoint); out.push(mountain(el, data, opts)); }); return out; } if (typeof document !== "undefined") { if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", () => mountMountains(), { once: true }); else mountMountains(); }