Split randomizer views and align NDS toolbox vocabulary
All checks were successful
Deploy Sokko G / deploy (push) Successful in 17s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 17s
This commit is contained in:
parent
6b85e58765
commit
fef7735a1d
57 changed files with 4932 additions and 4815 deletions
|
|
@ -0,0 +1,207 @@
|
|||
// 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";
|
||||
|
||||
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 timersRef = useRef([]);
|
||||
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]);
|
||||
|
||||
useEffect(() => () => {
|
||||
timersRef.current.forEach((timerId) => window.clearTimeout(timerId));
|
||||
timersRef.current = [];
|
||||
}, []);
|
||||
|
||||
function queueTimer(callback, delay) {
|
||||
const timerId = window.setTimeout(callback, delay);
|
||||
timersRef.current.push(timerId);
|
||||
return timerId;
|
||||
}
|
||||
|
||||
function clearWheelTimers() {
|
||||
timersRef.current.forEach((timerId) => window.clearTimeout(timerId));
|
||||
timersRef.current = [];
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue