Commit | Line | Data |
---|---|---|
7a00c409 BA |
1 | new 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: { | |
125 | doPairings: function() { | |
126 | // Simple case first: 4 by 4 | |
127 | let tables = []; | |
128 | let currentTable = []; | |
ade10194 | 129 | let ordering = _.shuffle(_.range(this.players.length)); |
7a00c409 BA |
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 | } | |
fd4a69e4 BA |
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 | }); | |
7a00c409 | 167 | this.tables = tables; |
1d2d7593 | 168 | this.sessions = tables.map( t => { return []; }); //empty sessions |
1369a09d BA |
169 | this.scored = tables.map( t => { return false; }); //nothing scored yet |
170 | this.currentIndex = -1; //required if reset while scoring | |
7a00c409 BA |
171 | }, |
172 | showScoreForm: function(table,index) { | |
1369a09d BA |
173 | if (this.sessions[index].length == 0) |
174 | this.sessions[index] = _.times(table.length, _.constant(0)); | |
7a00c409 BA |
175 | this.currentIndex = index; |
176 | }, | |
48bee368 BA |
177 | closeScoreForm: function() { |
178 | if (!this.scored[this.currentIndex]) | |
179 | this.sessions[this.currentIndex] = []; | |
180 | this.currentIndex = -1; | |
181 | }, | |
182 | getPdts: function() { | |
1d2d7593 | 183 | let sortedSessions = this.sessions[this.currentIndex] |
ce0473b4 BA |
184 | .map( (s,i) => { return {value:parseInt(s), index:i}; }) |
185 | .sort( (a,b) => { return b.value - a.value; }); | |
48bee368 | 186 | const ref_pdts = [4, 2, 1, 0]; |
1369a09d BA |
187 | // NOTE: take care of ex-aequos (spread points subtotal) |
188 | let curSum = 0, curCount = 0, start = 0; | |
48bee368 | 189 | let sortedPdts = []; |
1369a09d | 190 | for (let i=0; i<4; i++) |
7a00c409 | 191 | { |
48bee368 | 192 | curSum += ref_pdts[i]; |
1369a09d BA |
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++) | |
48bee368 | 198 | sortedPdts.push(pdt); |
1369a09d BA |
199 | curSum = 0; |
200 | curCount = 0; | |
201 | start = i+1; | |
202 | } | |
48bee368 BA |
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]; | |
1d2d7593 | 215 | this.players[this.tables[this.currentIndex][i]].session += parseInt(this.sessions[this.currentIndex][i]); |
7a00c409 | 216 | } |
48bee368 | 217 | Vue.set(this.scored, this.currentIndex, true); |
7a00c409 | 218 | this.currentIndex = -1; |
7a00c409 | 219 | }, |
48bee368 BA |
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); | |
6d7bb9ad | 228 | }, |
7a00c409 BA |
229 | }, |
230 | }, | |
2caa3889 BA |
231 | 'my-timer': { |
232 | data: function() { | |
233 | return { | |
234 | time: 0, //remaining time, in seconds | |
235 | running: false, | |
236 | }; | |
237 | }, | |
238 | template: ` | |
8d4d2300 | 239 | <div id="timer" :style="{lineHeight: divHeight + 'px', fontSize: 0.66*divHeight + 'px', width: divWidth + 'px', height: divHeight + 'px'}"> |
2caa3889 BA |
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 | }, | |
8d4d2300 | 252 | divHeight: function() { |
6d7bb9ad BA |
253 | return screen.height; |
254 | }, | |
8d4d2300 BA |
255 | divWidth: function() { |
256 | return screen.width; | |
257 | }, | |
2caa3889 BA |
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; | |
48b3a536 | 272 | this.time = 5400; //1:30 |
2caa3889 BA |
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 | }, | |
48b3a536 | 294 | 'my-ranking': { |
48bee368 | 295 | props: ['players','sortByScore','commitScores'], |
48b3a536 BA |
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"> | |
48bee368 BA |
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> | |
48b3a536 BA |
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 | |
48b3a536 BA |
340 | .sort(this.sortByScore); |
341 | }, | |
342 | resetPlayers: function() { | |
48bee368 BA |
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"; | |
48b3a536 BA |
359 | this.players |
360 | .slice(1) //discard Toto | |
48bee368 | 361 | .sort(this.sortByScore) |
48b3a536 | 362 | .forEach( p => { |
48bee368 | 363 | content += p.prenom + "," + p.nom + "," + p.pdt + "," + p.session + "\n"; |
48b3a536 | 364 | }); |
48bee368 BA |
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(); | |
48b3a536 BA |
370 | }, |
371 | }, | |
372 | }, | |
7a00c409 BA |
373 | }, |
374 | created: function() { | |
48bee368 BA |
375 | let players = JSON.parse(localStorage.getItem("players")); |
376 | if (players !== null) | |
377 | { | |
378 | this.addToto(players); | |
379 | this.players = players; | |
380 | } | |
7a00c409 | 381 | }, |
ade10194 | 382 | methods: { |
48bee368 BA |
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 | }, | |
1369a09d | 392 | // Used both in ranking and pairings: |
ade10194 BA |
393 | sortByScore: function(a,b) { |
394 | return b.pdt - a.pdt + (Math.atan(b.session - a.session) / (Math.PI/2)) / 2; | |
395 | }, | |
48bee368 BA |
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 | return {prenom: parts[0], nom: parts[1], pdt: 0, session:0, available: 1}; | |
412 | }); | |
413 | this.addToto(players); | |
414 | this.players = players; | |
1369a09d | 415 | }, |
ade10194 | 416 | }, |
7a00c409 | 417 | }); |