/** * Ортогональные коннекторы «задача → решения» через SVG (координаты из getBoundingClientRect). * Скругление локтя — дуга A (как border-bottom-left-radius у псевдоэлемента). */ (function initSkillConnectors() { const ATTR = "data-skill-connectors-js"; function cssVarPx(el, name, fallbackPx) { const raw = getComputedStyle(el).getPropertyValue(name).trim(); if (!raw) return fallbackPx; const rootFs = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; if (raw.endsWith("rem")) return parseFloat(raw) * rootFs; if (raw.endsWith("px")) return parseFloat(raw); const n = parseFloat(raw); if (Number.isFinite(n)) return n; return fallbackPx; } /** Радиус коннектора: переменная может быть calc(); берём итог из border-radius карточки решения. */ function connectorRadiusPx(solutionEl) { const v = cssVarPx(solutionEl, "--skill-solution-connector-radius", 0); if (v > 0) return v; const br = getComputedStyle(solutionEl).borderRadius.split(/\s+/)[0] || ""; const px = parseFloat(br); return Number.isFinite(px) ? px : 8; } function cssVarColor(el, name, fallback) { const v = getComputedStyle(el).getPropertyValue(name).trim(); return v || fallback; } function ensureSvgLayer(card) { let svg = card.querySelector(".skill-task-card__connector-svg"); if (svg) return svg; svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("class", "skill-task-card__connector-svg"); svg.setAttribute("aria-hidden", "true"); svg.setAttribute("focusable", "false"); card.insertBefore(svg, card.firstChild); return svg; } function clearSvg(svg) { while (svg.firstChild) svg.removeChild(svg.firstChild); } /** Одинаковое округление соседних сегментов, чтобы стыки line/path не «плавали» по субпикселю. */ function px(n) { return Math.round(n * 1000) / 1000; } function lineEl(x1, y1, x2, y2, stroke, sw, conn) { const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); line.setAttribute("x1", String(px(x1))); line.setAttribute("y1", String(px(y1))); line.setAttribute("x2", String(px(x2))); line.setAttribute("y2", String(px(y2))); line.setAttribute("stroke", stroke); line.setAttribute("stroke-width", String(sw)); line.setAttribute("stroke-linecap", "round"); line.setAttribute("vector-effect", "non-scaling-stroke"); if (conn) line.setAttribute("data-skill-conn", conn); line.setAttribute("data-skill-conn-stroke", stroke); return line; } function pathEl(d, stroke, sw, fill = "none", conn) { const p = document.createElementNS("http://www.w3.org/2000/svg", "path"); p.setAttribute("d", d); p.setAttribute("fill", fill); p.setAttribute("stroke", stroke); p.setAttribute("stroke-width", String(sw)); p.setAttribute("stroke-linecap", "round"); p.setAttribute("stroke-linejoin", "round"); p.setAttribute("vector-effect", "non-scaling-stroke"); if (conn) p.setAttribute("data-skill-conn", conn); p.setAttribute("data-skill-conn-stroke", stroke); return p; } /** Акцент только у ветки к наведённому решению: bridge + spine:i при idx≥i + elbow:i при idx=i */ function syncConnectorAccent(card) { const svg = card.querySelector(".skill-task-card__connector-svg"); if (!svg) return; const raw = card.getAttribute("data-skill-connector-hover"); const idx = raw === null || raw === "" ? -1 : parseInt(raw, 10); const accent = cssVarColor(card, "--accent", "currentColor"); const active = Number.isFinite(idx) && idx >= 0; svg.querySelectorAll("[data-skill-conn]").forEach((el) => { const base = el.getAttribute("data-skill-conn-stroke") || ""; const spec = el.getAttribute("data-skill-conn"); let on = false; if (active && spec) { if (spec === "bridge") on = true; else if (spec.startsWith("spine:")) { const i = parseInt(spec.slice(6), 10); on = idx >= i; } else if (spec.startsWith("elbow:")) { const i = parseInt(spec.slice(6), 10); on = idx === i; } } el.setAttribute("stroke", on ? accent : base); }); } function bindConnectorHover(card) { const solutions = card.querySelector(".skill-task-card__solutions"); if (!solutions) return; const sols = solutions.querySelectorAll(".skill-task-card__solution"); if (!sols.length) return; sols.forEach((sol, idx) => { sol.addEventListener("mouseenter", () => { card.setAttribute("data-skill-connector-hover", String(idx)); syncConnectorAccent(card); }); sol.addEventListener("mouseleave", (e) => { const to = e.relatedTarget; if ( to && typeof to.nodeType === "number" && to.nodeType === 1 && solutions.contains(to) && to.classList.contains("skill-task-card__solution") ) { return; } card.removeAttribute("data-skill-connector-hover"); syncConnectorAccent(card); }); }); } /** * Четверть круга от (sx, my-r) до (sx+r, my) — внешнее скругление локтя L * (как border-bottom-left-radius). sweep 0 даёт выпуклую дугу наружу; sweep 1 — вогнутую. */ function elbowArc(shiftX, my, r) { if (r <= 0) return ""; const x0 = px(shiftX); const y0 = px(my - r); const x1 = px(shiftX + r); const y1 = px(my); /* rx/ry без px(): иначе дуга не сходится с тем же r в вертикали */ return `M ${x0} ${y0} A ${r} ${r} 0 0 0 ${x1} ${y1}`; } function drawCard(card) { try { const task = card.querySelector(".skill-task-card__task"); const solutions = card.querySelector(".skill-task-card__solutions"); const sols = solutions ? Array.from(solutions.querySelectorAll(".skill-task-card__solution")) : []; if (!task || !solutions || sols.length === 0) return; const firstSol = sols[0]; let rRaw = connectorRadiusPx(firstSol); const svg = ensureSvgLayer(card); clearSvg(svg); const cr = card.getBoundingClientRect(); const w = Math.max(1, card.offsetWidth); const h = Math.max(1, card.offsetHeight); svg.setAttribute("width", String(w)); svg.setAttribute("height", String(h)); svg.setAttribute("viewBox", `0 0 ${w} ${h}`); const shiftX = cssVarPx(card, "--skill-connector-shift-right", 0); const tr = task.getBoundingClientRect(); const sr = solutions.getBoundingClientRect(); const taskBottom = tr.bottom - cr.top; const gapTop = sr.top - tr.bottom; const bridgeLen = Math.max(0, gapTop); const bridgeEnd = taskBottom + bridgeLen; const colBridge = cssVarColor(card, "--skill-connector-color-bridge", "currentColor"); const colSpine = cssVarColor(card, "--skill-connector-color-spine", "currentColor"); const colBranch = cssVarColor(card, "--skill-connector-color-elbow-h", "currentColor"); const mids = sols.map((el) => { const rect = el.getBoundingClientRect(); return { midY: rect.top + rect.height / 2 - cr.top, left: rect.left - cr.left, }; }); const n = mids.length; /** Ограничить r: влезает в горизонталь и между соседними midY */ const armLens = mids.map((m) => Math.max(0, m.left - shiftX - 1)); const minArm = Math.min(...armLens, 999); const gaps = []; for (let i = 0; i < n - 1; i++) gaps.push(mids[i + 1].midY - mids[i].midY); const minVertGap = gaps.length ? Math.min(...gaps) : 999; let r = Math.min(rRaw, minArm, minVertGap > 2 ? (minVertGap / 2) * 0.95 : minVertGap); if (!Number.isFinite(r) || r < 0.25) r = 0; const eps = 1e-4; if (r <= 0) { if (gapTop > eps) { svg.appendChild(lineEl(shiftX, taskBottom, shiftX, bridgeEnd, colBridge, 1, "bridge")); } if (n > 0) { let y = bridgeEnd; for (let i = 0; i < n; i++) { const yEnd = mids[i].midY; if (yEnd > y + eps) { svg.appendChild(lineEl(shiftX, y, shiftX, yEnd, colSpine, 1, `spine:${i}`)); y = yEnd; } } } mids.forEach(({ midY, left }, i) => { svg.appendChild(lineEl(shiftX, midY, left, midY, colBranch, 1, `elbow:${i}`)); }); syncConnectorAccent(card); return; } /* Мост (синий): только над зоной, куда заходит жёлтый — иначе тот же участок рисуется жёлтым поверх и синий не виден */ const firstSpineY = bridgeEnd - Math.min(r, bridgeLen); if (gapTop > eps && firstSpineY > taskBottom + eps) { svg.appendChild(lineEl(shiftX, taskBottom, shiftX, firstSpineY, colBridge, 1, "bridge")); } /* Столбец: конец у касания дуги (my−r). i=0: старт вверх в зону моста. i≥1: ySpine = my_prev+r — предыдущий жёлтый кончился на my_prev−r, значит старт (ySpine−2r), иначе зазор r между сегментами (заметно при n≥3) */ let ySpine = bridgeEnd; for (let i = 0; i < n; i++) { const my = mids[i].midY; const yTo = my - r; const yFrom = i === 0 ? bridgeEnd - Math.min(r, bridgeLen) : ySpine - 2 * r; if (yTo > yFrom + eps) { svg.appendChild(lineEl(shiftX, yFrom, shiftX, yTo, colSpine, 1, `spine:${i}`)); } ySpine = my + r; } /* Локти: дуга A + горизонталь к карточке */ for (let i = 0; i < n; i++) { const { midY: my, left } = mids[i]; const arcD = elbowArc(shiftX, my, r); const hx1 = left; const hx0 = shiftX + r; if (hx1 > hx0 + eps) { svg.appendChild(pathEl(`${arcD} L ${px(hx1)} ${px(my)}`, colBranch, 1, "none", `elbow:${i}`)); } else { svg.appendChild(pathEl(arcD, colBranch, 1, "none", `elbow:${i}`)); } } syncConnectorAccent(card); } catch (_) { /* одна карточка не должна ломать остальные */ } } function redrawAll() { document.querySelectorAll(".skill-task-card").forEach(drawCard); } window.addEventListener("site-theme-applied", redrawAll, { passive: true }); function bind() { document.documentElement.setAttribute(ATTR, ""); redrawAll(); const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => redrawAll()) : null; document.querySelectorAll(".skill-task-card").forEach((card) => { ro?.observe(card); bindConnectorHover(card); }); let t = 0; window.addEventListener( "scroll", () => { if (t) return; t = window.requestAnimationFrame(() => { t = 0; redrawAll(); }); }, { passive: true }, ); window.addEventListener("resize", redrawAll, { passive: true }); if (document.fonts?.ready) { document.fonts.ready.then(redrawAll).catch(() => {}); } } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", bind, { once: true }); } else { bind(); } })();