Better README, write first draft of documentation
[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'],
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>
31 `,
32 computed: {
33 sortedPlayers: function() {
34 return this.players
35 .map( (p,i) => { return Object.assign({}, p, {index: i}); })
36 .sort( (a,b) => {
37 return a.nom.localeCompare(b.nom);
38 });
39 },
40 },
41 methods: {
42 toggleAvailability: function(i) {
43 this.players[i].available = 1 - this.players[i].available;
44 this.$forceUpdate(); //TODO (Vue.set... ?!)
45 },
46 },
47 },
48 'my-pairings': {
49 props: ['players','writeScoreToDb'],
50 data: function() {
51 return {
52 unpaired: [],
53 tables: [], //array of arrays of players indices
54 sessions: [], //"mini-points" for each table
55 currentIndex: -1, //table index for scoring
56 scored: [], //boolean for each table index
57 };
58 },
59 template: `
60 <div id="pairings">
61 <div v-show="currentIndex < 0">
62 <button id="runPairing" class="block btn" @click="doPairings()">Nouvelle ronde</button>
63 <div class="pairing" v-for="(table,index) in tables" :class="{scored: scored[index]}"
64 @click="showScoreForm(table,index)">
65 <p>Table {{ index+1 }}</p>
66 <table>
67 <tr v-for="(i,j) in table">
68 <td :class="{toto: players[i].prenom=='Toto'}">{{ players[i].prenom }} {{ players[i].nom }}</td>
69 <td class="score"><span v-show="sessions[index].length > 0">{{ sessions[index][j] }}</span></td>
70 </tr>
71 </table>
72 </div>
73 <div v-if="unpaired.length>0" class="pairing unpaired">
74 <p>Exempts</p>
75 <div v-for="i in unpaired">
76 {{ players[i].prenom }} {{ players[i].nom }}
77 </div>
78 </div>
79 </div>
80 <div id="scoreInput" v-if="currentIndex >= 0">
81 <table>
82 <tr v-for="(index,i) in tables[currentIndex]">
83 <td :class="{toto: players[tables[currentIndex][i]].prenom=='Toto'}">
84 {{ players[tables[currentIndex][i]].prenom }} {{ players[tables[currentIndex][i]].nom }}
85 </td>
86 <td><input type="text" v-model="sessions[currentIndex][i]" value="0"/></td>
87 </tr>
88 </table>
89 <div class="button-container-horizontal">
90 <button class="btn validate" @click="setScore()">Enregistrer</button>
91 <button class="btn" @click="currentIndex = -1">Fermer</button>
92 </div>
93 <div v-if="scored[currentIndex]" class="warning">
94 Attention: un score a déjà été enregistré.
95 Les points indiqués ici s'ajouteront : il faut d'abord
96 <span class="link" @click="document.getElementById('restoreBtn').click()">restaurer l'état précédent.</span>
97 Si c'est déjà fait, ignorer ce message :)
98 </div>
99 </div>
100 </div>
101 `,
102 methods: {
103 doPairings: function() {
104 // Simple case first: 4 by 4
105 let tables = [];
106 let currentTable = [];
107 let ordering = _.shuffle(_.range(this.players.length));
108 for (let i=0; i<ordering.length; i++)
109 {
110 if ( ! this.players[ordering[i]].available )
111 continue;
112 if (currentTable.length >= 4)
113 {
114 tables.push(currentTable);
115 currentTable = [];
116 }
117 currentTable.push(ordering[i]);
118 }
119 // Analyse remainder
120 this.unpaired = [];
121 if (currentTable.length != 0)
122 {
123 if (currentTable.length < 3)
124 {
125 let missingPlayers = 3 - currentTable.length;
126 // Pick players from 'missingPlayers' random different tables, if possible
127 if (tables.length >= missingPlayers)
128 {
129 let tblNums = _.sample(_.range(tables.length), missingPlayers);
130 tblNums.forEach( num => {
131 currentTable.push(tables[num].pop());
132 });
133 }
134 }
135 if (currentTable.length >= 3)
136 tables.push(currentTable);
137 else
138 this.unpaired = currentTable;
139 }
140 // Ensure that all tables have 4 players
141 tables.forEach( t => {
142 if (t.length < 4)
143 t.push(0); //index of "Toto", ghost player
144 });
145 this.tables = tables;
146 this.sessions = tables.map( t => { return []; }); //empty sessions
147 this.scored = tables.map( t => { return false; }); //nothing scored yet
148 this.currentIndex = -1; //required if reset while scoring
149 },
150 showScoreForm: function(table,index) {
151 if (this.sessions[index].length == 0)
152 this.sessions[index] = _.times(table.length, _.constant(0));
153 this.currentIndex = index;
154 },
155 setScore: function() {
156 let sortedSessions = this.sessions[this.currentIndex]
157 .map( (s,i) => { return {value:s, index:i}; })
158 .sort( (a,b) => { return parseInt(b.value) - parseInt(a.value); });
159 let pdts = [4, 2, 1, 0];
160 // NOTE: take care of ex-aequos (spread points subtotal)
161 let curSum = 0, curCount = 0, start = 0;
162 for (let i=0; i<4; i++)
163 {
164 // Update pdts:
165 curSum += pdts[i];
166 curCount++;
167 if (i==3 || sortedSessions[i].value > sortedSessions[i+1].value)
168 {
169 let pdt = curSum / curCount;
170 for (let j=start; j<=i; j++)
171 this.players[this.tables[this.currentIndex][sortedSessions[j].index]].pdt += pdt;
172 curSum = 0;
173 curCount = 0;
174 start = i+1;
175 }
176 // Update sessions:
177 this.players[this.tables[this.currentIndex][i]].session += parseInt(this.sessions[this.currentIndex][i]);
178 }
179 this.scored[this.currentIndex] = true;
180 this.currentIndex = -1;
181 this.writeScoreToDb();
182 },
183 },
184 },
185 'my-timer': {
186 data: function() {
187 return {
188 time: 0, //remaining time, in seconds
189 running: false,
190 };
191 },
192 template: `
193 <div id="timer" :style="{lineHeight: screen.height+ 'px', fontSize: 0.66*screen.height + 'px'}">
194 <div @click.left="pauseResume()" @click.right.prevent="reset()" :class="{timeout:time==0}">
195 {{ formattedTime }}
196 </div>
197 <img class="close-cross" src="img/cross.svg" @click="$emit('clockover')"/>
198 </div>
199 `,
200 computed: {
201 formattedTime: function() {
202 let seconds = this.time % 60;
203 let minutes = Math.floor(this.time / 60);
204 return this.padToZero(minutes) + ":" + this.padToZero(seconds);
205 },
206 },
207 methods: {
208 padToZero: function(a) {
209 if (a < 10)
210 return "0" + a;
211 return a;
212 },
213 pauseResume: function() {
214 this.running = !this.running;
215 if (this.running)
216 this.start();
217 },
218 reset: function(e) {
219 this.running = false;
220 this.time = 5400; //1:30
221 },
222 start: function() {
223 if (!this.running)
224 return;
225 if (this.time == 0)
226 {
227 new Audio("sounds/gong.mp3").play();
228 this.running = false;
229 return;
230 }
231 setTimeout(() => {
232 if (this.running)
233 this.time--;
234 this.start();
235 }, 1000);
236 },
237 },
238 created: function() {
239 this.reset();
240 },
241 },
242 'my-ranking': {
243 props: ['players','sortByScore','writeScoreToDb'],
244 template: `
245 <div id="ranking">
246 <table class="ranking">
247 <tr class="title">
248 <th>Rang</th>
249 <th>Joueur</th>
250 <th>Points</th>
251 <th>Mini-pts</th>
252 </tr>
253 <tr v-for="p in sortedPlayers">
254 <td>{{ p.rank }}</td>
255 <td>{{ p.prenom }} {{ p.nom }}</td>
256 <td>{{ p.pdt }}</td>
257 <td>{{ p.session }}</td>
258 </tr>
259 </table>
260 <div class="button-container-vertical" style="width:200px">
261 <button class="btn cancel" @click="resetPlayers()">Réinitialiser</button>
262 <button id="restoreBtn" class="btn" @click="restoreLast()">Restaurer</button>
263 </div>
264 </div>
265 `,
266 computed: {
267 sortedPlayers: function() {
268 let res = this.rankPeople();
269 // Add rank information (taking care of ex-aequos)
270 let rank = 1;
271 for (let i=0; i<res.length; i++)
272 {
273 if (i==0 || this.sortByScore(res[i],res[i-1]) == 0)
274 res[i].rank = rank;
275 else //strictly lower scoring
276 res[i].rank = ++rank;
277 }
278 return res;
279 },
280 },
281 methods: {
282 rankPeople: function() {
283 return this.players
284 .slice(1) //discard Toto
285 .map( p => { return Object.assign({}, p); }) //to not alter original array
286 .sort(this.sortByScore);
287 },
288 resetPlayers: function() {
289 this.players
290 .slice(1) //discard Toto
291 .forEach( p => {
292 p.pdt = 0;
293 p.session = 0;
294 p.available = 1;
295 });
296 this.writeScoreToDb();
297 document.getElementById("runPairing").click(); //TODO: hack...
298 },
299 restoreLast: function() {
300 let xhr = new XMLHttpRequest();
301 let self = this;
302 xhr.onreadystatechange = function() {
303 if (this.readyState == 4 && this.status == 200)
304 {
305 let players = JSON.parse(xhr.responseText);
306 if (players.length > 0)
307 {
308 players.unshift({ //add ghost 4th player for 3-players tables
309 prenom: "Toto",
310 nom: "",
311 pdt: 0,
312 session: 0,
313 available: 0,
314 });
315 self.players = players;
316 }
317 }
318 };
319 xhr.open("GET", "scripts/rw_players.php?restore=1", true);
320 xhr.send(null);
321 },
322 },
323 },
324 },
325 created: function() {
326 let xhr = new XMLHttpRequest();
327 let self = this;
328 xhr.onreadystatechange = function() {
329 if (this.readyState == 4 && this.status == 200)
330 {
331 let players = JSON.parse(xhr.responseText);
332 players.forEach( p => {
333 p.pdt = !!p.pdt ? parseFloat(p.pdt) : 0;
334 p.session = !!p.session ? parseInt(p.session) : 0;
335 p.available = !!p.available ? p.available : 1; //use integer for fputcsv PHP func
336 });
337 players.unshift({ //add ghost 4th player for 3-players tables
338 prenom: "Toto",
339 nom: "",
340 pdt: 0,
341 session: 0,
342 available: 0,
343 });
344 self.players = players;
345 }
346 };
347 xhr.open("GET", "scripts/rw_players.php", true);
348 xhr.send(null);
349 },
350 methods: {
351 // Used both in ranking and pairings:
352 sortByScore: function(a,b) {
353 return b.pdt - a.pdt + (Math.atan(b.session - a.session) / (Math.PI/2)) / 2;
354 },
355 writeScoreToDb: function() {
356 let xhr = new XMLHttpRequest();
357 xhr.open("POST", "scripts/rw_players.php");
358 xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
359 let orderedPlayers = this.players
360 .slice(1) //discard Toto
361 .map( p => { return Object.assign({}, p); }) //deep (enough) copy
362 .sort(this.sortByScore);
363 xhr.send("players="+encodeURIComponent(JSON.stringify(orderedPlayers)));
364 },
365 },
366 });