192 lines
9.3 KiB
JavaScript
192 lines
9.3 KiB
JavaScript
// Rôle : gère l'onglet roue pondérée du Randomizer et son animation.
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { Icon } from "../../../../components/Icon.jsx";
|
|
import { RANDOMIZER_LIMITS, addRandomizerHistory, buildWheelSegments, clampNumber, drawWeightedEntries } from "../randomizerUtils.js";
|
|
import { ResultChips, RandomizerHistory } from "./RandomizerShared.jsx";
|
|
import { DRAW_PAUSE_MS, WHEEL_COLORS, WHEEL_SPIN_MS, describeWheelArc, getWheelTargetRotation, hasReducedMotion } from "./randomizerUiUtils.js";
|
|
import { useTimeoutQueue } from "./useTimeoutQueue.js";
|
|
|
|
export function WheelRandomizer({ data, textContent, onChange, context }) {
|
|
const [rotation, setRotation] = useState(0);
|
|
const [spinning, setSpinning] = useState(false);
|
|
const [drawing, setDrawing] = useState(false);
|
|
const [displayResults, setDisplayResults] = useState(data.results);
|
|
const [entriesText, setEntriesText] = useState("");
|
|
const { queueTimer, clearTimers: clearWheelTimers } = useTimeoutQueue();
|
|
const skipWheelSyncRef = useRef(false);
|
|
const segments = useMemo(() => buildWheelSegments(data.entries), [data.entries]);
|
|
|
|
useEffect(() => {
|
|
if (drawing) return;
|
|
if (skipWheelSyncRef.current) {
|
|
skipWheelSyncRef.current = false;
|
|
return;
|
|
}
|
|
setDisplayResults(data.results);
|
|
}, [data.results, drawing]);
|
|
|
|
function stopWheel() {
|
|
clearWheelTimers();
|
|
skipWheelSyncRef.current = true;
|
|
setSpinning(false);
|
|
setDrawing(false);
|
|
}
|
|
|
|
function addEntries(event) {
|
|
event.preventDefault();
|
|
const availableSlots = RANDOMIZER_LIMITS.wheelEntriesMax - data.entries.length;
|
|
const labels = entriesText
|
|
.split(/\r?\n/)
|
|
.map((label) => label.trim())
|
|
.filter(Boolean)
|
|
.slice(0, availableSlots);
|
|
if (!labels.length) return;
|
|
onChange({
|
|
...data,
|
|
entries: [
|
|
...data.entries,
|
|
...labels.map((label) => ({ id: context.uid("randomizer"), label, weight: 1 }))
|
|
]
|
|
});
|
|
setEntriesText("");
|
|
}
|
|
|
|
function updateEntry(entryId, patch) {
|
|
onChange({ ...data, entries: data.entries.map((entry) => entry.id === entryId ? { ...entry, ...patch } : entry) });
|
|
}
|
|
|
|
function removeEntry(entryId) {
|
|
onChange({ ...data, entries: data.entries.filter((entry) => entry.id !== entryId) });
|
|
}
|
|
|
|
function clearEntries() {
|
|
onChange({ ...data, entries: [], results: [] });
|
|
}
|
|
|
|
function clearWheelHistory() {
|
|
onChange({ ...data, history: [] });
|
|
}
|
|
|
|
function removeWheelHistoryEntry(entryId) {
|
|
onChange({ ...data, history: data.history.filter((entry) => entry.id !== entryId) });
|
|
}
|
|
|
|
function launchWheel() {
|
|
clearWheelTimers();
|
|
skipWheelSyncRef.current = false;
|
|
const drawnEntries = drawWeightedEntries(data.entries, data.draws);
|
|
if (!drawnEntries.length) {
|
|
context.notify?.(textContent.wheelInvalid || "Ajoutez au moins deux valeurs.");
|
|
return;
|
|
}
|
|
const results = drawnEntries.map((entry) => entry.label);
|
|
const historyEntry = { id: context.uid("randomizer"), at: new Date().toISOString(), results };
|
|
if (hasReducedMotion()) {
|
|
setDisplayResults(results);
|
|
onChange({ ...data, results, history: addRandomizerHistory(data.history, historyEntry) });
|
|
return;
|
|
}
|
|
setDrawing(true);
|
|
setDisplayResults([]);
|
|
let rotationCursor = rotation;
|
|
|
|
function animateDraw(index) {
|
|
const drawnEntry = drawnEntries[index];
|
|
const winnerSegment = segments.find((segment) => segment.entry.id === drawnEntry.id) || segments[0];
|
|
rotationCursor = getWheelTargetRotation(rotationCursor, winnerSegment.midAngle);
|
|
setSpinning(true);
|
|
setRotation(rotationCursor);
|
|
queueTimer(() => {
|
|
setDisplayResults((current) => [...current, drawnEntry.label]);
|
|
setSpinning(false);
|
|
if (index < drawnEntries.length - 1) {
|
|
queueTimer(() => animateDraw(index + 1), DRAW_PAUSE_MS);
|
|
return;
|
|
}
|
|
setDrawing(false);
|
|
onChange({ ...data, results, history: addRandomizerHistory(data.history, historyEntry) });
|
|
}, WHEEL_SPIN_MS);
|
|
}
|
|
|
|
animateDraw(0);
|
|
}
|
|
|
|
return (
|
|
<section className="randomizer-panel randomizer-wheel-panel">
|
|
<div className="randomizer-wheel-stage" aria-live="polite">
|
|
<span className="randomizer-wheel-spacer" aria-hidden="true" />
|
|
<div className="randomizer-wheel-center">
|
|
<span className="randomizer-wheel-pointer" aria-hidden="true" />
|
|
<svg className={`randomizer-wheel ${spinning ? "is-spinning" : ""}`} viewBox="0 0 100 100" style={{ "--randomizer-wheel-rotation": `${rotation}deg` }}>
|
|
{segments.length ? segments.map((segment) => (
|
|
<path key={segment.entry.id} d={describeWheelArc(segment.startAngle, segment.endAngle)} fill={WHEEL_COLORS[segment.index % WHEEL_COLORS.length]} />
|
|
)) : <circle cx="50" cy="50" r="48" fill="rgba(139, 92, 246, 0.18)" />}
|
|
<circle cx="50" cy="50" r="12" fill="rgba(7, 10, 24, 0.92)" />
|
|
</svg>
|
|
</div>
|
|
<div className="randomizer-wheel-legend nds-scrollbox" data-axis="vertical" aria-label={textContent.valuesTitle || "Valeurs"}>
|
|
{segments.length ? segments.map((segment) => (
|
|
<div className="randomizer-wheel-legend-row" key={segment.entry.id}>
|
|
<span className="randomizer-wheel-swatch" style={{ "--randomizer-swatch": WHEEL_COLORS[segment.index % WHEEL_COLORS.length] }} aria-hidden="true" />
|
|
<span className="randomizer-wheel-label">{segment.entry.label}</span>
|
|
</div>
|
|
)) : <p className="randomizer-empty">{textContent.wheelInvalid || "Ajoutez au moins deux valeurs."}</p>}
|
|
</div>
|
|
</div>
|
|
<div className="randomizer-live-results randomizer-wheel-results" aria-live="polite">
|
|
<ResultChips results={displayResults} />
|
|
</div>
|
|
|
|
<div className="randomizer-wheel-grid">
|
|
<section className="randomizer-wheel-form">
|
|
<form className="randomizer-entry-form" onSubmit={addEntries}>
|
|
<label className="randomizer-field">
|
|
<span>{textContent.valuesImportLabel || "Liste de valeurs"}</span>
|
|
<textarea className="nds-textarea" data-size="sm" value={entriesText} onChange={(event) => setEntriesText(event.target.value)} placeholder={textContent.valuesImportPlaceholder || "Une valeur par ligne"} />
|
|
</label>
|
|
<button className="nds-button" data-size="sm" data-variant="primary" type="submit">{textContent.addButton || "Ajouter"}</button>
|
|
</form>
|
|
<div className="randomizer-controls">
|
|
<label>
|
|
<span>{textContent.drawsLabel || "Tirages"}</span>
|
|
<input className="nds-input" data-size="sm" type="number" min="1" max="100" value={data.draws} onChange={(event) => onChange({ ...data, draws: clampNumber(event.target.value, 1, 100, data.draws) })} />
|
|
</label>
|
|
<button className="nds-button" data-size="sm" data-variant={drawing ? "danger" : "primary"} type="button" onClick={drawing ? stopWheel : launchWheel}>
|
|
<Icon name={drawing ? "stop-circle" : "play-circle"} />
|
|
<span>{drawing ? textContent.stopButton || "Arrêter" : textContent.drawButton || "Tirer"}</span>
|
|
</button>
|
|
</div>
|
|
</section>
|
|
<section className="randomizer-wheel-values-section">
|
|
<div className="randomizer-list-header randomizer-value-row randomizer-wheel-value-row" aria-hidden="true">
|
|
<span>{textContent.savedValuesLabel || "Valeurs enregistrées"}</span>
|
|
<span>{textContent.weightLabel || "Poids"}</span>
|
|
<span />
|
|
</div>
|
|
<div className="randomizer-value-list nds-scrollbox" data-axis="vertical" data-variant="ghost" aria-label={textContent.valuesTitle || "Valeurs"}>
|
|
{data.entries.map((entry) => (
|
|
<div className="randomizer-value-row randomizer-wheel-value-row" key={entry.id}>
|
|
<input className="nds-input" data-size="sm" value={entry.label} onChange={(event) => updateEntry(entry.id, { label: event.target.value })} aria-label={textContent.valuePlaceholder || "Valeur"} />
|
|
<input className="nds-input" data-size="sm" type="number" min="1" max="999" value={entry.weight} onChange={(event) => updateEntry(entry.id, { weight: clampNumber(event.target.value, 1, 999, entry.weight) })} aria-label={`${textContent.weightLabel || "Poids"} ${entry.label}`} />
|
|
<button className="nds-icon-button" data-size="sm" data-variant="danger" type="button" onClick={() => removeEntry(entry.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${entry.label}`} title={textContent.deleteTitle || "Supprimer"}>
|
|
<Icon name="trash" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<button className="nds-button" data-size="sm" type="button" onClick={clearEntries} disabled={!data.entries.length || drawing}>{textContent.clearValuesButton || "Supprimer toutes les valeurs"}</button>
|
|
</section>
|
|
<RandomizerHistory
|
|
title={textContent.historyTitle || "Historique"}
|
|
history={data.history}
|
|
renderEntry={(entry) => entry.results.join(" - ")}
|
|
onClear={clearWheelHistory}
|
|
onRemove={removeWheelHistoryEntry}
|
|
disabled={drawing}
|
|
textContent={textContent}
|
|
showEmpty
|
|
/>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|