gong at timer start - add a few TODOs
[westcastle.git] / js / index.js
CommitLineData
7a00c409
BA
1new Vue({
2 el: "#mahjong",
3 data: {
4 players: [], //array of objects, filled later
5 display: "players",
6 },
7 components: {
8 'my-players': {
48bee368 9 props: ['players','initPlayers'],
7a00c409
BA
10 template: `
11 <div id="players">
1369a09d 12 <div class="left">
7a00c409
BA
13 <p>Présents</p>
14 <table class="list">
15 <tr v-for="p in sortedPlayers" v-if="p.available" @click="toggleAvailability(p.index)">
16 <td>{{ p.prenom }}</td>
17 <td>{{ p.nom }}</td>
18 </tr>
19 </table>
20 </div>
1369a09d 21 <div id="inactive" class="right">
7a00c409
BA
22 <p>Absents</p>
23 <table class="list">
fd4a69e4 24 <tr v-for="p in sortedPlayers" v-if="!p.available && p.nom!=''" @click="toggleAvailability(p.index)">
7a00c409
BA
25 <td>{{ p.prenom }}</td>
26 <td>{{ p.nom }}</td>
27 </tr>
28 </table>
29 </div>
48bee368
BA
30 <div class="clear">
31 <input class="hide" id="upload" type="file" @change="upload"/>
32 <button class="btn block cancel" @click="uploadTrigger()" title="Charge la liste des joueurs, en principe en début de tournoi">
33 (Ré)initialiser
34 </button>
35 </div>
7a00c409
BA
36 </div>
37 `,
38 computed: {
39 sortedPlayers: function() {
40 return this.players
41 .map( (p,i) => { return Object.assign({}, p, {index: i}); })
42 .sort( (a,b) => {
43 return a.nom.localeCompare(b.nom);
44 });
45 },
46 },
47 methods: {
48 toggleAvailability: function(i) {
49 this.players[i].available = 1 - this.players[i].available;
48bee368
BA
50 },
51 uploadTrigger: function() {
52 document.getElementById("upload").click();
53 },
54 upload: function(e) {
55 let file = (e.target.files || e.dataTransfer.files)[0];
56 var reader = new FileReader();
57 reader.onloadend = ev => {
58 this.initPlayers(ev.currentTarget.result);
59 };
60 reader.readAsText(file);
7a00c409
BA
61 },
62 },
63 },
7a00c409 64 'my-pairings': {
48bee368 65 props: ['players','commitScores'],
7a00c409
BA
66 data: function() {
67 return {
68 unpaired: [],
69 tables: [], //array of arrays of players indices
1d2d7593 70 sessions: [], //"mini-points" for each table
7a00c409 71 currentIndex: -1, //table index for scoring
1369a09d 72 scored: [], //boolean for each table index
7a00c409
BA
73 };
74 },
75 template: `
76 <div id="pairings">
77 <div v-show="currentIndex < 0">
48bee368
BA
78 <div class="button-container-horizontal">
79 <button class="btn validate" :class="{hide: tables.length==0}" @click="commitScores()" title="Valide l'état actuel des scores (cf. rubrique Classement) en mémoire. Cette action est nécessaire au moins une fois en fin de tournoi après toutes les parties, et est recommandée après chaque ronde">
80 Valider
81 </button>
82 <button id="doPairings" class="btn" :class="{cancel: tables.length>0}" @click="doPairings()" title="Répartit les joueurs actifs aléatoirement sur les tables">
83 Nouvelle ronde
84 </button>
85 </div>
1369a09d 86 <div class="pairing" v-for="(table,index) in tables" :class="{scored: scored[index]}"
7a00c409
BA
87 @click="showScoreForm(table,index)">
88 <p>Table {{ index+1 }}</p>
89 <table>
90 <tr v-for="(i,j) in table">
fd4a69e4 91 <td :class="{toto: players[i].prenom=='Toto'}">{{ players[i].prenom }} {{ players[i].nom }}</td>
1d2d7593 92 <td class="score"><span v-show="sessions[index].length > 0">{{ sessions[index][j] }}</span></td>
7a00c409 93 </tr>
7a00c409
BA
94 </table>
95 </div>
96 <div v-if="unpaired.length>0" class="pairing unpaired">
97 <p>Exempts</p>
98 <div v-for="i in unpaired">
99 {{ players[i].prenom }} {{ players[i].nom }}
100 </div>
101 </div>
102 </div>
103 <div id="scoreInput" v-if="currentIndex >= 0">
104 <table>
105 <tr v-for="(index,i) in tables[currentIndex]">
fd4a69e4
BA
106 <td :class="{toto: players[tables[currentIndex][i]].prenom=='Toto'}">
107 {{ players[tables[currentIndex][i]].prenom }} {{ players[tables[currentIndex][i]].nom }}
108 </td>
48bee368 109 <td><input type="text" v-model="sessions[currentIndex][i]" :disabled="scored[currentIndex]"/></td>
7a00c409
BA
110 </tr>
111 </table>
1369a09d 112 <div class="button-container-horizontal">
48bee368
BA
113 <button :class="{hide:scored[currentIndex]}" class="btn validate" @click="setScore()" title="Enregistre le score dans la base (la rubrique Classement est mise à jour)">
114 Enregistrer
115 </button>
116 <button :class="{hide:!scored[currentIndex]}" class="btn cancel" @click="resetScore()" title="Annule le score précédemment enregistré : l'action est visible dans la rubrique Classement">
117 Annuler
118 </button>
119 <button class="btn" @click="closeScoreForm()">Fermer</button>
7a00c409
BA
120 </div>
121 </div>
122 </div>
123 `,
124 methods: {
3480360c
BA
125 // TODO: clic sur "Valider" télécharge la ronde courante
126 // TODO: mémoriser les appariements passés pour éviter que les mêmes joueurs se rencontrent plusieurs fois
7a00c409
BA
127 doPairings: function() {
128 // Simple case first: 4 by 4
129 let tables = [];
130 let currentTable = [];
ade10194 131 let ordering = _.shuffle(_.range(this.players.length));
7a00c409
BA
132 for (let i=0; i<ordering.length; i++)
133 {
134 if ( ! this.players[ordering[i]].available )
135 continue;
136 if (currentTable.length >= 4)
137 {
138 tables.push(currentTable);
139 currentTable = [];
140 }
141 currentTable.push(ordering[i]);
142 }
143 // Analyse remainder
144 this.unpaired = [];
145 if (currentTable.length != 0)
146 {
147 if (currentTable.length < 3)
148 {
149 let missingPlayers = 3 - currentTable.length;
150 // Pick players from 'missingPlayers' random different tables, if possible
151 if (tables.length >= missingPlayers)
152 {
153 let tblNums = _.sample(_.range(tables.length), missingPlayers);
154 tblNums.forEach( num => {
155 currentTable.push(tables[num].pop());
156 });
157 }
158 }
159 if (currentTable.length >= 3)
160 tables.push(currentTable);
161 else
162 this.unpaired = currentTable;
163 }
fd4a69e4
BA
164 // Ensure that all tables have 4 players
165 tables.forEach( t => {
166 if (t.length < 4)
167 t.push(0); //index of "Toto", ghost player
168 });
7a00c409 169 this.tables = tables;
1d2d7593 170 this.sessions = tables.map( t => { return []; }); //empty sessions
1369a09d
BA
171 this.scored = tables.map( t => { return false; }); //nothing scored yet
172 this.currentIndex = -1; //required if reset while scoring
7a00c409
BA
173 },
174 showScoreForm: function(table,index) {
1369a09d
BA
175 if (this.sessions[index].length == 0)
176 this.sessions[index] = _.times(table.length, _.constant(0));
7a00c409
BA
177 this.currentIndex = index;
178 },
48bee368
BA
179 closeScoreForm: function() {
180 if (!this.scored[this.currentIndex])
181 this.sessions[this.currentIndex] = [];
182 this.currentIndex = -1;
183 },
184 getPdts: function() {
1d2d7593 185 let sortedSessions = this.sessions[this.currentIndex]
ce0473b4
BA
186 .map( (s,i) => { return {value:parseInt(s), index:i}; })
187 .sort( (a,b) => { return b.value - a.value; });
48bee368 188 const ref_pdts = [4, 2, 1, 0];
1369a09d
BA
189 // NOTE: take care of ex-aequos (spread points subtotal)
190 let curSum = 0, curCount = 0, start = 0;
48bee368 191 let sortedPdts = [];
1369a09d 192 for (let i=0; i<4; i++)
7a00c409 193 {
48bee368 194 curSum += ref_pdts[i];
1369a09d
BA
195 curCount++;
196 if (i==3 || sortedSessions[i].value > sortedSessions[i+1].value)
197 {
198 let pdt = curSum / curCount;
199 for (let j=start; j<=i; j++)
48bee368 200 sortedPdts.push(pdt);
1369a09d
BA
201 curSum = 0;
202 curCount = 0;
203 start = i+1;
204 }
48bee368
BA
205 }
206 // Re-order pdts to match table order
207 let pdts = [0, 0, 0, 0];
208 for (let i=0; i<4; i++)
209 pdts[sortedSessions[i].index] = sortedPdts[i];
210 return pdts;
211 },
212 setScore: function() {
213 let pdts = this.getPdts();
214 for (let i=0; i<4; i++)
215 {
216 this.players[this.tables[this.currentIndex][i]].pdt += pdts[i];
1d2d7593 217 this.players[this.tables[this.currentIndex][i]].session += parseInt(this.sessions[this.currentIndex][i]);
7a00c409 218 }
48bee368 219 Vue.set(this.scored, this.currentIndex, true);
7a00c409 220 this.currentIndex = -1;
7a00c409 221 },
48bee368
BA
222 resetScore: function() {
223 let pdts = this.getPdts();
224 for (let i=0; i<4; i++)
225 {
226 this.players[this.tables[this.currentIndex][i]].pdt -= pdts[i];
227 this.players[this.tables[this.currentIndex][i]].session -= parseInt(this.sessions[this.currentIndex][i]);
228 }
229 Vue.set(this.scored, this.currentIndex, false);
6d7bb9ad 230 },
7a00c409
BA
231 },
232 },
2caa3889
BA
233 'my-timer': {
234 data: function() {
235 return {
236 time: 0, //remaining time, in seconds
237 running: false,
3480360c 238 initialTime: 5400, //1h30
2caa3889
BA
239 };
240 },
241 template: `
8d4d2300 242 <div id="timer" :style="{lineHeight: divHeight + 'px', fontSize: 0.66*divHeight + 'px', width: divWidth + 'px', height: divHeight + 'px'}">
2caa3889
BA
243 <div @click.left="pauseResume()" @click.right.prevent="reset()" :class="{timeout:time==0}">
244 {{ formattedTime }}
245 </div>
246 <img class="close-cross" src="img/cross.svg" @click="$emit('clockover')"/>
247 </div>
248 `,
249 computed: {
250 formattedTime: function() {
251 let seconds = this.time % 60;
252 let minutes = Math.floor(this.time / 60);
253 return this.padToZero(minutes) + ":" + this.padToZero(seconds);
254 },
8d4d2300 255 divHeight: function() {
6d7bb9ad
BA
256 return screen.height;
257 },
8d4d2300
BA
258 divWidth: function() {
259 return screen.width;
260 },
2caa3889
BA
261 },
262 methods: {
263 padToZero: function(a) {
264 if (a < 10)
265 return "0" + a;
266 return a;
267 },
268 pauseResume: function() {
269 this.running = !this.running;
270 if (this.running)
271 this.start();
272 },
273 reset: function(e) {
274 this.running = false;
3480360c 275 this.time = this.initialTime;
2caa3889
BA
276 },
277 start: function() {
278 if (!this.running)
279 return;
280 if (this.time == 0)
281 {
282 new Audio("sounds/gong.mp3").play();
283 this.running = false;
284 return;
285 }
3480360c
BA
286 if (this.time == this.initialTime)
287 new Audio("sounds/gong.mp3").play(); //gong at the beginning
2caa3889
BA
288 setTimeout(() => {
289 if (this.running)
290 this.time--;
291 this.start();
292 }, 1000);
293 },
294 },
295 created: function() {
296 this.reset();
297 },
298 },
48b3a536 299 'my-ranking': {
48bee368 300 props: ['players','sortByScore','commitScores'],
48b3a536
BA
301 template: `
302 <div id="ranking">
303 <table class="ranking">
304 <tr class="title">
305 <th>Rang</th>
306 <th>Joueur</th>
307 <th>Points</th>
308 <th>Mini-pts</th>
309 </tr>
310 <tr v-for="p in sortedPlayers">
311 <td>{{ p.rank }}</td>
312 <td>{{ p.prenom }} {{ p.nom }}</td>
313 <td>{{ p.pdt }}</td>
314 <td>{{ p.session }}</td>
315 </tr>
316 </table>
317 <div class="button-container-vertical" style="width:200px">
48bee368
BA
318 <a id="download" href="#"></a>
319 <button class="btn" @click="download()" title="Télécharge le classement courant au format CSV">Télécharger</button>
320 <button class="btn cancel" @click="resetPlayers()" title="Réinitialise les scores à zéro. ATTENTION: action irréversible">
321 Réinitialiser
322 </button>
48b3a536
BA
323 </div>
324 </div>
325 `,
326 computed: {
327 sortedPlayers: function() {
328 let res = this.rankPeople();
329 // Add rank information (taking care of ex-aequos)
330 let rank = 1;
331 for (let i=0; i<res.length; i++)
332 {
333 if (i==0 || this.sortByScore(res[i],res[i-1]) == 0)
334 res[i].rank = rank;
335 else //strictly lower scoring
336 res[i].rank = ++rank;
337 }
338 return res;
339 },
340 },
341 methods: {
342 rankPeople: function() {
343 return this.players
344 .slice(1) //discard Toto
48b3a536
BA
345 .sort(this.sortByScore);
346 },
347 resetPlayers: function() {
48bee368
BA
348 if (confirm('Êtes-vous sûr ?'))
349 {
350 this.players
351 .slice(1) //discard Toto
352 .forEach( p => {
353 p.pdt = 0;
354 p.session = 0;
355 p.available = 1;
356 });
357 this.commitScores();
358 document.getElementById("doPairings").click();
359 }
360 },
361 download: function() {
362 // Prepare file content
363 let content = "prénom,nom,pdt,session\n";
48b3a536
BA
364 this.players
365 .slice(1) //discard Toto
48bee368 366 .sort(this.sortByScore)
48b3a536 367 .forEach( p => {
48bee368 368 content += p.prenom + "," + p.nom + "," + p.pdt + "," + p.session + "\n";
48b3a536 369 });
48bee368
BA
370 // Prepare and trigger download link
371 let downloadAnchor = document.getElementById("download");
372 downloadAnchor.setAttribute("download", "classement.csv");
373 downloadAnchor.href = "data:text/plain;charset=utf-8," + encodeURIComponent(content);
374 downloadAnchor.click();
48b3a536
BA
375 },
376 },
377 },
7a00c409
BA
378 },
379 created: function() {
48bee368
BA
380 let players = JSON.parse(localStorage.getItem("players"));
381 if (players !== null)
382 {
383 this.addToto(players);
384 this.players = players;
385 }
7a00c409 386 },
ade10194 387 methods: {
48bee368
BA
388 addToto: function(array) {
389 array.unshift({ //add ghost 4th player for 3-players tables
390 prenom: "Toto",
391 nom: "",
392 pdt: 0,
393 session: 0,
394 available: 0,
395 });
396 },
1369a09d 397 // Used both in ranking and pairings:
ade10194
BA
398 sortByScore: function(a,b) {
399 return b.pdt - a.pdt + (Math.atan(b.session - a.session) / (Math.PI/2)) / 2;
400 },
48bee368
BA
401 commitScores: function() {
402 localStorage.setItem(
403 "players",
404 JSON.stringify(this.players.slice(1)) //discard Toto
405 );
406 },
407 // Used in players, reinit players array
408 initPlayers: function(csv) {
409 const allLines = csv
410 .split(/\r\n|\n|\r/) //line breaks
411 .splice(1); //discard header
412 let players = allLines
413 .filter( line => { return line.length > 0; }) //remove empty lines
414 .map( line => {
415 let parts = line.split(",");
d940eb68
BA
416 let p = { prenom: parts[0], nom: parts[1] };
417 p.pdt = parts.length > 2 ? parseFloat(parts[2]) : 0;
418 p.session = parts.length > 3 ? parseInt(parts[3]) : 0;
419 p.available = parts.length > 4 ? parts[4] : 1;
420 return p;
48bee368
BA
421 });
422 this.addToto(players);
423 this.players = players;
55043689 424 this.commitScores(); //save players in memory
1369a09d 425 },
ade10194 426 },
7a00c409 427});