Files
site-portfolio/public/js/skill-connectors.js
T

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