Initial commit

This commit is contained in:
2026-05-12 23:33:46 +02:00
commit ccf32dcece
104 changed files with 17439 additions and 0 deletions

249
js/keywordCloud.js Normal file
View 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);
});
})();