chiark / gitweb /
rotation, seems to work
[otter.git] / templates / script.ts
1 // -*- JavaScript -*-
2
3 // Copyright 2020 Ian Jackson
4 // SPDX-License-Identifier: AGPL-3.0-or-later
5 // There is NO WARRANTY. -->
6
7 // elemnts for a piece
8 //
9 // In svg toplevel
10 //
11 //   uelem
12 //      #use{}
13 //      <use id="use{}", href="#piece{}" x= y= >
14 //         .piece   piece id (static)
15 //      container to allow quick movement and hang stuff off
16 //
17 //   delem
18 //      #defs{}
19 //      <def id="defs{}">
20 //
21 // And in each delem
22 //
23 //   pelem
24 //   #piece{}
25 //         .dragraise   dragged more than this ?  raise to top!
26 //      <g id="piece{}" >
27 //      currently-displayed version of the piece
28 //      to allow addition/removal of selected indication
29 //      contains 1 or 3 subelements:
30 //      one is straight from server and not modified
31 //      one is possible <use href="#select{}" >
32 //      one is possible <use href="#halo{}" >
33 //
34 //   #select{}
35 //      generated by server, referenced by JS in pelem for selection
36 //
37 //   #def.{}.stuff
38 //      generated by server, reserved for Piece trait impl
39
40 type PieceId = string;
41 type PlayerId = string;
42 type Pos = [number, number];
43 type ClientSeq = number;
44 type Generation = number;
45 type UoKind = 'Client' | "Global"| "Piece" | "ClientExtra" | "GlobalExtra";
46 type WhatResponseToClientOp = "Predictable" | "Unpredictable" | "UpdateSvg";
47 type Timestamp = number; // unix time_t, will break in 285My
48 type Layout = 'Portrait' | 'Landscape';
49
50 type UoDescription = {
51   kind: UoKind;
52   wrc: WhatResponseToClientOp,
53   def_key: string,
54   opname: string,
55   desc: string,
56 }
57
58 type UoRecord = UoDescription & {
59   targets: PieceId[] | null,
60 }
61
62 type ZCoord = string;
63
64 type PieceInfo = {
65   held : PlayerId | null,
66   cseq : number | null,
67   cseq_updatesvg : number | null,
68   z : ZCoord,
69   zg : Generation,
70   angle: number,
71   pinned: boolean,
72   uos : UoDescription[],
73   uelem : SVGGraphicsElement,
74   delem : SVGGraphicsElement,
75   pelem : SVGGraphicsElement,
76   queued_moves : number,
77   last_seen_moved : DOMHighResTimeStamp | null, // non-0 means halo'd
78 }
79
80 let wasm : wasm_bindgen.InitOutput;
81
82 let pieces : { [piece: string]: PieceInfo } = Object.create(null);
83
84 type MessageHandler = (op: Object) => void;
85 type PieceHandler = (piece: PieceId, p: PieceInfo, info: Object) => void;
86 type PieceErrorHandler = (piece: PieceId, p: PieceInfo, m: PieceOpError)
87   => boolean;
88 interface DispatchTable<H> { [key: string]: H };
89
90 // xxx turn all var into let
91 // xxx any exceptions should have otter in them or something
92 var globalinfo_elem : HTMLElement;
93 var layout: Layout;
94 var general_timeout : number = 10000;
95 var messages : DispatchTable<MessageHandler> = Object();
96 var pieceops : DispatchTable<PieceHandler> = Object();
97 var update_error_handlers : DispatchTable<MessageHandler> = Object();
98 var piece_error_handlers : DispatchTable<PieceErrorHandler> = Object();
99 var our_dnd_type = "text/puvnex-game-server-dummy";
100 var api_queue : [string, Object][] = [];
101 var api_posting = false;
102 var us : PlayerId;
103 var gen : Generation = 0;
104 var cseq : ClientSeq = 0;
105 var ctoken : string;
106 var uo_map : { [k: string]: UoRecord | null } = Object.create(null);
107 var keyops_local : { [opname: string]: (uo: UoRecord) => void } = Object();
108 var last_log_ts: wasm_bindgen.TimestampAbbreviator;
109 var last_zoom_factor : number = 1.0;
110 var firefox_bug_zoom_factor_compensation : number = 1.0;
111 var gen_update_hook : () => void = function() { }
112
113 var svg_ns : string;
114 var space : SVGGraphicsElement;
115 var pieces_marker : SVGGraphicsElement;
116 var defs_marker : SVGGraphicsElement;
117 var log_elem : HTMLElement;
118 var logscroll_elem : HTMLElement;
119 var status_node : HTMLElement;
120 var uos_node : HTMLElement;
121 var zoom_val : HTMLInputElement;
122 var zoom_btn : HTMLInputElement;
123 var links_elem : HTMLElement;
124 var wresting: boolean;
125
126 const uo_kind_prec : { [kind: string]: number } = {
127   'GlobalExtra' :  50,
128   'Client'      :  70,
129   'Global'      : 100,
130   'Piece'       : 200,
131   'ClientExtra' : 500,
132 }
133
134 type PlayerInfo = {
135   dasharray : string,
136 }
137 var players : { [player: string]: PlayerInfo };
138
139 type MovementRecord = {
140   piece: PieceId,
141   p: PieceInfo,
142   this_motion: DOMHighResTimeStamp,
143 }
144 var movements : MovementRecord[] = [];
145
146 function xhr_post_then(url : string, data: string,
147                        good : (xhr: XMLHttpRequest) => void) {
148   var xhr : XMLHttpRequest = new XMLHttpRequest();
149   xhr.onreadystatechange = function(){
150     if (xhr.readyState != XMLHttpRequest.DONE) { return; }
151     if (xhr.status != 200) { xhr_report_error(xhr); }
152     else { good(xhr); }
153   };
154   xhr.timeout = general_timeout;
155   xhr.open('POST',url);
156   xhr.setRequestHeader('Content-Type','application/json');
157   xhr.send(data);
158 }
159
160 function xhr_report_error(xhr: XMLHttpRequest) {
161   json_report_error({
162     statusText : xhr.statusText,
163     responseText : xhr.responseText,
164   });
165 }
166
167 function json_report_error(error_for_json: Object) {
168   let error_message = JSON.stringify(error_for_json);
169   string_report_error(error_message);
170 }
171
172 function string_report_error(error_message: String) {
173   let errornode = document.getElementById('error')!;
174   errornode.textContent += '\nError (reloading may help?):' + error_message;
175   console.error("ERROR reported via log", error_message);
176   // todo want to fix this for at least basic game reconfigs, auto-reload?
177 }
178
179 function api(meth: string, data: Object) {
180   api_queue.push([meth, data]);
181   api_check();
182 }
183 function api_delay(meth: string, data: Object) {
184   if (api_queue.length==0) window.setTimeout(api_check, 10);
185   api_queue.push([meth, data]);
186 }
187 function api_check() {
188   if (api_posting) { return; }
189   if (!api_queue.length) { return; }
190   do {
191     var [meth, data] = api_queue.shift()!;
192     if (meth != 'm') break;
193     let piece = (data as any).piece;
194     let p = pieces[piece];
195     if (p == null) break;
196     p.queued_moves--;
197     if (p.queued_moves == 0) break;
198   } while(1);
199   api_posting = true;
200   xhr_post_then('/_/api/'+meth, JSON.stringify(data), api_posted);
201 }
202 function api_posted() {
203   api_posting = false;
204   api_check();
205 }
206
207 function api_piece(f: (meth: string, payload: Object) => void,
208                    meth: string,
209                    piece: PieceId, p: PieceInfo,
210                    op: Object) {
211   clear_halo(piece,p);
212   cseq += 1;
213   p.cseq = cseq;
214   f(meth, {
215     ctoken : ctoken,
216     piece : piece,
217     gen : gen,
218     cseq : cseq,
219     op : op,
220   })
221 }
222
223 function svg_element(id: string): SVGGraphicsElement | null {
224   let elem = document.getElementById(id);
225   return elem as unknown as (SVGGraphicsElement | null);
226 }
227 function piece_element(base: string, piece: PieceId): SVGGraphicsElement | null
228 {
229   return svg_element(base+piece);
230 }
231
232 // ----- key handling -----
233
234 function recompute_keybindings() {
235   uo_map = Object.create(null);
236   let all_targets = [];
237   for (let piece of Object.keys(pieces)) {
238     let p = pieces[piece];
239     if (p.held != us) continue;
240     all_targets.push(piece);
241     for (var uo of p.uos) {
242       let currently = uo_map[uo.def_key];
243       if (currently === null) continue;
244       if (currently !== undefined) {
245         if (currently.opname != uo.opname) {
246           uo_map[uo.def_key] = null;
247           continue;
248         }
249       } else {
250         currently = {
251           targets: [],
252           ...uo
253         };
254         uo_map[uo.def_key] = currently;
255       }
256       currently.desc = currently.desc < uo.desc ? currently.desc : uo.desc;
257       currently.targets!.push(piece);
258     }
259   }
260   let add_uo = function(targets: PieceId[] | null, uo: UoDescription) {
261     uo_map[uo.def_key] = {
262       targets: targets,
263       ...uo
264     };
265   };
266   if (all_targets.length) {
267     add_uo(all_targets, {
268       def_key: 'l',
269       kind: 'Client',
270       wrc: 'Predictable',
271       opname: "left",
272       desc: "rotate left",
273     });
274     add_uo(all_targets, {
275       def_key: 'r',
276       kind: 'Client',
277       wrc: 'Predictable',
278       opname: "right",
279       desc: "rotate right",
280     });
281     add_uo(all_targets, {
282       def_key: 'b',
283       kind: 'Client',
284       wrc: 'Predictable',
285       opname: "lower",
286       desc: "send to bottom (below other pieces)",
287     });
288   }
289   if (all_targets.length) {
290     let got = 0;
291     for (let t of all_targets) {
292       got |= 1 << Number(pieces[t]!.pinned);
293     }
294     if (got == 1) {
295       add_uo(all_targets, {
296         def_key: 'P',
297         kind: 'ClientExtra',
298         opname: 'pin',
299         desc: 'Pin to table',
300         wrc: 'Predictable',
301       });
302     } else if (got == 2) {
303       add_uo(all_targets, {
304         def_key: 'P',
305         kind: 'ClientExtra',
306         opname: 'unpin',
307         desc: 'Unpin from table',
308         wrc: 'Predictable',
309       });
310     }
311   }
312   add_uo(null, {
313     def_key: 'W',
314     kind: 'ClientExtra',
315     opname: 'wrest',
316     desc: wresting ? 'Exit wresting mode' : 'Enter wresting mode',
317     wrc: 'Predictable',
318   });
319   var uo_keys = Object.keys(uo_map);
320   uo_keys.sort(function (ak,bk) {
321     let a = uo_map[ak]!;
322     let b = uo_map[bk]!;
323     return uo_kind_prec[a.kind] - uo_kind_prec[b.kind]
324       || ak.localeCompare(bk);
325   });
326   let mid_elem = null;
327   for (let celem = uos_node.firstElementChild;
328        celem != null;
329        celem = nextelem) {
330     var nextelem = celem.nextElementSibling;
331     let cid = celem.getAttribute("id");
332     if (cid == "uos-mid") mid_elem = celem;
333     else if (celem.getAttribute("class") == 'uos-mid') { }
334     else celem.remove();
335   }
336   for (var kk of uo_keys) {
337     let uo = uo_map[kk]!;
338     let prec = uo_kind_prec[uo.kind];
339     let ent = document.createElement('div');
340     ent.innerHTML = '<b>' + kk + '</b> ' + uo.desc;
341     if (prec < 400) {
342       ent.setAttribute('class','uokey-l');
343       uos_node.insertBefore(ent, mid_elem);
344     } else {
345       ent.setAttribute('class','uokey-r');
346       uos_node.appendChild(ent);
347     }
348   }
349 }
350
351 function some_keydown(e: KeyboardEvent) {
352   // https://developer.mozilla.org/en-US/docs/Web/API/Document/keydown_event
353   // says to do this, something to do with CJK composition.
354   // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent
355   // says that keyCode is deprecated
356   // my tsc says this isComposing thing doesn't exist.  wat.
357   if ((e as any).isComposing /* || e.keyCode === 229 */) return;
358
359   let uo = uo_map[e.key];
360   if (uo === undefined || uo === null) return;
361
362   console.log('KEY UO', e, uo);
363   if (uo.kind == 'Client' || uo.kind == 'ClientExtra') {
364     let f = keyops_local[uo.opname];
365     f(uo);
366     return;
367   }
368   if (!(uo.kind == 'Global' || uo.kind == 'GlobalExtra'))
369     throw 'bad kind '+uo.kind;
370
371   if (uo.wrc == 'UpdateSvg' || uo.wrc == 'Predictable') {
372     for (var piece of uo.targets!) {
373       let p = pieces[piece]!;
374       api_piece(api, 'k', piece, p, { opname: uo.opname, wrc: uo.wrc });
375       if (uo.wrc == 'UpdateSvg') {
376         p.cseq_updatesvg = p.cseq;
377         redisplay_ancillaries(piece,p);
378       }
379     }
380   }
381 }
382
383 keyops_local['left' ] = function (uo: UoRecord) { rotate_targets(uo, +1); }
384 keyops_local['right'] = function (uo: UoRecord) { rotate_targets(uo, -1); }
385
386 function rotate_targets(uo: UoRecord, dangle: number): boolean {
387   for (let piece of uo.targets!) {
388     let p = pieces[piece]!;
389     p.angle += dangle + 8;
390     p.angle %= 8;
391     let transform = wasm_bindgen.angle_transform(p.angle);
392     p.pelem.setAttributeNS(null,'transform',transform);
393     api_piece(api, 'rotate', piece,p, p.angle);
394   }
395   recompute_keybindings();
396   return true;
397 }
398
399 type LowerTodoItem = {
400   piece: PieceId,
401   p: PieceInfo,
402   pinned: boolean,
403 };
404
405 type LowerTodoList = { [piece: string]: LowerTodoItem };
406
407 keyops_local['lower'] = function (uo: UoRecord) { lower_targets(uo); }
408
409 function lower_targets(uo: UoRecord): boolean {
410    function target_treat_pinned(p: PieceInfo): boolean {
411     return wresting || p.pinned;;
412   }
413
414   let targets_todo : LowerTodoList = Object.create(null);
415
416   for (let piece of uo.targets!) {
417     let p = pieces[piece]!;
418     let pinned = target_treat_pinned(p);
419     targets_todo[piece] = { p, piece, pinned, };
420   }
421   let problem = lower_pieces(targets_todo);
422   if (problem !== null) {
423     add_log_message('Cannot lower: ' + problem);
424     return false;
425   }
426   return true;
427 }
428
429 function lower_pieces(targets_todo: LowerTodoList):
430  string | null
431 {
432   // This is a bit subtle.  We don't want to lower below pinned pieces
433   // (unless we are pinned too, or the user is wresting).  But maybe
434   // the pinned pieces aren't already at the bottom.  For now we will
435   // declare that all pinned pieces "should" be below all non-pinned
436   // ones.  Not as an invariant, but as a thing we will do here to try
437   // to make a sensible result.  We implement this as follows: if we
438   // find pinned pieces above non-pinned pieces, we move those pinned
439   // pieces to the bottom too, just below us, preserving their
440   // relative order.
441   //
442   // Disregarding pinned targets:
443   //
444   // Z     <some stuff not including any unpinned targets>
445   // Z
446   //       topmost unpinned target         *
447   // B (
448   // B     unpinned non-target
449   // B |   unpinned target                 *
450   // B |   pinned non-target, mis-stacked  *
451   // B )*
452   // B
453   //       bottommost unpinned non-target
454   //        if that is below topmost unpinned target
455   //            <- tomove_unpinned: insert targets from * here        Q ->
456   //            <- tomove_misstacked: insert non-targets from * here  Q->
457   // A
458   // A     pinned things (nomove_pinned)
459   //            <- tomove_pinned: insert all pinned targets here      P ->
460   //
461   // When wresting, treat all targets as pinned.
462
463   type Entry = {
464     piece: PieceId,
465     p: PieceInfo,
466   };
467   // bottom of the stack order first
468   let tomove_unpinned     : Entry[] = [];
469   let tomove_misstacked   : Entry[] = [];
470   let nomove_pinned       : Entry[] = [];
471   let tomove_pinned       : Entry[] = [];
472   let bottommost_unpinned : Entry | null = null;
473
474   let n_targets_todo_unpinned = 0;
475   for (const piece of Object.keys(targets_todo)) {
476     let p = targets_todo[piece];
477     if (!p.pinned) n_targets_todo_unpinned++;
478   }
479
480   let walk = pieces_marker;
481   for (;;) { // starting at the bottom of the stack order
482     if (n_targets_todo_unpinned == 0
483         && bottommost_unpinned !== null) {
484       // no unpinned targets left, state Z, we can stop now
485       console.log('LOWER STATE Z FINISHED');
486       break;
487     }
488     if (Object.keys(targets_todo).length == 0 &&
489        bottommost_unpinned !== null) {
490       console.log('LOWER NO TARGETS BUT UNPINNED!', n_targets_todo_unpinned);
491       break;
492     }
493
494     let new_walk = walk.nextElementSibling;
495     if (new_walk == null) {
496       console.log('LOWER WALK NO SIBLING!');
497       break;
498     }
499     walk = new_walk as SVGGraphicsElement;
500     let piece = walk.dataset.piece;
501     if (piece == null) {
502       console.log('LOWER WALK REACHED TOP');
503       break;
504     }
505
506     let todo = targets_todo[piece];
507     if (todo) {
508       console.log('LOWER WALK', piece, 'TODO', todo.pinned);
509       delete targets_todo[piece];
510       if (!todo.pinned) n_targets_todo_unpinned--;
511       (todo.pinned ? tomove_pinned : tomove_unpinned).push(todo);
512       continue;
513     }
514
515     let p = pieces[piece]!;
516     if (bottommost_unpinned === null) { // state A
517       if (!p.pinned) {
518         console.log('LOWER WALK', piece, 'STATE A -> Z');
519         bottommost_unpinned = { p, piece };
520       } else {
521         console.log('LOWER WALK', piece, 'STATE A');
522         nomove_pinned.push({ p, piece });
523       }
524       continue;
525     }
526
527     // state B
528     if (p.pinned) {
529       console.log('LOWER WALK', piece, 'STATE B MIS-STACKED');
530       tomove_misstacked.push({ p, piece });
531     } else {
532       console.log('LOWER WALK', piece, 'STATE B');
533     }
534   }
535
536   let z_top =
537       bottommost_unpinned ? bottommost_unpinned.p.z :
538       walk.dataset.piece != null ? pieces[walk.dataset.piece!].z :
539       // rather a lack of things we are not adjusting!
540       wasm_bindgen.def_zcoord();
541
542   type PlanEntry = {
543     content: Entry[], // bottom to top
544     z_top: ZCoord | null,
545     z_bot: ZCoord | null,
546   };
547
548   let plan : PlanEntry[] = [];
549
550   let partQ = tomove_unpinned.concat(tomove_misstacked);
551   let partP = tomove_pinned;
552
553   if (nomove_pinned.length == 0) {
554     plan.push({
555       content: partQ.concat(partP),
556       z_top,
557       z_bot : null,
558     });
559   } else {
560     plan.push({
561       content: partQ,
562       z_top,
563       z_bot: nomove_pinned[nomove_pinned.length-1].p.z,
564     }, {
565       content: partP,
566       z_top: nomove_pinned[0].p.z,
567       z_bot: null,
568     });
569   }
570
571   console.log('LOWER PLAN', plan);
572
573   for (const pe of plan) {
574     for (const e of pe.content) {
575       if (e.p.held != null && e.p.held != us) {
576         return "lowering would disturb a piece held by another player";
577       }
578     }
579   }
580
581   z_top = null;
582   for (const pe of plan) {
583     if (pe.z_top != null) z_top = pe.z_top;
584     let z_bot = pe.z_bot;
585     let zrange = wasm_bindgen.range(z_bot, z_top, pe.content.length);
586     console.log('LOQER PLAN PE',
587                 pe, z_bot, z_top, pe.content.length, zrange.debug());
588     for (const e of pe.content) {
589       let p = e.p;
590       piece_set_zlevel(e.piece, p, (oldtop_piece) => {
591         let z = zrange.next();
592         p.z = z;
593         api_piece(api, "setz", e.piece, e.p, { z });
594       });
595     }
596   }
597   return null;
598 }
599
600 keyops_local['wrest'] = function (uo: UoRecord) {
601   wresting = !wresting;
602   document.getElementById('wresting-warning')!.innerHTML = !wresting ? "" :
603     " <strong>(wresting mode!)</strong>";
604   ungrab_all();
605   recompute_keybindings();
606 }
607
608 keyops_local['pin'  ] = function (uo) {
609   if (!lower_targets(uo)) return;
610   pin_unpin(uo, true);
611 }
612 keyops_local['unpin'] = function (uo) {
613   pin_unpin(uo, false);
614 }
615
616 function pin_unpin(uo: UoRecord, newpin: boolean) {
617   for (let piece of uo.targets!) {
618     let p = pieces[piece]!;
619     p.pinned = newpin;
620     api_piece(api, 'pin', piece,p, newpin);
621     redisplay_ancillaries(piece,p);
622   }
623   recompute_keybindings();
624 }
625
626 // ----- clicking/dragging pieces -----
627
628 type DragInfo = {
629   piece : PieceId,
630   dox : number,
631   doy : number,
632 }
633
634 enum DRAGGING { // bitmask
635   NO           = 0,
636   MAYBE_GRAB   = 1,
637   MAYBE_UNGRAB = 2,
638   YES          = 4,
639   RAISED       = 8,
640 };
641
642 var drag_pieces : DragInfo[] = [];
643 var dragging = DRAGGING.NO;
644 var dcx : number | null;
645 var dcy : number | null;
646
647 const DRAGTHRESH = 5;
648
649 function drag_add_piece(piece: PieceId, p: PieceInfo) {
650   drag_pieces.push({
651     piece: piece,
652     dox: parseFloat(p.uelem.getAttributeNS(null,"x")!),
653     doy: parseFloat(p.uelem.getAttributeNS(null,"y")!),
654   });
655 }
656
657 function some_mousedown(e : MouseEvent) {
658   console.log('mousedown', e, e.clientX, e.clientY, e.target);
659
660   if (e.button != 0) { return }
661   if (e.altKey) { return }
662   if (e.metaKey) { return }
663   if (e.ctrlKey) {
664     return;
665   } else {
666     drag_mousedown(e, e.shiftKey);
667   }
668 }
669
670 function drag_mousedown(e : MouseEvent, shifted: boolean) {
671   var target = e.target as SVGGraphicsElement; // we check this just now!
672   var piece = target.dataset.piece!;
673   if (!piece) { return; }
674   let p = pieces[piece]!;
675   let held = p.held;
676
677   drag_cancel();
678
679   drag_pieces = [];
680   if (held == us) {
681     dragging = DRAGGING.MAYBE_UNGRAB;
682     drag_add_piece(piece,p); // contrive to have this one first
683     for (let tpiece of Object.keys(pieces)) {
684       if (tpiece == piece) continue;
685       let tp = pieces[tpiece]!;
686       if (tp.held != us) continue;
687       drag_add_piece(tpiece,tp);
688     }
689   } else if (held == null || wresting) {
690     if (p.pinned && !wresting) {
691       add_log_message('That piece is pinned to the table.');
692       return;
693     }
694     if (!shifted) {
695       ungrab_all();
696     }
697     dragging = DRAGGING.MAYBE_GRAB;
698     drag_add_piece(piece,p);
699     set_grab(piece,p, us);
700     api_piece(api, wresting ? 'wrest' : 'grab', piece,p, { });
701   } else {
702     add_log_message('That piece is held by another player.');
703     return;
704   }
705   dcx = e.clientX;
706   dcy = e.clientY;
707
708   window.addEventListener('mousemove', drag_mousemove, true);
709   window.addEventListener('mouseup',   drag_mouseup,   true);
710 }
711
712 function ungrab_all() {
713   for (let tpiece of Object.keys(pieces)) {
714     let tp = pieces[tpiece]!;
715     if (tp.held == us) {
716           set_ungrab(tpiece,tp);
717       api_piece(api, 'ungrab', tpiece,tp, { });
718     }
719   }
720 }
721
722 function set_grab(piece: PieceId, p: PieceInfo, owner: PlayerId) {
723   p.held = owner;
724   redisplay_ancillaries(piece,p);
725   recompute_keybindings();
726 }
727 function set_ungrab(piece: PieceId, p: PieceInfo) {
728   p.held = null;
729   redisplay_ancillaries(piece,p);
730   recompute_keybindings();
731 }
732
733 function clear_halo(piece: PieceId, p: PieceInfo) {
734   p.last_seen_moved = null;
735   redisplay_ancillaries(piece,p);
736 }
737
738 function ancillary_node(piece: PieceId, stroke: string): SVGGraphicsElement {
739   var nelem = document.createElementNS(svg_ns,'use');
740   nelem.setAttributeNS(null,'href','#surround'+piece);
741   nelem.setAttributeNS(null,'stroke',stroke);
742   nelem.setAttributeNS(null,'fill','none');
743   return nelem as any;
744 }
745
746 function redisplay_ancillaries(piece: PieceId, p: PieceInfo) {
747   let href = '#surround'+piece;
748   console.log('REDISPLAY ANCILLARIES',href);
749
750   for (let celem = p.pelem.firstElementChild;
751        celem != null;
752        celem = nextelem) {
753     var nextelem = celem.nextElementSibling
754     let thref = celem.getAttributeNS(null,"href");
755     if (thref == href) {
756       celem.remove();
757     }
758   }
759
760   let halo_colour = null;
761   if (p.cseq_updatesvg != null) {
762     halo_colour = 'purple';
763   } else if (p.last_seen_moved != null) {
764     halo_colour = 'yellow';
765   } else if (p.held != null && p.pinned) {
766     halo_colour = '#8cf';
767   }
768   if (halo_colour != null) {
769     let nelem = ancillary_node(piece, halo_colour);
770     if (p.held != null) {
771       nelem.setAttributeNS(null,'stroke-width','2px');
772     }
773     p.pelem.prepend(nelem);
774   } 
775   if (p.held != null) {
776     let da = players[p.held!]!.dasharray;
777     let nelem = ancillary_node(piece, 'black');
778     nelem.setAttributeNS(null,'stroke-dasharray',da);
779     p.pelem.appendChild(nelem);
780   }
781 }
782
783 function drag_mousemove(e: MouseEvent) {
784   var ctm = space.getScreenCTM()!;
785   var ddx = (e.clientX - dcx!)/(ctm.a * firefox_bug_zoom_factor_compensation);
786   var ddy = (e.clientY - dcy!)/(ctm.d * firefox_bug_zoom_factor_compensation);
787   var ddr2 = ddx*ddx + ddy*ddy;
788   if (!(dragging & DRAGGING.YES)) {
789     if (ddr2 > DRAGTHRESH) {
790       dragging |= DRAGGING.YES;
791     }
792   }
793   //console.log('mousemove', ddx, ddy, dragging);
794   if (dragging & DRAGGING.YES) {
795     console.log('DRAG PIECES',drag_pieces);
796     for (let dp of drag_pieces) {
797       console.log('DRAG PIECES PIECE',dp);
798       let tpiece = dp.piece;
799       let tp = pieces[tpiece]!;
800       var x = Math.round(dp.dox + ddx);
801       var y = Math.round(dp.doy + ddy);
802       tp.uelem.setAttributeNS(null, "x", x+"");
803       tp.uelem.setAttributeNS(null, "y", y+"");
804       tp.queued_moves++;
805       api_piece(api_delay, 'm', tpiece,tp, [x, y] );
806     }
807     if (!(dragging & DRAGGING.RAISED) && drag_pieces.length==1) {
808       let dp = drag_pieces[0];
809       let piece = dp.piece;
810       let p = pieces[piece]!;
811       let dragraise = +p.pelem.dataset.dragraise!;
812       if (dragraise > 0 && ddr2 >= dragraise*dragraise) {
813         dragging |= DRAGGING.RAISED;
814         console.log('CHECK RAISE ', dragraise, dragraise*dragraise, ddr2);
815         piece_set_zlevel(piece,p, (oldtop_piece) => {
816           let oldtop_p = pieces[oldtop_piece]!;
817           let z = wasm_bindgen.increment(oldtop_p.z);
818           p.z = z;
819           api_piece(api, "setz", piece,p, { z: z });
820         });
821       }
822     }
823   }
824   return ddr2;
825 }
826
827 function drag_mouseup(e: MouseEvent) {
828   console.log('mouseup', dragging);
829   let ddr2 : number = drag_mousemove(e);
830   drag_end();
831 }
832
833 function drag_end() {
834   if (dragging == DRAGGING.MAYBE_UNGRAB ||
835       (dragging & ~DRAGGING.RAISED) == (DRAGGING.MAYBE_GRAB | DRAGGING.YES)) {
836     let dp = drag_pieces[0]!;
837     let piece = dp.piece;
838     let p = pieces[piece]!;
839     set_ungrab(piece,p);
840     api_piece(api, 'ungrab', piece,p, { });
841   }
842   drag_cancel();
843 }
844
845 function drag_cancel() {
846   window.removeEventListener('mousemove', drag_mousemove, true);
847   window.removeEventListener('mouseup',   drag_mouseup,   true);
848   dragging = DRAGGING.NO;
849   drag_pieces = [];
850 }
851
852 // ----- general -----
853
854 messages.AddPlayer = <MessageHandler>function
855 (j: { player: string, data: PlayerInfo }) {
856   players[j.player] = j.data;
857 }
858
859 messages.RemovePlayer = <MessageHandler>function
860 (j: { player: string }) {
861   delete players[j.player];
862 }
863
864 messages.SetLinks = <MessageHandler>function
865 (msg: string) {
866   if (msg.length != 0 && layout == 'Portrait') {
867     msg += " |";
868   }
869   links_elem.innerHTML = msg
870 }
871
872 // ----- logs -----
873
874 messages.Log = <MessageHandler>function
875 (j: { when: string, logent: { html: string } }) {
876   add_timestamped_log_message(j.when, j.logent.html);
877 }
878
879 function add_log_message(msg_html: string) {
880   add_timestamped_log_message('', msg_html);
881 }
882
883 function add_timestamped_log_message(ts_html: string, msg_html: string) {
884   var lastent = log_elem.lastElementChild;
885   var in_scrollback =
886     lastent == null ||
887     // inspired by
888     //   https://stackoverflow.com/questions/487073/how-to-check-if-element-is-visible-after-scrolling/21627295#21627295
889     // rejected
890       //   https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
891       (() => {
892         let le_top = lastent.getBoundingClientRect()!.top;
893         let le_bot = lastent.getBoundingClientRect()!.bottom;
894         let ld_bot = logscroll_elem.getBoundingClientRect()!.bottom;
895         console.log("ADD_LOG_MESSAGE bboxes: le t b, bb",
896                     le_top, le_bot, ld_bot);
897         return 0.5 * (le_bot + le_top) > ld_bot;
898       })();
899
900   console.log('ADD LOG MESSAGE ',in_scrollback, layout, msg_html);
901
902   var ne : HTMLElement;
903
904   function add_thing(elemname: string, cl: string, html: string) {
905     var ie = document.createElement(elemname);
906     ie.innerHTML = html;
907     ie.setAttribute("class", cl);
908     ne.appendChild(ie);
909   }
910
911   if (layout == 'Portrait') {
912     ne = document.createElement('tr');
913     add_thing('td', 'logmsg', msg_html);
914     add_thing('td', 'logts',  ts_html);
915   } else if (layout == 'Landscape') {
916     ts_html = last_log_ts.update(ts_html);
917     ne = document.createElement('div');
918     add_thing('span', 'logts',  ts_html);
919     ne.appendChild(document.createElement('br'));
920     add_thing('span', 'logmsg', msg_html);
921     ne.appendChild(document.createElement('br'));
922   } else {
923     throw 'bad layout ' + layout;
924   }
925   log_elem.appendChild(ne);
926
927   if (!in_scrollback) {
928     logscroll_elem.scrollTop = logscroll_elem.scrollHeight;
929   }
930 }
931
932 // ----- zoom -----
933
934 function zoom_pct (): number | undefined {
935   let str = zoom_val.value;
936   let val = parseFloat(str);
937   if (isNaN(val)) {
938     return undefined;
939   } else {
940     return val;
941   }
942 }
943
944 function zoom_enable() {
945   zoom_btn.disabled = (zoom_pct() === undefined);
946 }
947
948 function zoom_activate() {
949   let pct = zoom_pct();
950   if (pct !== undefined) {
951     let fact = pct * 0.01;
952     let last_ctm_a = space.getScreenCTM()!.a;
953     (document.getElementsByTagName('body')[0] as HTMLElement)
954       .style.transform = 'scale('+fact+','+fact+')';
955     if (fact != last_zoom_factor) {
956       if (last_ctm_a == space.getScreenCTM()!.a) {
957         console.log('FIREFOX GETSCREENCTM BUG');
958         firefox_bug_zoom_factor_compensation = fact;
959       } else {
960         console.log('No firefox getscreenctm bug');
961         firefox_bug_zoom_factor_compensation = 1.0;
962       }
963       last_zoom_factor = fact;
964     }
965   }
966   zoom_btn.disabled = true;
967 }
968
969 // ----- test counter, startup -----
970
971 messages.Piece = <MessageHandler>function
972 (j: { piece: PieceId, op: Object }) {
973   console.log('PIECE UPDATE ',j)
974   var piece = j.piece;
975   var m = j.op as { [k: string]: Object };
976   var k = Object.keys(m)[0];
977   let p = pieces[piece]!;
978   pieceops[k](piece,p, m[k]);
979 };
980
981 type PieceStateMessage = {
982   svg: string,
983   held: PlayerId,
984   pos: Pos,
985   z: ZCoord,
986   zg: Generation,
987   pinned: boolean,
988   uos: UoDescription[],
989 }
990
991 pieceops.ModifyQuiet = <PieceHandler>function
992 (piece: PieceId, p: PieceInfo, info: PieceStateMessage) {
993   console.log('PIECE UPDATE MODIFY QUIET ',piece,info)
994   piece_modify(piece, p, info, false);
995 }
996
997 pieceops.Modify = <PieceHandler>function
998 (piece: PieceId, p: PieceInfo, info: PieceStateMessage) {
999   console.log('PIECE UPDATE MODIFY LOuD ',piece,info)
1000   piece_note_moved(piece,p);
1001   piece_modify(piece, p, info, false);
1002 }
1003
1004 piece_error_handlers.PosOffTable = <PieceErrorHandler>function()
1005 { return true ; }
1006 piece_error_handlers.Conflict = <PieceErrorHandler>function()
1007 { return true ; }
1008
1009 function piece_modify(piece: PieceId, p: PieceInfo, info: PieceStateMessage,
1010                       conflict_expected: boolean) {
1011   p.delem.innerHTML = info.svg;
1012   p.pelem= piece_element('piece',piece)!;
1013   p.uelem.setAttributeNS(null, "x", info.pos[0]+"");
1014   p.uelem.setAttributeNS(null, "y", info.pos[1]+"");
1015   p.held = info.held;
1016   p.pinned = info.pinned;
1017   p.uos = info.uos;
1018   piece_set_zlevel(piece,p, (oldtop_piece)=>{
1019     p.z  = info.z;
1020     p.zg = info.zg;
1021   });
1022   piece_checkconflict_nrda(piece,p,conflict_expected);
1023   redisplay_ancillaries(piece,p);
1024   recompute_keybindings();
1025   console.log('MODIFY DONE');
1026 }
1027
1028 /*
1029 pieceops.Insert = <PieceHandler>function
1030 (piece: PieceId, p: null,
1031  info: { svg: string, held: PlayerId, pos: Pos, z: number, zg: Generation}) {
1032   console.log('PIECE UPDATE INSERT ',piece,info)
1033   delem = document.createElementNS(svg_ns,'defs');
1034   delem.setAttributeNS(null,'id','defs'+piece);
1035   delem.innerHTML = info.svg;
1036   space.appendChild(delem);
1037   pelem = 
1038
1039   nelem.setAttributeNS(null,'stroke',stroke);
1040   nelem.setAttributeNS(null,'fill','none');
1041 */
1042
1043 function piece_set_zlevel(piece: PieceId, p: PieceInfo,
1044                           modify : (oldtop_piece: PieceId) => void) {
1045   // Calls modify, which should set .z and/or .gz, and/or
1046   // make any necessary API call.
1047   //
1048   // Then moves uelem to the right place in the DOM.  This is done
1049   // by assuming that uelem ought to go at the end, so this is
1050   // O(new depth), which is right (since the UI for inserting
1051   // an object is itself O(new depth) UI operations to prepare.
1052
1053   let oldtop_elem = (defs_marker.previousElementSibling! as
1054                      unknown as SVGGraphicsElement);
1055   let oldtop_piece = oldtop_elem.dataset.piece!;
1056   modify(oldtop_piece);
1057
1058   let ins_before = defs_marker
1059   let earlier_elem;
1060   for (; ; ins_before = earlier_elem) {
1061     earlier_elem = (ins_before.previousElementSibling! as
1062                    unknown as SVGGraphicsElement);
1063     if (earlier_elem == pieces_marker) break;
1064     if (earlier_elem == p.uelem) continue;
1065     let earlier_p = pieces[earlier_elem.dataset.piece!]!;
1066     if (!piece_z_before(p, earlier_p)) break;
1067   }
1068   if (ins_before != p.uelem)
1069     space.insertBefore(p.uelem, ins_before);
1070 }
1071
1072 function piece_note_moved(piece: PieceId, p: PieceInfo) {
1073   let now = performance.now();
1074
1075   let need_redisplay = p.last_seen_moved == null;
1076   p.last_seen_moved = now;
1077   if (need_redisplay) redisplay_ancillaries(piece,p);
1078
1079   let cutoff = now-1000.;
1080   while (movements.length > 0 && movements[0].this_motion < cutoff) {
1081     let mr = movements.shift()!;
1082     if (mr.p.last_seen_moved != null &&
1083         mr.p.last_seen_moved < cutoff) {
1084       mr.p.last_seen_moved = null;
1085       redisplay_ancillaries(mr.piece,mr.p);
1086     }
1087   }
1088
1089   movements.push({ piece: piece, p: p, this_motion: now });
1090 }
1091
1092 function piece_z_before(a: PieceInfo, b: PieceInfo) {
1093   if (a.z  < b.z ) return true;
1094   if (a.z  > b.z ) return false;
1095   if (a.zg < b.zg) return true;
1096   if (a.zg > b.zg) return false;
1097   return false;
1098 }
1099
1100 pieceops.Move = <PieceHandler>function
1101 (piece,p, info: Pos ) {
1102   piece_checkconflict_nrda(piece,p,false);
1103   piece_note_moved(piece, p);
1104
1105   p.uelem.setAttributeNS(null, "x", info[0]+"");
1106   p.uelem.setAttributeNS(null, "y", info[1]+"");
1107 }
1108
1109 pieceops.SetZLevel = <PieceHandler>function
1110 (piece,p, info: { z: ZCoord, zg: Generation }) {
1111   piece_note_moved(piece,p);
1112   piece_set_zlevel(piece,p, (oldtop_piece)=>{
1113     let oldtop_p = pieces[oldtop_piece]!;
1114     p.z  = info.z;
1115     p.zg = info.zg;
1116   });
1117 }
1118
1119 messages.Recorded = <MessageHandler>function
1120 (j: { piece: PieceId, cseq: ClientSeq, gen: Generation
1121       zg: Generation|null, svg: string | null } ) {
1122   let piece = j.piece;
1123   let p = pieces[piece]!;
1124   if (p.cseq != null && j.cseq >= p.cseq) {
1125     p.cseq = null;
1126   }
1127   if (p.cseq_updatesvg != null && j.cseq >= p.cseq_updatesvg) {
1128     p.cseq_updatesvg = null;
1129     redisplay_ancillaries(piece,p);
1130   }
1131   if (j.svg != null) {
1132     p.delem.innerHTML = j.svg;
1133     p.pelem= piece_element('piece',piece)!;
1134     redisplay_ancillaries(piece,p);
1135   }
1136   if (j.zg != null) {
1137     var zg_new = j.zg; // type narrowing doesn't propagate :-/
1138     piece_set_zlevel(piece,p, (oldtop_piece: PieceId)=>{
1139       p.zg = zg_new;
1140     });
1141   }
1142   gen = j.gen;
1143 }
1144
1145 messages.Error = <MessageHandler>function
1146 (m: any) {
1147   console.log('ERROR UPDATE ', m);
1148   var k = Object.keys(m)[0];
1149   update_error_handlers[k](m[k]);
1150 }
1151
1152 type PieceOpError = {
1153   piece: PieceId,
1154   error: string,
1155   state: PieceStateMessage,
1156 };
1157
1158 update_error_handlers.PieceOpError = <MessageHandler>function
1159 (m: PieceOpError) {
1160   let p = pieces[m.piece];
1161   if (p == null) return;
1162   let conflict_expected = piece_error_handlers[m.error](m.piece, p, m);
1163   piece_modify(m.piece, p, m.state, conflict_expected);
1164 }
1165
1166 function piece_checkconflict_nrda(piece: PieceId, p: PieceInfo,
1167                                   conflict_expected: boolean): boolean {
1168   if (p.cseq != null) {
1169     p.cseq = null;
1170     if (drag_pieces.some(function(dp) { return dp.piece == piece; })) {
1171       console.log('drag end due to conflict');
1172       drag_end();
1173     }
1174     if (!conflict_expected) {
1175       add_log_message('Conflict! - simultaneous update');
1176     }
1177   }
1178   return false;
1179 }
1180
1181 function test_swap_stack() {
1182   let old_bot = pieces_marker.nextElementSibling!;
1183   let container = old_bot.parentElement!;
1184   container.insertBefore(old_bot, defs_marker);
1185   window.setTimeout(test_swap_stack, 1000);
1186 }
1187
1188 function startup() {
1189   console.log('STARTUP');
1190   console.log(wasm_bindgen.setup("OK"));
1191
1192   var body = document.getElementById("main-body")!;
1193   zoom_btn = document.getElementById("zoom-btn") as any;
1194   zoom_val = document.getElementById("zoom-val") as any;
1195   links_elem = document.getElementById("links") as any;
1196   ctoken = body.dataset.ctoken!;
1197   us = body.dataset.us!;
1198   gen = +body.dataset.gen!;
1199   let sse_url_prefix = body.dataset.sseUrlPrefix!;
1200   status_node = document.getElementById('status')!;
1201   status_node.innerHTML = 'js-done';
1202   log_elem = document.getElementById("log")!;
1203   logscroll_elem = document.getElementById("logscroll") || log_elem;
1204   let dataload = JSON.parse(body.dataset.load!);
1205   players = dataload.players!;
1206   delete body.dataset.load;
1207   uos_node = document.getElementById("uos")!;
1208
1209   space = svg_element('space')!;
1210   pieces_marker = svg_element("pieces_marker")!;
1211   defs_marker = svg_element("defs_marker")!;
1212   svg_ns = space.getAttribute('xmlns')!;
1213
1214   for (let uelem = pieces_marker.nextElementSibling! as SVGGraphicsElement;
1215        uelem != defs_marker;
1216        uelem = uelem.nextElementSibling! as SVGGraphicsElement) {
1217     let piece = uelem.dataset.piece!;
1218     let p = JSON.parse(uelem.dataset.info!);
1219     p.uelem = uelem;
1220     p.delem = piece_element('defs',piece);
1221     p.pelem = piece_element('piece',piece);
1222     p.queued_moves = 0;
1223     delete uelem.dataset.info;
1224     pieces[piece] = p;
1225     redisplay_ancillaries(piece,p);
1226   }
1227
1228   last_log_ts = wasm_bindgen.timestamp_abbreviator(dataload.last_log_ts);
1229
1230   var es = new EventSource(
1231     sse_url_prefix + "/_/updates?ctoken="+ctoken+'&gen='+gen
1232   );
1233   es.onmessage = function(event) {
1234     console.log('GOTEVE', event.data);
1235     var k;
1236     var m;
1237     try {
1238       var [tgen, ms] = JSON.parse(event.data);
1239       for (m of ms) {
1240         k = Object.keys(m)[0];
1241         messages[k](m[k]);
1242       }
1243       gen = tgen;
1244       gen_update_hook();
1245     } catch (exc) {
1246       var s = exc.toString();
1247       string_report_error('exception handling update '
1248                           + k + ': ' + JSON.stringify(m) + ': ' + s);
1249     }
1250   }
1251   es.addEventListener('commsworking', function(event) {
1252     console.log('GOTDATA', (event as any).data);
1253     status_node.innerHTML = (event as any).data;
1254   });
1255   es.addEventListener('player-gone', function(event) {
1256     console.log('PLAYER-GONE', event);
1257     status_node.innerHTML = (event as any).data;
1258     add_log_message('<strong>You are no longer in the game</strong>');
1259     space.removeEventListener('mousedown', some_mousedown);
1260     document.removeEventListener('keydown', some_keydown);
1261     es.close();
1262   });
1263   es.addEventListener('updates-expired', function(event) {
1264     console.log('UPDATES-EXPIRED', event);
1265     string_report_error('connection to server interrupted too long');
1266   });
1267   es.onerror = function(e) {
1268     console.log('FOO',e,es);
1269     json_report_error({
1270       updates_error : e,
1271       updates_event_source : es,
1272       updates_event_source_ready : es.readyState,
1273       update_oe : (e as any).className,
1274     })
1275   }
1276   recompute_keybindings();
1277   space.addEventListener('mousedown', some_mousedown);
1278   document.addEventListener('keydown',   some_keydown);
1279 }
1280
1281 declare var wasm_input : any;
1282 var wasm_promise : Promise<any>;;
1283
1284 function doload(){
1285   console.log('DOLOAD');
1286   globalinfo_elem = document.getElementById('global-info')!;
1287   layout = globalinfo_elem!.dataset!.layout! as any;
1288   var elem = document.getElementById('loading_token')!;
1289   var ptoken = elem.dataset.ptoken;
1290   xhr_post_then('/_/session/' + layout, 
1291                 JSON.stringify({ ptoken : ptoken }),
1292                 loaded);
1293
1294   wasm_promise = wasm_input
1295     .then(wasm_bindgen);
1296 }
1297
1298 function loaded(xhr: XMLHttpRequest){
1299   console.log('LOADED');
1300   var body = document.getElementById('loading_body')!;
1301   wasm_promise.then((got_wasm) => {
1302     wasm = got_wasm;
1303     body.outerHTML = xhr.response;
1304     startup();
1305   });
1306 }
1307
1308 // todo scroll of log messages to bottom did not always work somehow
1309 //    think I have fixed this with approximation
1310
1311 doload();