Initial commit: Wycz WoW Portfolio
This commit is contained in:
@@ -0,0 +1,772 @@
|
||||
(function () {
|
||||
const CLASSES = [
|
||||
{ name: 'Warrior', fr: 'Guerrier', logo: 'War.png', color: '#C79C6E' },
|
||||
{ name: 'Paladin', fr: 'Paladin', logo: 'Pal.png', color: '#F58CBA' },
|
||||
{ name: 'Hunter', fr: 'Chasseur', logo: 'Hunt.png', color: '#ABD473' },
|
||||
{ name: 'Rogue', fr: 'Voleur', logo: 'Rogue.png', color: '#FFF569' },
|
||||
{ name: 'Priest', fr: 'Prêtre', logo: 'Priest.png', color: '#FFFFFF' },
|
||||
{ name: 'Death Knight', fr: 'Chevalier de la mort', logo: 'DK.png', color: '#C41F3B' },
|
||||
{ name: 'Shaman', fr: 'Chaman', logo: 'Sham.png', color: '#0070DE' },
|
||||
{ name: 'Mage', fr: 'Mage', logo: 'Mage.png', color: '#40C7EB' },
|
||||
{ name: 'Warlock', fr: 'Démoniste', logo: 'Lock.png', color: '#8787ED' },
|
||||
{ name: 'Monk', fr: 'Moine', logo: 'monk.png', color: '#00FF96' },
|
||||
{ name: 'Druid', fr: 'Druide', logo: 'Druid.png', color: '#FF7D0A' },
|
||||
{ name: 'Demon Hunter', fr: 'Chasseur de démons', logo: 'dh.png', color: '#A330C9' },
|
||||
{ name: 'Evoker', fr: 'Évocateur', logo: 'evoker.png', color: '#33937F' }
|
||||
];
|
||||
|
||||
const CLASS_BY_NAME = Object.fromEntries(CLASSES.map((c) => [c.name, c]));
|
||||
const classInfo = (name) => CLASS_BY_NAME[name] || { name: name || '?', fr: name || 'Inconnu', logo: null, color: '#8b94a7' };
|
||||
|
||||
const SPECS_FR = {
|
||||
'Elemental': 'Élémentaire', 'Enhancement': 'Amélioration', 'Restoration': 'Restauration',
|
||||
'Holy': 'Sacré', 'Protection': 'Protection', 'Retribution': 'Rétribution',
|
||||
'Discipline': 'Discipline', 'Shadow': 'Ombre', 'Blood': 'Sang', 'Frost': 'Givre', 'Unholy': 'Impiété',
|
||||
'Havoc': 'Dévastation', 'Vengeance': 'Vengeance', 'Devastation': 'Dévastation', 'Preservation': 'Préservation',
|
||||
'Augmentation': 'Augmentation', 'Arcane': 'Arcane', 'Fire': 'Feu', 'Assassination': 'Assassinat',
|
||||
'Outlaw': 'Hors-la-loi', 'Subtlety': 'Subtilité', 'Beast Mastery': 'Maîtrise des bêtes',
|
||||
'Marksmanship': 'Précision', 'Survival': 'Survie', 'Arms': 'Armes', 'Fury': 'Fureur',
|
||||
'Balance': 'Équilibre', 'Feral': 'Farouche', 'Guardian': 'Gardien', 'Brewmaster': 'Maître brasseur',
|
||||
'Mistweaver': 'Tisse-brume', 'Windwalker': 'Marche-vent', 'Affliction': 'Affliction',
|
||||
'Demonology': 'Démonologie', 'Destruction': 'Destruction'
|
||||
};
|
||||
const specFr = (s) => (s ? (SPECS_FR[s] || s) : null);
|
||||
|
||||
const SPECS_BY_CLASS = {
|
||||
'Death Knight': [
|
||||
{ key: 'Blood', fr: 'Sang', icon: 'spell_deathknight_bloodpresence.jpg' },
|
||||
{ key: 'Frost', fr: 'Givre', icon: 'spell_deathknight_frostpresence.jpg' },
|
||||
{ key: 'Unholy', fr: 'Impie', icon: 'spell_deathknight_unholypresence.jpg' }
|
||||
],
|
||||
'Demon Hunter': [
|
||||
{ key: 'Havoc', fr: 'Dévastation', icon: 'ability_demonhunter_specdps.jpg' },
|
||||
{ key: 'Vengeance', fr: 'Vengeance', icon: 'ability_demonhunter_spectank.jpg' },
|
||||
{ key: 'Devoration', fr: 'Dévoration', icon: 'classicon_demonhunter_void.jpg' }
|
||||
],
|
||||
'Druid': [
|
||||
{ key: 'Balance', fr: 'Équilibre', icon: 'spell_nature_starfall.jpg' },
|
||||
{ key: 'Feral', fr: 'Farouche', icon: 'ability_druid_catform.jpg' },
|
||||
{ key: 'Guardian', fr: 'Gardien', icon: 'ability_racial_bearform.jpg' },
|
||||
{ key: 'Restoration', fr: 'Restauration', icon: 'spell_nature_healingtouch.jpg' }
|
||||
],
|
||||
'Evoker': [
|
||||
{ key: 'Devastation', fr: 'Dévastation', icon: 'classicon_evoker_devastation.jpg' },
|
||||
{ key: 'Preservation', fr: 'Préservation', icon: 'classicon_evoker_preservation.jpg' },
|
||||
{ key: 'Augmentation', fr: 'Augmentation', icon: 'classicon_evoker_augmentation.jpg' }
|
||||
],
|
||||
'Hunter': [
|
||||
{ key: 'Beast Mastery', fr: 'Maîtrise des bêtes', icon: 'ability_hunter_bestialdiscipline.jpg' },
|
||||
{ key: 'Marksmanship', fr: 'Précision', icon: 'ability_hunter_focusedaim.jpg' },
|
||||
{ key: 'Survival', fr: 'Survie', icon: 'ability_hunter_camouflage.jpg' }
|
||||
],
|
||||
'Mage': [
|
||||
{ key: 'Arcane', fr: 'Arcanes', icon: 'spell_holy_magicalsentry.jpg' },
|
||||
{ key: 'Fire', fr: 'Feu', icon: 'spell_fire_firebolt02.jpg' },
|
||||
{ key: 'Frost', fr: 'Givre', icon: 'spell_frost_frostbolt02.jpg' }
|
||||
],
|
||||
'Monk': [
|
||||
{ key: 'Brewmaster', fr: 'Maître brasseur', icon: 'spell_monk_brewmaster_spec.jpg' },
|
||||
{ key: 'Mistweaver', fr: 'Tisse-brume', icon: 'spell_monk_mistweaver_spec.jpg' },
|
||||
{ key: 'Windwalker', fr: 'Marche-vent', icon: 'spell_monk_windwalker_spec.jpg' }
|
||||
],
|
||||
'Paladin': [
|
||||
{ key: 'Holy', fr: 'Sacré', icon: 'spell_holy_holybolt.jpg' },
|
||||
{ key: 'Protection', fr: 'Protection', icon: 'ability_paladin_shieldofthetemplar.jpg' },
|
||||
{ key: 'Retribution', fr: 'Vindicte', icon: 'spell_holy_auraoflight.jpg' }
|
||||
],
|
||||
'Priest': [
|
||||
{ key: 'Discipline', fr: 'Discipline', icon: 'spell_holy_powerwordshield.jpg' },
|
||||
{ key: 'Holy', fr: 'Sacré', icon: 'spell_holy_guardianspirit.jpg' },
|
||||
{ key: 'Shadow', fr: 'Ombre', icon: 'spell_shadow_shadowwordpain.jpg' }
|
||||
],
|
||||
'Rogue': [
|
||||
{ key: 'Assassination', fr: 'Assassinat', icon: 'ability_rogue_deadlybrew.jpg' },
|
||||
{ key: 'Outlaw', fr: 'Hors-la-loi', icon: 'ability_rogue_waylay.jpg' },
|
||||
{ key: 'Subtlety', fr: 'Finesse', icon: 'ability_stealth.jpg' }
|
||||
],
|
||||
'Shaman': [
|
||||
{ key: 'Elemental', fr: 'Élémentaire', icon: 'spell_nature_lightning.jpg' },
|
||||
{ key: 'Enhancement', fr: 'Amélioration', icon: 'spell_shaman_improvedstormstrike.jpg' },
|
||||
{ key: 'Restoration', fr: 'Restauration', icon: 'spell_nature_magicimmunity.jpg' }
|
||||
],
|
||||
'Warlock': [
|
||||
{ key: 'Affliction', fr: 'Affliction', icon: 'spell_shadow_deathcoil.jpg' },
|
||||
{ key: 'Demonology', fr: 'Démonologie', icon: 'spell_shadow_metamorphosis.jpg' },
|
||||
{ key: 'Destruction', fr: 'Destruction', icon: 'spell_shadow_rainoffire.jpg' }
|
||||
],
|
||||
'Warrior': [
|
||||
{ key: 'Arms', fr: 'Armes', icon: 'ability_warrior_innerrage.jpg' },
|
||||
{ key: 'Fury', fr: 'Fureur', icon: 'ability_warrior_savageblow.jpg' },
|
||||
{ key: 'Protection', fr: 'Protection', icon: 'ability_warrior_defensivestance.jpg' }
|
||||
]
|
||||
};
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
|
||||
let classGroups = new Map();
|
||||
let classTitles = { glad: [], legend: [] };
|
||||
|
||||
function fmt(n) {
|
||||
if (n === null || n === undefined) return '—';
|
||||
return n.toLocaleString('fr-FR');
|
||||
}
|
||||
|
||||
function fmtSet(v) {
|
||||
if (v === null || v === undefined || v === '' || v === 0) return '—';
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function pct(n, d) {
|
||||
return d > 0 ? Math.max(0, Math.min(100, Math.round((n / d) * 100))) : 0;
|
||||
}
|
||||
|
||||
function charTopRating(c) {
|
||||
let best = 0;
|
||||
const pm = c.pvp_manual || {};
|
||||
for (const k of ['ARENA_2v2', 'ARENA_3v3', 'RATED_BG']) {
|
||||
const m = pm[k] || {};
|
||||
const v = Math.max(m.max || 0, m.season || 0);
|
||||
if (v > best) best = v;
|
||||
}
|
||||
const specs = pm.specs || {};
|
||||
for (const k of Object.keys(specs)) {
|
||||
for (const br of ['SOLO_SHUFFLE', 'BLITZ']) {
|
||||
const m = specs[k][br] || {};
|
||||
const v = Math.max(m.max || 0, m.season || 0);
|
||||
if (v > best) best = v;
|
||||
}
|
||||
}
|
||||
if (best) return best;
|
||||
const auto = (c.data && (c.data.blizzard || c.data.raiderio || {}).pvp) || {};
|
||||
return Math.max(0, ...Object.values(auto).map((x) => (x && typeof x.rating === 'number' ? x.rating : 0)));
|
||||
}
|
||||
|
||||
function armoryImage(thumbnail) {
|
||||
if (!thumbnail) return null;
|
||||
return thumbnail.replace(/-avatar\.jpg/i, '-inset.jpg');
|
||||
}
|
||||
|
||||
function overviewBadges(classes) {
|
||||
const covered = new Set(classes || []);
|
||||
const items = CLASSES.map((cls) => {
|
||||
const done = covered.has(cls.name);
|
||||
const img = cls.logo
|
||||
? `<img class="overview-badge" src="/images/classes/${esc(cls.logo)}" alt="${esc(cls.fr)}" title="${esc(cls.fr)}" loading="lazy">`
|
||||
: `<span class="overview-badge overview-badge-txt" title="${esc(cls.fr)}">${esc(cls.fr[0])}</span>`;
|
||||
return `<span class="goal-class${done ? ' done' : ''}" title="${esc(cls.fr)}">${img}${done ? '<span class="goal-check">✓</span>' : ''}</span>`;
|
||||
}).join('');
|
||||
return `<div class="overview-badges">${items}</div>`;
|
||||
}
|
||||
|
||||
const BEST_ORDER = [['ARENA_2v2', '2v2'], ['ARENA_3v3', '3v3'], ['SOLO_SHUFFLE', 'Solo Shuffle'], ['BLITZ', 'Blitz'], ['RATED_BG', 'RBG']];
|
||||
|
||||
const BEST_COLOR = {
|
||||
ARENA_2v2: '#5aa9ff',
|
||||
ARENA_3v3: '#ff5c5c',
|
||||
SOLO_SHUFFLE: '#37e0ff',
|
||||
BLITZ: '#ffb347',
|
||||
RATED_BG: '#7ed957'
|
||||
};
|
||||
|
||||
function bestsTable(bests) {
|
||||
const rows = BEST_ORDER.map(([key, label]) => {
|
||||
const b = bests[key] || null;
|
||||
if (!b) {
|
||||
return `<div class="best-panel" style="--brk:${BEST_COLOR[key] || '#5aa9ff'}">
|
||||
<div class="best-head"><span class="best-brk">${label}</span></div>
|
||||
<div class="best-empty">—</div>
|
||||
</div>`;
|
||||
}
|
||||
const info = classInfo(b.class);
|
||||
const spec = b.spec ? specFr(b.spec) : null;
|
||||
const specEntry = (SPECS_BY_CLASS[info.name] || []).find((s) => s.key === b.spec);
|
||||
const avatar = specEntry && specEntry.icon
|
||||
? `<img class="best-avatar" src="/images/specs/${esc(specEntry.icon)}" alt="" loading="lazy">`
|
||||
: (info.logo ? `<img class="best-avatar" src="/images/classes/${esc(info.logo)}" alt="" loading="lazy">` : '<span class="best-avatar best-avatar-empty"></span>');
|
||||
const tier = tierFor(b.rating);
|
||||
return `<div class="best-panel" style="--brk:${BEST_COLOR[key] || '#5aa9ff'}">
|
||||
<div class="best-head">
|
||||
<span class="best-brk">${label}</span>
|
||||
${tier ? `<span class="brk-tier" style="color:${tier.color};border-color:${tier.color}">${tier.name}</span>` : ''}
|
||||
</div>
|
||||
<div class="best-char">
|
||||
${avatar}
|
||||
<div class="best-cname">${esc(info.fr)}${spec ? `<span class="muted"> · ${esc(spec)}</span>` : ''}</div>
|
||||
<div class="best-csub">${esc(b.name || '')}</div>
|
||||
</div>
|
||||
<div class="best-rating">${fmt(b.rating)}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `<div class="overview-card bests-card">
|
||||
<div class="overview-head"><h3>Meilleure classe & spé par bracket</h3></div>
|
||||
<div class="bests-grid">${rows}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function overviewCard(g) {
|
||||
const manualBadge = g.manual && g.key !== 'achievements' ? '<span class="badge muted">manuel</span>' : '';
|
||||
const done = !!g.achieved;
|
||||
const doneBadge = done ? '<span class="badge done">✓ Atteint</span>' : '';
|
||||
let value;
|
||||
let sub;
|
||||
let progress = 0;
|
||||
let badges = '';
|
||||
if (g.key === 'class90' || g.key === 'ss2300') {
|
||||
value = `<b>${fmt(g.covered)}</b> / ${g.total} classes`;
|
||||
sub = g.total > 0 ? `${pct(g.covered, g.total)}%` : '—';
|
||||
progress = g.total > 0 ? g.covered / g.total : 0;
|
||||
badges = overviewBadges(g.classes);
|
||||
} else if (g.key === 'gladiator_s2') {
|
||||
const w = g.wins || 0;
|
||||
const t = g.total || 50;
|
||||
value = g.obtained ? '<b style="color:var(--green)">Obtenu</b>' : `<b>${fmt(w)}</b> / ${fmt(t)} victoires`;
|
||||
sub = '';
|
||||
progress = t > 0 ? Math.min(1, w / t) : 0;
|
||||
badges = overviewBadges(g.classes || []);
|
||||
} else if (g.key === 'achievements') {
|
||||
const pc = g.total > 0 ? pct(g.points, g.total) : 0;
|
||||
value = `<b>${fmt(g.points)}</b> / ${fmt(g.total)}`;
|
||||
sub = `${pc}% · Points de hauts faits`;
|
||||
progress = g.total > 0 ? Math.min(1, g.points / g.total) : 0;
|
||||
}
|
||||
return `
|
||||
<div class="overview-card${done ? ' achieved' : ''}">
|
||||
<div class="overview-head">
|
||||
<h3>${esc(g.title)}</h3>
|
||||
${manualBadge}
|
||||
${doneBadge}
|
||||
</div>
|
||||
<div class="overview-value">${value}</div>
|
||||
${sub ? `<div class="overview-sub">${esc(sub)}</div>` : ''}
|
||||
<div class="progress-bar"><div class="fill" style="width:${pct(progress * 100, 100)}%"></div></div>
|
||||
${badges}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const TIERS = [
|
||||
{ min: 2400, name: 'Elite', color: '#ff8000' },
|
||||
{ min: 2100, name: 'Duelist', color: '#a335ee' },
|
||||
{ min: 1800, name: 'Rival', color: '#0070dd' },
|
||||
{ min: 1400, name: 'Challenger', color: '#1eff00' },
|
||||
{ min: 0, name: 'Combatant', color: '#9d9d9d' }
|
||||
];
|
||||
const tierFor = (rating) => {
|
||||
if (!rating || rating <= 0) return null;
|
||||
return TIERS.find((t) => rating >= t.min) || null;
|
||||
};
|
||||
|
||||
const brkBlock = (label, color, b) => {
|
||||
const current = b.season || null;
|
||||
const max = b.max || null;
|
||||
const tier = tierFor(current);
|
||||
const total = (b.won || 0) + (b.lost || 0);
|
||||
const wr = total > 0 ? Math.round((b.won / total) * 100) : null;
|
||||
return `
|
||||
<div class="brk-block" style="--brk:${color}">
|
||||
<div class="brk-head">
|
||||
<span class="brk-name">${label}</span>
|
||||
${tier ? `<span class="brk-tier" style="color:${tier.color};border-color:${tier.color}">${tier.name}</span>` : '<span class="brk-tier muted">Non classé</span>'}
|
||||
${b.isRecord ? '<span class="brk-record">★ Record</span>' : ''}
|
||||
</div>
|
||||
<div class="brk-rating">
|
||||
<span class="brk-current">${current ? fmt(current) : '—'}</span>
|
||||
${max != null ? `<span class="brk-maxsep">/</span><span class="brk-max" title="Max XP (saison en cours)">${fmt(max)}</span>` : ''}
|
||||
</div>
|
||||
<div class="brk-sub">${current != null ? `Côte actuelle · ` : ''}${max != null ? `Max XP ${fmt(max)}` : (current != null ? 'Aucun max enregistré' : 'Aucune côte')}</div>
|
||||
<div class="brk-bar${total ? '' : ' empty'}"><div class="brk-bar-fill" style="width:${wr || 0}%"></div></div>
|
||||
<div class="brk-wl">${fmt(b.won)}V · ${fmt(b.lost)}D${wr != null ? ` <b>${wr}%</b>` : ''}</div>
|
||||
<div class="brk-games">${total ? `${fmt(total)} games` : 'Aucune game cette saison'}</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
function bracketValues(c) {
|
||||
const pm = c.pvp_manual || {};
|
||||
const auto = c.pvp || {};
|
||||
const autoOf = (key) => (auto[key] && typeof auto[key].rating === 'number' ? auto[key] : null);
|
||||
const resolve = (key, m) => {
|
||||
const a = autoOf(key) || {};
|
||||
const season = m.season || (a.rating || null);
|
||||
const max = m.max || (c.pvp_best && c.pvp_best[key]) || null;
|
||||
return {
|
||||
season,
|
||||
max,
|
||||
won: m.won || a.won || 0,
|
||||
lost: m.lost || a.lost || 0,
|
||||
weekly_won: a.weekly_won || 0,
|
||||
weekly_lost: a.weekly_lost || 0,
|
||||
tier: a.tier || null,
|
||||
isRecord: !!(c.pvp_records && c.pvp_records[key])
|
||||
};
|
||||
};
|
||||
return {
|
||||
generalVal: (key) => resolve(key, pm[key] || {}),
|
||||
specVal: (spec, key) => {
|
||||
const s = (pm.specs && pm.specs[spec]) || {};
|
||||
return resolve(key, s[key] || {});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function bracketsGrid(c, spec) {
|
||||
const { generalVal, specVal } = bracketValues(c);
|
||||
const PANELS = [
|
||||
['2v2', '#5aa9ff', generalVal('ARENA_2v2')],
|
||||
['3v3', '#ff5c5c', generalVal('ARENA_3v3')],
|
||||
['Solo', '#37e0ff', spec ? specVal(spec, 'SOLO_SHUFFLE') : null],
|
||||
['Blitz', '#ffb347', spec ? specVal(spec, 'BLITZ') : null],
|
||||
['RBG', '#7ed957', generalVal('RATED_BG')]
|
||||
];
|
||||
return `<div class="brk-grid">${PANELS.map(([label, color, b]) => brkBlock(label, color, b)).join('')}</div>`;
|
||||
}
|
||||
|
||||
let modalChars = [];
|
||||
let modalSpecByChar = {};
|
||||
const fullCache = {};
|
||||
function getFull(id) {
|
||||
if (!fullCache[id]) fullCache[id] = api.get(`/api/characters/${id}`);
|
||||
return fullCache[id];
|
||||
}
|
||||
|
||||
function charRow(c) {
|
||||
const info = classInfo(c.class);
|
||||
const frozen = (c.media && (c.media.main || c.media.inset || c.media.avatar)) || renderImage(c.thumbnail_url) || c.thumbnail_url || null;
|
||||
const ilvl = c.ilvl || null;
|
||||
const specs = SPECS_BY_CLASS[info.name] || [];
|
||||
const sel = modalSpecByChar[c.id] || null;
|
||||
const specPanels = specs.map((s) => `
|
||||
<button type="button" class="wm-spec-panel${sel === s.key ? ' on' : ''}" data-char="${c.id}" data-spec="${esc(s.key)}" title="${esc(s.fr)}">
|
||||
<img class="spec-icon" src="/images/specs/${esc(s.icon)}" alt="" loading="lazy">
|
||||
<span>${esc(s.fr)}</span>
|
||||
</button>`).join('');
|
||||
const metaBits = [];
|
||||
if (c.realm) metaBits.push(esc(c.realm));
|
||||
if (c.race) metaBits.push(esc(c.race));
|
||||
const faction = c.faction === 'ALLIANCE' || c.faction === 'Alliance' ? 'Alliance' : c.faction === 'HORDE' || c.faction === 'Horde' ? 'Horde' : null;
|
||||
if (faction) metaBits.push(faction);
|
||||
const metaText = metaBits.filter(Boolean).join(' · ');
|
||||
let detail = '';
|
||||
if (sel) {
|
||||
detail = `
|
||||
<div class="wm-detail">
|
||||
${bracketsGrid(c, sel)}
|
||||
<div class="modal-section wm-section">
|
||||
<h4 class="modal-section-title">Équipement</h4>
|
||||
<div class="modal-gear" data-gear="${c.id}"><div class="loading">Chargement…</div></div>
|
||||
</div>
|
||||
<div class="modal-section wm-section">
|
||||
<h4 class="modal-section-title">Spécialisation</h4>
|
||||
<div class="modal-talents" data-talents="${c.id}"><div class="loading">Chargement…</div></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
return `
|
||||
<div class="class-char wm-card" data-id="${c.id}" style="--class-color:${info.color}">
|
||||
<div class="wm-specs wm-specs-top">${specPanels}</div>
|
||||
<div class="wm-img" aria-hidden="true">
|
||||
${frozen ? `<img src="${esc(frozen)}" alt="" loading="lazy">` : `<span class="wm-img-empty">${esc(c.name[0])}</span>`}
|
||||
</div>
|
||||
<div class="wm-info">
|
||||
<div class="wm-name">${c.check_pvp_url ? `<a class="char-link" href="${esc(c.check_pvp_url)}" target="_blank" rel="noopener">${esc(c.name)}</a>` : esc(c.name)}</div>
|
||||
<div class="wm-meta">${metaText}${ilvl != null ? `<span class="wm-ilvl">ILVL ${fmt(ilvl)}</span>` : ''}</div>
|
||||
</div>
|
||||
<div class="wm-titles">${classTitleEntries(info, classTitles)}</div>
|
||||
${detail}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function titleTags(cls, titles) {
|
||||
const parts = [];
|
||||
if ((titles.glad || []).includes(cls.name)) parts.push('<img class="hdr-vignette" src="/images/titles/gladiateur.jpg" alt="Gladiateur" title="Titre Gladiateur">');
|
||||
if ((titles.legend || []).includes(cls.name)) parts.push('<img class="hdr-vignette" src="/images/titles/legende.webp" alt="Légende" title="Titre Légende">');
|
||||
if (!parts.length) return '';
|
||||
return `<span class="hdr-titles">${parts.join('')}</span>`;
|
||||
}
|
||||
|
||||
function classTitleEntries(cls, titles) {
|
||||
const items = [];
|
||||
for (const e of (titles.glad_entries || [])) {
|
||||
if (e.class === cls.name) items.push(`<span class="wm-title"><img src="/images/titles/gladiateur.jpg" alt="Gladiateur" title="Titre Gladiateur"><b>Gladiateur</b>${e.season ? esc(e.season) : ''}</span>`);
|
||||
}
|
||||
for (const e of (titles.legend_entries || [])) {
|
||||
if (e.class === cls.name) items.push(`<span class="wm-title"><img src="/images/titles/legende.webp" alt="Légende" title="Titre Légende"><b>Légende</b>${e.season ? esc(e.season) : ''}</span>`);
|
||||
}
|
||||
if (!items.length) {
|
||||
if ((titles.glad || []).includes(cls.name)) items.push(`<span class="wm-title"><img src="/images/titles/gladiateur.jpg" alt="Gladiateur" title="Titre Gladiateur"><b>Gladiateur</b>${titles.glad_season ? esc(titles.glad_season) : ''}</span>`);
|
||||
if ((titles.legend || []).includes(cls.name)) items.push(`<span class="wm-title"><img src="/images/titles/legende.webp" alt="Légende" title="Titre Légende"><b>Légende</b>${titles.legend_season ? esc(titles.legend_season) : ''}</span>`);
|
||||
}
|
||||
return items.join('');
|
||||
}
|
||||
|
||||
function renderModalChars() {
|
||||
if (!modalChars.length) {
|
||||
$('#class-modal-chars').innerHTML = '<div class="class-empty">Aucun personnage pour cette classe</div>';
|
||||
return;
|
||||
}
|
||||
$('#class-modal-chars').innerHTML = modalChars.map((c) => charRow(c)).join('');
|
||||
for (const c of modalChars) {
|
||||
const sel = modalSpecByChar[c.id];
|
||||
if (!sel) continue;
|
||||
const gearEl = document.querySelector(`[data-gear="${c.id}"]`);
|
||||
const talentsEl = document.querySelector(`[data-talents="${c.id}"]`);
|
||||
if (gearEl) {
|
||||
getFull(c.id).then((full) => {
|
||||
if (gearEl) gearEl.innerHTML = gearHTML(full.gear);
|
||||
}).catch(() => {
|
||||
if (gearEl) gearEl.innerHTML = '<div class="gear-empty">Équipement indisponible</div>';
|
||||
});
|
||||
}
|
||||
if (talentsEl) {
|
||||
talentsEl.innerHTML = '<div class="loading">Chargement…</div>';
|
||||
api.get(`/api/characters/${c.id}/talents?spec=${encodeURIComponent(sel)}`).then((t) => {
|
||||
if (talentsEl) talentsEl.innerHTML = talentTreeHTML(t);
|
||||
}).catch(() => {
|
||||
if (talentsEl) talentsEl.innerHTML = '<div class="gear-empty">Détails indisponibles</div>';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openClassModal(cls, chars, titles) {
|
||||
modalChars = chars;
|
||||
modalSpecByChar = {};
|
||||
$('#class-modal-logo').src = cls.logo ? `/images/classes/${cls.logo}` : '';
|
||||
$('#class-modal-logo').style.display = cls.logo ? '' : 'none';
|
||||
renderModalChars();
|
||||
$('#class-modal-backdrop').hidden = false;
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
$('#class-modal-chars').addEventListener('click', (e) => {
|
||||
const panel = e.target.closest('.wm-spec-panel');
|
||||
if (!panel) return;
|
||||
const id = panel.dataset.char;
|
||||
const spec = panel.dataset.spec;
|
||||
modalSpecByChar[id] = modalSpecByChar[id] === spec ? null : spec;
|
||||
renderModalChars();
|
||||
const selPanel = $('#class-modal-chars').querySelector(`.wm-spec-panel.on[data-char="${CSS.escape(id)}"]`);
|
||||
if (selPanel) {
|
||||
selPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
const detail = selPanel.closest('.class-char').querySelector('.wm-detail');
|
||||
if (detail) detail.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
});
|
||||
|
||||
function closeClassModal() {
|
||||
$('#class-modal-backdrop').hidden = true;
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const statusEl = $('#header-status');
|
||||
const overviewEl = $('#overview-grid');
|
||||
|
||||
try {
|
||||
const [overview, chars] = await Promise.all([api.get('/api/overview'), api.get('/api/characters')]);
|
||||
const cards = [
|
||||
{ key: 'class90', title: 'Classes niveau 90', covered: overview.class90.covered, total: overview.class90.total, manual: overview.class90.manual, classes: overview.class90.classes, achieved: overview.class90.total > 0 && overview.class90.covered >= overview.class90.total },
|
||||
{ key: 'ss2300', title: 'Toutes les classes à 2300 en soloshuffle — Saison 2', covered: overview.ss2300.covered, total: overview.ss2300.total, manual: overview.ss2300.manual, classes: overview.ss2300.classes, achieved: overview.ss2300.total > 0 && overview.ss2300.covered >= overview.ss2300.total },
|
||||
{ key: 'gladiator_s2', title: 'Gladiateur S2 Midnight', obtained: overview.gladiator_s2.obtained, wins: overview.gladiator_s2.wins, total: overview.gladiator_s2.total, rating: overview.gladiator_s2.rating, class: overview.gladiator_s2.class, classes: (overview.titles && overview.titles.glad_s2) || [], achieved: !!overview.gladiator_s2.obtained }
|
||||
];
|
||||
overviewEl.innerHTML = cards.map(overviewCard).join('') + bestsTable(overview.bests || {});
|
||||
|
||||
const grouped = new Map();
|
||||
for (const c of chars) {
|
||||
const key = classInfo(c.class).name;
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
grouped.get(key).push(c);
|
||||
}
|
||||
for (const list of grouped.values()) {
|
||||
list.sort((a, b) => charTopRating(b) - charTopRating(a));
|
||||
}
|
||||
classGroups = grouped;
|
||||
classTitles = overview.titles || { glad: [], legend: [] };
|
||||
|
||||
const headerClasses = $('#header-classes');
|
||||
if (headerClasses) {
|
||||
const class90 = new Set(overview.class90.classes || []);
|
||||
headerClasses.innerHTML = CLASSES.map((cls) => `
|
||||
<button type="button" class="class-icon header-class-icon${class90.has(cls.name) ? '' : ' dim'}" data-class="${esc(cls.name)}" style="--class-color:${cls.color}" title="${esc(cls.fr)}">
|
||||
${cls.logo ? `<img class="class-icon-img" src="/images/classes/${esc(cls.logo)}" alt="${esc(cls.fr)}" loading="lazy">` : `<span class="class-icon-empty">${esc(cls.fr[0])}</span>`}
|
||||
${titleTags(cls, classTitles)}
|
||||
</button>`).join('');
|
||||
}
|
||||
|
||||
const twitchUrl = overview.twitch.url || 'https://twitch.tv/wycz';
|
||||
updateLiveStatus(statusEl, twitchUrl);
|
||||
renderSocials($('#socials'), {
|
||||
youtube: (overview.youtube && overview.youtube.url) || '',
|
||||
tiktok: (overview.tiktok && overview.tiktok.url) || ''
|
||||
});
|
||||
} catch (e) {
|
||||
overviewEl.innerHTML = `<div class="loading">Erreur de chargement : ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateLiveStatus(statusEl, twitchUrl) {
|
||||
const channel = twitchUrl.replace(/\/+$/, '').split('/').pop() || 'wycz';
|
||||
const safeChannel = encodeURIComponent(channel);
|
||||
const link = `<a class="live-status" href="${esc(twitchUrl)}" target="_blank" rel="noopener">`;
|
||||
const twitchLogo = `<span class="twitch-logo-img">${SOCIAL_SVG.twitch}</span>`;
|
||||
try {
|
||||
const res = await fetch(`https://decapi.me/twitch/uptime/${safeChannel}`, { mode: 'cors' });
|
||||
const text = (await res.text()).trim();
|
||||
const isLive = text && !/offline|hors ligne|hors-ligne|not live/i.test(text);
|
||||
statusEl.innerHTML = `${link}${twitchLogo}<span class="live-dot${isLive ? ' on' : ''}"></span></a>`;
|
||||
} catch {
|
||||
statusEl.innerHTML = `${link}${twitchLogo}<span class="live-dot"></span></a>`;
|
||||
}
|
||||
}
|
||||
|
||||
const SOCIAL_SVG = {
|
||||
twitch: '<svg viewBox="0 0 24 24"><path d="M4.265 0L.79 3.517v16.966h5.804V24h3.225l3.476-3.517h4.455L23.21 12V0H4.265zm17.55 11.31l-3.225 3.586h-4.586l-3.215 3.379v-3.379H5.526V2.62h16.29v8.69z"/></svg>',
|
||||
youtube: '<svg viewBox="0 0 24 24"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>',
|
||||
tiktok: '<svg viewBox="0 0 24 24"><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg>'
|
||||
};
|
||||
|
||||
function renderSocials(el, socials) {
|
||||
const buttons = [];
|
||||
if (socials.youtube) {
|
||||
buttons.push(`<a class="social-btn" href="${esc(socials.youtube)}" target="_blank" rel="noopener" title="YouTube">${SOCIAL_SVG.youtube}</a>`);
|
||||
}
|
||||
if (socials.tiktok) {
|
||||
buttons.push(`<a class="social-btn" href="${esc(socials.tiktok)}" target="_blank" rel="noopener" title="TikTok">${SOCIAL_SVG.tiktok}</a>`);
|
||||
}
|
||||
el.innerHTML = buttons.join('');
|
||||
}
|
||||
|
||||
const backdrop = $('#modal-backdrop');
|
||||
const modalArt = $('#modal-art');
|
||||
const modalError = $('#modal-error');
|
||||
|
||||
function closeModal() {
|
||||
backdrop.hidden = true;
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const copyBtn = e.target.closest('[data-copy]');
|
||||
if (copyBtn) {
|
||||
navigator.clipboard.writeText(copyBtn.dataset.copy || '').then(() => {
|
||||
copyBtn.textContent = 'Copié !';
|
||||
setTimeout(() => { copyBtn.textContent = 'Copier le build'; }, 1600);
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const classIcon = e.target.closest('.class-icon');
|
||||
if (classIcon) {
|
||||
const name = classIcon.dataset.class;
|
||||
openClassModal(classInfo(name), classGroups.get(name) || [], classTitles);
|
||||
return;
|
||||
}
|
||||
if (e.target === $('#class-modal-backdrop')) {
|
||||
closeClassModal();
|
||||
return;
|
||||
}
|
||||
const openBtn = e.target.closest('[data-open3d]');
|
||||
if (openBtn) {
|
||||
if (e.target.closest('a')) return;
|
||||
const id = openBtn.dataset.open3d;
|
||||
modalError.hidden = true;
|
||||
modalArt.style.display = 'flex';
|
||||
modalArt.innerHTML = '<div class="loading">Chargement…</div>';
|
||||
$('#modal-stats').innerHTML = '';
|
||||
$('#modal-title').textContent = '…';
|
||||
$('#modal-subtitle').textContent = '';
|
||||
backdrop.hidden = false;
|
||||
document.body.style.overflow = 'hidden';
|
||||
api.get(`/api/characters/${id}`).then((full) => {
|
||||
$('#modal-title').textContent = full.name;
|
||||
const sub = [];
|
||||
if (full.spec) sub.push(specFr(full.spec));
|
||||
if (full.race) sub.push(full.race);
|
||||
if (full.faction) sub.push(full.faction);
|
||||
sub.push(full.realm);
|
||||
$('#modal-subtitle').textContent = sub.filter(Boolean).join(' · ');
|
||||
const thumb = (full.media && full.media.main) || full.media?.avatar || full.thumbnail_url || null;
|
||||
const img = full.media && full.media.main ? thumb : renderImage(thumb);
|
||||
if (img) {
|
||||
modalArt.innerHTML = `<img src="${esc(img)}" alt="">`;
|
||||
} else {
|
||||
modalArt.innerHTML = '<div class="loading">Image de l\'armurerie non disponible</div>';
|
||||
}
|
||||
renderStats(full);
|
||||
$('#modal-gear').innerHTML = gearHTML(full.gear);
|
||||
if (full.spec) {
|
||||
$('#modal-talents').innerHTML = '<div class="loading">Chargement…</div>';
|
||||
api.get(`/api/characters/${id}/talents?spec=${encodeURIComponent(full.spec)}`).then((t) => {
|
||||
$('#modal-talents').innerHTML = talentTreeHTML(t);
|
||||
}).catch(() => {
|
||||
$('#modal-talents').innerHTML = talentHTML(full);
|
||||
});
|
||||
} else {
|
||||
$('#modal-talents').innerHTML = talentHTML(full);
|
||||
}
|
||||
}).catch((err) => {
|
||||
modalArt.innerHTML = '';
|
||||
modalError.textContent = `Impossible d'afficher les détails : ${err.message}`;
|
||||
modalError.hidden = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (e.target === backdrop) closeModal();
|
||||
});
|
||||
|
||||
function renderImage(thumbnail) {
|
||||
if (!thumbnail) return null;
|
||||
return thumbnail.split('?')[0].replace(/-avatar\.jpg/i, '-main-raw.png');
|
||||
}
|
||||
|
||||
const GEAR_ORDER = [
|
||||
['head', 'Tête'], ['neck', 'Cou'], ['shoulder', 'Épaules'], ['back', 'Dos'], ['chest', 'Torse'],
|
||||
['wrist', 'Poignets'], ['hands', 'Mains'], ['waist', 'Taille'], ['legs', 'Jambes'], ['feet', 'Pieds'],
|
||||
['finger1', 'Doigt 1'], ['finger2', 'Doigt 2'], ['trinket1', 'Bijou 1'], ['trinket2', 'Bijou 2'],
|
||||
['mainhand', 'Main droite'], ['offhand', 'Main gauche']
|
||||
];
|
||||
const QUALITY_COLOR = ['#9d9d9d', '#ffffff', '#1eff00', '#0070dd', '#a335ee', '#ff8000', '#e6cc80'];
|
||||
|
||||
function gearHTML(gear) {
|
||||
if (!gear || !gear.length) return '<div class="gear-empty">Aucun équipement disponible (données Raider.io)</div>';
|
||||
const bySlot = {};
|
||||
for (const it of gear) bySlot[it.slot_name] = it;
|
||||
const items = GEAR_ORDER.map(([slot, label]) => bySlot[slot] || { slot, slot_name: slot, label, empty: true });
|
||||
const main = (it) => it.empty
|
||||
? `<span class="gear-item gear-empty-item">
|
||||
<span class="gear-icon"><span class="gear-slot-letter">${esc((it.label || it.slot_name || '?')[0])}</span></span>
|
||||
<span class="gear-info"><span class="gear-name">${esc(it.label || it.slot_name)}</span><span class="gear-ilvl">Vide</span></span>
|
||||
</span>`
|
||||
: `<span class="gear-item" style="--q:${it.quality >= 0 ? QUALITY_COLOR[Math.min(it.quality, QUALITY_COLOR.length - 1)] : 'var(--text)'}">
|
||||
<span class="gear-icon">${it.icon
|
||||
? `<img src="https://wow.zamimg.com/images/wow/icons/large/${esc(it.icon)}.jpg" alt="" loading="lazy" onerror="this.style.display='none'">`
|
||||
: `<span class="gear-slot-letter">${esc((it.name || '?')[0])}</span>`}</span>
|
||||
<span class="gear-info">
|
||||
${it.item_id
|
||||
? `<a class="gear-name" data-wowhead="item=${it.item_id}${it.item_level ? `&ilvl=${it.item_level}` : ''}&domain=fr" href="https://www.wowhead.com/item=${it.item_id}" target="_blank" rel="noopener">${esc(it.name)}</a>`
|
||||
: `<span class="gear-name">${esc(it.name)}</span>`}
|
||||
<span class="gear-ilvl">${it.item_level ? `ILVL ${it.item_level}` : ''}</span>
|
||||
</span>
|
||||
</span>`;
|
||||
return items.map(main).join('');
|
||||
}
|
||||
|
||||
const TALENT_BG = {
|
||||
'Warrior': { Arms: 'warrior-arms', Fury: 'warrior-fury', Protection: 'warrior-protection' },
|
||||
'Paladin': { Holy: 'paladin-holy', Protection: 'paladin-protection', Retribution: 'paladin-retribution' },
|
||||
'Hunter': { 'Beast Mastery': 'hunter-beastmastery', Marksmanship: 'hunter-marksmanship', Survival: 'hunter-survival' },
|
||||
'Rogue': { Assassination: 'rogue-assassination', Outlaw: 'rogue-outlaw', Subtlety: 'rogue-subtlety' },
|
||||
'Priest': { Discipline: 'priest-discipline', Holy: 'priest-holy', Shadow: 'priest-shadow' },
|
||||
'Death Knight': { Blood: 'death-knight-blood', Frost: 'death-knight-frost', Unholy: 'death-knight-unholy' },
|
||||
'Shaman': { Elemental: 'shaman-elemental', Enhancement: 'shaman-enhancement', Restoration: 'shaman-restoration' },
|
||||
'Mage': { Arcane: 'mage-arcane', Fire: 'mage-fire', Frost: 'mage-frost' },
|
||||
'Warlock': { Affliction: 'warlock-affliction', Demonology: 'warlock-demonology', Destruction: 'warlock-destruction' },
|
||||
'Monk': { Brewmaster: 'monk-brewmaster', Mistweaver: 'monk-mistweaver', Windwalker: 'monk-windwalker' },
|
||||
'Druid': { Balance: 'druid-balance', Feral: 'druid-feral', Guardian: 'druid-guardian', Restoration: 'druid-restoration' },
|
||||
'Demon Hunter': { Havoc: 'demon-hunter-havoc', Vengeance: 'demon-hunter-vengeance' },
|
||||
'Evoker': { Devastation: 'evoker-devastation', Preservation: 'evoker-preservation', Augmentation: 'evoker-augmentation' }
|
||||
};
|
||||
|
||||
function talentHTML(full) {
|
||||
const cls = full.class || '';
|
||||
const spec = full.spec || '';
|
||||
const bg = TALENT_BG[cls] && TALENT_BG[cls][spec] ? `talentbg-${TALENT_BG[cls][spec]}.jpg` : null;
|
||||
return `<div class="talent-panel${bg ? '' : ' no-bg'}">
|
||||
${bg ? `<img class="talent-bg" src="https://wow.zamimg.com/images/tools/dragonflight-talent-calc/blizzard/${bg}" alt="" loading="lazy">` : ''}
|
||||
<div class="talent-overlay">
|
||||
<span class="talent-title">Arbre de talents — ${esc(specFr(spec) || spec || '?')}</span>
|
||||
<span class="talent-note">Arbre indisponible pour le moment.</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function talentTreeHTML(data) {
|
||||
if (!data || !data.trees) return '<div class="gear-empty">Arbre de talents indisponible</div>';
|
||||
const selByTalent = { class: {}, spec: {} };
|
||||
const selected = data.selected || { class: [], spec: [] };
|
||||
for (const k of ['class', 'spec']) {
|
||||
for (const t of (selected[k] || [])) {
|
||||
const arr = selByTalent[k][t.id] || (selByTalent[k][t.id] = []);
|
||||
arr.push(t.rank || 1);
|
||||
}
|
||||
}
|
||||
const CELL = 58, PAD = 8;
|
||||
const renderTree = (kind, title) => {
|
||||
const tree = data.trees[kind];
|
||||
if (!tree || !tree.nodes || !tree.nodes.length) return '';
|
||||
const w = (tree.width ?? 12) + 1;
|
||||
const h = (tree.height ?? 10) + 1;
|
||||
const nodes = tree.nodes.map((n) => {
|
||||
const ranks = selByTalent[kind][n.id] || selByTalent[kind][n.talent_id];
|
||||
const on = ranks && ranks.length;
|
||||
const letter = esc((n.talent_name || '?')[0]);
|
||||
const badge = on ? `<span class="tn-rank">${ranks[0]}${n.max_rank > 1 ? '/' + n.max_rank : ''}</span>` : '';
|
||||
const tip = n.spell_id
|
||||
? `data-wowhead="spell=${n.spell_id}&domain=fr" title="${esc(n.talent_name || n.spell_name || '')}"`
|
||||
: (n.talent_name ? `title="${esc(n.talent_name)}"` : '');
|
||||
return `<span class="tn${on ? ' on' : ''}" style="left:${PAD + n.x * CELL}px;top:${PAD + n.y * CELL}px" ${tip}>
|
||||
${n.icon ? `<img src="${esc(n.icon.startsWith('http') ? n.icon : `https://wow.zamimg.com/images/wow/icons/large/${esc(n.icon)}.jpg`)}" alt="" loading="lazy" onerror="this.parentElement.classList.add('no-icon');this.remove()">` : ''}
|
||||
<span class="tn-letter">${letter}</span>
|
||||
${badge}
|
||||
</span>`;
|
||||
}).join('');
|
||||
return `<div class="tt-block">
|
||||
<div class="tt-head"><b>${title}</b>${tree.name ? `<span class="muted">${esc(tree.name)}</span>` : ''}</div>
|
||||
<div class="tt-scroll"><div class="tt-grid" style="width:${PAD * 2 + w * CELL}px;height:${PAD * 2 + h * CELL}px">${nodes}</div></div>
|
||||
</div>`;
|
||||
};
|
||||
const copyBtn = data.loadout_text
|
||||
? `<button type="button" class="wm-copy" data-copy="${esc(data.loadout_text)}" title="Copier le build (import en jeu)">Copier le build</button>`
|
||||
: '';
|
||||
return `<div class="tt-wrap">
|
||||
<div class="tt-top"><span class="talent-title">Talents — ${esc(specFr(data.spec) || data.spec)}</span>${copyBtn}</div>
|
||||
${renderTree('class', 'Arbre de classe')}
|
||||
${renderTree('spec', 'Arbre de spécialisation')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderStats(card) {
|
||||
const stats = [];
|
||||
stats.push(['ILVL', fmt(card.ilvl || null)]);
|
||||
stats.push(['Hauts faits', fmt(card.achievement_points || 0)]);
|
||||
stats.push(['Sets PvP', fmtSet(card.pvp_sets)]);
|
||||
stats.push(['Sets PvE', fmtSet(card.pve_sets)]);
|
||||
for (const [label, key] of [['Blitz', 'BLITZ'], ['Solo Shuffle', 'SOLO_SHUFFLE'], ['2v2', 'ARENA_2v2'], ['3v3', 'ARENA_3v3'], ['RBG', 'RATED_BG']]) {
|
||||
const rating = card.pvp && card.pvp[key] ? card.pvp[key].rating : null;
|
||||
const best = card.pvp_best && card.pvp_best[key] || null;
|
||||
const v = rating != null ? `${fmt(rating)}${best != null ? `<span class="stat-max${best > rating ? ' on' : ''}" title="Max XP">/ ${fmt(best)}</span>` : ''}` : '—';
|
||||
stats.push([label, v]);
|
||||
}
|
||||
$('#modal-stats').innerHTML = stats
|
||||
.map(([l, v]) => `<span class="stat-chip">${l} <b>${v}</b></span>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
$('#modal-close').addEventListener('click', closeModal);
|
||||
$('#class-modal-close').addEventListener('click', closeClassModal);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (!backdrop.hidden) closeModal();
|
||||
if (!$('#class-modal-backdrop').hidden) closeClassModal();
|
||||
}
|
||||
});
|
||||
|
||||
function initStarfield() {
|
||||
const field = $('#starfield');
|
||||
if (!field) return;
|
||||
const count = Math.max(26, Math.min(70, Math.round((window.innerWidth * window.innerHeight) / 34000)));
|
||||
const color = '#f5a623';
|
||||
let html = '';
|
||||
for (let i = 0; i < count; i++) {
|
||||
const size = (Math.random() * 7 + 3.5).toFixed(2);
|
||||
const left = (Math.random() * 100).toFixed(2);
|
||||
const fall = (Math.random() * 16 + 12).toFixed(2);
|
||||
const sway = (Math.random() * 4 + 3.5).toFixed(2);
|
||||
const delay = (-Math.random() * 30).toFixed(2);
|
||||
const peak = (Math.random() * 0.25 + 0.12).toFixed(2);
|
||||
html += `<span class="star" style="left:${left}%;width:${size}px;height:${size}px;background:${color};box-shadow:0 0 ${(parseFloat(size) * 2.2).toFixed(1)}px ${color};animation-duration:${fall}s, ${sway}s;animation-delay:${delay}s, ${delay}s;--peak:${peak}"></span>`;
|
||||
}
|
||||
field.innerHTML = html;
|
||||
}
|
||||
|
||||
initStarfield();
|
||||
load();
|
||||
})();
|
||||
Reference in New Issue
Block a user