| 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 | }); |