305 lines
12 KiB
JavaScript
305 lines
12 KiB
JavaScript
/**
|
||
* Ортогональные коннекторы «задача → решения» через 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();
|
||
}
|
||
})();
|