gong at timer start - add a few TODOs
[westcastle.git] / js / index.js
1 new Vue({
2 el: "#mahjong",
3 data: {
4 players: [], //array of objects, filled later
5 display: "players",
6 },
7 components: {
8 'my-players': {
9 props: ['players','initPlayers'],
10 template: `
11 <div id="players">
12 <div class="left">
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>
21 <div id="inactive" class="right">
22 <p>Absents</p>
23 <table class="list">
24 <tr v-for="p in sortedPlayers" v-if="!p.available && p.nom!=''" @click="toggleAvailability(p.index)">
25 <td>{{ p.prenom }}</td>
26 <td>{{ p.nom }}</td>
27 </tr>
28 </table>
29 </div>
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>
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;
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);
61 },
62 },
63 },
64 'my-pairings': {
65 props: ['players','commitScores'],
66 data: function() {
67 return {
68 unpaired: [],
69 tables: [], //array of arrays of players indices
70 sessions: [], //"mini-points" for each table
71 currentIndex: -1, //table index for scoring
72 scored: [], //boolean for each table index
73 };
74 },
75 template: `
76 <div id="pairings">
77 <div v-show="currentIndex < 0">
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>
86 <div class="pairing" v-for="(table,index) in tables" :class="{scored: scored[index]}"
87 @click="showScoreForm(table,index)">
88 <p>Table {{ index+1 }}</p>
89 <table>
90 <tr v-for="(i,j) in table">
91 <td :class="{toto: players[i].prenom=='Toto'}">{{ players[i].prenom }} {{ players[i].nom }}</td>
92 <td class="score"><span v-show="sessions[index].length > 0">{{ sessions[index][j] }}</span></td>
93 </tr>
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]">
106 <td :class="{toto: players[tables[currentIndex][i]].prenom=='Toto'}">
107 {{ players[tables[currentIndex][i]].prenom }} {{ players[tables[currentIndex][i]].nom }}
108 </td>
109 <td><input type="text" v-model="sessions[currentIndex][i]" :disabled="scored[currentIndex]"/></td>
110 </tr>
111 </table>
112 <div class="button-container-horizontal">
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>
120 </div>
121 </div>
122 </div>
123 `,
124 methods: {
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
127 doPairings: function() {
128 // Simple case first: 4 by 4
129 let tables = [];
130 let currentTable = [];
131 let ordering = _.shuffle(_.range(this.players.length));
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 }
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 });
169 this.tables = tables;
170 this.sessions = tables.map( t => { return []; }); //empty sessions
171 this.scored = tables.map( t => { return false; }); //nothing scored yet
172 this.currentIndex = -1; //required if reset while scoring
173 },
174 showScoreForm: function(table,index) {
175 if (this.sessions[index].length == 0)
176 this.sessions[index] = _.times(table.length, _.constant(0));
177 this.currentIndex = index;
178 },
179 closeScoreForm: function() {
180 if (!this.scored[this.currentIndex])
181 this.sessions[this.currentIndex] = [];
182 this.currentIndex = -1;
183 },
184 getPdts: function() {
185 let sortedSessions = this.sessions[this.currentIndex]
186 .map( (s,i) => { return {value:parseInt(s), index:i}; })
187 .sort( (a,b) => { return b.value - a.value; });
188 const ref_pdts = [4, 2, 1, 0];
189 // NOTE: take care of ex-aequos (spread points subtotal)
190 let curSum = 0, curCount = 0, start = 0;
191 let sortedPdts = [];
192 for (let i=0; i<4; i++)
193 {
194 curSum += ref_pdts[i];
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++)
200 sortedPdts.push(pdt);
201 curSum = 0;
202 curCount = 0;
203 start = i+1;
204 }
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];
217 this.players[this.tables[this.currentIndex][i]].session += parseInt(this.sessions[this.currentIndex][i]);
218 }
219 Vue.set(this.scored, this.currentIndex, true);
220 this.currentIndex = -1;
221 },
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);
230 },
231 },
232 },
233 'my-timer': {
234 data: function() {
235 return {
236 time: 0, //remaining time, in seconds
237 running: false,
238 initialTime: 5400, //1h30
239 };
240 },
241 template: `
242 <div id="timer" :style="{lineHeight: divHeight + 'px', fontSize: 0.66*divHeight + 'px', width: divWidth + 'px', height: divHeight + 'px'}">
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 },
255 divHeight: function() {
256 return screen.height;
257 },
258 divWidth: function() {
259 return screen.width;
260 },
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;
275 this.time = this.initialTime;
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 }
286 if (this.time == this.initialTime)
287 new Audio("sounds/gong.mp3").play(); //gong at the beginning
288 setTimeout(() => {
289 if (this.running)
290 this.time--;
291 this.start();
292 }, 1000);
293 },
294 },
295 created: function() {
296 this.reset();
297 },
298 },
299 'my-ranking': {
300 props: ['players','sortByScore','commitScores'],
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">
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>
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
345 .sort(this.sortByScore);
346 },
347 resetPlayers: function() {
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";
364 this.players
365 .slice(1) //discard Toto
366 .sort(this.sortByScore)
367 .forEach( p => {
368 content += p.prenom + "," + p.nom + "," + p.pdt + "," + p.session + "\n";
369 });
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();
375 },
376 },
377 },
378 },
379 created: function() {
380 let players = JSON.parse(localStorage.getItem("players"));
381 if (players !== null)
382 {
383 this.addToto(players);
384 this.players = players;
385 }
386 },
387 methods: {
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 },
397 // Used both in ranking and pairings:
398 sortByScore: function(a,b) {
399 return b.pdt - a.pdt + (Math.atan(b.session - a.session) / (Math.PI/2)) / 2;
400 },
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(",");
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;
421 });
422 this.addToto(players);
423 this.players = players;
424 this.commitScores(); //save players in memory
425 },
426 },
427 });