VR keyboard swipe: only the pressing controller's laser builds the path

With both controllers on the keyboard, the idle one's hover (mouse events,
pointerId 1) arrived during the other's press and went into the swipe path
("tust" for "test"). A gesture now belongs to the contact that started it
(touch identifier, pointerId or mouse; gesture-input.js): other contacts'
moves, releases and cancels are ignored, a second press meanwhile stays
Steam's tap. Test with a recorded two-controller swipe.
This commit is contained in:
Pierre Kisters committed 2026-10-01 03:12:36 +02:00
1 parent 26641d76a0
commit 7d050b8c44
8 files changed
+376 -40

No files matched your search

+2 -1
View File
@@ -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
+8 -2
View File
@@ -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
+3 -2
View File
@@ -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
'';
+3 -1
View File
@@ -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 '
+76
View File
@@ -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 };
})()
+30 -34
View File
@@ -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 ("word<TAB>zipf*10\n...").
// mkPatch convention plus five more arguments: swipe-decoder.js,
// textmodel.js, corrector.js, the dictionary text ("word<TAB>zipf*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;
}
@@ -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 <gesture-input.js> <two-controllers.json>
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`);
@@ -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]]]
]