This commit is contained in:
parent
07de342fc2
commit
35fe877d58
32 changed files with 3822 additions and 200 deletions
40
website/src/features/toolboxes/AGENTS.md
Normal file
40
website/src/features/toolboxes/AGENTS.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Agent Instructions
|
||||
|
||||
Cette zone contient les outils toolbox, leur stockage local et leurs comportements partagés.
|
||||
|
||||
## Références obligatoires
|
||||
|
||||
Avant de modifier un outil toolbox, le stockage IndexedDB, l'import/export ou les formats de données, lire :
|
||||
|
||||
- `../../../../docs/STORAGE_SCHEMA.md`
|
||||
- `../../../../docs/FEATURE_CHECKLIST.md`
|
||||
|
||||
Après changement de format ou de workflow, mettre ces documents à jour.
|
||||
|
||||
## Modules toolbox
|
||||
|
||||
Chaque outil toolbox doit rester dans son propre fichier dans :
|
||||
|
||||
```text
|
||||
website/src/features/toolboxes/modules/
|
||||
```
|
||||
|
||||
Quand un outil est ajouté ou modifié :
|
||||
|
||||
- déclarer l'outil dans `modules/index.jsx` ;
|
||||
- ajouter les textes dans `../../../public/data/site.json` ;
|
||||
- mettre à jour la validation dans `../../../../tests/helpers/data-validation.mjs` ;
|
||||
- mettre à jour `../../../../docs/STORAGE_SCHEMA.md` si la structure IndexedDB change ;
|
||||
- vérifier l'affichage page toolbox et panneau latéral.
|
||||
|
||||
Quand la structure de données d'un outil change, ne pas prévoir de compatibilité legacy, de migration ou de tolérance d'anciens formats, sauf demande explicite. Le format documenté dans `STORAGE_SCHEMA.md` reste le format cible strict.
|
||||
|
||||
## Hooks et comportements partagés
|
||||
|
||||
Quand un comportement est déjà couvert par un hook ou un helper partagé, privilégier son utilisation plutôt qu'une réimplémentation locale.
|
||||
|
||||
- Pour la réorganisation par drag & drop, utiliser `useGroupedReorder` dès qu'il s'agit d'items, groupes, catégories, boundaries, parent/enfant ou listes horizontales/verticales.
|
||||
- Réserver `usePointerReorder` au moteur bas niveau ou aux cas DOM très spécifiques qui ne correspondent pas au modèle applicatif de `useGroupedReorder`.
|
||||
- Si une variation est nécessaire, vérifier d'abord si elle doit devenir une option du hook partagé.
|
||||
- Demander ou expliciter le choix uniquement quand la variation est réellement métier et pourrait alourdir le hook.
|
||||
- Éviter de dupliquer dans un outil une règle de reorder générique déjà prise en charge par le hook.
|
||||
|
|
@ -26,6 +26,7 @@ import {
|
|||
normalizeMemosData,
|
||||
normalizeModuleOrder,
|
||||
normalizeNotepadData,
|
||||
normalizeRandomizerData,
|
||||
normalizeTableData,
|
||||
normalizeTaskPlannerData,
|
||||
normalizeTimerData,
|
||||
|
|
@ -200,6 +201,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
|
|||
normalizeCalendarData,
|
||||
normalizeImageAnnotationData,
|
||||
normalizeNotepadData,
|
||||
normalizeRandomizerData,
|
||||
normalizeTableData,
|
||||
normalizeTimerData,
|
||||
normalizeTaskPlannerData,
|
||||
|
|
|
|||
873
website/src/features/toolboxes/modules/RandomizerModule.jsx
Normal file
873
website/src/features/toolboxes/modules/RandomizerModule.jsx
Normal file
|
|
@ -0,0 +1,873 @@
|
|||
// Rôle : fournit l'outil Randomizer avec dé, roue pondérée et équipes équilibrées.
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { Tabs } from "../../../components/Tabs.jsx";
|
||||
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
import {
|
||||
RANDOMIZER_LIMITS,
|
||||
RANDOMIZER_TABS,
|
||||
addRandomizerHistory,
|
||||
buildWheelSegments,
|
||||
clampNumber,
|
||||
createBalancedTeams,
|
||||
drawWeightedEntries,
|
||||
filterExclusionsForPlayers,
|
||||
getDiceShape,
|
||||
rollDice
|
||||
} from "./randomizerUtils.js";
|
||||
|
||||
const WHEEL_COLORS = ["#8b5cf6", "#f6c453", "#22d3ee", "#d946ef", "#6366f1", "#22c55e"];
|
||||
const DRAW_PAUSE_MS = 500;
|
||||
const DICE_ROLL_DELAYS = [62, 70, 82, 98, 120, 148, 182, 224, 276, 338, 410];
|
||||
const TEAM_REEL_DELAYS = [58, 66, 78, 94, 116, 144, 180, 226, 286, 360];
|
||||
const WHEEL_SPIN_MS = 1120;
|
||||
const DEFAULT_EXCLUSION_DRAFT = { playerAId: "", playerBIds: [], mode: "group" };
|
||||
|
||||
function hasReducedMotion() {
|
||||
return window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
|
||||
}
|
||||
|
||||
function polarToCartesian(radius, angle) {
|
||||
const radians = (angle * Math.PI) / 180;
|
||||
return {
|
||||
x: 50 + radius * Math.cos(radians),
|
||||
y: 50 + radius * Math.sin(radians)
|
||||
};
|
||||
}
|
||||
|
||||
function describeWheelArc(startAngle, endAngle) {
|
||||
const start = polarToCartesian(48, startAngle);
|
||||
const end = polarToCartesian(48, endAngle);
|
||||
const largeArc = endAngle - startAngle > 180 ? 1 : 0;
|
||||
return `M 50 50 L ${start.x} ${start.y} A 48 48 0 ${largeArc} 1 ${end.x} ${end.y} Z`;
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
const date = new Date(value);
|
||||
return Number.isFinite(date.getTime()) ? date.toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" }) : "";
|
||||
}
|
||||
|
||||
function getWheelTargetRotation(currentRotation, targetAngle) {
|
||||
const pointerAngle = -90;
|
||||
const targetRotation = pointerAngle - targetAngle;
|
||||
const currentModulo = ((currentRotation % 360) + 360) % 360;
|
||||
const targetModulo = ((targetRotation % 360) + 360) % 360;
|
||||
const delta = (targetModulo - currentModulo + 360) % 360;
|
||||
return currentRotation + 1080 + delta;
|
||||
}
|
||||
|
||||
function pickRandomItems(items, count) {
|
||||
const nextItems = [...items];
|
||||
for (let index = nextItems.length - 1; index > 0; index -= 1) {
|
||||
const targetIndex = Math.floor(Math.random() * (index + 1));
|
||||
[nextItems[index], nextItems[targetIndex]] = [nextItems[targetIndex], nextItems[index]];
|
||||
}
|
||||
return nextItems.slice(0, count);
|
||||
}
|
||||
|
||||
export function RandomizerModule({ toolboxId, moduleId, context, textExchangeOpen }) {
|
||||
const data = context.normalizeRandomizerData(context.getModuleData(toolboxId, moduleId, {}));
|
||||
const textContent = context.moduleText?.randomizer || {};
|
||||
|
||||
function save(nextData) {
|
||||
context.setModuleData(toolboxId, moduleId, nextData);
|
||||
}
|
||||
|
||||
function updateSection(section, nextSection) {
|
||||
save({ ...data, [section]: nextSection });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{textExchangeOpen && (
|
||||
<div className="module-text-exchange-panel">
|
||||
<TextExchangeActions type="randomizer" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "randomizer")} />
|
||||
</div>
|
||||
)}
|
||||
<div className="randomizer-tool">
|
||||
<Tabs
|
||||
tabs={RANDOMIZER_TABS.map((tab) => ({ ...tab, label: textContent[`${tab.id}Tab`] || tab.label }))}
|
||||
activeTab={data.activeTab}
|
||||
onChange={(activeTab) => save({ ...data, activeTab })}
|
||||
className="randomizer-tabs"
|
||||
size="md"
|
||||
/>
|
||||
{data.activeTab === "dice" && <DiceRandomizer data={data.dice} textContent={textContent} onChange={(dice) => updateSection("dice", dice)} context={context} />}
|
||||
{data.activeTab === "wheel" && <WheelRandomizer data={data.wheel} textContent={textContent} onChange={(wheel) => updateSection("wheel", wheel)} context={context} />}
|
||||
{data.activeTab === "teams" && <TeamsRandomizer data={data.teams} textContent={textContent} onChange={(teams) => updateSection("teams", teams)} context={context} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DiceRandomizer({ data, textContent, onChange, context }) {
|
||||
const [displayValue, setDisplayValue] = useState(data.results[0] || 1);
|
||||
const [displayResults, setDisplayResults] = useState(data.results);
|
||||
const [rolling, setRolling] = useState(false);
|
||||
const [rollingIndex, setRollingIndex] = useState(-1);
|
||||
const [rollDuration, setRollDuration] = useState(DICE_ROLL_DELAYS[0]);
|
||||
const timersRef = useRef([]);
|
||||
const skipDiceSyncRef = useRef(false);
|
||||
const shape = getDiceShape(data.faces);
|
||||
|
||||
useEffect(() => {
|
||||
if (rolling) return;
|
||||
if (skipDiceSyncRef.current) {
|
||||
skipDiceSyncRef.current = false;
|
||||
return;
|
||||
}
|
||||
setDisplayValue(data.results[0] || 1);
|
||||
setDisplayResults(data.results);
|
||||
}, [data.results, rolling]);
|
||||
|
||||
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 clearDiceTimers() {
|
||||
timersRef.current.forEach((timerId) => window.clearTimeout(timerId));
|
||||
timersRef.current = [];
|
||||
}
|
||||
|
||||
function stopDice() {
|
||||
clearDiceTimers();
|
||||
skipDiceSyncRef.current = true;
|
||||
setRolling(false);
|
||||
setRollingIndex(-1);
|
||||
}
|
||||
|
||||
function updateDiceField(field, value) {
|
||||
const limitMin = field === "faces" ? RANDOMIZER_LIMITS.diceFacesMin : RANDOMIZER_LIMITS.diceRollsMin;
|
||||
const limitMax = field === "faces" ? RANDOMIZER_LIMITS.diceFacesMax : RANDOMIZER_LIMITS.diceRollsMax;
|
||||
onChange({ ...data, [field]: clampNumber(value, limitMin, limitMax, data[field]) });
|
||||
}
|
||||
|
||||
function launchDice() {
|
||||
clearDiceTimers();
|
||||
skipDiceSyncRef.current = false;
|
||||
const results = rollDice(data.faces, data.rolls);
|
||||
const historyEntry = { id: context.uid("randomizer"), at: new Date().toISOString(), results };
|
||||
if (hasReducedMotion()) {
|
||||
onChange({ ...data, results, history: addRandomizerHistory(data.history, historyEntry) });
|
||||
return;
|
||||
}
|
||||
setRolling(true);
|
||||
setRollingIndex(0);
|
||||
setDisplayResults([]);
|
||||
|
||||
function animateRoll(index) {
|
||||
let ticks = 0;
|
||||
const tick = () => {
|
||||
ticks += 1;
|
||||
const isFinalTick = ticks >= DICE_ROLL_DELAYS.length;
|
||||
const tickDelay = DICE_ROLL_DELAYS[Math.min(ticks, DICE_ROLL_DELAYS.length - 1)];
|
||||
setDisplayValue(isFinalTick ? results[index] : rollDice(data.faces, 1)[0]);
|
||||
setRollDuration(Math.max(54, Math.round(tickDelay * 0.86)));
|
||||
if (!isFinalTick) {
|
||||
queueTimer(tick, tickDelay);
|
||||
return;
|
||||
}
|
||||
setDisplayResults((current) => [...current, results[index]]);
|
||||
if (index < results.length - 1) {
|
||||
setRollingIndex(index + 1);
|
||||
queueTimer(() => animateRoll(index + 1), DRAW_PAUSE_MS);
|
||||
return;
|
||||
}
|
||||
setRolling(false);
|
||||
setRollingIndex(-1);
|
||||
onChange({ ...data, results, history: addRandomizerHistory(data.history, historyEntry) });
|
||||
};
|
||||
tick();
|
||||
}
|
||||
|
||||
animateRoll(0);
|
||||
}
|
||||
|
||||
function clearDiceHistory() {
|
||||
onChange({ ...data, history: [] });
|
||||
}
|
||||
|
||||
function removeDiceHistoryEntry(entryId) {
|
||||
onChange({ ...data, history: data.history.filter((entry) => entry.id !== entryId) });
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="randomizer-panel randomizer-dice-panel">
|
||||
<div className="randomizer-dice-stage">
|
||||
<RandomizerDie shape={shape} rolling={rolling} value={displayValue} duration={rollDuration} faces={data.faces} />
|
||||
<div className="randomizer-live-results" aria-live="polite">
|
||||
<ResultChips results={displayResults} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="randomizer-dice-grid">
|
||||
<div className="randomizer-dice-form">
|
||||
<div className="randomizer-controls">
|
||||
<label>
|
||||
<span>{textContent.facesLabel || "Faces"}</span>
|
||||
<input className="nds-input" data-size="sm" type="number" min="2" max="999" value={data.faces} onChange={(event) => updateDiceField("faces", event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>{textContent.rollsLabel || "Lancers"}</span>
|
||||
<input className="nds-input" data-size="sm" type="number" min="1" max="100" value={data.rolls} onChange={(event) => updateDiceField("rolls", event.target.value)} />
|
||||
</label>
|
||||
<button className="nds-button" data-size="sm" data-variant={rolling ? "danger" : "primary"} type="button" onClick={rolling ? stopDice : launchDice}>
|
||||
<Icon name={rolling ? "stop-circle" : "play-circle"} />
|
||||
<span>{rolling ? `${textContent.stopButton || "Arrêter"} ${rollingIndex + 1}/${data.rolls}` : textContent.rollButton || "Lancer"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<RandomizerHistory
|
||||
title={textContent.historyTitle || "Historique"}
|
||||
history={data.history}
|
||||
renderEntry={(entry) => `${entry.results.join(" - ")} · ${textContent.totalLabel || "Total"} ${entry.results.reduce((sum, result) => sum + result, 0)}`}
|
||||
onClear={clearDiceHistory}
|
||||
onRemove={removeDiceHistoryEntry}
|
||||
disabled={rolling}
|
||||
textContent={textContent}
|
||||
showEmpty
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RandomizerDie({ shape, rolling, value, duration, faces }) {
|
||||
const cleanFaces = clampNumber(faces, RANDOMIZER_LIMITS.diceFacesMin, RANDOMIZER_LIMITS.diceFacesMax, 6);
|
||||
const valueIndex = clampNumber(value, 1, cleanFaces, 1) - 1;
|
||||
const values = useMemo(() => Array.from({ length: cleanFaces }, (_, index) => index + 1), [cleanFaces]);
|
||||
|
||||
return (
|
||||
<div className={`randomizer-die is-${shape} ${rolling ? "is-rolling" : ""}`} aria-live="polite">
|
||||
<svg className="randomizer-die-shape" viewBox="0 0 120 120" aria-hidden="true" focusable="false">
|
||||
{shape === "circle" && <circle cx="60" cy="60" r="52" />}
|
||||
{shape === "triangle" && <path d="M60 9 Q66 9 70 17 L111 96 Q115 105 106 110 L14 110 Q5 105 9 96 L50 17 Q54 9 60 9 Z" />}
|
||||
{shape === "square" && <rect x="10" y="10" width="100" height="100" rx="16" />}
|
||||
{shape === "hexagon" && <path d="M36 9 L84 9 Q92 9 96 16 L115 52 Q119 60 115 68 L96 104 Q92 111 84 111 L36 111 Q28 111 24 104 L5 68 Q1 60 5 52 L24 16 Q28 9 36 9 Z" />}
|
||||
</svg>
|
||||
<span className="randomizer-die-value">
|
||||
<span className="randomizer-die-value-track" style={{ "--randomizer-die-index": valueIndex, "--randomizer-die-duration": `${duration}ms` }} aria-hidden="true">
|
||||
{values.map((item) => <span key={item}>{item}</span>)}
|
||||
</span>
|
||||
<span className="sr-only">{value}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamsRandomizer({ data, textContent, onChange, context }) {
|
||||
const exclusionDraft = useDraftForm(DEFAULT_EXCLUSION_DRAFT);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
const [animationTeams, setAnimationTeams] = useState([]);
|
||||
const [reelPlayers, setReelPlayers] = useState([]);
|
||||
const [reelIndex, setReelIndex] = useState(0);
|
||||
const [reelDuration, setReelDuration] = useState(TEAM_REEL_DELAYS[0]);
|
||||
const [namesText, setNamesText] = useState("");
|
||||
const timersRef = useRef([]);
|
||||
const activePlayers = data.players.filter((player) => player.active);
|
||||
const storedSelectionCount = clampNumber(data.playerSelectionCount, data.teamCount, RANDOMIZER_LIMITS.teamPlayersMax, data.teamCount);
|
||||
const [selectionCountDraft, setSelectionCountDraft] = useState(String(storedSelectionCount));
|
||||
const selectionCount = clampNumber(selectionCountDraft, data.teamCount, RANDOMIZER_LIMITS.teamPlayersMax, storedSelectionCount);
|
||||
const displayedTeams = animating ? animationTeams : data.teams;
|
||||
const exclusionMode = exclusionDraft.values.mode === "source" ? "source" : "group";
|
||||
|
||||
useEffect(() => {
|
||||
setSelectionCountDraft(String(storedSelectionCount));
|
||||
}, [storedSelectionCount]);
|
||||
|
||||
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 clearTeamTimers() {
|
||||
timersRef.current.forEach((timerId) => window.clearTimeout(timerId));
|
||||
timersRef.current = [];
|
||||
}
|
||||
|
||||
function commitSelectionCount(value = selectionCountDraft) {
|
||||
const nextSelectionCount = clampNumber(value, data.teamCount, RANDOMIZER_LIMITS.teamPlayersMax, storedSelectionCount);
|
||||
setSelectionCountDraft(String(nextSelectionCount));
|
||||
onChange({ ...data, playerSelectionCount: nextSelectionCount });
|
||||
}
|
||||
|
||||
function importPlayers(event) {
|
||||
event.preventDefault();
|
||||
const availableSlots = RANDOMIZER_LIMITS.teamPlayersMax - data.players.length;
|
||||
const names = namesText
|
||||
.split(/\r?\n/)
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, availableSlots);
|
||||
if (!names.length) return;
|
||||
onChange({
|
||||
...data,
|
||||
players: [
|
||||
...data.players,
|
||||
...names.map((name) => ({ id: context.uid("randomizer"), name, score: 0, active: true }))
|
||||
]
|
||||
});
|
||||
setNamesText("");
|
||||
}
|
||||
|
||||
function updatePlayer(playerId, patch) {
|
||||
onChange({ ...data, players: data.players.map((player) => player.id === playerId ? { ...player, ...patch } : player) });
|
||||
}
|
||||
|
||||
function removePlayer(playerId) {
|
||||
onChange({
|
||||
...data,
|
||||
players: data.players.filter((player) => player.id !== playerId),
|
||||
exclusions: data.exclusions
|
||||
.filter((exclusion) => exclusion.playerAId !== playerId)
|
||||
.map((exclusion) => ({ ...exclusion, playerBIds: (exclusion.playerBIds || []).filter((targetId) => targetId !== playerId) }))
|
||||
.filter((exclusion) => exclusion.playerBIds.length)
|
||||
});
|
||||
}
|
||||
|
||||
function clearPlayers() {
|
||||
onChange({ ...data, players: [], exclusions: [], teams: [] });
|
||||
}
|
||||
|
||||
function toggleExclusionTarget(playerId) {
|
||||
const currentIds = exclusionDraft.values.playerBIds || [];
|
||||
exclusionDraft.setField(
|
||||
"playerBIds",
|
||||
currentIds.includes(playerId) ? currentIds.filter((targetId) => targetId !== playerId) : [...currentIds, playerId]
|
||||
);
|
||||
}
|
||||
|
||||
function addExclusion(event) {
|
||||
exclusionDraft.handleSubmit(event, (draft, { reset }) => {
|
||||
const mode = draft.mode === "source" ? "source" : "group";
|
||||
const selectedIds = [...new Set(Array.isArray(draft.playerBIds) ? draft.playerBIds.filter(Boolean) : [])];
|
||||
const playerAId = mode === "group" ? selectedIds[0] : draft.playerAId;
|
||||
const playerBIds = mode === "group" ? selectedIds.slice(1) : selectedIds.filter((playerId) => playerId !== draft.playerAId);
|
||||
if (!playerAId || !playerBIds.length) return;
|
||||
const exists = data.exclusions.some((exclusion) => exclusion.playerAId === playerAId && (exclusion.mode || "source") === mode && [...(exclusion.playerBIds || [])].sort().join(":") === [...playerBIds].sort().join(":"));
|
||||
if (exists) return;
|
||||
onChange({ ...data, exclusions: [...data.exclusions, { id: context.uid("randomizer"), playerAId, playerBIds, mode }] });
|
||||
reset();
|
||||
});
|
||||
}
|
||||
|
||||
function launchTeams() {
|
||||
clearTeamTimers();
|
||||
const selectedPlayers = data.playerSelectionMode === "count" ? pickRandomItems(activePlayers, Math.min(selectionCount, activePlayers.length)) : activePlayers;
|
||||
const selectedExclusions = filterExclusionsForPlayers(data.exclusions, selectedPlayers);
|
||||
const result = createBalancedTeams(selectedPlayers, data.teamCount, selectedExclusions);
|
||||
if (!result.ok) {
|
||||
context.notify?.(result.error === "not_enough_players" ? textContent.notEnoughPlayers || "Pas assez de joueurs actifs." : textContent.constraintsImpossible || "Contraintes impossibles à satisfaire.");
|
||||
return;
|
||||
}
|
||||
const historyEntry = { id: context.uid("randomizer"), at: new Date().toISOString(), teams: result.teams };
|
||||
if (hasReducedMotion()) {
|
||||
onChange({ ...data, teams: result.teams, history: addRandomizerHistory(data.history, historyEntry) });
|
||||
return;
|
||||
}
|
||||
const emptyTeams = result.teams.map((team) => ({ ...team, score: 0, players: [] }));
|
||||
const assignments = result.teams.flatMap((team) => team.players.map((player) => ({ teamId: team.id, player })));
|
||||
setAnimating(true);
|
||||
setAnimationTeams(emptyTeams);
|
||||
setReelPlayers(selectedPlayers);
|
||||
setReelIndex(0);
|
||||
setReelDuration(TEAM_REEL_DELAYS[0]);
|
||||
|
||||
function animateAssignment(index) {
|
||||
const assignment = assignments[index];
|
||||
if (!assignment) {
|
||||
setAnimating(false);
|
||||
setReelPlayers([]);
|
||||
setReelIndex(0);
|
||||
onChange({ ...data, teams: result.teams, history: addRandomizerHistory(data.history, historyEntry) });
|
||||
return;
|
||||
}
|
||||
|
||||
let ticks = 0;
|
||||
const tick = () => {
|
||||
ticks += 1;
|
||||
const isFinalTick = ticks >= TEAM_REEL_DELAYS.length;
|
||||
const tickDelay = TEAM_REEL_DELAYS[Math.min(ticks, TEAM_REEL_DELAYS.length - 1)];
|
||||
const nextIndex = isFinalTick
|
||||
? Math.max(0, selectedPlayers.findIndex((player) => player.id === assignment.player.id))
|
||||
: Math.floor(Math.random() * selectedPlayers.length);
|
||||
setReelIndex(nextIndex);
|
||||
setReelDuration(Math.max(54, Math.round(tickDelay * 0.86)));
|
||||
if (!isFinalTick) {
|
||||
queueTimer(tick, tickDelay);
|
||||
return;
|
||||
}
|
||||
queueTimer(() => {
|
||||
setAnimationTeams((currentTeams) => currentTeams.map((team) => {
|
||||
if (team.id !== assignment.teamId) return team;
|
||||
return {
|
||||
...team,
|
||||
score: team.score + assignment.player.score,
|
||||
players: [...team.players, { ...assignment.player, arriving: true }]
|
||||
};
|
||||
}));
|
||||
queueTimer(() => animateAssignment(index + 1), 260);
|
||||
}, 220);
|
||||
};
|
||||
tick();
|
||||
}
|
||||
|
||||
animateAssignment(0);
|
||||
}
|
||||
|
||||
function clearTeamsHistory() {
|
||||
onChange({ ...data, history: [] });
|
||||
}
|
||||
|
||||
function removeTeamsHistoryEntry(entryId) {
|
||||
onChange({ ...data, history: data.history.filter((entry) => entry.id !== entryId) });
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="randomizer-panel randomizer-teams-panel">
|
||||
<div className="randomizer-team-stage">
|
||||
<div className={`randomizer-team-reel ${animating ? "is-spinning" : ""}`} aria-live="polite">
|
||||
{reelPlayers.length ? (
|
||||
<>
|
||||
<div className="randomizer-team-reel-track" style={{ "--randomizer-reel-index": reelIndex, "--randomizer-reel-duration": `${reelDuration}ms` }} aria-hidden="true">
|
||||
{reelPlayers.map((player) => <span key={player.id}>{player.name}</span>)}
|
||||
</div>
|
||||
<span className="sr-only">{reelPlayers[reelIndex]?.name || ""}</span>
|
||||
</>
|
||||
) : <span className="randomizer-team-reel-placeholder" />}
|
||||
</div>
|
||||
<div className={`randomizer-teams ${displayedTeams.length === 1 ? "is-single-team" : ""} ${animating ? "is-animating" : ""}`} aria-live="polite">
|
||||
{displayedTeams.length ? displayedTeams.map((team) => (
|
||||
<article className="randomizer-team" key={team.id}>
|
||||
<h3>{team.name}</h3>
|
||||
<span>{textContent.teamScoreLabel || "Score"} {team.score}</span>
|
||||
{team.players.map((player) => <b className={player.arriving ? "is-arriving" : ""} key={player.id}>{player.name}</b>)}
|
||||
</article>
|
||||
)) : <p className="randomizer-empty">{textContent.emptyTeams ?? ""}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="randomizer-team-settings-grid">
|
||||
<section className="randomizer-team-basic">
|
||||
<form className="randomizer-player-import-form" onSubmit={importPlayers}>
|
||||
<label className="randomizer-field">
|
||||
<span>{textContent.namesImportLabel || "Liste de noms"}</span>
|
||||
<textarea className="nds-textarea" data-size="sm" value={namesText} onChange={(event) => setNamesText(event.target.value)} placeholder={textContent.namesImportPlaceholder || "Un nom par ligne"} />
|
||||
</label>
|
||||
<button className="nds-button" data-size="sm" data-variant="primary" type="submit">{textContent.importNamesButton || "Ajouter"}</button>
|
||||
</form>
|
||||
<div className="randomizer-controls">
|
||||
<label>
|
||||
<span>{textContent.teamCountLabel || "Équipes"}</span>
|
||||
<input className="nds-input" data-size="sm" type="number" min={RANDOMIZER_LIMITS.teamCountMin} max={RANDOMIZER_LIMITS.teamCountMax} value={data.teamCount} onChange={(event) => onChange({ ...data, teamCount: clampNumber(event.target.value, RANDOMIZER_LIMITS.teamCountMin, RANDOMIZER_LIMITS.teamCountMax, data.teamCount) })} />
|
||||
</label>
|
||||
<label>
|
||||
<span>{textContent.playerSelectionModeLabel || "Répartition"}</span>
|
||||
<select className="nds-select" data-size="sm" value={data.playerSelectionMode} onChange={(event) => onChange({ ...data, playerSelectionMode: event.target.value })}>
|
||||
<option value="all">{textContent.playerSelectionAll || "Tous"}</option>
|
||||
<option value="count">{textContent.playerSelectionCount || "Nombre défini"}</option>
|
||||
</select>
|
||||
</label>
|
||||
{data.playerSelectionMode === "count" && (
|
||||
<label>
|
||||
<span>{textContent.playerSelectionCountLabel || "Nombre"}</span>
|
||||
<input className="nds-input" data-size="sm" type="number" min={data.teamCount} max={RANDOMIZER_LIMITS.teamPlayersMax} value={selectionCountDraft} onChange={(event) => setSelectionCountDraft(event.target.value)} onBlur={(event) => commitSelectionCount(event.target.value)} />
|
||||
</label>
|
||||
)}
|
||||
<button className="nds-button" data-size="sm" data-variant="primary" type="button" onClick={launchTeams} disabled={animating}>
|
||||
<Icon name="play-circle" />
|
||||
<span>{textContent.createTeamsButton || "Créer les équipes"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="randomizer-team-advanced">
|
||||
<section className="randomizer-team-advanced-section">
|
||||
<div className="randomizer-list-header randomizer-value-row randomizer-player-row" aria-hidden="true">
|
||||
<span>{textContent.activeLabel || "Actif"}</span>
|
||||
<span>{textContent.nameLabel || "Nom"}</span>
|
||||
<span>{textContent.scoreLabel || "Score"}</span>
|
||||
<span />
|
||||
</div>
|
||||
<div className="randomizer-value-list nds-scrollbox" data-axis="vertical" data-variant="ghost">
|
||||
{data.players.map((player) => (
|
||||
<div className="randomizer-value-row randomizer-player-row" key={player.id}>
|
||||
<input className="nds-checkbox" data-size="sm" type="checkbox" checked={player.active} onChange={(event) => updatePlayer(player.id, { active: event.target.checked })} aria-label={`${textContent.activeLabel || "Actif"} ${player.name}`} />
|
||||
<input className="nds-input" data-size="sm" value={player.name} onChange={(event) => updatePlayer(player.id, { name: event.target.value })} aria-label={textContent.nameLabel || "Nom"} />
|
||||
<input className="nds-input" data-size="sm" type="number" min="0" max="9999" value={player.score} onChange={(event) => updatePlayer(player.id, { score: clampNumber(event.target.value, 0, 9999, player.score) })} aria-label={textContent.scoreLabel || "Score"} />
|
||||
<button className="nds-icon-button" data-size="sm" data-variant="danger" type="button" onClick={() => removePlayer(player.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${player.name}`} title={textContent.deleteTitle || "Supprimer"}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="nds-button" data-size="sm" type="button" onClick={clearPlayers} disabled={!data.players.length || animating}>{textContent.clearNamesButton || "Supprimer tous les noms"}</button>
|
||||
</section>
|
||||
<section className="randomizer-team-advanced-section randomizer-exclusion-block">
|
||||
<form className={`inline-form randomizer-exclusion-form ${exclusionMode === "group" ? "is-group-mode" : ""}`} onSubmit={addExclusion}>
|
||||
<label className="randomizer-field randomizer-exclusion-mode-field">
|
||||
<span>{textContent.exclusionModeLabel || "Type"}</span>
|
||||
<select className="nds-select" data-size="sm" value={exclusionMode} onChange={(event) => exclusionDraft.updateValues({ mode: event.target.value, playerAId: event.target.value === "group" ? "" : exclusionDraft.values.playerAId })} aria-label={textContent.exclusionModeLabel || "Type"}>
|
||||
<option value="group">{textContent.exclusionModeGroup || "Tous séparés"}</option>
|
||||
<option value="source">{textContent.exclusionModeSource || "Exclusions granulaires"}</option>
|
||||
</select>
|
||||
</label>
|
||||
{exclusionMode === "source" && (
|
||||
<label className="randomizer-field">
|
||||
<span>{textContent.firstPlayerLabel || "Nom source"}</span>
|
||||
<select className="nds-select" data-size="sm" value={exclusionDraft.values.playerAId} onChange={(event) => exclusionDraft.updateValues({ playerAId: event.target.value, playerBIds: (exclusionDraft.values.playerBIds || []).filter((playerId) => playerId !== event.target.value) })} aria-label={textContent.firstPlayerLabel || "Nom source"}>
|
||||
<option value="">{textContent.selectNamesButton || "Choisir"}</option>
|
||||
{activePlayers.map((player) => <option key={player.id} value={player.id}>{player.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="randomizer-field">
|
||||
<span>{textContent.secondPlayerLabel || "Noms exclus"}</span>
|
||||
<CompactDropdown
|
||||
className="randomizer-checkbox-dropdown"
|
||||
menuClassName="randomizer-checkbox-menu nds-scrollbox"
|
||||
label={textContent.secondPlayerLabel || "Noms exclus"}
|
||||
renderTrigger={({ open, toggle }) => (
|
||||
<button className={`randomizer-multi-trigger nds-select-trigger ${open ? "active" : ""}`} data-size="sm" type="button" onClick={toggle} aria-expanded={open}>
|
||||
<span className="randomizer-multi-trigger-label">{exclusionDraft.values.playerBIds?.length ? `${exclusionDraft.values.playerBIds.length} ${textContent.selectedNamesLabel || "noms"}` : textContent.selectNamesButton || "Choisir"}</span>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{() => (
|
||||
<>
|
||||
{activePlayers.map((player) => (
|
||||
<label className="randomizer-checkbox-option" key={player.id}>
|
||||
<input className="nds-checkbox" data-size="sm" type="checkbox" checked={(exclusionDraft.values.playerBIds || []).includes(player.id)} disabled={exclusionMode === "source" && player.id === exclusionDraft.values.playerAId} onChange={() => toggleExclusionTarget(player.id)} />
|
||||
<span>{player.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CompactDropdown>
|
||||
</div>
|
||||
<button className="nds-button" data-size="sm" type="submit">{textContent.addExclusionButton || "Exclure"}</button>
|
||||
</form>
|
||||
<span className="randomizer-section-label">{textContent.exclusionsListLabel || "Combinaisons exclues"}</span>
|
||||
<div className="randomizer-exclusions">
|
||||
{data.exclusions.map((exclusion) => {
|
||||
const playerA = data.players.find((player) => player.id === exclusion.playerAId);
|
||||
const playerBs = (exclusion.playerBIds || []).map((playerId) => data.players.find((player) => player.id === playerId)).filter(Boolean);
|
||||
if (!playerA || !playerBs.length) return null;
|
||||
const exclusionLabel = exclusion.mode === "group"
|
||||
? [playerA, ...playerBs].map((player) => player.name).join(" / ")
|
||||
: `${playerA.name} / ${playerBs.map((player) => player.name).join(" ou ")}`;
|
||||
return (
|
||||
<span className="randomizer-chip" key={exclusion.id}>
|
||||
<span className="randomizer-chip-label">{exclusionLabel}</span>
|
||||
<button className="nds-icon-button" data-size="xxs" data-variant="danger" type="button" onClick={() => onChange({ ...data, exclusions: data.exclusions.filter((item) => item.id !== exclusion.id) })} aria-label={`${textContent.deleteTitle || "Supprimer"} ${playerA.name}`}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RandomizerHistory
|
||||
title={textContent.historyTitle || "Historique"}
|
||||
history={data.history}
|
||||
renderEntry={(entry) => `${entry.teams.length} ${entry.teams.length > 1 ? textContent.teamsPluralLabel || "équipes" : textContent.teamsSingularLabel || "équipe"}`}
|
||||
renderDetails={(entry) => (
|
||||
<div className={`randomizer-history-teams ${entry.teams.length > 4 ? "has-team-rows" : ""}`}>
|
||||
{entry.teams.map((team) => (
|
||||
<div className="randomizer-history-team" key={team.id}>
|
||||
<strong>{team.name}</strong>
|
||||
<span>{textContent.teamScoreLabel || "Score"} {team.score}</span>
|
||||
<ul>
|
||||
{team.players.map((player) => <li key={player.id}>{player.name}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
onClear={clearTeamsHistory}
|
||||
onRemove={removeTeamsHistoryEntry}
|
||||
disabled={animating}
|
||||
textContent={textContent}
|
||||
showEmpty
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultChips({ results, onRemove, disabled = false, textContent = {} }) {
|
||||
if (!results?.length) return null;
|
||||
return (
|
||||
<div className="randomizer-results">
|
||||
{results.map((result, index) => (
|
||||
<span className="randomizer-chip" key={`${result}-${index}`}>
|
||||
{result}
|
||||
{onRemove && (
|
||||
<button className="nds-icon-button" data-size="xs" data-variant="danger" type="button" onClick={() => onRemove(index)} disabled={disabled} aria-label={`${textContent.deleteTitle || "Supprimer"} ${result}`}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RandomizerHistory({ title, history, renderEntry, renderDetails, onClear, onRemove, disabled = false, textContent = {}, showEmpty = false }) {
|
||||
const hasHistory = Boolean(history?.length);
|
||||
if (!hasHistory && !showEmpty) return null;
|
||||
return (
|
||||
<section className="randomizer-history nds-scrollbox" data-axis="vertical">
|
||||
<header>
|
||||
<h3>{title}</h3>
|
||||
<button className="nds-button randomizer-history-clear" data-size="xs" type="button" onClick={onClear} disabled={disabled || !hasHistory}>{textContent.clearHistoryButton || "Effacer tout"}</button>
|
||||
</header>
|
||||
{hasHistory ? (
|
||||
<div className="randomizer-list">
|
||||
{history.map((entry, index) => (
|
||||
<div className={`randomizer-history-row ${index === 0 ? "is-latest" : ""} ${onRemove ? "has-action" : ""}`} key={entry.id}>
|
||||
{renderDetails ? (
|
||||
<details className="randomizer-history-main randomizer-history-accordion">
|
||||
<summary>
|
||||
<Icon name="chevron-down" />
|
||||
<span className="randomizer-history-value">{renderEntry(entry)}</span>
|
||||
<time dateTime={entry.at}>{formatDateTime(entry.at)}</time>
|
||||
</summary>
|
||||
{renderDetails(entry)}
|
||||
</details>
|
||||
) : (
|
||||
<div className="randomizer-history-main">
|
||||
<span className="randomizer-history-value">{renderEntry(entry)}</span>
|
||||
<time dateTime={entry.at}>{formatDateTime(entry.at)}</time>
|
||||
</div>
|
||||
)}
|
||||
{onRemove && (
|
||||
<button className="nds-icon-button" data-size="md" data-variant="danger" type="button" onClick={() => onRemove(entry.id)} disabled={disabled} aria-label={`${textContent.deleteTitle || "Supprimer"} ${formatDateTime(entry.at)}`}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <p className="randomizer-empty">{textContent.emptyHistory || "Aucun historique."}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
182
website/src/features/toolboxes/modules/randomizerUtils.js
Normal file
182
website/src/features/toolboxes/modules/randomizerUtils.js
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
// Rôle : fournit les calculs de tirage et d'équilibrage pour l'outil Randomizer.
|
||||
export const RANDOMIZER_TABS = [
|
||||
{ id: "dice", label: "Dé", icon: "dice" },
|
||||
{ id: "wheel", label: "Roue", icon: "wheel" },
|
||||
{ id: "teams", label: "Équipes", icon: "parent" }
|
||||
];
|
||||
|
||||
export const RANDOMIZER_HISTORY_LIMIT = 20;
|
||||
export const RANDOMIZER_EXCLUSION_MODES = new Set(["source", "group"]);
|
||||
export const RANDOMIZER_LIMITS = {
|
||||
diceFacesMin: 2,
|
||||
diceFacesMax: 999,
|
||||
diceRollsMin: 1,
|
||||
diceRollsMax: 100,
|
||||
wheelEntriesMax: 60,
|
||||
wheelWeightMin: 1,
|
||||
wheelWeightMax: 999,
|
||||
teamCountMin: 1,
|
||||
teamCountMax: 12,
|
||||
teamPlayersMax: 200,
|
||||
teamScoreMin: 0,
|
||||
teamScoreMax: 9999
|
||||
};
|
||||
|
||||
export function clampNumber(value, min, max, fallback = min) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.min(max, Math.max(min, parsed));
|
||||
}
|
||||
|
||||
export function getDiceShape(faces) {
|
||||
if (faces === 2) return "circle";
|
||||
if (faces === 3) return "triangle";
|
||||
if (faces >= 7) return "hexagon";
|
||||
return "square";
|
||||
}
|
||||
|
||||
export function rollDice(faces, rolls, random = Math.random) {
|
||||
const cleanFaces = clampNumber(faces, RANDOMIZER_LIMITS.diceFacesMin, RANDOMIZER_LIMITS.diceFacesMax, 6);
|
||||
const cleanRolls = clampNumber(rolls, RANDOMIZER_LIMITS.diceRollsMin, RANDOMIZER_LIMITS.diceRollsMax, 1);
|
||||
return Array.from({ length: cleanRolls }, () => Math.floor(random() * cleanFaces) + 1);
|
||||
}
|
||||
|
||||
export function getValidWheelEntries(entries) {
|
||||
return (Array.isArray(entries) ? entries : [])
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
label: String(entry?.label || "").trim(),
|
||||
weight: clampNumber(entry?.weight, RANDOMIZER_LIMITS.wheelWeightMin, RANDOMIZER_LIMITS.wheelWeightMax, 1)
|
||||
}))
|
||||
.filter((entry) => entry.label)
|
||||
.slice(0, RANDOMIZER_LIMITS.wheelEntriesMax);
|
||||
}
|
||||
|
||||
export function drawWeightedEntries(entries, draws, random = Math.random) {
|
||||
const validEntries = getValidWheelEntries(entries);
|
||||
const totalWeight = validEntries.reduce((sum, entry) => sum + entry.weight, 0);
|
||||
const cleanDraws = clampNumber(draws, RANDOMIZER_LIMITS.diceRollsMin, RANDOMIZER_LIMITS.diceRollsMax, 1);
|
||||
if (validEntries.length < 2 || totalWeight <= 0) return [];
|
||||
|
||||
return Array.from({ length: cleanDraws }, () => {
|
||||
let cursor = random() * totalWeight;
|
||||
return validEntries.find((entry) => {
|
||||
cursor -= entry.weight;
|
||||
return cursor < 0;
|
||||
}) || validEntries.at(-1);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildWheelSegments(entries) {
|
||||
const validEntries = getValidWheelEntries(entries);
|
||||
const totalWeight = validEntries.reduce((sum, entry) => sum + entry.weight, 0);
|
||||
let startAngle = -90;
|
||||
if (!totalWeight) return [];
|
||||
|
||||
return validEntries.map((entry, index) => {
|
||||
const angle = (entry.weight / totalWeight) * 360;
|
||||
const segment = {
|
||||
entry,
|
||||
index,
|
||||
startAngle,
|
||||
endAngle: startAngle + angle,
|
||||
midAngle: startAngle + angle / 2
|
||||
};
|
||||
startAngle += angle;
|
||||
return segment;
|
||||
});
|
||||
}
|
||||
|
||||
function shuffleItems(items, random) {
|
||||
const nextItems = [...items];
|
||||
for (let index = nextItems.length - 1; index > 0; index -= 1) {
|
||||
const targetIndex = Math.floor(random() * (index + 1));
|
||||
[nextItems[index], nextItems[targetIndex]] = [nextItems[targetIndex], nextItems[index]];
|
||||
}
|
||||
return nextItems;
|
||||
}
|
||||
|
||||
function getExcludedPlayerIds(playerId, exclusions) {
|
||||
return new Set((exclusions || []).flatMap((exclusion) => {
|
||||
const playerBIds = Array.isArray(exclusion.playerBIds) ? exclusion.playerBIds : [exclusion.playerBId];
|
||||
if (exclusion.mode === "group") {
|
||||
const groupIds = [exclusion.playerAId, ...playerBIds].filter(Boolean);
|
||||
return groupIds.includes(playerId) ? groupIds.filter((groupId) => groupId !== playerId) : [];
|
||||
}
|
||||
if (exclusion.playerAId === playerId) return playerBIds;
|
||||
if (playerBIds.includes(playerId)) return [exclusion.playerAId];
|
||||
return [];
|
||||
}));
|
||||
}
|
||||
|
||||
function canAddPlayerToTeam(player, team, exclusions) {
|
||||
const excludedIds = getExcludedPlayerIds(player.id, exclusions);
|
||||
return !team.players.some((teamPlayer) => excludedIds.has(teamPlayer.id));
|
||||
}
|
||||
|
||||
export function createBalancedTeams(players, teamCount, exclusions, random = Math.random, attempts = 120) {
|
||||
const activePlayers = (Array.isArray(players) ? players : [])
|
||||
.filter((player) => player?.active !== false && String(player?.name || "").trim())
|
||||
.map((player) => ({
|
||||
...player,
|
||||
name: String(player.name).trim(),
|
||||
score: clampNumber(player.score, RANDOMIZER_LIMITS.teamScoreMin, RANDOMIZER_LIMITS.teamScoreMax, 0)
|
||||
}))
|
||||
.slice(0, RANDOMIZER_LIMITS.teamPlayersMax);
|
||||
const cleanTeamCount = clampNumber(teamCount, RANDOMIZER_LIMITS.teamCountMin, RANDOMIZER_LIMITS.teamCountMax, 2);
|
||||
if (activePlayers.length < cleanTeamCount) return { ok: false, teams: [], error: "not_enough_players" };
|
||||
|
||||
let bestTeams = null;
|
||||
let bestSpread = Number.POSITIVE_INFINITY;
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
const teams = Array.from({ length: cleanTeamCount }, (_, index) => ({ id: `team-${index + 1}`, name: `Équipe ${index + 1}`, score: 0, players: [] }));
|
||||
const orderedPlayers = shuffleItems(activePlayers, random).sort((a, b) => b.score - a.score || random() - 0.5);
|
||||
let failed = false;
|
||||
|
||||
orderedPlayers.forEach((player) => {
|
||||
if (failed) return;
|
||||
const candidates = teams
|
||||
.filter((team) => canAddPlayerToTeam(player, team, exclusions))
|
||||
.sort((a, b) => a.score - b.score || a.players.length - b.players.length || random() - 0.5);
|
||||
const targetTeam = candidates[0];
|
||||
if (!targetTeam) {
|
||||
failed = true;
|
||||
return;
|
||||
}
|
||||
targetTeam.players.push(player);
|
||||
targetTeam.score += player.score;
|
||||
});
|
||||
|
||||
if (!failed) {
|
||||
const scores = teams.map((team) => team.score);
|
||||
const sizes = teams.map((team) => team.players.length);
|
||||
const spread = Math.max(...scores) - Math.min(...scores) + (Math.max(...sizes) - Math.min(...sizes)) * 10000;
|
||||
if (spread < bestSpread) {
|
||||
bestTeams = teams;
|
||||
bestSpread = spread;
|
||||
}
|
||||
if (spread === 0) break;
|
||||
}
|
||||
}
|
||||
|
||||
return bestTeams ? { ok: true, teams: bestTeams, error: "" } : { ok: false, teams: [], error: "constraints_impossible" };
|
||||
}
|
||||
|
||||
export function filterExclusionsForPlayers(exclusions, players) {
|
||||
const playerIds = new Set((Array.isArray(players) ? players : []).map((player) => player.id));
|
||||
return (Array.isArray(exclusions) ? exclusions : [])
|
||||
.map((exclusion) => {
|
||||
if (exclusion?.mode === "group") {
|
||||
const groupIds = [exclusion.playerAId, ...(exclusion.playerBIds || [])].filter((playerId) => playerIds.has(playerId));
|
||||
return groupIds.length > 1 ? { ...exclusion, playerAId: groupIds[0], playerBIds: groupIds.slice(1) } : null;
|
||||
}
|
||||
if (!playerIds.has(exclusion?.playerAId)) return null;
|
||||
const playerBIds = (exclusion.playerBIds || [exclusion.playerBId]).filter((playerId) => playerIds.has(playerId));
|
||||
return playerBIds.length ? { ...exclusion, playerBIds } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function addRandomizerHistory(history, entry) {
|
||||
return [entry, ...(Array.isArray(history) ? history : [])].slice(0, RANDOMIZER_HISTORY_LIMIT);
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import { exportCombosText, importCombosText } from "./textImport/combos.js";
|
|||
import { exportEquipmentPlannerText, importEquipmentPlannerText } from "./textImport/equipment.js";
|
||||
import { exportImageAnnotationText, exportImagesText, importImageAnnotationText, importImagesText } from "./textImport/media.js";
|
||||
import { exportGoalTreeText, exportTaskPlannerText, importGoalTreeText, importTaskPlannerText } from "./textImport/planning.js";
|
||||
import { exportRandomizerText, importRandomizerText } from "./textImport/randomizer.js";
|
||||
import { fail, parseColonImportLines } from "./textImport/shared.js";
|
||||
import { exportCalendarText, exportTimerText, importCalendarText, importTimerText } from "./textImport/time.js";
|
||||
|
||||
|
|
@ -50,6 +51,11 @@ const TEXT_EXCHANGE_HANDLERS = {
|
|||
exportText: exportCountersText,
|
||||
importText: importCountersText
|
||||
},
|
||||
randomizer: {
|
||||
hasContent: (data) => Boolean(data?.dice?.history?.length || data?.wheel?.entries?.length || data?.teams?.players?.length),
|
||||
exportText: exportRandomizerText,
|
||||
importText: importRandomizerText
|
||||
},
|
||||
combos: {
|
||||
hasContent: (data) => Boolean(data?.combos?.length),
|
||||
exportText: exportCombosText,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
// Rôle : gère l'import/export texte des configurations Randomizer.
|
||||
import { lineError, parseInteger, parseMetaLine, parsePipeParts, splitLines } from "./shared.js";
|
||||
|
||||
function findPlayerIdByName(players, name) {
|
||||
const cleanName = String(name || "").trim().toLowerCase();
|
||||
return players.find((player) => player.name.toLowerCase() === cleanName)?.id || "";
|
||||
}
|
||||
|
||||
export function exportRandomizerText(data) {
|
||||
const lines = [`@activeTab: ${data.activeTab || "dice"}`];
|
||||
lines.push("[dice]", `faces: ${data.dice?.faces || 6}`, `rolls: ${data.dice?.rolls || 1}`);
|
||||
lines.push("", "[wheel]", `draws: ${data.wheel?.draws || 1}`);
|
||||
(data.wheel?.entries || []).forEach((entry) => {
|
||||
lines.push(`- ${entry.label} | weight=${entry.weight || 1}`);
|
||||
});
|
||||
lines.push(
|
||||
"",
|
||||
"[teams]",
|
||||
`teamCount: ${data.teams?.teamCount || 2}`,
|
||||
`playerSelectionMode: ${data.teams?.playerSelectionMode || "all"}`,
|
||||
`playerSelectionCount: ${data.teams?.playerSelectionCount || 2}`
|
||||
);
|
||||
(data.teams?.players || []).forEach((player) => {
|
||||
lines.push(`- ${player.name} | score=${player.score || 0}${player.active === false ? " | active=false" : ""}`);
|
||||
});
|
||||
(data.teams?.exclusions || []).forEach((exclusion) => {
|
||||
const playerA = data.teams.players.find((player) => player.id === exclusion.playerAId);
|
||||
const playerBs = (exclusion.playerBIds || [exclusion.playerBId])
|
||||
.map((playerId) => data.teams.players.find((player) => player.id === playerId))
|
||||
.filter(Boolean);
|
||||
if (playerA && playerBs.length) lines.push(`! ${[playerA.name, ...playerBs.map((player) => player.name)].join(" / ")}${exclusion.mode === "group" ? " | mode=group" : ""}`);
|
||||
});
|
||||
return lines.join("\n").trim();
|
||||
}
|
||||
|
||||
export function importRandomizerText(text, context) {
|
||||
const data = {
|
||||
activeTab: "dice",
|
||||
dice: { faces: 6, rolls: 1 },
|
||||
wheel: { draws: 1, entries: [] },
|
||||
teams: { teamCount: 2, playerSelectionMode: "all", playerSelectionCount: 2, players: [], exclusions: [] }
|
||||
};
|
||||
let section = "";
|
||||
|
||||
splitLines(text).forEach((rawLine, index) => {
|
||||
const cleanLine = rawLine.trim();
|
||||
if (!cleanLine) return;
|
||||
const meta = parseMetaLine(cleanLine);
|
||||
if (meta?.key === "activeTab") {
|
||||
data.activeTab = meta.value;
|
||||
return;
|
||||
}
|
||||
if (/^\[[a-z]+\]$/i.test(cleanLine)) {
|
||||
section = cleanLine.slice(1, -1).toLowerCase();
|
||||
return;
|
||||
}
|
||||
const separatorIndex = cleanLine.indexOf(":");
|
||||
if (separatorIndex > 0 && !cleanLine.startsWith("-") && !cleanLine.startsWith("!")) {
|
||||
const key = cleanLine.slice(0, separatorIndex).trim();
|
||||
const value = cleanLine.slice(separatorIndex + 1).trim();
|
||||
if (section === "dice" && key === "faces") data.dice.faces = parseInteger(value, 6);
|
||||
if (section === "dice" && key === "rolls") data.dice.rolls = parseInteger(value, 1);
|
||||
if (section === "wheel" && key === "draws") data.wheel.draws = parseInteger(value, 1);
|
||||
if (section === "teams" && key === "teamCount") data.teams.teamCount = parseInteger(value, 2);
|
||||
if (section === "teams" && key === "playerSelectionMode") data.teams.playerSelectionMode = value;
|
||||
if (section === "teams" && key === "playerSelectionCount") data.teams.playerSelectionCount = parseInteger(value, 2);
|
||||
return;
|
||||
}
|
||||
if (section === "wheel" && cleanLine.startsWith("-")) {
|
||||
const { title, meta: pipeMeta } = parsePipeParts(cleanLine.replace(/^[-*]\s*/, ""));
|
||||
if (!title) throw lineError(index, "valeur de roue manquante.");
|
||||
data.wheel.entries.push({ id: context.uid("randomizer"), label: title, weight: parseInteger(pipeMeta.weight, 1) });
|
||||
return;
|
||||
}
|
||||
if (section === "teams" && cleanLine.startsWith("-")) {
|
||||
const { title, meta: pipeMeta } = parsePipeParts(cleanLine.replace(/^[-*]\s*/, ""));
|
||||
if (!title) throw lineError(index, "joueur manquant.");
|
||||
data.teams.players.push({
|
||||
id: context.uid("randomizer"),
|
||||
name: title,
|
||||
score: parseInteger(pipeMeta.score, 0),
|
||||
active: pipeMeta.active !== "false"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (section === "teams" && cleanLine.startsWith("!")) {
|
||||
const { title, meta: pipeMeta } = parsePipeParts(cleanLine.replace(/^!\s*/, ""));
|
||||
const names = title.split("/").map((name) => name.trim()).filter(Boolean);
|
||||
const playerAId = findPlayerIdByName(data.teams.players, names[0]);
|
||||
const playerBIds = names.slice(1).map((name) => findPlayerIdByName(data.teams.players, name)).filter(Boolean);
|
||||
if (!playerAId || !playerBIds.length) throw lineError(index, "exclusion avec joueur inconnu.");
|
||||
data.teams.exclusions.push({ id: context.uid("randomizer"), playerAId, playerBIds, mode: pipeMeta.mode === "group" ? "group" : "source" });
|
||||
return;
|
||||
}
|
||||
throw lineError(index, `ligne randomizer invalide: ${cleanLine}`);
|
||||
});
|
||||
|
||||
return context.normalizeRandomizerData(data);
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import { ImagesModule } from "./ImagesModule.jsx";
|
|||
import { LinksModule } from "./LinksModule.jsx";
|
||||
import { MemosModule } from "./MemosModule.jsx";
|
||||
import { NotepadModule } from "./NotepadModule.jsx";
|
||||
import { RandomizerModule } from "./RandomizerModule.jsx";
|
||||
import { TableModule } from "./TableModule.jsx";
|
||||
import { TaskPlannerModule } from "./TaskPlannerModule.jsx";
|
||||
import { TimerModule } from "./TimerModule.jsx";
|
||||
|
|
@ -26,6 +27,7 @@ export const MODULE_COMPONENTS = {
|
|||
images: { ...TOOLBOX_MODULE_DEFINITIONS.images, Component: ImagesModule },
|
||||
links: { ...TOOLBOX_MODULE_DEFINITIONS.links, Component: LinksModule },
|
||||
counters: { ...TOOLBOX_MODULE_DEFINITIONS.counters, Component: CountersModule },
|
||||
randomizer: { ...TOOLBOX_MODULE_DEFINITIONS.randomizer, Component: RandomizerModule },
|
||||
combos: { ...TOOLBOX_MODULE_DEFINITIONS.combos, Component: CombosModule },
|
||||
calculator: { ...TOOLBOX_MODULE_DEFINITIONS.calculator, Component: CalculatorModule },
|
||||
calendar: { ...TOOLBOX_MODULE_DEFINITIONS.calendar, Component: CalendarModule },
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { compactImageAnnotationDataForStorage, compactImagesDataForStorage } fro
|
|||
import { compactLinksDataForStorage } from "./links.js";
|
||||
import { compactMemosDataForStorage } from "./memos.js";
|
||||
import { compactNotepadDataForStorage } from "./notepad.js";
|
||||
import { compactRandomizerDataForStorage } from "./randomizer.js";
|
||||
import { compactTableDataForStorage } from "./table.js";
|
||||
import { compactTaskPlannerDataForStorage } from "./taskPlanner.js";
|
||||
import { compactTimerDataForStorage } from "./timer.js";
|
||||
|
|
@ -22,6 +23,7 @@ export const MODULE_DATA_COMPACTORS = {
|
|||
imageAnnotation: compactImageAnnotationDataForStorage,
|
||||
links: compactLinksDataForStorage,
|
||||
counters: compactCountersDataForStorage,
|
||||
randomizer: compactRandomizerDataForStorage,
|
||||
combos: compactCombosDataForStorage,
|
||||
calculator: compactCalculatorDataForStorage,
|
||||
calendar: compactCalendarDataForStorage,
|
||||
|
|
|
|||
172
website/src/features/toolboxes/storage/modules/randomizer.js
Normal file
172
website/src/features/toolboxes/storage/modules/randomizer.js
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
// Rôle : normalise et compacte les configurations de l'outil Randomizer.
|
||||
import { RANDOMIZER_EXCLUSION_MODES, RANDOMIZER_LIMITS, clampNumber } from "../../modules/randomizerUtils.js";
|
||||
import { normalizeIsoDate, uid } from "./shared.js";
|
||||
|
||||
const TAB_IDS = new Set(["dice", "wheel", "teams"]);
|
||||
const TEAM_SELECTION_MODES = new Set(["all", "count"]);
|
||||
|
||||
function normalizeHistoryEntry(entry, type) {
|
||||
const at = normalizeIsoDate(entry?.at);
|
||||
if (!at) return null;
|
||||
if (type === "dice") {
|
||||
const results = (Array.isArray(entry?.results) ? entry.results : [])
|
||||
.map((result) => clampNumber(result, 1, RANDOMIZER_LIMITS.diceFacesMax, 0))
|
||||
.filter((result) => result > 0);
|
||||
return results.length ? { id: entry?.id || uid("randomizer"), at, results } : null;
|
||||
}
|
||||
if (type === "wheel") {
|
||||
const results = (Array.isArray(entry?.results) ? entry.results : [])
|
||||
.map((result) => String(result || "").trim())
|
||||
.filter(Boolean);
|
||||
return results.length ? { id: entry?.id || uid("randomizer"), at, results } : null;
|
||||
}
|
||||
const teams = normalizeTeamResults(entry?.teams);
|
||||
return teams.length ? { id: entry?.id || uid("randomizer"), at, teams } : null;
|
||||
}
|
||||
|
||||
function normalizeHistory(history, type) {
|
||||
return (Array.isArray(history) ? history : [])
|
||||
.map((entry) => normalizeHistoryEntry(entry, type))
|
||||
.filter(Boolean)
|
||||
.slice(0, 20);
|
||||
}
|
||||
|
||||
function normalizeDice(data) {
|
||||
return {
|
||||
faces: clampNumber(data?.faces, RANDOMIZER_LIMITS.diceFacesMin, RANDOMIZER_LIMITS.diceFacesMax, 6),
|
||||
rolls: clampNumber(data?.rolls, RANDOMIZER_LIMITS.diceRollsMin, RANDOMIZER_LIMITS.diceRollsMax, 1),
|
||||
results: (Array.isArray(data?.results) ? data.results : []).map((result) => clampNumber(result, 1, RANDOMIZER_LIMITS.diceFacesMax, 1)),
|
||||
history: normalizeHistory(data?.history, "dice")
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWheelEntry(entry) {
|
||||
const label = String(entry?.label || "").trim();
|
||||
if (!label) return null;
|
||||
return {
|
||||
id: entry?.id || uid("randomizer"),
|
||||
label,
|
||||
weight: clampNumber(entry?.weight, RANDOMIZER_LIMITS.wheelWeightMin, RANDOMIZER_LIMITS.wheelWeightMax, 1)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWheel(data) {
|
||||
const entries = (Array.isArray(data?.entries) ? data.entries : [])
|
||||
.map(normalizeWheelEntry)
|
||||
.filter(Boolean)
|
||||
.slice(0, RANDOMIZER_LIMITS.wheelEntriesMax);
|
||||
return {
|
||||
draws: clampNumber(data?.draws, RANDOMIZER_LIMITS.diceRollsMin, RANDOMIZER_LIMITS.diceRollsMax, 1),
|
||||
entries,
|
||||
results: (Array.isArray(data?.results) ? data.results : []).map((result) => String(result || "").trim()).filter(Boolean),
|
||||
history: normalizeHistory(data?.history, "wheel")
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlayer(player) {
|
||||
const name = String(player?.name || "").trim();
|
||||
if (!name) return null;
|
||||
return {
|
||||
id: player?.id || uid("randomizer"),
|
||||
name,
|
||||
score: clampNumber(player?.score, RANDOMIZER_LIMITS.teamScoreMin, RANDOMIZER_LIMITS.teamScoreMax, 0),
|
||||
active: player?.active !== false
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeExclusions(exclusions, players) {
|
||||
const playerIds = new Set(players.map((player) => player.id));
|
||||
const seenPairs = new Set();
|
||||
const seenGroups = new Set();
|
||||
return (Array.isArray(exclusions) ? exclusions : [])
|
||||
.map((exclusion) => {
|
||||
const playerAId = String(exclusion?.playerAId || "");
|
||||
if (!playerIds.has(playerAId)) return null;
|
||||
const mode = RANDOMIZER_EXCLUSION_MODES.has(exclusion?.mode) ? exclusion.mode : "source";
|
||||
const playerBIds = (Array.isArray(exclusion?.playerBIds) ? exclusion.playerBIds : [exclusion?.playerBId])
|
||||
.map((playerId) => String(playerId || ""))
|
||||
.filter((playerId) => playerIds.has(playerId) && playerId !== playerAId);
|
||||
let uniquePlayerBIds = [...new Set(playerBIds)];
|
||||
if (mode === "group") {
|
||||
const groupIds = [playerAId, ...uniquePlayerBIds];
|
||||
const groupKey = [...groupIds].sort().join(":");
|
||||
if (seenGroups.has(groupKey)) return null;
|
||||
seenGroups.add(groupKey);
|
||||
groupIds.forEach((sourceId, sourceIndex) => {
|
||||
groupIds.slice(sourceIndex + 1).forEach((targetId) => {
|
||||
seenPairs.add([sourceId, targetId].sort().join(":"));
|
||||
});
|
||||
});
|
||||
} else {
|
||||
uniquePlayerBIds = uniquePlayerBIds.filter((playerBId) => {
|
||||
const pairKey = [playerAId, playerBId].sort().join(":");
|
||||
if (seenPairs.has(pairKey)) return false;
|
||||
seenPairs.add(pairKey);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
if (!uniquePlayerBIds.length) return null;
|
||||
return { id: exclusion?.id || uid("randomizer"), playerAId, playerBIds: uniquePlayerBIds, mode };
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeTeamResults(teams) {
|
||||
return (Array.isArray(teams) ? teams : [])
|
||||
.map((team, index) => ({
|
||||
id: team?.id || `team-${index + 1}`,
|
||||
name: String(team?.name || `Équipe ${index + 1}`).trim() || `Équipe ${index + 1}`,
|
||||
score: clampNumber(team?.score, 0, Number.MAX_SAFE_INTEGER, 0),
|
||||
players: (Array.isArray(team?.players) ? team.players : [])
|
||||
.map((player) => ({
|
||||
id: player?.id || uid("randomizer"),
|
||||
name: String(player?.name || "").trim(),
|
||||
score: clampNumber(player?.score, RANDOMIZER_LIMITS.teamScoreMin, RANDOMIZER_LIMITS.teamScoreMax, 0)
|
||||
}))
|
||||
.filter((player) => player.name)
|
||||
}))
|
||||
.filter((team) => team.players.length);
|
||||
}
|
||||
|
||||
function normalizeTeams(data) {
|
||||
const players = (Array.isArray(data?.players) ? data.players : [])
|
||||
.map(normalizePlayer)
|
||||
.filter(Boolean)
|
||||
.slice(0, RANDOMIZER_LIMITS.teamPlayersMax);
|
||||
return {
|
||||
teamCount: clampNumber(data?.teamCount, RANDOMIZER_LIMITS.teamCountMin, RANDOMIZER_LIMITS.teamCountMax, 2),
|
||||
playerSelectionMode: TEAM_SELECTION_MODES.has(data?.playerSelectionMode) ? data.playerSelectionMode : "all",
|
||||
playerSelectionCount: clampNumber(data?.playerSelectionCount, RANDOMIZER_LIMITS.teamCountMin, RANDOMIZER_LIMITS.teamPlayersMax, 2),
|
||||
players,
|
||||
exclusions: normalizeExclusions(data?.exclusions, players),
|
||||
teams: normalizeTeamResults(data?.teams),
|
||||
history: normalizeHistory(data?.history, "teams")
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeRandomizerData(data) {
|
||||
return {
|
||||
activeTab: TAB_IDS.has(data?.activeTab) ? data.activeTab : "dice",
|
||||
dice: normalizeDice(data?.dice),
|
||||
wheel: normalizeWheel(data?.wheel),
|
||||
teams: normalizeTeams(data?.teams)
|
||||
};
|
||||
}
|
||||
|
||||
export function compactRandomizerDataForStorage(value) {
|
||||
const normalized = normalizeRandomizerData(value);
|
||||
const compact = {};
|
||||
if (normalized.activeTab !== "dice") compact.activeTab = normalized.activeTab;
|
||||
if (normalized.dice.faces !== 6 || normalized.dice.rolls !== 1 || normalized.dice.results.length || normalized.dice.history.length) compact.dice = normalized.dice;
|
||||
if (normalized.wheel.entries.length || normalized.wheel.draws !== 1 || normalized.wheel.results.length || normalized.wheel.history.length) compact.wheel = normalized.wheel;
|
||||
if (
|
||||
normalized.teams.players.length ||
|
||||
normalized.teams.exclusions.length ||
|
||||
normalized.teams.teamCount !== 2 ||
|
||||
normalized.teams.playerSelectionMode !== "all" ||
|
||||
normalized.teams.playerSelectionCount !== 2 ||
|
||||
normalized.teams.teams.length ||
|
||||
normalized.teams.history.length
|
||||
) compact.teams = normalized.teams;
|
||||
return Object.keys(compact).length ? compact : null;
|
||||
}
|
||||
|
|
@ -24,7 +24,8 @@ export const ID_PREFIXES = {
|
|||
material: "n",
|
||||
socketItem: "j",
|
||||
socketLink: "u",
|
||||
socketBonus: "b"
|
||||
socketBonus: "b",
|
||||
randomizer: "x"
|
||||
};
|
||||
|
||||
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export { labelFromFileName, normalizeImageAnnotationData } from "./modules/image
|
|||
export { hostnameFromUrl, normalizeLinksData, normalizeUrl } from "./modules/links.js";
|
||||
export { normalizeMemosData } from "./modules/memos.js";
|
||||
export { normalizeNotepadData, sanitizeNotepadHtml } from "./modules/notepad.js";
|
||||
export { normalizeRandomizerData } from "./modules/randomizer.js";
|
||||
export { uid } from "./modules/shared.js";
|
||||
export { normalizeTableData } from "./modules/table.js";
|
||||
export { normalizeTaskPlannerData } from "./modules/taskPlanner.js";
|
||||
|
|
@ -216,6 +217,27 @@ function remapModuleDataForExport(type, data, nextId) {
|
|||
return { counters: compact.counters.map((counter) => ({ ...counter, id: nextId("counter") })) };
|
||||
}
|
||||
|
||||
if (type === "randomizer") {
|
||||
const remapped = { ...compact };
|
||||
if (compact.wheel?.entries) remapped.wheel = { ...compact.wheel, entries: compact.wheel.entries.map((entry) => ({ ...entry, id: nextId("randomizer") })) };
|
||||
if (compact.teams?.players) {
|
||||
const playerIdMap = new Map(compact.teams.players.map((player) => [player.id, nextId("randomizer")]));
|
||||
remapped.teams = {
|
||||
...compact.teams,
|
||||
players: compact.teams.players.map((player) => ({ ...player, id: playerIdMap.get(player.id) })),
|
||||
exclusions: (compact.teams.exclusions || []).map((exclusion) => ({
|
||||
...exclusion,
|
||||
id: nextId("randomizer"),
|
||||
playerAId: playerIdMap.get(exclusion.playerAId),
|
||||
playerBIds: (exclusion.playerBIds || [exclusion.playerBId])
|
||||
.map((playerId) => playerIdMap.get(playerId))
|
||||
.filter(Boolean)
|
||||
})).filter((exclusion) => exclusion.playerAId && exclusion.playerBIds.length)
|
||||
};
|
||||
}
|
||||
return remapped;
|
||||
}
|
||||
|
||||
if (type === "memos") {
|
||||
const remapped = {};
|
||||
if (compact.tickerEnabled === false) remapped.tickerEnabled = false;
|
||||
|
|
|
|||
|
|
@ -55,6 +55,16 @@ export const TOOLBOX_MODULE_DEFINITIONS = {
|
|||
scrollable: true,
|
||||
libraryCategory: "notesTracking"
|
||||
},
|
||||
randomizer: {
|
||||
label: "Randomizer",
|
||||
defaultTitle: "Randomizer",
|
||||
mode: "Dé + roue + équipes",
|
||||
icon: "dice",
|
||||
editable: true,
|
||||
addable: false,
|
||||
scrollable: true,
|
||||
libraryCategory: "calculationData"
|
||||
},
|
||||
combos: {
|
||||
label: "Combos",
|
||||
defaultTitle: "Combos",
|
||||
|
|
@ -146,7 +156,7 @@ export const TOOLBOX_MODULES = Object.fromEntries(Object.entries(TOOLBOX_MODULE_
|
|||
export const LIBRARY_CATEGORY_DEFINITIONS = [
|
||||
{ key: "notesTracking", types: ["memos", "notepad", "checklist", "counters"] },
|
||||
{ key: "references", types: ["images", "imageAnnotation", "links"] },
|
||||
{ key: "calculationData", types: ["calculator", "table", "goalTree"] },
|
||||
{ key: "calculationData", types: ["calculator", "table", "goalTree", "randomizer"] },
|
||||
{ key: "timeRoutines", types: ["timer", "calendar", "taskPlanner"] },
|
||||
{ key: "buildsCommands", types: ["combos", "equipmentPlanner"] }
|
||||
];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue