diff --git a/docs/development.md b/docs/development.md index 8e01254..f95c702 100644 --- a/docs/development.md +++ b/docs/development.md @@ -104,12 +104,13 @@ modules/ swipe-decoder.js Steam (argument of patch.js): swipe path -> words textmodel.js Steam (argument of patch.js): what the keyboard typed corrector.js Steam (argument of patch.js): corrections, completions + gesture-input.js Steam (argument of patch.js): a gesture's own events suggestions-panel/ patch.js, unpatch.js SteamVR: the strip as a panel above/below the keyboard relay.mjs service vr-keyboard-relay: strip state Steam <-> SteamVR dictionary.nix, gen-dict.py build: dictionary from wordfreq + Hunspell check.nix, tests/ test (also built before the patch): text model, - corrector, swipe-decoder accuracy + corrector, swipe-decoder accuracy, gesture input dashboard-windows.nix, dashboard-windows/ SteamVR: window scale/distance limits steam-close-button.nix, steam-close-button/ SteamVR: X on the Steam window window-curvature.nix, window-curvature/ SteamVR: curvature wheel diff --git a/docs/keyboard.md b/docs/keyboard.md index 79dd793..7f41fa3 100644 --- a/docs/keyboard.md +++ b/docs/keyboard.md @@ -101,6 +101,9 @@ default): release on the last. The word is typed with a space before it if needed (`text.autoSpace`); alternatives show in the strip. `'` and `-` are typed, not swiped. + With both controllers on the keyboard, a swipe follows only the + controller that pressed; the other one's laser doesn't enter its path, + and its presses meanwhile are plain taps. - **Suggestions** never change text by themselves: a finished tapped word that isn't in the dictionary gets corrections (itself first; `autocorrect`), a word being tapped gets completions (the typed letters @@ -141,7 +144,9 @@ via its xdotool helper). changes them the keyboard stays stock (see [after a Steam update](ui-patches.md#after-a-steam-update)). Accented words of other languages are in the dictionary but only swipable where the layout -has the letters. +has the letters. With two controllers on the keyboard, SteamVR sometimes +sends no laser movement for a press (seen for the controller that didn't +own the keyboard's cursor just before): that press stays a tap. **Remove when** Steam's VR keyboard gets swipe typing and suggestions. @@ -162,7 +167,8 @@ has the letters. - Found by signature (entries `vr-keyboard`, `vr-keyboard-panel`). **Tests:** `nix flake check` (checks `vr-keyboard`: text model, corrector, -decoder accuracy on German + English) and `keyboard.vr.checks` for the +decoder accuracy on German + English, +gesture input from two controllers) and `keyboard.vr.checks` for the configured dictionary. **Debugging:** `window.__sfuiSwipeLog` and `__sfuiSwipePaths` in Steam's diff --git a/modules/vr-keyboard.nix b/modules/vr-keyboard.nix index 12165c4..0117b36 100644 --- a/modules/vr-keyboard.nix +++ b/modules/vr-keyboard.nix @@ -1,6 +1,7 @@ # Swipe typing, suggestions and a Backspace drag for Steam's VR keyboard # (steamFrame.keyboard.vr; the extra keys are vr-keyboard-extra-keys.nix). -# - vr-keyboard/patch.js (Steam UI, 8080): swipe gestures (swipe-decoder.js), +# - vr-keyboard/patch.js (Steam UI, 8080): swipe gestures (swipe-decoder.js; +# gesture-input.js: the events of the laser that pressed), # a model of what the keyboard typed (textmodel.js), suggestions (swipe # alternatives, corrections and completions, corrector.js; never changing # text by themselves), Backspace drag (left: delete with a detent at word @@ -55,7 +56,7 @@ let inherit (cfg.backspaceDrag) wordDetentPixels; inherit (cfg) haptics; }; - extraArgs = [ ./vr-keyboard/swipe-decoder.js ./vr-keyboard/textmodel.js ./vr-keyboard/corrector.js dictionaryJs ]; + extraArgs = [ ./vr-keyboard/swipe-decoder.js ./vr-keyboard/textmodel.js ./vr-keyboard/corrector.js dictionaryJs ./vr-keyboard/gesture-input.js ]; }} $out node --check $out ''; diff --git a/modules/vr-keyboard/check.nix b/modules/vr-keyboard/check.nix index 157c143..67dc52f 100644 --- a/modules/vr-keyboard/check.nix +++ b/modules/vr-keyboard/check.nix @@ -1,6 +1,7 @@ # Tests of the VR keyboard (flake check `vr-keyboard`, also built before the # patch by vr-keyboard.nix with the configured dictionary): text model, -# corrector and swipe-decoder accuracy (tests/*.test.mjs). The accuracy +# gesture input (two controllers), corrector and swipe-decoder accuracy +# (tests/*.test.mjs). The accuracy # thresholds (German keyboard geometry, German and English words) fail the # build only for German + English dictionaries, the default here. { pkgs @@ -20,6 +21,7 @@ in pkgs.runCommand "vr-keyboard-checks" { nativeBuildInputs = [ pkgs.nodejs ]; } '' set -o pipefail node ${./tests/textmodel.test.mjs} ${./textmodel.js} + node ${./tests/gesture-input.test.mjs} ${./gesture-input.js} ${./tests/two-controllers.json} node ${./tests/corrector.test.mjs} ${./corrector.js} ${./swipe-decoder.js} ${words} ${lib.optionalString (!strict) "|| echo warning: corrector test failed"} node ${./tests/swipe-decoder.test.mjs} ${./swipe-decoder.js} ${words} 100 1 | tee $out ${lib.optionalString (!strict) "|| true"} node -e ' diff --git a/modules/vr-keyboard/gesture-input.js b/modules/vr-keyboard/gesture-input.js new file mode 100644 index 0000000..76a0059 --- /dev/null +++ b/modules/vr-keyboard/gesture-input.js @@ -0,0 +1,76 @@ +// gesture-input.js: which input events belong to the gesture in progress +// (patch.js). Evaluates to { VERSION, create }. +// +// SteamVR's lasers reach the keyboard page as touches (a trigger press: one +// touch with a new identifier per press) and as mouse hover (pointerId 1, +// no hand distinguisher; Chromium's pointer events for touches are cancelled +// after a few moves). With both controllers on the keyboard the idle one's +// hover keeps arriving during the other's press. A gesture is therefore owned +// by the contact that started it: a touch identifier, a pointerId or the +// mouse. Moves, the release and a cancel of other contacts are ignored, so +// they never enter the swipe path or end it. A second press while a gesture +// runs is ignored (Steam handles it as stock); one whose owner is gone +// (missed release) replaces it. +(() => { + const VERSION = 1; + + function create() { + let owner = null, sawPointer = false; + + // The contacts of an event: [{ id, x, y, target }]. + function contacts(e) { + if (e.type.startsWith('touch')) { + const list = e.type === 'touchmove' ? e.touches : e.changedTouches; + return [...(list || [])].map((t) => ({ id: `t${t.identifier}`, x: t.clientX, y: t.clientY, target: t.target })); + } + if (e.type.startsWith('pointer')) { + sawPointer = true; + if (e.pointerType === 'touch') return []; // touch events carry these + return [{ id: `p${e.pointerId}`, x: e.clientX, y: e.clientY, target: e.target }]; + } + if (sawPointer) return []; // pointer events carry the mouse + return [{ id: 'm', x: e.clientX, y: e.clientY, target: e.target }]; + } + const PHASE = { + touchstart: 'down', pointerdown: 'down', mousedown: 'down', + touchmove: 'move', pointermove: 'move', mousemove: 'move', + touchend: 'up', pointerup: 'up', mouseup: 'up', + touchcancel: 'cancel', pointercancel: 'cancel', + }; + + return { + VERSION, + get owner() { return owner; }, + // { phase, id, x, y, target } if the event concerns the gesture (or may + // start one: phase 'down'), else null. An 'up' or 'cancel' ends the + // ownership. + read(e) { + const phase = PHASE[e.type]; + if (!phase) return null; + const cs = contacts(e); + if (phase === 'down') { + // One touch at a time starts a gesture (a multi-touch press is Steam's). + if (e.type === 'touchstart' && (e.touches?.length ?? 0) > 1) { + if (owner?.[0] === 't' && ![...e.touches].some((t) => `t${t.identifier}` === owner)) owner = null; + return null; + } + const c = cs[0]; + if (!c) return null; + // Busy: ignored, unless the owner is gone (a lone touch, or its own id again). + if (owner && !(e.type === 'touchstart' || c.id === owner)) return null; + owner = null; + return { phase, ...c }; + } + if (!owner) return null; + const c = cs.find((x) => x.id === owner); + if (!c) return null; + if (phase !== 'move') owner = null; + return { phase, ...c }; + }, + claim(c) { owner = c.id; }, // the 'down' contact starts a gesture + release() { owner = null; }, + }; + } + + return { VERSION, create }; +})() diff --git a/modules/vr-keyboard/patch.js b/modules/vr-keyboard/patch.js index 4663385..faa1950 100644 --- a/modules/vr-keyboard/patch.js +++ b/modules/vr-keyboard/patch.js @@ -1,8 +1,9 @@ // Gestures and suggestions for Steam's VR keyboard (vr-keyboard.nix), // injected into Steam's SharedJSContext (8080). The keyboard is a popup of // that context ("SteamVR - Keyboard"); everything works on its document. -// mkPatch convention plus four more arguments: swipe-decoder.js, -// textmodel.js, corrector.js and the dictionary text ("wordzipf*10\n..."). +// mkPatch convention plus five more arguments: swipe-decoder.js, +// textmodel.js, corrector.js, the dictionary text ("wordzipf*10\n...") +// and gesture-input.js. // // - Swipe: the laser arrives as touch events; Steam types the key a touch // started on at release. Once a press leaves its first key we drop that @@ -27,8 +28,8 @@ // missing the keyboard stays stock. Only passive listeners; never blocks // Steam's events. Debugging: __sfuiSwipeLog, __sfuiSwipePaths // (scripts/vr-keyboard-replay.mjs). -((find, sigs, opts, hooks, D, T, C, DICT) => { - const VERSION = 24; +((find, sigs, opts, hooks, D, T, C, DICT, P) => { + const VERSION = 25; const G = window; const O = opts; @@ -376,25 +377,18 @@ // ---- gestures ------------------------------------------------------------------ // down: { kind: 'strip', index } | { kind: 'backspace', ... } | { kind: 'key', ... }. - // Touch is read from touch events (they go on after Chromium's - // pointercancel); a mouse from pointer events, or mouse events if there - // are no pointer events. - let down = null, sawPointer = false; + // input (gesture-input.js): the events of the contact that started it + // (a touch per laser press, they go on after Chromium's pointercancel; a + // mouse from pointer events, or mouse events if there are none). The + // other controller's hover, a second press: ignored (Steam's, as stock). + let down = null; + const input = P.create(); const keyOf = (t) => t?.closest?.('[data-key]'); const stripIndexOf = (t) => { const el = t?.closest?.('#sfui-swipe-strip > div'); return el && strip.contains(el) ? +el.dataset.index : -1; }; const at = (x, y) => doc.elementFromPoint(x, y); - function point(e) { - if (e.type.startsWith('touch')) { - const t = e.type === 'touchend' || e.type === 'touchcancel' ? e.changedTouches[0] : e.touches[0]; - return t && !(e.type === 'touchstart' && e.touches.length > 1) ? [t.clientX, t.clientY, t.target] : null; - } - if (e.type.startsWith('pointer')) { sawPointer = true; if (e.pointerType === 'touch') return null; } - else if (sawPointer) return null; - return [e.clientX, e.clientY, e.target]; - } // Keep Steam from typing the pressed key at release and from its long press. function takeOver(d) { const inst = d.inst; @@ -406,22 +400,24 @@ } catch (e) { log('takeover-error', String(e)); } } function onDown(e) { - const p = point(e); - if (!p) return; - const [x, y, target] = p; + const c = input.read(e); + if (!c) return; + if (down?.active) clearTrail(); // its release was missed + down = start(c); + if (down) input.claim(c); + } + function start({ x, y, target }) { const si = stripIndexOf(target); - if (si !== -1) { down = { kind: 'strip', index: si }; return; } + if (si !== -1) return { kind: 'strip', index: si }; const keyEl = keyOf(target), key = keyEl?.getAttribute('data-key'), inst = keyEl && kbInst(keyEl); - down = null; - if (!inst) return; + if (!inst) return null; hookInst(inst); if (key === 'Backspace' && O.pixelsPerChar > 0) { - down = { kind: 'backspace', keyEl, inst, x0: x, active: false, planned: 0, + return { kind: 'backspace', keyEl, inst, x0: x, active: false, planned: 0, g: model.dragStart({ px: O.pixelsPerChar, detentPx: O.wordDetentPixels }) }; - } else if (O.swipe && isLetter(key)) { - const lay = currentLayout(); - if (lay) down = { kind: 'key', key, keyEl, inst, lay, pts: [[x, y]], active: false }; } + const lay = O.swipe && isLetter(key) && currentLayout(); + return lay ? { kind: 'key', key, keyEl, inst, lay, pts: [[x, y]], active: false } : null; } // Backspace drag: a character per px of leftward travel (the first at px; // releasing before is Steam's tap), a detent of detentPx at a word border; @@ -445,9 +441,9 @@ } } function onMove(e) { - const p = point(e); - if (!p || !down || down.kind === 'strip') return; - const [x, y] = p; + const c = input.read(e); + if (!c || !down || down.kind === 'strip') return; + const { x, y } = c; if (down.kind === 'backspace') { backspaceMove(down, x); return; } const q = down.pts[down.pts.length - 1]; if (Math.hypot(x - q[0], y - q[1]) < 2) return; @@ -461,9 +457,9 @@ drawTrail(down.pts, 'rgba(26, 159, 255, 0.75)'); } function onUp(e) { - const p = point(e); - if (!p) return; - const [x, y] = p, d = down; + const c = input.read(e); + if (!c) return; + const { x, y } = c, d = down; down = null; if (d?.kind === 'strip') { // released anywhere on the strip: the button pressed if (strip.contains(at(x, y))) pick(d.index); @@ -492,7 +488,7 @@ commit(d.inst, results); } function onCancel(e) { - if (e.type === 'pointercancel' && e.pointerType === 'touch') return; // touch events go on + if (!input.read(e)) return; // another contact; Chromium's touch pointercancel if (down?.active) clearTrail(); down = null; } diff --git a/modules/vr-keyboard/tests/gesture-input.test.mjs b/modules/vr-keyboard/tests/gesture-input.test.mjs new file mode 100644 index 0000000..d1365c9 --- /dev/null +++ b/modules/vr-keyboard/tests/gesture-input.test.mjs @@ -0,0 +1,134 @@ +// gesture-input.js: a gesture only gets the events of the contact that +// started it. Like patch.js: a 'down' starts a path (claim), moves extend +// it, the release ends it. Fixture two-controllers.json: a swipe recorded in +// VR (Steam's keyboard page) while the other controller pointed at the +// keyboard; its hover (485,106) once broke into the path ("tust" for "test"). +// usage: node gesture-input.test.mjs +import { readFileSync } from 'node:fs'; +import assert from 'node:assert/strict'; + +const P = (0, eval)(readFileSync(process.argv[2], 'utf8')); +let passed = 0; + +const touch = ([identifier, clientX, clientY]) => ({ identifier, clientX, clientY, target: `@${clientX},${clientY}` }); +const ev = { + touch: (type, touches, changed) => ({ type, touches: touches.map(touch), changedTouches: changed.map(touch) }), + pointer: (type, pointerType, pointerId, clientX, clientY) => ({ type, pointerType, pointerId, clientX, clientY, target: 'p' }), + mouse: (type, clientX, clientY) => ({ type, clientX, clientY, target: 'm' }), +}; +// A recorded event: [type, touches, changed] | [type, pointerType, id, x, y] | [type, x, y]. +const fromRecord = (r) => (r[0].startsWith('touch') ? ev.touch(...r) : r[0].startsWith('pointer') ? ev.pointer(...r) : ev.mouse(...r)); + +// Feeds events like patch.js; returns the finished paths [{ id, pts, ended }]. +function run(events, { startIf = () => true } = {}) { + const input = P.create(), paths = []; + let cur = null; + for (const e of events) { + const c = input.read(e); + if (!c) continue; + if (c.phase === 'down') { + cur = null; + if (startIf(c)) { cur = { id: c.id, pts: [[c.x, c.y]] }; input.claim(c); } + } else if (c.phase === 'move') { + assert.ok(cur, 'a move without a gesture'); + cur.pts.push([c.x, c.y]); + } else { + assert.ok(cur, `${c.phase} without a gesture`); + cur.pts.push([c.x, c.y]); + cur.ended = c.phase; + paths.push(cur); + cur = null; + } + } + if (cur) paths.push(cur); + return paths; +} +function test(name, f) { f(); passed++; console.log(`ok ${name}`); } + +test('recorded swipe with the other controller hovering', () => { + const rec = JSON.parse(readFileSync(process.argv[3], 'utf8')); + assert.ok(rec.some((r) => r[0] === 'pointermove' && r[1] === 'mouse' && r[3] === 485), 'fixture has the stray hover'); + const paths = run(rec.map(fromRecord)); + assert.equal(paths.length, 1); + const [p] = paths; + assert.equal(p.id, 't814'); + assert.equal(p.ended, 'up'); + assert.deepEqual(p.pts[0], [334, 74]); + const touchMoves = rec.filter((r) => r[0] === 'touchmove').map((r) => r[1][0].slice(1)); + assert.deepEqual(p.pts.slice(1, -1), touchMoves, 'the path is exactly its touch moves'); + assert.ok(!p.pts.some(([x, y]) => x === 485 && y === 106), 'no hover of the other controller'); +}); + +test('two touches interleaved: the second is ignored until the first ends', () => { + const T = (type, touches, changed) => ev.touch(type, touches, changed); + const paths = run([ + ev.pointer('pointermove', 'mouse', 1, 600, 150), // B hovers + T('touchstart', [[1, 100, 50]], [[1, 100, 50]]), // A presses + ev.pointer('pointermove', 'mouse', 1, 610, 150), // B's hover meanwhile + T('touchmove', [[1, 120, 55]], [[1, 120, 55]]), + T('touchstart', [[1, 120, 55], [2, 500, 120]], [[2, 500, 120]]), // B presses + T('touchmove', [[1, 120, 55], [2, 520, 120]], [[2, 520, 120]]), // only B moved + T('touchmove', [[1, 140, 60], [2, 520, 120]], [[1, 140, 60]]), + T('touchend', [[1, 140, 60]], [[2, 530, 121]]), // B releases first + T('touchmove', [[1, 160, 70]], [[1, 160, 70]]), + ev.mouse('mousemove', 700, 160), // no pointer events seen yet: still not A's + T('touchend', [], [[1, 170, 72]]), // A releases + T('touchmove', [[3, 300, 90]], [[3, 300, 90]]), // a touch that started elsewhere + T('touchstart', [[4, 200, 80]], [[4, 200, 80]]), // B swipes alone + ev.pointer('pointermove', 'mouse', 1, 50, 50), // A's hover + T('touchmove', [[4, 230, 85]], [[4, 230, 85]]), + T('touchend', [], [[4, 240, 90]]), + ]); + assert.deepEqual(paths, [ + { id: 't1', pts: [[100, 50], [120, 55], [120, 55], [140, 60], [160, 70], [170, 72]], ended: 'up' }, + { id: 't4', pts: [[200, 80], [230, 85], [240, 90]], ended: 'up' }, + ]); +}); + +test("a cancel of another contact doesn't end the gesture", () => { + const paths = run([ + ev.pointer('pointerdown', 'touch', 7, 10, 10), + ev.touch('touchstart', [[9, 10, 10]], [[9, 10, 10]]), + ev.pointer('pointercancel', 'touch', 7, 0, 0), // Chromium's, after a few moves + ev.touch('touchcancel', [[9, 12, 10]], [[8, 0, 0]]), + ev.touch('touchmove', [[9, 30, 12]], [[9, 30, 12]]), + ev.touch('touchcancel', [], [[9, 30, 12]]), + ]); + assert.deepEqual(paths, [{ id: 't9', pts: [[10, 10], [30, 12], [30, 12]], ended: 'cancel' }]); +}); + +test('a missed release: the next lone press takes over', () => { + const paths = run([ + ev.touch('touchstart', [[1, 10, 10]], [[1, 10, 10]]), + ev.touch('touchmove', [[1, 20, 10]], [[1, 20, 10]]), + ev.touch('touchstart', [[2, 50, 50]], [[2, 50, 50]]), // touch 1 is gone + ev.touch('touchend', [], [[1, 25, 10]]), + ev.touch('touchend', [], [[2, 60, 50]]), + ]); + assert.deepEqual(paths.map((p) => [p.id, p.ended]), [['t2', 'up']]); +}); + +test('presses that start no gesture leave the next one free', () => { + const paths = run([ + ev.touch('touchstart', [[1, 10, 10]], [[1, 10, 10]]), // Shift held by one hand + ev.touch('touchmove', [[1, 11, 10]], [[1, 11, 10]]), + ev.touch('touchend', [], [[1, 11, 10]]), + ev.touch('touchstart', [[2, 50, 50]], [[2, 50, 50]]), + ev.touch('touchend', [], [[2, 60, 50]]), + ], { startIf: (c) => c.x > 30 }); + assert.deepEqual(paths, [{ id: 't2', pts: [[50, 50], [60, 50]], ended: 'up' }]); +}); + +test('mouse: pointer events by pointerId, mouse events only without them', () => { + assert.deepEqual(run([ + ev.mouse('mousedown', 1, 1), ev.mouse('mousemove', 2, 1), ev.mouse('mouseup', 3, 1), + ]), [{ id: 'm', pts: [[1, 1], [2, 1], [3, 1]], ended: 'up' }]); + assert.deepEqual(run([ + ev.pointer('pointerdown', 'mouse', 1, 1, 1), ev.mouse('mousedown', 1, 1), + ev.pointer('pointermove', 'pen', 2, 9, 9), ev.mouse('mousemove', 9, 9), + ev.pointer('pointermove', 'mouse', 1, 2, 1), + ev.pointer('pointerup', 'mouse', 1, 3, 1), ev.mouse('mouseup', 3, 1), + ]), [{ id: 'p1', pts: [[1, 1], [2, 1], [3, 1]], ended: 'up' }]); +}); + +console.log(`gesture-input: ${passed} tests passed`); diff --git a/modules/vr-keyboard/tests/two-controllers.json b/modules/vr-keyboard/tests/two-controllers.json new file mode 100644 index 0000000..c361ff1 --- /dev/null +++ b/modules/vr-keyboard/tests/two-controllers.json @@ -0,0 +1,120 @@ +[ + ["pointerover","mouse",1,403,122], + ["mouseover",403,122], + ["pointermove","mouse",1,403,122], + ["mousemove",403,122], + ["pointerover","touch",812,334,74], + ["pointerdown","touch",812,334,74], + ["touchstart",[[814,334,74]],[[814,334,74]]], + ["pointerout","mouse",1,0,0], + ["pointermove","touch",812,335,74], + ["pointermove","touch",812,335,75], + ["pointermove","touch",812,336,75], + ["pointerover","mouse",1,485,106], + ["mouseover",485,106], + ["pointermove","mouse",1,485,106], + ["mousemove",485,106], + ["pointermove","touch",812,338,79], + ["pointermove","touch",812,339,80], + ["pointermove","touch",812,341,83], + ["pointermove","touch",812,342,84], + ["pointermove","touch",812,341,84], + ["pointermove","touch",812,341,83], + ["pointermove","touch",812,338,81], + ["pointermove","touch",812,334,80], + ["pointermove","touch",812,322,77], + ["pointermove","touch",812,317,77], + ["touchmove",[[814,317,77]],[[814,317,77]]], + ["pointercancel","touch",812,0,0], + ["pointerout","touch",812,0,0], + ["touchmove",[[814,304,76]],[[814,304,76]]], + ["touchmove",[[814,296,76]],[[814,296,76]]], + ["touchmove",[[814,288,76]],[[814,288,76]]], + ["touchmove",[[814,276,77]],[[814,276,77]]], + ["touchmove",[[814,272,79]],[[814,272,79]]], + ["touchmove",[[814,268,81]],[[814,268,81]]], + ["touchmove",[[814,265,83]],[[814,265,83]]], + ["touchmove",[[814,261,86]],[[814,261,86]]], + ["touchmove",[[814,255,91]],[[814,255,91]]], + ["touchmove",[[814,253,93]],[[814,253,93]]], + ["touchmove",[[814,249,96]],[[814,249,96]]], + ["touchmove",[[814,247,98]],[[814,247,98]]], + ["touchmove",[[814,241,101]],[[814,241,101]]], + ["touchmove",[[814,238,102]],[[814,238,102]]], + ["touchmove",[[814,236,103]],[[814,236,103]]], + ["touchmove",[[814,233,104]],[[814,233,104]]], + ["touchmove",[[814,232,106]],[[814,232,106]]], + ["touchmove",[[814,230,108]],[[814,230,108]]], + ["touchmove",[[814,229,111]],[[814,229,111]]], + ["touchmove",[[814,228,113]],[[814,228,113]]], + ["touchmove",[[814,226,115]],[[814,226,115]]], + ["touchmove",[[814,225,116]],[[814,225,116]]], + ["touchmove",[[814,223,117]],[[814,223,117]]], + ["touchmove",[[814,221,118]],[[814,221,118]]], + ["touchmove",[[814,218,118]],[[814,218,118]]], + ["touchmove",[[814,216,119]],[[814,216,119]]], + ["touchmove",[[814,213,119]],[[814,213,119]]], + ["touchmove",[[814,210,120]],[[814,210,120]]], + ["touchmove",[[814,208,121]],[[814,208,121]]], + ["touchmove",[[814,207,123]],[[814,207,123]]], + ["touchmove",[[814,205,124]],[[814,205,124]]], + ["touchmove",[[814,204,126]],[[814,204,126]]], + ["touchmove",[[814,202,128]],[[814,202,128]]], + ["touchmove",[[814,200,129]],[[814,200,129]]], + ["touchmove",[[814,199,129]],[[814,199,129]]], + ["touchmove",[[814,198,129]],[[814,198,129]]], + ["touchmove",[[814,197,130]],[[814,197,130]]], + ["touchmove",[[814,197,131]],[[814,197,131]]], + ["touchmove",[[814,197,132]],[[814,197,132]]], + ["touchmove",[[814,197,133]],[[814,197,133]]], + ["touchmove",[[814,197,134]],[[814,197,134]]], + ["touchmove",[[814,197,135]],[[814,197,135]]], + ["touchmove",[[814,197,134]],[[814,197,134]]], + ["touchmove",[[814,198,134]],[[814,198,134]]], + ["touchmove",[[814,199,134]],[[814,199,134]]], + ["touchmove",[[814,200,134]],[[814,200,134]]], + ["touchmove",[[814,201,134]],[[814,201,134]]], + ["touchmove",[[814,202,134]],[[814,202,134]]], + ["touchmove",[[814,204,133]],[[814,204,133]]], + ["touchmove",[[814,206,133]],[[814,206,133]]], + ["touchmove",[[814,208,133]],[[814,208,133]]], + ["touchmove",[[814,210,132]],[[814,210,132]]], + ["touchmove",[[814,213,132]],[[814,213,132]]], + ["touchmove",[[814,215,132]],[[814,215,132]]], + ["touchmove",[[814,218,132]],[[814,218,132]]], + ["touchmove",[[814,221,131]],[[814,221,131]]], + ["touchmove",[[814,224,130]],[[814,224,130]]], + ["touchmove",[[814,229,129]],[[814,229,129]]], + ["touchmove",[[814,234,128]],[[814,234,128]]], + ["touchmove",[[814,239,125]],[[814,239,125]]], + ["touchmove",[[814,247,120]],[[814,247,120]]], + ["touchmove",[[814,257,111]],[[814,257,111]]], + ["touchmove",[[814,268,102]],[[814,268,102]]], + ["touchmove",[[814,280,93]],[[814,280,93]]], + ["touchmove",[[814,289,86]],[[814,289,86]]], + ["touchmove",[[814,297,81]],[[814,297,81]]], + ["touchmove",[[814,304,77]],[[814,304,77]]], + ["touchmove",[[814,311,74]],[[814,311,74]]], + ["touchmove",[[814,315,73]],[[814,315,73]]], + ["touchmove",[[814,320,72]],[[814,320,72]]], + ["touchmove",[[814,322,72]],[[814,322,72]]], + ["touchmove",[[814,323,71]],[[814,323,71]]], + ["touchmove",[[814,324,70]],[[814,324,70]]], + ["touchmove",[[814,326,68]],[[814,326,68]]], + ["touchmove",[[814,328,67]],[[814,328,67]]], + ["touchmove",[[814,330,65]],[[814,330,65]]], + ["touchmove",[[814,333,64]],[[814,333,64]]], + ["touchmove",[[814,336,64]],[[814,336,64]]], + ["touchmove",[[814,339,65]],[[814,339,65]]], + ["touchmove",[[814,341,65]],[[814,341,65]]], + ["touchmove",[[814,342,68]],[[814,342,68]]], + ["touchmove",[[814,343,71]],[[814,343,71]]], + ["touchmove",[[814,342,76]],[[814,342,76]]], + ["touchmove",[[814,342,77]],[[814,342,77]]], + ["touchmove",[[814,342,78]],[[814,342,78]]], + ["touchmove",[[814,341,78]],[[814,341,78]]], + ["touchmove",[[814,341,77]],[[814,341,77]]], + ["touchmove",[[814,341,76]],[[814,341,76]]], + ["touchmove",[[814,340,76]],[[814,340,76]]], + ["touchend",[],[[814,340,76]]] +]