Initial commit
This commit is contained in:
249
js/keywordCloud.js
Normal file
249
js/keywordCloud.js
Normal file
@@ -0,0 +1,249 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var container = document.getElementById('keyword-container');
|
||||
var tags = window.thalimTags;
|
||||
|
||||
if (!container || !tags || !tags.length) return;
|
||||
|
||||
// — init —
|
||||
|
||||
var GAP = 14;
|
||||
var COLORS = ['#e0775d', '#7cc0c6', '#e05680', '#46ae51', '#bb8dd9'];
|
||||
var colorTimeouts = new Map();
|
||||
|
||||
// Mélange aléatoire pour varier la disposition à chaque chargement
|
||||
var shuffled = tags.slice().sort(function () { return Math.random() - 0.5; });
|
||||
|
||||
// Crée les éléments une seule fois (réutilisés à chaque layout)
|
||||
var items = shuffled.map(function (tag) {
|
||||
var el = document.createElement('a');
|
||||
el.className = 'keyword';
|
||||
el.href = tag.url;
|
||||
el.textContent = tag.name;
|
||||
|
||||
el.addEventListener('mouseenter', function () {
|
||||
if (colorTimeouts.has(el)) clearTimeout(colorTimeouts.get(el));
|
||||
el.style.color = COLORS[Math.floor(Math.random() * COLORS.length)];
|
||||
colorTimeouts.set(el, setTimeout(function () {
|
||||
el.style.color = '';
|
||||
colorTimeouts.delete(el);
|
||||
}, 2000));
|
||||
});
|
||||
|
||||
container.appendChild(el);
|
||||
return { el: el, w: 0, h: 0 };
|
||||
});
|
||||
|
||||
var gradOverlay = null;
|
||||
var lastLayoutWidth = 0;
|
||||
|
||||
function layoutCloud() {
|
||||
var cw = container.offsetWidth;
|
||||
if (cw === lastLayoutWidth) return;
|
||||
lastLayoutWidth = cw;
|
||||
|
||||
var cx = cw / 2;
|
||||
var isMobile = cw < 768;
|
||||
var a = cx - GAP; // demi-grand axe horizontal
|
||||
var b = Math.round(cw * (isMobile ? 0.45 : 0.20)); // demi-petit axe vertical (plus haut sur mobile)
|
||||
var R_V = Math.round(b * (isMobile ? 0.45 : 0.70)); // demi-axe vertical de la zone d'exclusion (plus petit sur mobile)
|
||||
var R_H = Math.round(b * (isMobile ? 0.70 : 1.15)); // demi-axe horizontal (plus petit sur mobile)
|
||||
|
||||
// Re-mesurer les éléments (la taille peut changer avec le viewport)
|
||||
// +1 compense les arrondis sub-pixel sur écrans haute densité
|
||||
items.forEach(function (item) {
|
||||
var rect = item.el.getBoundingClientRect();
|
||||
item.w = Math.ceil(rect.width) + 1;
|
||||
item.h = Math.ceil(rect.height) + 1;
|
||||
});
|
||||
|
||||
var placed = [];
|
||||
|
||||
function hasOverlap(x, y, w, h) {
|
||||
for (var i = 0; i < placed.length; i++) {
|
||||
var p = placed[i];
|
||||
if (x < p.x + p.w + GAP &&
|
||||
x + w + GAP > p.x &&
|
||||
y < p.y + p.h + GAP &&
|
||||
y + h + GAP > p.y) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Placement contraint à l'anneau elliptique :
|
||||
// { intérieur ellipse (a, b) } ∩ { extérieur ellipse d'exclusion (R_H, R_V) }
|
||||
// Les candidats sont triés par distance à l'ellipse d'exclusion (croissante) :
|
||||
// les mots s'accumulent au plus près du centre avant de s'étendre.
|
||||
function findPos(w, h) {
|
||||
var candidates = [];
|
||||
|
||||
for (var x = 0; x <= cw - w; x += 8) {
|
||||
var dx = (x + w / 2) - cx;
|
||||
var ratio = dx / a;
|
||||
if (Math.abs(ratio) >= 1) continue;
|
||||
|
||||
// Limite verticale du centre du mot imposée par l'ellipse externe (avec marge h/2)
|
||||
var maxPcy = b * Math.sqrt(1 - ratio * ratio);
|
||||
if (maxPcy < h / 2) continue;
|
||||
var maxAbsY = maxPcy - h / 2;
|
||||
|
||||
// Limite verticale minimale imposée par l'ellipse d'exclusion (R_H, R_V)
|
||||
var minAbsY = (Math.abs(dx) >= R_H) ? 0
|
||||
: R_V * Math.sqrt(Math.max(0, 1 - (dx * dx) / (R_H * R_H)));
|
||||
if (minAbsY > maxAbsY) continue;
|
||||
|
||||
for (var absY = minAbsY; absY <= maxAbsY; absY += 4) {
|
||||
// Distance normalisée à l'ellipse d'exclusion (0 = sur le bord)
|
||||
var nx = dx / R_H, ny = absY / R_V;
|
||||
var dist = Math.sqrt(nx * nx + ny * ny) - 1;
|
||||
var yB = Math.round(absY - h / 2);
|
||||
if (absY > 0) {
|
||||
candidates.push({ x: x, y: Math.round(-absY - h / 2), dist: dist });
|
||||
}
|
||||
candidates.push({ x: x, y: yB, dist: dist });
|
||||
}
|
||||
}
|
||||
|
||||
// Trier par distance à l'ellipse d'exclusion croissante → attraction vers le centre
|
||||
candidates.sort(function (ca, cb) { return ca.dist - cb.dist; });
|
||||
|
||||
for (var i = 0; i < candidates.length; i++) {
|
||||
var c = candidates[i];
|
||||
if (!hasOverlap(c.x, c.y, w, h)) return { x: c.x, y: c.y };
|
||||
}
|
||||
|
||||
return null; // aucune position dans l'anneau
|
||||
}
|
||||
|
||||
// Place les mots dans l'anneau elliptique, collecte les débordements
|
||||
var overflow = [];
|
||||
items.forEach(function (item) {
|
||||
var pos = findPos(item.w, item.h);
|
||||
if (pos) {
|
||||
item.pos = { x: pos.x, y: pos.y, w: item.w, h: item.h };
|
||||
placed.push(item.pos);
|
||||
item.el.style.left = pos.x + 'px';
|
||||
item.el.style.top = pos.y + 'px';
|
||||
} else {
|
||||
overflow.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
// Placement des débordements en lignes centrées (style flex-wrap center)
|
||||
if (overflow.length) {
|
||||
var startY = placed.reduce(function (m, p) { return Math.max(m, p.y + p.h); }, 0) + GAP;
|
||||
var rows = [];
|
||||
var currentRow = [];
|
||||
var currentRowW = 0;
|
||||
|
||||
overflow.forEach(function (item) {
|
||||
var needed = currentRowW > 0 ? item.w + GAP : item.w;
|
||||
if (currentRowW + needed > cw && currentRow.length > 0) {
|
||||
rows.push(currentRow);
|
||||
currentRow = [];
|
||||
currentRowW = 0;
|
||||
}
|
||||
currentRow.push(item);
|
||||
currentRowW += (currentRowW > 0 ? GAP : 0) + item.w;
|
||||
});
|
||||
if (currentRow.length) rows.push(currentRow);
|
||||
|
||||
var curY = startY;
|
||||
rows.forEach(function (row) {
|
||||
var rowW = row.reduce(function (s, item) { return s + item.w; }, 0) + (row.length - 1) * GAP;
|
||||
var rowH = row.reduce(function (m, item) { return Math.max(m, item.h); }, 0);
|
||||
var offsetX = Math.round((cw - rowW) / 2);
|
||||
|
||||
row.forEach(function (item) {
|
||||
item.pos = { x: offsetX, y: curY, w: item.w, h: item.h };
|
||||
placed.push(item.pos);
|
||||
item.el.style.left = offsetX + 'px';
|
||||
item.el.style.top = curY + 'px';
|
||||
offsetX += item.w + GAP;
|
||||
});
|
||||
curY += rowH + GAP;
|
||||
});
|
||||
}
|
||||
|
||||
// Normalisation Y : cy=0 → shift px depuis le haut du conteneur
|
||||
var minY = items.reduce(function (m, item) { return Math.min(m, item.pos.y); }, Infinity);
|
||||
var shift = Math.max(0, GAP - minY);
|
||||
|
||||
items.forEach(function (item) {
|
||||
item.pos.y += shift;
|
||||
item.el.style.top = item.pos.y + 'px';
|
||||
});
|
||||
|
||||
// Hauteur basée sur le contenu réel (évite le débordement sur mobile)
|
||||
var maxPlacedY = items.reduce(function (m, item) { return Math.max(m, item.pos.y + item.pos.h); }, 0);
|
||||
container.style.height = (maxPlacedY + GAP) + 'px';
|
||||
|
||||
// Dégradé sur un overlay séparé pour permettre l'animation scale au survol
|
||||
if (gradOverlay) {
|
||||
container.removeChild(gradOverlay);
|
||||
}
|
||||
gradOverlay = document.createElement('div');
|
||||
gradOverlay.style.cssText =
|
||||
'position:absolute;inset:0;pointer-events:none;' +
|
||||
'background:radial-gradient(circle ' + Math.round(R_V * 2) + 'px at ' + cx + 'px ' + shift + 'px,' +
|
||||
'rgba(247,255,41,1) 0%,rgba(247,255,41,0.6) 16%,rgba(247,255,41,0.15) 55%,transparent 70%);' +
|
||||
'transform-origin:' + cx + 'px ' + shift + 'px;' +
|
||||
'transition:transform 0.4s ease;';
|
||||
container.insertBefore(gradOverlay, container.firstChild);
|
||||
}
|
||||
|
||||
// Premier layout
|
||||
layoutCloud();
|
||||
|
||||
// Événements du dégradé (référencent gradOverlay via closure)
|
||||
container.addEventListener('mouseenter', function () {
|
||||
if (!gradOverlay) return;
|
||||
gradOverlay.style.transition = 'transform 0.4s ease';
|
||||
gradOverlay.style.transform = 'scale(0.82)';
|
||||
});
|
||||
container.addEventListener('mousemove', function (e) {
|
||||
if (!gradOverlay) return;
|
||||
var cw = container.offsetWidth;
|
||||
var cx = cw / 2;
|
||||
var rect = container.getBoundingClientRect();
|
||||
var dx = (e.clientX - rect.left) - cx;
|
||||
var dy = (e.clientY - rect.top) - parseFloat(gradOverlay.style.transformOrigin.split(' ')[1]);
|
||||
var tx = (-dx * 0.09).toFixed(2);
|
||||
var ty = (-dy * 0.09).toFixed(2);
|
||||
gradOverlay.style.transition = 'transform 0.15s ease-out';
|
||||
gradOverlay.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(0.82)';
|
||||
});
|
||||
container.addEventListener('mouseleave', function () {
|
||||
if (!gradOverlay) return;
|
||||
gradOverlay.style.transition = 'transform 0.9s cubic-bezier(0.25, 0.46, 0.45, 0.94)';
|
||||
gradOverlay.style.transform = 'scale(1)';
|
||||
});
|
||||
|
||||
// Resize avec debounce
|
||||
var resizeTimer;
|
||||
window.addEventListener('resize', function () {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(function () {
|
||||
lastLayoutWidth = 0; // forcer le recalcul
|
||||
layoutCloud();
|
||||
}, 250);
|
||||
});
|
||||
|
||||
// Animation d'apparition au scroll
|
||||
var observer = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (entry) {
|
||||
if (entry.isIntersecting) {
|
||||
items.forEach(function (item, i) {
|
||||
item.el.style.animationDelay = (i * 0.03) + 's';
|
||||
item.el.classList.add('keyword--visible');
|
||||
});
|
||||
observer.unobserve(container);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.05 });
|
||||
|
||||
observer.observe(container);
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user