dd-timer/src/presentation/components/DragodindeCard.ts
POL Mickaël 3e485fd09b chore: normalise fins de ligne CRLF → LF dans tout le repo
Applique .gitattributes sur tous les fichiers existants.
Élimine les différences fantômes entre WSL et Windows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 08:55:10 +02:00

467 lines
21 KiB
TypeScript
Executable File

import type { CommandBus } from '@application/handlers/CommandBus';
import type { QueryBus } from '@application/handlers/QueryBus';
import type { Enclos } from '@domain/entities/Enclos';
import type { Dragodinde } from '@domain/entities/Dragodinde';
import type { GaugeType, StatType } from '@domain/value-objects/GaugeType';
import { GAUGE_DEFS, STAT_DEFS } from '@domain/value-objects/GaugeType';
import { tierNum, tierRate } from '@domain/value-objects/Tier';
import { computeGaugeLive, calcSerenEtaLive, calcLevelEtaLive, calcLevel200EtaLive, elapsedLive } from '@presentation/helpers/gauge-live';
import { esc, fmt, fmtClock } from '@presentation/helpers/format';
import { Toast } from './Toast';
import { ConfirmModal } from './ConfirmModal';
import { UndoManager } from '@presentation/services/UndoManager';
interface StatPillDef {
key: StatType;
icon: string;
color: string;
min: number;
max: number;
}
const STAT_PILLS: StatPillDef[] = [
{ key: 'serenite', icon: 'sentiment_satisfied_alt', color: '96,165,250', min: -5000, max: 5000 },
{ key: 'endurance', icon: 'bolt', color: '250,204,21', min: 0, max: 20000 },
{ key: 'maturite', icon: 'water_drop', color: '34,211,238', min: 0, max: 20000 },
{ key: 'amour', icon: 'favorite', color: '248,113,113', min: 0, max: 20000 },
{ key: 'xp', icon: 'star', color: '254,240,138', min: 1, max: 200 },
];
export class DragodindeCard {
private el: HTMLElement | null = null;
private enclosId = 0;
private ddId = 0;
private lastTick = -1;
constructor(
private commandBus: CommandBus,
private queryBus: QueryBus,
private onReorder?: () => void,
) {}
render(container: HTMLElement, enclosId: number, ddId: number): void {
this.enclosId = enclosId;
this.ddId = ddId;
this.el = document.createElement('div');
this.el.className = 'dd-card enc-dd-card';
this.el.id = `ddc-${enclosId}-${ddId}`;
this.el.draggable = true;
this.el.addEventListener('dragstart', (e) => {
e.dataTransfer!.effectAllowed = 'move';
e.dataTransfer?.setData('text/dd-id', String(ddId));
e.dataTransfer?.setData('text/enc-id', String(enclosId));
// Délai pour que le navigateur capture le snapshot avant d'appliquer l'opacité
requestAnimationFrame(() => this.el!.classList.add('dragging'));
});
this.el.addEventListener('dragend', () => {
this.el!.classList.remove('dragging');
this.el!.classList.remove('drag-over');
});
this.el.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer!.dropEffect = 'move';
this.el!.classList.add('drag-over');
});
this.el.addEventListener('dragleave', (e) => {
// Ignorer si on entre dans un élément enfant
if (this.el!.contains(e.relatedTarget as Node)) return;
this.el!.classList.remove('drag-over');
});
this.el.addEventListener('drop', (e) => {
e.preventDefault();
this.el!.classList.remove('drag-over');
const srcDdId = e.dataTransfer?.getData('text/dd-id');
const srcEncId = e.dataTransfer?.getData('text/enc-id');
if (srcEncId === String(enclosId) && srcDdId && srcDdId !== String(ddId)) {
this.commandBus.execute({
type: 'reorder-dragodinde',
enclosId,
fromDdId: Number(srcDdId),
toDdId: ddId,
});
this.onReorder?.();
}
});
container.appendChild(this.el);
this.renderInner();
}
private renderInner(): void {
if (!this.el) return;
const enc = this.queryBus.execute<Enclos>({ type: 'get-enclos-detail', enclosId: this.enclosId });
const dd = enc.dragodindes.find(d => d.id === this.ddId);
if (!dd) return;
const eId = this.enclosId;
const dId = this.ddId;
/* stat pills */
const pillsHtml = STAT_PILLS.map(sp => {
const val = dd.stats[sp.key];
const atMax = val >= sp.max;
const atMin = sp.key === 'serenite' && val <= sp.min;
const atLimit = atMax || atMin;
return `<div class="dd-stat-pill enc-dd-stat-badge${atLimit ? ' at-max' : ''}" style="border-color:rgba(${sp.color},0.5)${atLimit ? `;box-shadow:0 0 8px rgba(${sp.color},0.5);background:rgba(${sp.color},0.18)` : ''}">
<span class="material-symbols-outlined enc-dd-stat-icon" style="color:rgb(${sp.color})">${sp.icon}</span>
<input type="number" class="pill-input enc-dd-stat-input"
id="pstat-${eId}-${dId}-${sp.key}"
data-stat="${sp.key}" data-prev="${val}"
min="${sp.min}" max="${sp.max}"
value="${val}">
<span class="pill-delta" id="pill-delta-${eId}-${dId}-${sp.key}"></span>
</div>`;
}).join('');
/* Mapping icônes Material Symbols pour boutons jauges */
const GAUGE_MS_ICONS: Partial<Record<string, string>> = {
baffeur: 'remove', caresseur: 'add', foudroyeur: 'bolt',
abreuvoir: 'water_drop', dragofesse: 'favorite',
};
/* active gauge blocks */
const gaugeBlocksHtml = enc.activeGauges.map(gid => {
const def = GAUGE_DEFS[gid];
if (gid === 'mangeoire') {
return `<div class="enc-dd-gauge-block enc-dd-gauge-xp" data-gid="${gid}">
<div class="enc-dd-xp-main">
<div class="enc-dd-xp-left">
<span class="enc-dd-xp-niv" id="slv-${eId}-${dId}-${gid}" style="color:var(${def.cssVar})">NIV. 1</span>
<span class="enc-dd-xp-sub">XP <span id="eta200-pct-${eId}-${dId}">0%</span></span>
</div>
<span class="live-cd enc-dd-xp-cd" id="scd-${eId}-${dId}-${gid}">--:--:--</span>
</div>
<span id="eta200-${eId}-${dId}" class="enc-dd-xp-eta200">→ NIV. 200 : —</span>
<div class="enc-dd-bar-bg">
<div class="enc-dd-bar-fill" id="eta200-bar-${eId}-${dId}" style="width:0%;background:var(${def.cssVar})"></div>
</div>
<div class="enc-dd-bar-bg" style="margin-top:4px">
<div class="enc-dd-bar-fill" id="spb-${eId}-${dId}-${gid}" style="width:0%;background:var(${def.cssVar})"></div>
</div>
<span class="live-delta" id="sdelta-${eId}-${dId}-${gid}" style="display:none"></span>
</div>`;
}
const msIcon = GAUGE_MS_ICONS[gid] ?? 'circle';
return `<div class="enc-dd-gauge-block" data-gid="${gid}">
<div class="enc-dd-gauge-btn">
<span class="enc-dd-gauge-btn-left">
<span class="material-symbols-outlined" style="font-size:14px">${msIcon}</span>
<span class="enc-dd-gauge-btn-name">${def.label.toUpperCase()}</span>
</span>
<span class="live-val" id="slv-${eId}-${dId}-${gid}" style="display:none"></span>
<span class="live-delta" id="sdelta-${eId}-${dId}-${gid}" style="display:none"></span>
<span class="live-cd enc-dd-gauge-btn-cd" id="scd-${eId}-${dId}-${gid}">--:--:--</span>
</div>
<div class="enc-dd-bar-bg">
<div class="enc-dd-bar-fill" id="spb-${eId}-${dId}-${gid}" style="width:0%;background:var(${def.cssVar})"></div>
</div>
</div>`;
}).join('');
this.el.innerHTML = `
<div class="enc-dd-done-badge" id="dd-done-${eId}-${dId}" style="display:none">✓ TERMINÉ</div>
<div class="enc-dd-card-head">
<span class="dd-drag-handle enc-dd-drag-handle" title="Déplacer">⠿</span>
<input type="text" class="dd-name enc-dd-name-input" value="${esc(dd.name)}"
data-prev="${esc(dd.name)}" id="ddname-${eId}-${dId}">
<button class="dd-del enc-dd-del-btn" title="Supprimer">
<span class="material-symbols-outlined" style="font-size:16px">close</span>
</button>
</div>
<div class="enc-dd-body">
<div class="enc-dd-stats-grid">${pillsHtml}</div>
<div class="enc-dd-cibles">
<div class="enc-dd-cible-row">
<div class="enc-dd-cible-left">
<span class="material-symbols-outlined" style="font-size:18px;color:rgb(96,165,250)">sentiment_satisfied_alt</span>
<span class="enc-dd-cible-lbl">Cible</span>
</div>
<div class="enc-dd-cible-right">
<input type="number" class="enc-dd-cible-inp" id="ser-tgt-${eId}-${dId}"
min="${enc.activeGauges.includes('baffeur') ? '-5000' : '0'}"
max="${enc.activeGauges.includes('caresseur') ? '5000' : '0'}"
value="${dd.sereniteTarget ?? ''}" placeholder="${enc.activeGauges.includes('baffeur') ? '-5000…0' : enc.activeGauges.includes('caresseur') ? '0…5000' : '—'}">
<button class="enc-dd-cible-clr" id="ser-clr-${eId}-${dId}" title="Réinitialiser" ${dd.sereniteTarget == null ? 'style="visibility:hidden"' : ''}>✕</button>
<span class="enc-dd-cible-eta" id="ser-eta-${eId}-${dId}">—</span>
</div>
</div>
<div class="enc-dd-cible-row">
<div class="enc-dd-cible-left">
<span class="material-symbols-outlined" style="font-size:18px;color:rgb(234,179,8)">stars</span>
<span class="enc-dd-cible-lbl">Niveau</span>
</div>
<div class="enc-dd-cible-right">
<input type="number" class="enc-dd-cible-inp" id="lvl-tgt-${eId}-${dId}"
min="1" max="200"
value="${dd.levelTarget ?? ''}" placeholder="—">
<button class="enc-dd-cible-clr" id="lvl-clr-${eId}-${dId}" title="Réinitialiser" ${dd.levelTarget == null ? 'style="visibility:hidden"' : ''}>✕</button>
<span class="enc-dd-cible-eta" id="lvl-eta-${eId}-${dId}">—</span>
</div>
</div>
</div>
<div class="enc-dd-gauge-blocks">${gaugeBlocksHtml}</div>
</div>
`;
this.bindEvents(dd);
}
private bindEvents(dd: Dragodinde): void {
if (!this.el) return;
const eId = this.enclosId;
const dId = this.ddId;
/* Delete button */
const delBtn = this.el.querySelector('.dd-del');
delBtn?.addEventListener('click', async () => {
const ok = await ConfirmModal.show('Retirer la dragodinde', 'Retirer cette dragodinde de l\'enclos ?');
if (!ok) return;
const hasSnap = await UndoManager.snapshotCurrent('Dragodinde retirée');
this.commandBus.execute({ type: 'remove-dragodinde', enclosId: eId, ddId: dId });
Toast.show('success', 'Dragodinde retirée.', hasSnap ? { label: 'Annuler', callback: () => UndoManager.undo() } : undefined);
});
/* Name input */
const nameInput = this.el.querySelector<HTMLInputElement>('.dd-name');
if (nameInput) {
nameInput.addEventListener('focus', () => {
nameInput.dataset.prev = nameInput.value;
nameInput.value = '';
});
nameInput.addEventListener('blur', () => {
const v = nameInput.value.trim();
if (!v) nameInput.value = nameInput.dataset.prev || dd.name;
else this.commandBus.execute({ type: 'rename-dragodinde', enclosId: eId, ddId: dId, name: v });
});
nameInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { nameInput.value = nameInput.dataset.prev || dd.name; nameInput.blur(); }
else if (e.key === 'Enter') nameInput.blur();
});
}
/* Stat pill inputs */
this.el.querySelectorAll<HTMLInputElement>('.pill-input').forEach(inp => {
const stat = inp.dataset.stat as StatType;
inp.addEventListener('focus', () => {
inp.dataset.prev = inp.value;
inp.value = '';
});
inp.addEventListener('input', () => {
if (!inp.value) return;
const v = Number(inp.value);
if (!isNaN(v)) this.commandBus.execute({ type: 'update-dd-stat', enclosId: eId, ddId: dId, stat, value: v });
});
inp.addEventListener('blur', () => {
if (inp.value === '') { inp.value = inp.dataset.prev || '0'; return; }
const v = Number(inp.value);
if (!isNaN(v)) this.commandBus.execute({ type: 'update-dd-stat', enclosId: eId, ddId: dId, stat, value: v });
});
inp.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { inp.value = inp.dataset.prev || '0'; inp.blur(); }
else if (e.key === 'Enter') inp.blur();
});
});
/* Clear buttons */
this.el.querySelector(`#ser-clr-${eId}-${dId}`)?.addEventListener('click', () => {
this.commandBus.execute({ type: 'update-dd-seren-target', enclosId: eId, ddId: dId, target: null });
const inp = this.el?.querySelector<HTMLInputElement>(`#ser-tgt-${eId}-${dId}`);
if (inp) inp.value = '';
const btn = this.el?.querySelector<HTMLElement>(`#ser-clr-${eId}-${dId}`);
if (btn) btn.style.visibility = 'hidden';
});
this.el.querySelector(`#lvl-clr-${eId}-${dId}`)?.addEventListener('click', () => {
this.commandBus.execute({ type: 'update-dd-level-target', enclosId: eId, ddId: dId, target: null });
const inp = this.el?.querySelector<HTMLInputElement>(`#lvl-tgt-${eId}-${dId}`);
if (inp) inp.value = '';
const btn = this.el?.querySelector<HTMLElement>(`#lvl-clr-${eId}-${dId}`);
if (btn) btn.style.visibility = 'hidden';
});
/* Serenite target — clamp selon la jauge active (baffeur → négatif, caresseur → positif) */
const serTgt = this.el.querySelector<HTMLInputElement>(`#ser-tgt-${eId}-${dId}`);
if (serTgt) {
const clampSeren = (raw: number): number => {
const enc = this.queryBus.execute<Enclos>({ type: 'get-enclos-detail', enclosId: eId });
if (enc.activeGauges.includes('baffeur')) return Math.min(0, Math.max(-5000, raw));
if (enc.activeGauges.includes('caresseur')) return Math.max(0, Math.min(5000, raw));
return Math.min(5000, Math.max(-5000, raw));
};
serTgt.addEventListener('focus', () => { serTgt.dataset.prev = serTgt.value; serTgt.value = ''; });
serTgt.addEventListener('input', () => {
if (!serTgt.value) return;
const v = Number(serTgt.value);
if (isNaN(v)) return;
const clamped = clampSeren(v);
this.commandBus.execute({ type: 'update-dd-seren-target', enclosId: eId, ddId: dId, target: clamped });
});
serTgt.addEventListener('blur', () => {
if (serTgt.value === '') { serTgt.value = serTgt.dataset.prev || ''; return; }
const v = Number(serTgt.value);
if (isNaN(v)) { serTgt.value = serTgt.dataset.prev || ''; return; }
const clamped = clampSeren(v);
this.commandBus.execute({ type: 'update-dd-seren-target', enclosId: eId, ddId: dId, target: clamped });
serTgt.value = String(clamped);
});
serTgt.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { serTgt.value = serTgt.dataset.prev || ''; serTgt.blur(); }
else if (e.key === 'Enter') serTgt.blur();
});
}
/* Level target */
const lvlTgt = this.el.querySelector<HTMLInputElement>(`#lvl-tgt-${eId}-${dId}`);
if (lvlTgt) {
lvlTgt.addEventListener('focus', () => { lvlTgt.dataset.prev = lvlTgt.value; lvlTgt.value = ''; });
lvlTgt.addEventListener('input', () => {
if (!lvlTgt.value) return;
const v = Number(lvlTgt.value);
this.commandBus.execute({ type: 'update-dd-level-target', enclosId: eId, ddId: dId, target: isNaN(v) ? null : v });
});
lvlTgt.addEventListener('blur', () => {
if (lvlTgt.value === '') { lvlTgt.value = lvlTgt.dataset.prev || ''; return; }
const v = Number(lvlTgt.value);
this.commandBus.execute({ type: 'update-dd-level-target', enclosId: eId, ddId: dId, target: isNaN(v) ? null : v });
});
lvlTgt.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { lvlTgt.value = lvlTgt.dataset.prev || ''; lvlTgt.blur(); }
else if (e.key === 'Enter') lvlTgt.blur();
});
}
}
update(enc: Enclos, dd: Dragodinde, el: number, started: boolean): void {
if (!this.el) return;
const eId = this.enclosId;
const dId = this.ddId;
/* Détection de tick (toutes les 10 sec).
* Après complétion automatique, le temps réel continue pour que les animations
* continuent sur toutes les jauges qui se vident en fond. */
const elForTick = started ? elapsedLive(enc) : 0;
const tick = started ? Math.floor(elForTick / 10) : -1;
if (!started) {
this.lastTick = -1;
} else if (this.lastTick === -1) {
this.lastTick = tick; // initialise sans déclencher au démarrage
}
const newTick = started && tick !== this.lastTick;
if (newTick) this.lastTick = tick;
let allDone = enc.activeGauges.length > 0;
/* Update active gauge blocks */
enc.activeGauges.forEach(gid => {
const r = computeGaugeLive(enc, dd, gid, el, started);
const def = GAUGE_DEFS[gid];
// Toutes les jauges comptent pour le badge "✓ TERMINÉ"
if (!r.done) allDone = false;
const lvEl = this.el!.querySelector(`#slv-${eId}-${dId}-${gid}`);
if (lvEl) lvEl.textContent = r.liveText;
/* Delta live-delta : pop à chaque tick.
* Continue jusqu'au cap absolu de la stat (pas juste la cible). */
const sd = def.isXp ? null : STAT_DEFS[def.stat as keyof typeof STAT_DEFS];
const atAbsCap = def.isXp
? (r.estStat as number) >= 200
: (def.dir > 0 ? (r.estStat as number) >= sd!.max : (r.estStat as number) <= sd!.min);
const deltaActive = started && !atAbsCap;
const deltaEl = this.el!.querySelector<HTMLElement>(`#sdelta-${eId}-${dId}-${gid}`);
if (deltaEl) {
deltaEl.textContent = r.deltaText;
if (newTick && deltaActive) {
deltaEl.classList.remove('show');
void deltaEl.offsetWidth; // force reflow pour relancer l'animation
deltaEl.classList.add('show');
} else if (!deltaActive) {
deltaEl.classList.remove('show');
}
}
const cdEl = this.el!.querySelector(`#scd-${eId}-${dId}-${gid}`);
if (cdEl) cdEl.textContent = r.done ? '✅' : (!isFinite(r.cntDown) ? '∞' : fmtClock(r.cntDown));
/* ETA + barre de progression niveau 200 (mangeoire uniquement) */
if (gid === 'mangeoire') {
const eta200El = this.el!.querySelector(`#eta200-${eId}-${dId}`);
if (eta200El) {
const eta = calcLevel200EtaLive(enc, dd, el, started);
eta200El.textContent = `→ NIV. 200 : ${eta || '—'}`;
}
const pct200 = Math.min(100, Math.max(0, ((r.estStat as number) - 1) / 199 * 100));
const barFillEl = this.el!.querySelector<HTMLElement>(`#eta200-bar-${eId}-${dId}`);
if (barFillEl) barFillEl.style.width = `${pct200.toFixed(1)}%`;
const pctEl = this.el!.querySelector<HTMLElement>(`#eta200-pct-${eId}-${dId}`);
if (pctEl) pctEl.textContent = `${Math.round(pct200)}%`;
}
const pbEl = this.el!.querySelector<HTMLElement>(`#spb-${eId}-${dId}-${gid}`);
if (pbEl) pbEl.style.width = `${r.progPct.toFixed(1)}%`;
/* Mise à jour live du badge de stat correspondant.
* Désactivée après complétion (__done__) : dd.stats est déjà à jour
* et l'utilisateur doit pouvoir corriger les valeurs à la main. */
if (started && !enc.alerted['__done__']) {
const pillInput = this.el!.querySelector<HTMLInputElement>(`#pstat-${eId}-${dId}-${def.stat}`);
if (pillInput && document.activeElement !== pillInput) {
pillInput.value = String(Math.round(r.estStat as number));
}
const sp = STAT_PILLS.find(p => p.key === def.stat);
if (sp) {
const val = Math.round(r.estStat as number);
const atLimit = val >= sp.max || (sp.key === 'serenite' && val <= sp.min);
const pill = pillInput?.closest<HTMLElement>('.dd-stat-pill');
if (pill) {
pill.classList.toggle('at-max', atLimit);
pill.style.background = atLimit ? `rgba(${sp.color},0.18)` : '';
pill.style.boxShadow = atLimit ? `0 0 8px rgba(${sp.color},0.5)` : '';
}
}
}
/* Pill delta : pop à chaque tick — uniquement endurance, maturite, amour */
const pillDeltaStats = ['endurance', 'maturite', 'amour'];
if (pillDeltaStats.includes(def.stat)) {
const pillDelta = this.el!.querySelector<HTMLElement>(`#pill-delta-${eId}-${dId}-${def.stat}`);
if (pillDelta) {
pillDelta.textContent = r.deltaText;
if (newTick && started && !r.done) {
pillDelta.classList.remove('show');
void pillDelta.offsetWidth;
pillDelta.classList.add('show');
} else if (!started || r.done) {
pillDelta.classList.remove('show');
}
}
}
});
/* Done badge */
const doneBadge = this.el.querySelector<HTMLElement>(`#dd-done-${eId}-${dId}`);
if (doneBadge) {
doneBadge.style.display = (allDone && started && enc.activeGauges.length > 0) ? '' : 'none';
}
/* Serenity ETA */
const serEta = this.el.querySelector(`#ser-eta-${eId}-${dId}`);
if (serEta) serEta.innerHTML = calcSerenEtaLive(enc, dd, el, started);
const serClr = this.el.querySelector<HTMLElement>(`#ser-clr-${eId}-${dId}`);
if (serClr) serClr.style.visibility = dd.sereniteTarget == null ? 'hidden' : 'visible';
/* Level ETA */
const lvlEta = this.el.querySelector(`#lvl-eta-${eId}-${dId}`);
if (lvlEta) lvlEta.innerHTML = calcLevelEtaLive(enc, dd, el, started);
const lvlClr = this.el.querySelector<HTMLElement>(`#lvl-clr-${eId}-${dId}`);
if (lvlClr) lvlClr.style.visibility = dd.levelTarget == null ? 'hidden' : 'visible';
}
destroy(): void {
this.el?.remove();
this.el = null;
}
}