chiark / gitweb /
2f43123e3460bdbbbcad08ea948ea0da514f7765
[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 = 
392     api_piece(api, 'rotate', piece,p, p.angle);
393   }
394   recompute_keybindings();
395   return true;
396 }
397
398 type LowerTodoItem = {
399   piece: PieceId,
400   p: PieceInfo,
401   pinned: boolean,
402 };
403
404 type LowerTodoList = { [piece: string]: LowerTodoItem };
405
406 keyops_local['lower'] = function (uo: UoRecord) { lower_targets(uo); }
407
408 function lower_targets(uo: UoRecord): boolean {
409    function target_treat_pinned(p: PieceInfo): boolean {
410     return wresting || p.pinned;;
411   }
412
413   let targets_todo : LowerTodoList = Object.create(null);
414
415   for (let piece of uo.targets!) {
416     let p = pieces[piece]!;
417     let pinned = target_treat_pinned(p);
418     targets_todo[piece] = { p, piece, pinned, };
419   }
420   let problem = lower_pieces(targets_todo);
421   if (problem !== null) {
422     add_log_message('Cannot lower: ' + problem);
423     return false;
424   }
425   return true;
426 }
427
428 function lower_pieces(targets_todo: LowerTodoList):
429  string | null
430 {
431   // This is a bit subtle.  We don't want to lower below pinned pieces
432   // (unless we are pinned too, or the user is wresting).  But maybe
433   // the pinned pieces aren't already at the bottom.  For now we will
434   // declare that all pinned pieces "should" be below all non-pinned
435   // ones.  Not as an invariant, but as a thing we will do here to try
436   // to make a sensible result.  We implement this as follows: if we
437   // find pinned pieces above non-pinned pieces, we move those pinned
438   // pieces to the bottom too, just below us, preserving their
439   // relative order.
440   //
441   // Disregarding pinned targets:
442   //
443   // Z     <some stuff not including any unpinned targets>
444   // Z
445   //       topmost unpinned target         *
446   // B (
447   // B     unpinned non-target
448   // B |   unpinned target                 *
449   // B |   pinned non-target, mis-stacked  *
450   // B )*
451   // B
452   //       bottommost unpinned non-target
453   //        if that is below topmost unpinned target
454   //            <- tomove_unpinned: insert targets from * here        Q ->
455   //            <- tomove_misstacked: insert non-targets from * here  Q->
456   // A
457   // A     pinned things (nomove_pinned)
458   //            <- tomove_pinned: insert all pinned targets here      P ->
459   //
460   // When wresting, treat all targets as pinned.
461
462   type Entry = {
463     piece: PieceId,
464     p: PieceInfo,
465   };
466   // bottom of the stack order first
467   let tomove_unpinned     : Entry[] = [];
468   let tomove_misstacked   : Entry[] = [];
469   let nomove_pinned       : Entry[] = [];
470   let tomove_pinned       : Entry[] = [];
471   let bottommost_unpinned : Entry | null = null;
472
473   let n_targets_todo_unpinned = 0;
474   for (const piece of Object.keys(targets_todo)) {
475     let p = targets_todo[piece];
476     if (!p.pinned) n_targets_todo_unpinned++;
477   }
478
479   let walk = pieces_marker;
480   for (;;) { // starting at the bottom of the stack order
481     if (n_targets_todo_unpinned == 0
482         && bottommost_unpinned !== null) {
483       // no unpinned targets left, state Z, we can stop now
484       console.log('LOWER STATE Z FINISHED');
485       break;
486     }
487     if (Object.keys(targets_todo).length == 0 &&
488        bottommost_unpinned !== null) {
489       console.log('LOWER NO TARGETS BUT UNPINNED!', n_targets_todo_unpinned);
490       break;
491     }
492
493     let new_walk = walk.nextElementSibling;
494     if (new_walk == null) {
495       console.log('LOWER WALK NO SIBLING!');
496       break;
497     }
498     walk = new_walk as SVGGraphicsElement;
499     let piece = walk.dataset.piece;
500     if (piece == null) {
501       console.log('LOWER WALK REACHED TOP');
502       break;
503     }
504
505     let todo = targets_todo[piece];
506     if (todo) {
507       console.log('LOWER WALK', piece, 'TODO', todo.pinned);
508       delete targets_todo[piece];
509       if (!todo.pinned) n_targets_todo_unpinned--;
510       (todo.pinned ? tomove_pinned : tomove_unpinned).push(todo);
511       continue;
512     }
513
514     let p = pieces[piece]!;
515     if (bottommost_unpinned === null) { // state A
516       if (!p.pinned) {
517         console.log('LOWER WALK', piece, 'STATE A -> Z');
518         bottommost_unpinned = { p, piece };
519       } else {
520         console.log('LOWER WALK', piece, 'STATE A');
521         nomove_pinned.push({ p, piece });
522       }
523       continue;
524     }
525
526     // state B
527     if (p.pinned) {
528       console.log('LOWER WALK', piece, 'STATE B MIS-STACKED');
529       tomove_misstacked.push({ p, piece });
530     } else {
531       console.log('LOWER WALK', piece, 'STATE B');
532     }
533   }
534
535   let z_top =
536       bottommost_unpinned ? bottommost_unpinned.p.z :
537       walk.dataset.piece != null ? pieces[walk.dataset.piece!].z :
538       // rather a lack of things we are not adjusting!
539       wasm_bindgen.def_zcoord();
540
541   type PlanEntry = {
542     content: Entry[], // bottom to top
543     z_top: ZCoord | null,
544     z_bot: ZCoord | null,
545   };
546
547   let plan : PlanEntry[] = [];
548
549   let partQ = tomove_unpinned.concat(tomove_misstacked);
550   let partP = tomove_pinned;
551
552   if (nomove_pinned.length == 0) {
553     plan.push({
554       content: partQ.concat(partP),
555       z_top,
556       z_bot : null,
557     });
558   } else {
559     plan.push({
560       content: partQ,
561       z_top,
562       z_bot: nomove_pinned[nomove_pinned.length-1].p.z,
563     }, {
564       content: partP,
565       z_top: nomove_pinned[0].p.z,
566       z_bot: null,
567     });
568   }
569
570   console.log('LOWER PLAN', plan);
571
572   for (const pe of plan) {
573     for (const e of pe.content) {
574       if (e.p.held != null && e.p.held != us) {
575         return "lowering would disturb a piece held by another player";
576       }
577     }
578   }
579
580   z_top = null;
581   for (const pe of plan) {
582     if (pe.z_top != null) z_top = pe.z_top;
583     let z_bot = pe.z_bot;
584     let zrange = wasm_bindgen.range(z_bot, z_top, pe.content.length);
585     console.log('LOQER PLAN PE',
586                 pe, z_bot, z_top, pe.content.length, zrange.debug());
587     for (const e of pe.content) {
588       let p = e.p;
589       piece_set_zlevel(e.piece, p, (oldtop_piece) => {
590         let z = zrange.next();
591         p.z = z;
592         api_piece(api, "setz", e.piece, e.p, { z });
593       });
594     }
595   }
596   return null;
597 }
598
599 keyops_local['wrest'] = function (uo: UoRecord) {
600   wresting = !wresting;
601   document.getElementById('wresting-warning')!.innerHTML = !wresting ? "" :
602     " <strong>(wresting mode!)</strong>";
603   ungrab_all();
604   recompute_keybindings();
605 }
606
607 keyops_local['pin'  ] = function (uo) {
608   if (!lower_targets(uo)) return;
609   pin_unpin(uo, true);
610 }
611 keyops_local['unpin'] = function (uo) {
612   pin_unpin(uo, false);
613 }
614
615 function pin_unpin(uo: UoRecord, newpin: boolean) {
616   for (let piece of uo.targets!) {
617     let p = pieces[piece]!;
618     p.pinned = newpin;
619     api_piece(api, 'pin', piece,p, newpin);
620     redisplay_ancillaries(piece,p);
621   }
622   recompute_keybindings();
623 }
624
625 // ----- clicking/dragging pieces -----
626
627 type DragInfo = {
628   piece : PieceId,
629   dox : number,
630   doy : number,
631 }
632
633 enum DRAGGING { // bitmask
634   NO           = 0,
635   MAYBE_GRAB   = 1,
636   MAYBE_UNGRAB = 2,
637   YES          = 4,
638   RAISED       = 8,
639 };
640
641 var drag_pieces : DragInfo[] = [];
642 var dragging = DRAGGING.NO;
643 var dcx : number | null;
644 var dcy : number | null;
645
646 const DRAGTHRESH = 5;
647
648 function drag_add_piece(piece: PieceId, p: PieceInfo) {
649   drag_pieces.push({
650     piece: piece,
651     dox: parseFloat(p.uelem.getAttributeNS(null,"x")!),
652     doy: parseFloat(p.uelem.getAttributeNS(null,"y")!),
653   });
654 }
655
656 function some_mousedown(e : MouseEvent) {
657   console.log('mousedown', e, e.clientX, e.clientY, e.target);
658
659   if (e.button != 0) { return }
660   if (e.altKey) { return }
661   if (e.metaKey) { return }
662   if (e.ctrlKey) {
663     return;
664   } else {
665     drag_mousedown(e, e.shiftKey);
666   }
667 }
668
669 function drag_mousedown(e : MouseEvent, shifted: boolean) {
670   var target = e.target as SVGGraphicsElement; // we check this just now!
671   var piece = target.dataset.piece!;
672   if (!piece) { return; }
673   let p = pieces[piece]!;
674   let held = p.held;
675
676   drag_cancel();
677
678   drag_pieces = [];
679   if (held == us) {
680     dragging = DRAGGING.MAYBE_UNGRAB;
681     drag_add_piece(piece,p); // contrive to have this one first
682     for (let tpiece of Object.keys(pieces)) {
683       if (tpiece == piece) continue;
684       let tp = pieces[tpiece]!;
685       if (tp.held != us) continue;
686       drag_add_piece(tpiece,tp);
687     }
688   } else if (held == null || wresting) {
689     if (p.pinned && !wresting) {
690       add_log_message('That piece is pinned to the table.');
691       return;
692     }
693     if (!shifted) {
694       ungrab_all();
695     }
696     dragging = DRAGGING.MAYBE_GRAB;
697     drag_add_piece(piece,p);
698     set_grab(piece,p, us);
699     api_piece(api, wresting ? 'wrest' : 'grab', piece,p, { });
700   } else {
701     add_log_message('That piece is held by another player.');
702     return;
703   }
704   dcx = e.clientX;
705   dcy = e.clientY;
706
707   window.addEventListener('mousemove', drag_mousemove, true);
708   window.addEventListener('mouseup',   drag_mouseup,   true);
709 }
710
711 function ungrab_all() {
712   for (let tpiece of Object.keys(pieces)) {
713     let tp = pieces[tpiece]!;
714     if (tp.held == us) {
715           set_ungrab(tpiece,tp);
716       api_piece(api, 'ungrab', tpiece,tp, { });
717     }
718   }
719 }
720
721 function set_grab(piece: PieceId, p: PieceInfo, owner: PlayerId) {
722   p.held = owner;
723   redisplay_ancillaries(piece,p);
724   recompute_keybindings();
725 }
726 function set_ungrab(piece: PieceId, p: PieceInfo) {
727   p.held = null;
728   redisplay_ancillaries(piece,p);
729   recompute_keybindings();
730 }
731
732 function clear_halo(piece: PieceId, p: PieceInfo) {
733   p.last_seen_moved = null;
734   redisplay_ancillaries(piece,p);
735 }
736
737 function ancillary_node(piece: PieceId, stroke: string): SVGGraphicsElement {
738   var nelem = document.createElementNS(svg_ns,'use');
739   nelem.setAttributeNS(null,'href','#surround'+piece);
740   nelem.setAttributeNS(null,'stroke',stroke);
741   nelem.setAttributeNS(null,'fill','none');
742   return nelem as any;
743 }
744
745 function redisplay_ancillaries(piece: PieceId, p: PieceInfo) {
746   let href = '#surround'+piece;
747   console.log('REDISPLAY ANCILLARIES',href);
748
749   for (let celem = p.pelem.firstElementChild;
750        celem != null;
751        celem = nextelem) {
752     var nextelem = celem.nextElementSibling
753     let thref = celem.getAttributeNS(null,"href");
754     if (thref == href) {
755       celem.remove();
756     }
757   }
758
759   let halo_colour = null;
760   if (p.cseq_updatesvg != null) {
761     halo_colour = 'purple';
762   } else if (p.last_seen_moved != null) {
763     halo_colour = 'yellow';
764   } else if (p.held != null && p.pinned) {
765     halo_colour = '#8cf';
766   }
767   if (halo_colour != null) {
768     let nelem = ancillary_node(piece, halo_colour);
769     if (p.held != null) {
770       nelem.setAttributeNS(null,'stroke-width','2px');
771     }
772     p.pelem.prepend(nelem);
773   } 
774   if (p.held != null) {
775     let da = players[p.held!]!.dasharray;
776     let nelem = ancillary_node(piece, 'black');
777     nelem.setAttributeNS(null,'stroke-dasharray',da);
778     p.pelem.appendChild(nelem);
779   }
780 }
781
782 function drag_mousemove(e: MouseEvent) {
783   var ctm = space.getScreenCTM()!;
784   var ddx = (e.clientX - dcx!)/(ctm.a * firefox_bug_zoom_factor_compensation);
785   var ddy = (e.clientY - dcy!)/(ctm.d * firefox_bug_zoom_factor_compensation);
786   var ddr2 = ddx*ddx + ddy*ddy;
787   if (!(dragging & DRAGGING.YES)) {
788     if (ddr2 > DRAGTHRESH) {
789       dragging |= DRAGGING.YES;
790     }
791   }
792   //console.log('mousemove', ddx, ddy, dragging);
793   if (dragging & DRAGGING.YES) {
794     console.log('DRAG PIECES',drag_pieces);
795     for (let dp of drag_pieces) {
796       console.log('DRAG PIECES PIECE',dp);
797       let tpiece = dp.piece;
798       let tp = pieces[tpiece]!;
799       var x = Math.round(dp.dox + ddx);
800       var y = Math.round(dp.doy + ddy);
801       tp.uelem.setAttributeNS(null, "x", x+"");
802       tp.uelem.setAttributeNS(null, "y", y+"");
803       tp.queued_moves++;
804       api_piece(api_delay, 'm', tpiece,tp, [x, y] );
805     }
806     if (!(dragging & DRAGGING.RAISED) && drag_pieces.length==1) {
807       let dp = drag_pieces[0];
808       let piece = dp.piece;
809       let p = pieces[piece]!;
810       let dragraise = +p.pelem.dataset.dragraise!;
811       if (dragraise > 0 && ddr2 >= dragraise*dragraise) {
812         dragging |= DRAGGING.RAISED;
813         console.log('CHECK RAISE ', dragraise, dragraise*dragraise, ddr2);
814         piece_set_zlevel(piece,p, (oldtop_piece) => {
815           let oldtop_p = pieces[oldtop_piece]!;
816           let z = wasm_bindgen.increment(oldtop_p.z);
817           p.z = z;
818           api_piece(api, "setz", piece,p, { z: z });
819         });
820       }
821     }
822   }
823   return ddr2;
824 }
825
826 function drag_mouseup(e: MouseEvent) {
827   console.log('mouseup', dragging);
828   let ddr2 : number = drag_mousemove(e);
829   drag_end();
830 }
831
832 function drag_end() {
833   if (dragging == DRAGGING.MAYBE_UNGRAB ||
834       (dragging & ~DRAGGING.RAISED) == (DRAGGING.MAYBE_GRAB | DRAGGING.YES)) {
835     let dp = drag_pieces[0]!;
836     let piece = dp.piece;
837     let p = pieces[piece]!;
838     set_ungrab(piece,p);
839     api_piece(api, 'ungrab', piece,p, { });
840   }
841   drag_cancel();
842 }
843
844 function drag_cancel() {
845   window.removeEventListener('mousemove', drag_mousemove, true);
846   window.removeEventListener('mouseup',   drag_mouseup,   true);
847   dragging = DRAGGING.NO;
848   drag_pieces = [];
849 }
850
851 // ----- general -----
852
853 messages.AddPlayer = <MessageHandler>function
854 (j: { player: string, data: PlayerInfo }) {
855   players[j.player] = j.data;
856 }
857
858 messages.RemovePlayer = <MessageHandler>function
859 (j: { player: string }) {
860   delete players[j.player];
861 }
862
863 messages.SetLinks = <MessageHandler>function
864 (msg: string) {
865   if (msg.length != 0 && layout == 'Portrait') {
866     msg += " |";
867   }
868   links_elem.innerHTML = msg
869 }
870
871 // ----- logs -----
872
873 messages.Log = <MessageHandler>function
874 (j: { when: string, logent: { html: string } }) {
875   add_timestamped_log_message(j.when, j.logent.html);
876 }
877
878 function add_log_message(msg_html: string) {
879   add_timestamped_log_message('', msg_html);
880 }
881
882 function add_timestamped_log_message(ts_html: string, msg_html: string) {
883   var lastent = log_elem.lastElementChild;
884   var in_scrollback =
885     lastent == null ||
886     // inspired by
887     //   https://stackoverflow.com/questions/487073/how-to-check-if-element-is-visible-after-scrolling/21627295#21627295
888     // rejected
889       //   https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
890       (() => {
891         let le_top = lastent.getBoundingClientRect()!.top;
892         let le_bot = lastent.getBoundingClientRect()!.bottom;
893         let ld_bot = logscroll_elem.getBoundingClientRect()!.bottom;
894         console.log("ADD_LOG_MESSAGE bboxes: le t b, bb",
895                     le_top, le_bot, ld_bot);
896         return 0.5 * (le_bot + le_top) > ld_bot;
897       })();
898
899   console.log('ADD LOG MESSAGE ',in_scrollback, layout, msg_html);
900
901   var ne : HTMLElement;
902
903   function add_thing(elemname: string, cl: string, html: string) {
904     var ie = document.createElement(elemname);
905     ie.innerHTML = html;
906     ie.setAttribute("class", cl);
907     ne.appendChild(ie);
908   }
909
910   if (layout == 'Portrait') {
911     ne = document.createElement('tr');
912     add_thing('td', 'logmsg', msg_html);
913     add_thing('td', 'logts',  ts_html);
914   } else if (layout == 'Landscape') {
915     ts_html = last_log_ts.update(ts_html);
916     ne = document.createElement('div');
917     add_thing('span', 'logts',  ts_html);
918     ne.appendChild(document.createElement('br'));
919     add_thing('span', 'logmsg', msg_html);
920     ne.appendChild(document.createElement('br'));
921   } else {
922     throw 'bad layout ' + layout;
923   }
924   log_elem.appendChild(ne);
925
926   if (!in_scrollback) {
927     logscroll_elem.scrollTop = logscroll_elem.scrollHeight;
928   }
929 }
930
931 // ----- zoom -----
932
933 function zoom_pct (): number | undefined {
934   let str = zoom_val.value;
935   let val = parseFloat(str);
936   if (isNaN(val)) {
937     return undefined;
938   } else {
939     return val;
940   }
941 }
942
943 function zoom_enable() {
944   zoom_btn.disabled = (zoom_pct() === undefined);
945 }
946
947 function zoom_activate() {
948   let pct = zoom_pct();
949   if (pct !== undefined) {
950     let fact = pct * 0.01;
951     let last_ctm_a = space.getScreenCTM()!.a;
952     (document.getElementsByTagName('body')[0] as HTMLElement)
953       .style.transform = 'scale('+fact+','+fact+')';
954     if (fact != last_zoom_factor) {
955       if (last_ctm_a == space.getScreenCTM()!.a) {
956         console.log('FIREFOX GETSCREENCTM BUG');
957         firefox_bug_zoom_factor_compensation = fact;
958       } else {
959         console.log('No firefox getscreenctm bug');
960         firefox_bug_zoom_factor_compensation = 1.0;
961       }
962       last_zoom_factor = fact;
963     }
964   }
965   zoom_btn.disabled = true;
966 }
967
968 // ----- test counter, startup -----
969
970 messages.Piece = <MessageHandler>function
971 (j: { piece: PieceId, op: Object }) {
972   console.log('PIECE UPDATE ',j)
973   var piece = j.piece;
974   var m = j.op as { [k: string]: Object };
975   var k = Object.keys(m)[0];
976   let p = pieces[piece]!;
977   pieceops[k](piece,p, m[k]);
978 };
979
980 type PieceStateMessage = {
981   svg: string,
982   held: PlayerId,
983   pos: Pos,
984   z: ZCoord,
985   zg: Generation,
986   pinned: boolean,
987   uos: UoDescription[],
988 }
989
990 pieceops.ModifyQuiet = <PieceHandler>function
991 (piece: PieceId, p: PieceInfo, info: PieceStateMessage) {
992   console.log('PIECE UPDATE MODIFY QUIET ',piece,info)
993   piece_modify(piece, p, info, false);
994 }
995
996 pieceops.Modify = <PieceHandler>function
997 (piece: PieceId, p: PieceInfo, info: PieceStateMessage) {
998   console.log('PIECE UPDATE MODIFY LOuD ',piece,info)
999   piece_note_moved(piece,p);
1000   piece_modify(piece, p, info, false);
1001 }
1002
1003 piece_error_handlers.PosOffTable = <PieceErrorHandler>function()
1004 { return true ; }
1005 piece_error_handlers.Conflict = <PieceErrorHandler>function()
1006 { return true ; }
1007
1008 function piece_modify(piece: PieceId, p: PieceInfo, info: PieceStateMessage,
1009                       conflict_expected: boolean) {
1010   p.delem.innerHTML = info.svg;
1011   p.pelem= piece_element('piece',piece)!;
1012   p.uelem.setAttributeNS(null, "x", info.pos[0]+"");
1013   p.uelem.setAttributeNS(null, "y", info.pos[1]+"");
1014   p.held = info.held;
1015   p.pinned = info.pinned;
1016   p.uos = info.uos;
1017   piece_set_zlevel(piece,p, (oldtop_piece)=>{
1018     p.z  = info.z;
1019     p.zg = info.zg;
1020   });
1021   piece_checkconflict_nrda(piece,p,conflict_expected);
1022   redisplay_ancillaries(piece,p);
1023   recompute_keybindings();
1024   console.log('MODIFY DONE');
1025 }
1026
1027 /*
1028 pieceops.Insert = <PieceHandler>function
1029 (piece: PieceId, p: null,
1030  info: { svg: string, held: PlayerId, pos: Pos, z: number, zg: Generation}) {
1031   console.log('PIECE UPDATE INSERT ',piece,info)
1032   delem = document.createElementNS(svg_ns,'defs');
1033   delem.setAttributeNS(null,'id','defs'+piece);
1034   delem.innerHTML = info.svg;
1035   space.appendChild(delem);
1036   pelem = 
1037
1038   nelem.setAttributeNS(null,'stroke',stroke);
1039   nelem.setAttributeNS(null,'fill','none');
1040 */
1041
1042 function piece_set_zlevel(piece: PieceId, p: PieceInfo,
1043                           modify : (oldtop_piece: PieceId) => void) {
1044   // Calls modify, which should set .z and/or .gz, and/or
1045   // make any necessary API call.
1046   //
1047   // Then moves uelem to the right place in the DOM.  This is done
1048   // by assuming that uelem ought to go at the end, so this is
1049   // O(new depth), which is right (since the UI for inserting
1050   // an object is itself O(new depth) UI operations to prepare.
1051
1052   let oldtop_elem = (defs_marker.previousElementSibling! as
1053                      unknown as SVGGraphicsElement);
1054   let oldtop_piece = oldtop_elem.dataset.piece!;
1055   modify(oldtop_piece);
1056
1057   let ins_before = defs_marker
1058   let earlier_elem;
1059   for (; ; ins_before = earlier_elem) {
1060     earlier_elem = (ins_before.previousElementSibling! as
1061                    unknown as SVGGraphicsElement);
1062     if (earlier_elem == pieces_marker) break;
1063     if (earlier_elem == p.uelem) continue;
1064     let earlier_p = pieces[earlier_elem.dataset.piece!]!;
1065     if (!piece_z_before(p, earlier_p)) break;
1066   }
1067   if (ins_before != p.uelem)
1068     space.insertBefore(p.uelem, ins_before);
1069 }
1070
1071 function piece_note_moved(piece: PieceId, p: PieceInfo) {
1072   let now = performance.now();
1073
1074   let need_redisplay = p.last_seen_moved == null;
1075   p.last_seen_moved = now;
1076   if (need_redisplay) redisplay_ancillaries(piece,p);
1077
1078   let cutoff = now-1000.;
1079   while (movements.length > 0 && movements[0].this_motion < cutoff) {
1080     let mr = movements.shift()!;
1081     if (mr.p.last_seen_moved != null &&
1082         mr.p.last_seen_moved < cutoff) {
1083       mr.p.last_seen_moved = null;
1084       redisplay_ancillaries(mr.piece,mr.p);
1085     }
1086   }
1087
1088   movements.push({ piece: piece, p: p, this_motion: now });
1089 }
1090
1091 function piece_z_before(a: PieceInfo, b: PieceInfo) {
1092   if (a.z  < b.z ) return true;
1093   if (a.z  > b.z ) return false;
1094   if (a.zg < b.zg) return true;
1095   if (a.zg > b.zg) return false;
1096   return false;
1097 }
1098
1099 pieceops.Move = <PieceHandler>function
1100 (piece,p, info: Pos ) {
1101   piece_checkconflict_nrda(piece,p,false);
1102   piece_note_moved(piece, p);
1103
1104   p.uelem.setAttributeNS(null, "x", info[0]+"");
1105   p.uelem.setAttributeNS(null, "y", info[1]+"");
1106 }
1107
1108 pieceops.SetZLevel = <PieceHandler>function
1109 (piece,p, info: { z: ZCoord, zg: Generation }) {
1110   piece_note_moved(piece,p);
1111   piece_set_zlevel(piece,p, (oldtop_piece)=>{
1112     let oldtop_p = pieces[oldtop_piece]!;
1113     p.z  = info.z;
1114     p.zg = info.zg;
1115   });
1116 }
1117
1118 messages.Recorded = <MessageHandler>function
1119 (j: { piece: PieceId, cseq: ClientSeq, gen: Generation
1120       zg: Generation|null, svg: string | null } ) {
1121   let piece = j.piece;
1122   let p = pieces[piece]!;
1123   if (p.cseq != null && j.cseq >= p.cseq) {
1124     p.cseq = null;
1125   }
1126   if (p.cseq_updatesvg != null && j.cseq >= p.cseq_updatesvg) {
1127     p.cseq_updatesvg = null;
1128     redisplay_ancillaries(piece,p);
1129   }
1130   if (j.svg != null) {
1131     p.delem.innerHTML = j.svg;
1132     p.pelem= piece_element('piece',piece)!;
1133     redisplay_ancillaries(piece,p);
1134   }
1135   if (j.zg != null) {
1136     var zg_new = j.zg; // type narrowing doesn't propagate :-/
1137     piece_set_zlevel(piece,p, (oldtop_piece: PieceId)=>{
1138       p.zg = zg_new;
1139     });
1140   }
1141   gen = j.gen;
1142 }
1143
1144 messages.Error = <MessageHandler>function
1145 (m: any) {
1146   console.log('ERROR UPDATE ', m);
1147   var k = Object.keys(m)[0];
1148   update_error_handlers[k](m[k]);
1149 }
1150
1151 type PieceOpError = {
1152   piece: PieceId,
1153   error: string,
1154   state: PieceStateMessage,
1155 };
1156
1157 update_error_handlers.PieceOpError = <MessageHandler>function
1158 (m: PieceOpError) {
1159   let p = pieces[m.piece];
1160   if (p == null) return;
1161   let conflict_expected = piece_error_handlers[m.error](m.piece, p, m);
1162   piece_modify(m.piece, p, m.state, conflict_expected);
1163 }
1164
1165 function piece_checkconflict_nrda(piece: PieceId, p: PieceInfo,
1166                                   conflict_expected: boolean): boolean {
1167   if (p.cseq != null) {
1168     p.cseq = null;
1169     if (drag_pieces.some(function(dp) { return dp.piece == piece; })) {
1170       console.log('drag end due to conflict');
1171       drag_end();
1172     }
1173     if (!conflict_expected) {
1174       add_log_message('Conflict! - simultaneous update');
1175     }
1176   }
1177   return false;
1178 }
1179
1180 function test_swap_stack() {
1181   let old_bot = pieces_marker.nextElementSibling!;
1182   let container = old_bot.parentElement!;
1183   container.insertBefore(old_bot, defs_marker);
1184   window.setTimeout(test_swap_stack, 1000);
1185 }
1186
1187 function startup() {
1188   console.log('STARTUP');
1189   console.log(wasm_bindgen.setup("OK"));
1190
1191   var body = document.getElementById("main-body")!;
1192   zoom_btn = document.getElementById("zoom-btn") as any;
1193   zoom_val = document.getElementById("zoom-val") as any;
1194   links_elem = document.getElementById("links") as any;
1195   ctoken = body.dataset.ctoken!;
1196   us = body.dataset.us!;
1197   gen = +body.dataset.gen!;
1198   let sse_url_prefix = body.dataset.sseUrlPrefix!;
1199   status_node = document.getElementById('status')!;
1200   status_node.innerHTML = 'js-done';
1201   log_elem = document.getElementById("log")!;
1202   logscroll_elem = document.getElementById("logscroll") || log_elem;
1203   let dataload = JSON.parse(body.dataset.load!);
1204   players = dataload.players!;
1205   delete body.dataset.load;
1206   uos_node = document.getElementById("uos")!;
1207
1208   space = svg_element('space')!;
1209   pieces_marker = svg_element("pieces_marker")!;
1210   defs_marker = svg_element("defs_marker")!;
1211   svg_ns = space.getAttribute('xmlns')!;
1212
1213   for (let uelem = pieces_marker.nextElementSibling! as SVGGraphicsElement;
1214        uelem != defs_marker;
1215        uelem = uelem.nextElementSibling! as SVGGraphicsElement) {
1216     let piece = uelem.dataset.piece!;
1217     let p = JSON.parse(uelem.dataset.info!);
1218     p.uelem = uelem;
1219     p.delem = piece_element('defs',piece);
1220     p.pelem = piece_element('piece',piece);
1221     p.queued_moves = 0;
1222     delete uelem.dataset.info;
1223     pieces[piece] = p;
1224     redisplay_ancillaries(piece,p);
1225   }
1226
1227   last_log_ts = wasm_bindgen.timestamp_abbreviator(dataload.last_log_ts);
1228
1229   var es = new EventSource(
1230     sse_url_prefix + "/_/updates?ctoken="+ctoken+'&gen='+gen
1231   );
1232   es.onmessage = function(event) {
1233     console.log('GOTEVE', event.data);
1234     var k;
1235     var m;
1236     try {
1237       var [tgen, ms] = JSON.parse(event.data);
1238       for (m of ms) {
1239         k = Object.keys(m)[0];
1240         messages[k](m[k]);
1241       }
1242       gen = tgen;
1243       gen_update_hook();
1244     } catch (exc) {
1245       var s = exc.toString();
1246       string_report_error('exception handling update '
1247                           + k + ': ' + JSON.stringify(m) + ': ' + s);
1248     }
1249   }
1250   es.addEventListener('commsworking', function(event) {
1251     console.log('GOTDATA', (event as any).data);
1252     status_node.innerHTML = (event as any).data;
1253   });
1254   es.addEventListener('player-gone', function(event) {
1255     console.log('PLAYER-GONE', event);
1256     status_node.innerHTML = (event as any).data;
1257     add_log_message('<strong>You are no longer in the game</strong>');
1258     space.removeEventListener('mousedown', some_mousedown);
1259     document.removeEventListener('keydown', some_keydown);
1260     es.close();
1261   });
1262   es.addEventListener('updates-expired', function(event) {
1263     console.log('UPDATES-EXPIRED', event);
1264     string_report_error('connection to server interrupted too long');
1265   });
1266   es.onerror = function(e) {
1267     console.log('FOO',e,es);
1268     json_report_error({
1269       updates_error : e,
1270       updates_event_source : es,
1271       updates_event_source_ready : es.readyState,
1272       update_oe : (e as any).className,
1273     })
1274   }
1275   recompute_keybindings();
1276   space.addEventListener('mousedown', some_mousedown);
1277   document.addEventListener('keydown',   some_keydown);
1278 }
1279
1280 declare var wasm_input : any;
1281 var wasm_promise : Promise<any>;;
1282
1283 function doload(){
1284   console.log('DOLOAD');
1285   globalinfo_elem = document.getElementById('global-info')!;
1286   layout = globalinfo_elem!.dataset!.layout! as any;
1287   var elem = document.getElementById('loading_token')!;
1288   var ptoken = elem.dataset.ptoken;
1289   xhr_post_then('/_/session/' + layout, 
1290                 JSON.stringify({ ptoken : ptoken }),
1291                 loaded);
1292
1293   wasm_promise = wasm_input
1294     .then(wasm_bindgen);
1295 }
1296
1297 function loaded(xhr: XMLHttpRequest){
1298   console.log('LOADED');
1299   var body = document.getElementById('loading_body')!;
1300   wasm_promise.then((got_wasm) => {
1301     wasm = got_wasm;
1302     body.outerHTML = xhr.response;
1303     startup();
1304   });
1305 }
1306
1307 // todo scroll of log messages to bottom did not always work somehow
1308 //    think I have fixed this with approximation
1309
1310 doload();