dd-timer/src/presentation/components/ReapproView.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

472 lines
17 KiB
TypeScript
Executable File

import type { CommandBus } from '@application/handlers/CommandBus';
import type { QueryBus } from '@application/handlers/QueryBus';
import { RACES_DATA, GEN_COLORS, RACE_GEN, BREEDING_RECIPES } from '@domain/value-objects/Race';
import { getDDImage } from '@presentation/helpers/dd-image';
import { esc } from '@presentation/helpers/format';
interface ApproNeeds {
total: number;
m: number;
f: number;
}
interface ApproStep {
baby: string;
parentA: string;
parentB: string;
couples: number;
gen: number;
}
interface ApproState {
target: string;
qty: number;
repro: Record<string, number>;
inverted: Record<string, boolean>;
genFilter: number;
search: string;
}
export class ReapproView {
private el: HTMLElement | null = null;
private approState: ApproState = { target: '', qty: 1, repro: {}, inverted: {}, genFilter: 0, search: '' };
private dirty = true;
constructor(
private commandBus: CommandBus,
private queryBus: QueryBus,
) {}
render(container: HTMLElement): void {
this.el = document.createElement('div');
this.el.className = 'reappro-view-new';
container.appendChild(this.el);
this.dirty = true; this.update();
}
update(): void {
if (!this.el || !this.dirty) return;
this.dirty = false;
const { target } = this.approState;
if (target) {
this.renderResults();
} else {
this.renderTargetSelection();
}
}
destroy(): void {
this.el?.remove();
this.el = null;
}
/* ── Target selection ── */
private renderTargetSelection(): void {
if (!this.el) return;
const { genFilter, search } = this.approState;
const allRaces: { name: string; gen: number }[] = [];
for (const [g, rs] of Object.entries(RACES_DATA)) {
const gen = parseInt(g);
if (gen < 2) continue;
for (const r of rs) allRaces.push({ name: r.name, gen });
}
const q = search.trim().toLowerCase();
const filtered = allRaces.filter(r =>
(genFilter > 0 ? r.gen === genFilter : true) &&
(q ? r.name.toLowerCase().includes(q) : true)
);
let html = '';
// Header
html += `<div class="reappro-section-header">
<div>
<h2 class="reappro-section-title">Sélectionne ta cible</h2>
<div class="reappro-title-bar"></div>
</div>
<div class="accoup-gen-chips" style="margin-bottom:0;border-bottom:none;padding-bottom:0">
<button class="accoup-gen-chip${genFilter === 0 ? ' active' : ''}" data-gen="0">Toutes</button>`;
for (let g = 2; g <= 10; g++) {
html += `<button class="accoup-gen-chip${genFilter === g ? ' active' : ''}" data-gen="${g}">Gen ${g}</button>`;
}
html += `</div></div>`;
// Search
html += `<div class="accoup-search-wrap">
<input class="accoup-search" id="appro-search-input" type="text"
placeholder="Rechercher une race…" value="${esc(search)}" autocomplete="off">
${search ? `<button class="accoup-search-clear" title="Effacer"><span class="material-symbols-outlined" style="font-size:16px">close</span></button>` : ''}
</div>`;
// Race grid
if (filtered.length === 0) {
html += `<div class="accoup-empty">Aucune race trouvée</div>`;
} else {
html += `<div class="accoup-race-grid">`;
for (const race of filtered) {
const genCol = GEN_COLORS[race.gen] ?? '#888';
html += `<div class="accoup-race-card" data-race="${esc(race.name)}">
<div class="accoup-race-card-img">
${getDDImage(race.name)}
<div class="accoup-race-card-gen" style="color:${genCol}">GEN ${race.gen}</div>
</div>
<div class="accoup-race-card-name">${esc(race.name)}</div>
</div>`;
}
html += `</div>`;
}
this.el.innerHTML = html;
this.bindTargetSelectionEvents();
if (search) {
const inp = this.el.querySelector<HTMLInputElement>('#appro-search-input');
if (inp) { inp.focus(); inp.setSelectionRange(inp.value.length, inp.value.length); }
}
}
private bindTargetSelectionEvents(): void {
if (!this.el) return;
const searchInput = this.el.querySelector<HTMLInputElement>('#appro-search-input');
if (searchInput) {
searchInput.addEventListener('input', () => {
this.approState.search = searchInput.value;
this.dirty = true; this.update();
});
}
const clearBtn = this.el.querySelector('.accoup-search-clear');
if (clearBtn) {
clearBtn.addEventListener('click', () => {
this.approState.search = '';
this.dirty = true; this.update();
});
}
this.el.querySelectorAll('.accoup-gen-chip').forEach(btn => {
btn.addEventListener('click', () => {
this.approState.genFilter = parseInt((btn as HTMLElement).dataset.gen ?? '0');
this.dirty = true; this.update();
});
});
this.el.querySelectorAll('.accoup-race-card').forEach(card => {
card.addEventListener('click', () => {
this.approState.target = (card as HTMLElement).dataset.race!;
this.approState.qty = 1;
this.approState.repro = {};
this.approState.inverted = {};
this.approState.search = '';
this.dirty = true; this.update();
});
});
}
/* ── Results view ── */
private renderResults(): void {
if (!this.el) return;
const { target, qty } = this.approState;
const targetGen = RACE_GEN[target] ?? 0;
const { materials, steps } = this.calcAppro(target, qty);
// Group steps by generation
const stepsByGen = new Map<number, ApproStep[]>();
for (const step of steps) {
const list = stepsByGen.get(step.gen) ?? [];
list.push(step);
stepsByGen.set(step.gen, list);
}
const sortedGens = [...stepsByGen.keys()].sort((a, b) => a - b);
let html = '';
// Top bar: back + quantity (top-left)
html += `<div class="reappro-top-bar">
<button class="reappro-back-btn" id="appro-back">
<span class="material-symbols-outlined" style="font-size:18px">arrow_back</span>
Retour
</button>
<div class="reappro-qty-wrap">
<label class="accoup-center-label">Quantité</label>
<input class="accoup-center-input" id="appro-qty" type="number" min="1" value="${qty}" style="width:70px">
</div>
</div>`;
// Materials (Gen 1)
if (materials.length > 0) {
const totalMat = materials.reduce((s, m) => s + m.m + m.f, 0);
html += `<div class="reappro-step-panel reappro-step-primary">`;
html += `<div class="reappro-step-header reappro-step-header-primary">
<h3 class="reappro-step-title">
<span class="reappro-step-badge reappro-badge-primary">1</span>
MATIÈRES PREMIÈRES — GÉNÉRATION 1
</h3>
<span class="reappro-step-count">Total : ${totalMat} dragodindes requises</span>
</div>`;
html += `<div class="reappro-materials-grid">`;
for (const mat of materials) {
html += this.renderMaterialCard(mat.race, mat.m, mat.f);
}
html += `</div></div>`;
// Arrow separator
html += `<div class="reappro-arrow-sep">
<span class="material-symbols-outlined reappro-arrow-icon">expand_more</span>
</div>`;
}
// Steps grouped by generation
let stepNum = 2;
for (let gi = 0; gi < sortedGens.length; gi++) {
const gen = sortedGens[gi];
const genSteps = stepsByGen.get(gen)!;
const isFinalGen = gi === sortedGens.length - 1;
const genCol = GEN_COLORS[gen] ?? '#888';
const panelClass = isFinalGen ? 'reappro-step-panel reappro-step-final' : 'reappro-step-panel';
const headerClass = isFinalGen ? 'reappro-step-header reappro-step-header-final' : 'reappro-step-header';
const badgeClass = isFinalGen ? 'reappro-step-badge reappro-badge-primary' : 'reappro-step-badge';
html += `<div class="${panelClass}">`;
html += `<div class="${headerClass}">
<h3 class="reappro-step-title">
<span class="${badgeClass}">${stepNum}</span>
${isFinalGen ? 'ÉTAPE FINALE' : 'CROISEMENTS'} — GÉNÉRATION ${gen}
</h3>
<span class="reappro-step-count">${genSteps.length} croisement${genSteps.length > 1 ? 's' : ''}</span>
</div>`;
html += `<div class="reappro-crossings-grid">`;
for (const step of genSteps) {
const invKey = step.baby;
const isInverted = this.approState.inverted[invKey] ?? false;
const reproCount = this.approState.repro[invKey] ?? 0;
const isLast = isFinalGen && genSteps.length === 1;
html += `<div class="reappro-crossing-card${isLast ? ' reappro-crossing-final' : ''}">`;
html += `<div class="reappro-crossing-row">`;
// Parents
const pA = isInverted ? step.parentB : step.parentA;
const pB = isInverted ? step.parentA : step.parentB;
html += this.renderCrossingParent(pA, '♂', step.couples);
html += `<span class="material-symbols-outlined reappro-crossing-op">add</span>`;
html += this.renderCrossingParent(pB, '♀', step.couples);
html += `<span class="material-symbols-outlined reappro-crossing-op" style="color:var(--md-primary)">arrow_forward</span>`;
// Baby result
const babyGen = RACE_GEN[step.baby] ?? 0;
html += `<div class="reappro-crossing-baby${isLast ? ' reappro-crossing-baby-final' : ''}">
<div class="reappro-baby-avatar">
${getDDImage(step.baby)}
<span class="reappro-baby-gen-badge" style="background:${genCol}">G${babyGen}</span>
</div>
<span class="reappro-baby-name">${esc(step.baby)}</span>
<span class="reappro-baby-qty">&times;${step.couples}</span>
</div>`;
html += `</div>`; // .reappro-crossing-row
// Controls row
html += `<div class="reappro-crossing-controls">
<label class="reappro-repro-label">
<span style="font-size:11px;color:var(--md-on-surface-variant)">Reproducteurs</span>
<input type="number" class="reappro-repro-input" data-race="${esc(invKey)}" min="0" value="${reproCount}">
</label>
<button class="reappro-invert-btn" data-race="${esc(invKey)}" title="Inverser ♂/♀">
<span class="material-symbols-outlined" style="font-size:16px">swap_horiz</span>
</button>
<span class="reappro-couples-badge">${step.couples} couple${step.couples > 1 ? 's' : ''}</span>
</div>`;
html += `</div>`; // .reappro-crossing-card
}
html += `</div>`; // .reappro-crossings-grid
html += `</div>`; // .reappro-step-panel
stepNum++;
// Arrow between gen panels (not after last)
if (!isFinalGen) {
html += `<div class="reappro-arrow-sep">
<span class="material-symbols-outlined reappro-arrow-icon">expand_more</span>
</div>`;
}
}
// Sticky target bar at bottom with save button
html += `<div class="reappro-target-bar-sticky">
<div class="reappro-target-info">
<div class="reappro-target-icon">
<span class="material-symbols-outlined mso-fill">auto_awesome</span>
</div>
<div>
<div class="reappro-target-label">Cible de réapprovisionnement</div>
<div class="reappro-target-name">${esc(target)} (Génération ${targetGen})</div>
</div>
</div>
<button class="reappro-save-btn" id="appro-save">
<span class="material-symbols-outlined" style="font-size:18px">save</span>
Sauvegarder ce plan
</button>
</div>`;
this.el.innerHTML = html;
this.bindResultsEvents();
}
private renderMaterialCard(race: string, m: number, f: number): string {
const gen = RACE_GEN[race] ?? 1;
const genCol = GEN_COLORS[gen] ?? '#888';
const parts: string[] = [];
if (m > 0) parts.push(`<span class="reappro-mat-gender" style="color:#50a0ff">♂ ${m}</span>`);
if (f > 0) parts.push(`<span class="reappro-mat-gender" style="color:#ff64a0">♀ ${f}</span>`);
return `<div class="reappro-material-card">
<div class="reappro-mat-avatar">
${getDDImage(race)}
</div>
<span class="reappro-mat-name">${esc(race)}</span>
<div class="reappro-mat-qty">${parts.join(' ')}</div>
</div>`;
}
private renderCrossingParent(race: string, gender: string, qty: number): string {
const genderColor = gender === '♂' ? '#50a0ff' : '#ff64a0';
return `<div class="reappro-crossing-parent">
<div class="reappro-crossing-parent-avatar">
${getDDImage(race)}
</div>
<span class="reappro-crossing-parent-name">${esc(race)}</span>
<span class="reappro-crossing-parent-gender" style="color:${genderColor}">${gender} ${qty}</span>
</div>`;
}
private bindResultsEvents(): void {
if (!this.el) return;
// Back buttons
this.el.querySelectorAll('#appro-back').forEach(btn => {
btn.addEventListener('click', () => {
this.approState.target = '';
this.dirty = true; this.update();
});
});
const qtyInput = this.el.querySelector('#appro-qty') as HTMLInputElement | null;
if (qtyInput) {
let prevQty = qtyInput.value;
qtyInput.addEventListener('focus', () => { prevQty = qtyInput.value; qtyInput.value = ''; });
qtyInput.addEventListener('blur', () => {
if (qtyInput.value === '') qtyInput.value = prevQty;
this.approState.qty = Math.max(1, parseInt(qtyInput.value) || 1);
qtyInput.value = String(this.approState.qty);
this.dirty = true; this.update();
});
}
this.el.querySelectorAll('.reappro-repro-input').forEach(inp => {
const inpEl = inp as HTMLInputElement;
let prev = inpEl.value;
inpEl.addEventListener('focus', () => { prev = inpEl.value; inpEl.value = ''; });
inpEl.addEventListener('blur', () => {
if (inpEl.value === '') inpEl.value = prev;
const race = inpEl.dataset.race!;
this.approState.repro[race] = Math.max(0, parseInt(inpEl.value) || 0);
this.dirty = true; this.update();
});
});
this.el.querySelectorAll('.reappro-invert-btn').forEach(btn => {
btn.addEventListener('click', () => {
const race = (btn as HTMLElement).dataset.race!;
this.approState.inverted[race] = !this.approState.inverted[race];
this.dirty = true; this.update();
});
});
const saveBtn = this.el.querySelector('#appro-save');
if (saveBtn) {
saveBtn.addEventListener('click', () => {
const { target, qty, repro } = this.approState;
const { materials, steps } = this.calcAppro(target, qty);
this.commandBus.execute({
type: 'save-workflow',
target,
qty,
materials,
steps,
repro: { ...repro },
});
(saveBtn as HTMLButtonElement).innerHTML = '<span class="material-symbols-outlined" style="font-size:16px;vertical-align:middle;margin-right:6px">check</span> Sauvegardé !';
setTimeout(() => {
(saveBtn as HTMLButtonElement).innerHTML = '<span class="material-symbols-outlined" style="font-size:16px;vertical-align:middle;margin-right:6px">save</span> Sauvegarder ce plan';
}, 2000);
});
}
}
/* ── Breeding plan calculation ── */
private calcAppro(target: string, qty: number): { materials: { race: string; m: number; f: number }[]; steps: ApproStep[] } {
const targetGen = RACE_GEN[target] ?? 0;
if (targetGen < 2 || !BREEDING_RECIPES[target]) {
return { materials: [], steps: [] };
}
const needs: Record<string, ApproNeeds> = {};
needs[target] = { total: qty, m: 0, f: 0 };
const steps: ApproStep[] = [];
for (let g = targetGen; g >= 2; g--) {
const racesAtGen: string[] = [];
for (const [rg, rs] of Object.entries(RACES_DATA)) {
if (parseInt(rg) === g) {
for (const r of rs) {
if (needs[r.name]) racesAtGen.push(r.name);
}
}
}
for (const race of racesAtGen) {
const recipe = BREEDING_RECIPES[race];
if (!recipe) continue;
const need = needs[race];
if (!need || need.total <= 0) continue;
const reproCount = this.approState.repro[race] ?? 0;
const couples = Math.max(1, need.total - reproCount);
const isInverted = this.approState.inverted[race] ?? false;
const [recipeA, recipeB] = recipe;
const parentA = isInverted ? recipeB : recipeA;
const parentB = isInverted ? recipeA : recipeB;
if (!needs[parentA]) needs[parentA] = { total: 0, m: 0, f: 0 };
needs[parentA].total += couples;
needs[parentA].m += couples;
if (!needs[parentB]) needs[parentB] = { total: 0, m: 0, f: 0 };
needs[parentB].total += couples;
needs[parentB].f += couples;
steps.push({ baby: race, parentA, parentB, couples, gen: g });
}
}
const materials: { race: string; m: number; f: number }[] = [];
for (const [race, need] of Object.entries(needs)) {
if (!BREEDING_RECIPES[race] && need.total > 0) {
materials.push({ race, m: need.m, f: need.f });
}
}
steps.reverse();
return { materials, steps };
}
}