allow scores reset through CSV init
[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 doPairings: function() {
126 // Simple case first: 4 by 4
127 let tables = [];
128 let currentTable = [];
129 let ordering = _.shuffle(_.range(this.players.length));
130 for (let i=0; i<ordering.length; i++)
131 {
132 if ( ! this.players[ordering[i]].available )
133 continue;
134 if (currentTable.length >= 4)
135 {
136 tables.push(currentTable);
137 currentTable = [];
138 }
139 currentTable.push(ordering[i]);
140 }
141 // Analyse remainder
142 this.unpaired = [];
143 if (currentTable.length != 0)
144 {
145 if (currentTable.length < 3)
146 {
147 let missingPlayers = 3 - currentTable.length;
148 // Pick players from 'missingPlayers' random different tables, if possible
149 if (tables.length >= missingPlayers)
150 {
151 let tblNums = _.sample(_.range(tables.length), missingPlayers);
152 tblNums.forEach( num => {
153 currentTable.push(tables[num].pop());
154 });
155 }
156 }
157 if (currentTable.length >= 3)
158 tables.push(currentTable);
159 else
160 this.unpaired = currentTable;
161 }
162 // Ensure that all tables have 4 players
163 tables.forEach( t => {
164 if (t.length < 4)
165 t.push(0); //index of "Toto", ghost player
166 });
167 this.tables = tables;
168 this.sessions = tables.map( t => { return []; }); //empty sessions
169 this.scored = tables.map( t => { return false; }); //nothing scored yet
170 this.currentIndex = -1; //required if reset while scoring
171 },
172 showScoreForm: function(table,index) {
173 if (this.sessions[index].length == 0)
174 this.sessions[index] = _.times(table.length, _.constant(0));
175 this.currentIndex = index;
176 },
177 closeScoreForm: function() {
178 if (!this.scored[this.currentIndex])
179 this.sessions[this.currentIndex] = [];
180 this.currentIndex = -1;
181 },
182 getPdts: function() {
183 let sortedSessions = this.sessions[this.currentIndex]
184 .map( (s,i) => { return {value:parseInt(s), index:i}; })
185 .sort( (a,b) => { return b.value - a.value; });
186 const ref_pdts = [4, 2, 1, 0];
187 // NOTE: take care of ex-aequos (spread points subtotal)
188 let curSum = 0, curCount = 0, start = 0;
189 let sortedPdts = [];
190 for (let i=0; i<4; i++)
191 {
192 curSum += ref_pdts[i];
193 curCount++;
194 if (i==3 || sortedSessions[i].value > sortedSessions[i+1].value)
195 {
196 let pdt = curSum / curCount;
197 for (let j=start; j<=i; j++)
198 sortedPdts.push(pdt);
199 curSum = 0;
200 curCount = 0;
201 start = i+1;
202 }
203 }
204 // Re-order pdts to match table order
205 let pdts = [0, 0, 0, 0];
206 for (let i=0; i<4; i++)
207 pdts[sortedSessions[i].index] = sortedPdts[i];
208 return pdts;
209 },
210 setScore: function() {
211 let pdts = this.getPdts();
212 for (let i=0; i<4; i++)
213 {
214 this.players[this.tables[this.currentIndex][i]].pdt += pdts[i];
215 this.players[this.tables[this.currentIndex][i]].session += parseInt(this.sessions[this.currentIndex][i]);
216 }
217 Vue.set(this.scored, this.currentIndex, true);
218 this.currentIndex = -1;
219 },
220 resetScore: function() {
221 let pdts = this.getPdts();
222 for (let i=0; i<4; i++)
223 {
224 this.players[this.tables[this.currentIndex][i]].pdt -= pdts[i];
225 this.players[this.tables[this.currentIndex][i]].session -= parseInt(this.sessions[this.currentIndex][i]);
226 }
227 Vue.set(this.scored, this.currentIndex, false);
228 },
229 },
230 },
231 'my-timer': {
232 data: function() {
233 return {
234 time: 0, //remaining time, in seconds
235 running: false,
236 };
237 },
238 template: `
239 <div id="timer" :style="{lineHeight: divHeight + 'px', fontSize: 0.66*divHeight + 'px', width: divWidth + 'px', height: divHeight + 'px'}">
240 <div @click.left="pauseResume()" @click.right.prevent="reset()" :class="{timeout:time==0}">
241 {{ formattedTime }}
242 </div>
243 <img class="close-cross" src="img/cross.svg" @click="$emit('clockover')"/>
244 </div>
245 `,
246 computed: {
247 formattedTime: function() {
248 let seconds = this.time % 60;
249 let minutes = Math.floor(this.time / 60);
250 return this.padToZero(minutes) + ":" + this.padToZero(seconds);
251 },
252 divHeight: function() {
253 return screen.height;
254 },
255 divWidth: function() {
256 return screen.width;
257 },
258 },
259 methods: {
260 padToZero: function(a) {
261 if (a < 10)
262 return "0" + a;
263 return a;
264 },
265 pauseResume: function() {
266 this.running = !this.running;
267 if (this.running)
268 this.start();
269 },
270 reset: function(e) {
271 this.running = false;
272 this.time = 5400; //1:30
273 },
274 start: function() {
275 if (!this.running)
276 return;
277 if (this.time == 0)
278 {
279 new Audio("sounds/gong.mp3").play();
280 this.running = false;
281 return;
282 }
283 setTimeout(() => {
284 if (this.running)
285 this.time--;
286 this.start();
287 }, 1000);
288 },
289 },
290 created: function() {
291 this.reset();
292 },
293 },
294 'my-ranking': {
295 props: ['players','sortByScore','commitScores'],
296 template: `
297 <div id="ranking">
298 <table class="ranking">
299 <tr class="title">
300 <th>Rang</th>
301 <th>Joueur</th>
302 <th>Points</th>
303 <th>Mini-pts</th>
304 </tr>
305 <tr v-for="p in sortedPlayers">
306 <td>{{ p.rank }}</td>
307 <td>{{ p.prenom }} {{ p.nom }}</td>
308 <td>{{ p.pdt }}</td>
309 <td>{{ p.session }}</td>
310 </tr>
311 </table>
312 <div class="button-container-vertical" style="width:200px">
313 <a id="download" href="#"></a>
314 <button class="btn" @click="download()" title="Télécharge le classement courant au format CSV">Télécharger</button>
315 <button class="btn cancel" @click="resetPlayers()" title="Réinitialise les scores à zéro. ATTENTION: action irréversible">
316 Réinitialiser
317 </button>
318 </div>
319 </div>
320 `,
321 computed: {
322 sortedPlayers: function() {
323 let res = this.rankPeople();
324 // Add rank information (taking care of ex-aequos)
325 let rank = 1;
326 for (let i=0; i<res.length; i++)
327 {
328 if (i==0 || this.sortByScore(res[i],res[i-1]) == 0)
329 res[i].rank = rank;
330 else //strictly lower scoring
331 res[i].rank = ++rank;
332 }
333 return res;
334 },
335 },
336 methods: {
337 rankPeople: function() {
338 return this.players
339 .slice(1) //discard Toto
340 .sort(this.sortByScore);
341 },
342 resetPlayers: function() {
343 if (confirm('Êtes-vous sûr ?'))
344 {
345 this.players
346 .slice(1) //discard Toto
347 .forEach( p => {
348 p.pdt = 0;
349 p.session = 0;
350 p.available = 1;
351 });
352 this.commitScores();
353 document.getElementById("doPairings").click();
354 }
355 },
356 download: function() {
357 // Prepare file content
358 let content = "prénom,nom,pdt,session\n";
359 this.players
360 .slice(1) //discard Toto
361 .sort(this.sortByScore)
362 .forEach( p => {
363 content += p.prenom + "," + p.nom + "," + p.pdt + "," + p.session + "\n";
364 });
365 // Prepare and trigger download link
366 let downloadAnchor = document.getElementById("download");
367 downloadAnchor.setAttribute("download", "classement.csv");
368 downloadAnchor.href = "data:text/plain;charset=utf-8," + encodeURIComponent(content);
369 downloadAnchor.click();
370 },
371 },
372 },
373 },
374 created: function() {
375 let players = JSON.parse(localStorage.getItem("players"));
376 if (players !== null)
377 {
378 this.addToto(players);
379 this.players = players;
380 }
381 },
382 methods: {
383 addToto: function(array) {
384 array.unshift({ //add ghost 4th player for 3-players tables
385 prenom: "Toto",
386 nom: "",
387 pdt: 0,
388 session: 0,
389 available: 0,
390 });
391 },
392 // Used both in ranking and pairings:
393 sortByScore: function(a,b) {
394 return b.pdt - a.pdt + (Math.atan(b.session - a.session) / (Math.PI/2)) / 2;
395 },
396 commitScores: function() {
397 localStorage.setItem(
398 "players",
399 JSON.stringify(this.players.slice(1)) //discard Toto
400 );
401 },
402 // Used in players, reinit players array
403 initPlayers: function(csv) {
404 const allLines = csv
405 .split(/\r\n|\n|\r/) //line breaks
406 .splice(1); //discard header
407 let players = allLines
408 .filter( line => { return line.length > 0; }) //remove empty lines
409 .map( line => {
410 let parts = line.split(",");
411 let p = { prenom: parts[0], nom: parts[1] };
412 p.pdt = parts.length > 2 ? parseFloat(parts[2]) : 0;
413 p.session = parts.length > 3 ? parseInt(parts[3]) : 0;
414 p.available = parts.length > 4 ? parts[4] : 1;
415 return p;
416 });
417 this.addToto(players);
418 this.players = players;
419 this.commitScores(); //save players in memory
420 },
421 },
422 });