canvas-website/src/gestures.ts

322 lines
9.8 KiB
TypeScript

/** Modified $1 for TS & tldraw */
/**
* The $1 Unistroke Recognizer (JavaScript version)
*
* Jacob O. Wobbrock, Ph.D.
* The Information School
* University of Washington
* Seattle, WA 98195-2840
* wobbrock@uw.edu
*
* Andrew D. Wilson, Ph.D.
* Microsoft Research
* One Microsoft Way
* Redmond, WA 98052
* awilson@microsoft.com
*
* Yang Li, Ph.D.
* Department of Computer Science and Engineering
* University of Washington
* Seattle, WA 98195-2840
* yangli@cs.washington.edu
*
* The academic publication for the $1 recognizer, and what should be
* used to cite it, is:
*
* Wobbrock, J.O., Wilson, A.D. and Li, Y. (2007). Gestures without
* libraries, toolkits or training: A $1 recognizer for user interface
* prototypes. Proceedings of the ACM Symposium on User Interface
* Software and Technology (UIST '07). Newport, Rhode Island (October
* 7-10, 2007). New York: ACM Press, pp. 159-168.
* https://dl.acm.org/citation.cfm?id=1294238
*
* The Protractor enhancement was separately published by Yang Li and programmed
* here by Jacob O. Wobbrock:
*
* Li, Y. (2010). Protractor: A fast and accurate gesture
* recognizer. Proceedings of the ACM Conference on Human
* Factors in Computing Systems (CHI '10). Atlanta, Georgia
* (April 10-15, 2010). New York: ACM Press, pp. 2169-2172.
* https://dl.acm.org/citation.cfm?id=1753654
*
* This software is distributed under the "New BSD License" agreement:
*
* Copyright (C) 2007-2012, Jacob O. Wobbrock, Andrew D. Wilson and Yang Li.
* All rights reserved. Last updated July 14, 2018.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* * Neither the names of the University of Washington nor Microsoft,
* nor the names of its contributors may be used to endorse or promote
* products derived from this software without specific prior written
* permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
* IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
* THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL Jacob O. Wobbrock OR Andrew D. Wilson
* OR Yang Li BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
* OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
**/
import { Editor, TLDrawShape, VecLike, BoxModel } from "tldraw"
const NUM_POINTS = 64
const SQUARE_SIZE = 250.0
const ORIGIN = { x: 0, y: 0 }
interface Result {
name: string
score: number
time: number
onComplete?: (editor: Editor, gesture?: TLDrawShape) => void
}
export interface Gesture {
name: string
points: VecLike[]
onComplete?: (editor: Editor, gesture?: TLDrawShape) => void
}
class Unistroke {
name: string
points: VecLike[]
vector: number[]
private _originalPoints: VecLike[]
onComplete?: (editor: Editor, gesture?: TLDrawShape) => void
constructor(
name: string,
points: VecLike[],
onComplete?: (editor: Editor, gesture?: TLDrawShape) => void,
) {
this.name = name
this.onComplete = onComplete
this._originalPoints = points
this.points = Resample(points, NUM_POINTS)
const radians = IndicativeAngle(this.points)
this.points = RotateBy(this.points, -radians)
this.points = ScaleTo(this.points, SQUARE_SIZE)
this.points = TranslateTo(this.points, ORIGIN)
this.vector = Vectorize(this.points) // for Protractor
}
originalPoints(): VecLike[] {
return this._originalPoints
}
}
export class DollarRecognizer {
unistrokes: Unistroke[] = []
constructor(gestures: Gesture[]) {
for (const gesture of gestures) {
this.unistrokes.push(
new Unistroke(gesture.name, gesture.points, gesture.onComplete),
)
}
}
/**
* Recognize a gesture
* @param points The points of the gesture
* @returns The result
*/
recognize(points: VecLike[]): Result {
const t0 = Date.now()
const candidate = new Unistroke("", points)
let u = -1
let b = +Infinity
for (
let i = 0;
i < this.unistrokes.length;
i++ // for each unistroke template
) {
const d = OptimalCosineDistance(
this.unistrokes[i].vector,
candidate.vector,
) // Protractor
if (d < b) {
b = d // best (least) distance
u = i // unistroke index
}
}
const t1 = Date.now()
return u === -1
? { name: "No match.", score: 0.0, time: t1 - t0 }
: {
name: this.unistrokes[u].name,
score: 1.0 - b,
time: t1 - t0,
onComplete: this.unistrokes[u].onComplete,
}
}
/**
* Add a gesture to the recognizer
* @param name The name of the gesture
* @param points The points of the gesture
* @returns The number of gestures
*/
addGesture(name: string, points: VecLike[]): number {
this.unistrokes[this.unistrokes.length] = new Unistroke(name, points) // append new unistroke
let num = 0
for (let i = 0; i < this.unistrokes.length; i++) {
if (this.unistrokes[i].name === name) num++
}
return num
}
/**
* Remove a gesture from the recognizer
* @param name The name of the gesture
* @returns The number of gestures after removal
*/
removeGesture(name: string): number {
this.unistrokes = this.unistrokes.filter((gesture) => gesture.name !== name)
return this.unistrokes.length
}
}
//
// Private helper functions from here on down
//
function Resample(points: VecLike[], n: number): VecLike[] {
const I = PathLength(points) / (n - 1) // interval length
let D = 0.0
const newpoints = new Array(points[0])
for (let i = 1; i < points.length; i++) {
const d = Distance(points[i - 1], points[i])
if (D + d >= I) {
const qx =
points[i - 1].x + ((I - D) / d) * (points[i].x - points[i - 1].x)
const qy =
points[i - 1].y + ((I - D) / d) * (points[i].y - points[i - 1].y)
const q = { x: qx, y: qy }
newpoints[newpoints.length] = q // append new point 'q'
points.splice(i, 0, q) // insert 'q' at position i in points s.t. 'q' will be the next i
D = 0.0
} else D += d
}
if (newpoints.length === n - 1)
// somtimes we fall a rounding-error short of adding the last point, so add it if so
newpoints[newpoints.length] = {
x: points[points.length - 1].x,
y: points[points.length - 1].y,
}
return newpoints
}
function IndicativeAngle(points: VecLike[]): number {
const c = Centroid(points)
return Math.atan2(c.y - points[0].y, c.x - points[0].x)
}
function RotateBy(points: VecLike[], radians: number): VecLike[] {
// rotates points around centroid
const c = Centroid(points)
const cos = Math.cos(radians)
const sin = Math.sin(radians)
const newpoints = new Array()
for (let i = 0; i < points.length; i++) {
const qx = (points[i].x - c.x) * cos - (points[i].y - c.y) * sin + c.x
const qy = (points[i].x - c.x) * sin + (points[i].y - c.y) * cos + c.y
newpoints[newpoints.length] = { x: qx, y: qy }
}
return newpoints
}
function ScaleTo(points: VecLike[], size: number): VecLike[] {
// non-uniform scale; assumes 2D gestures (i.e., no lines)
const B = BoundingBox(points)
const newpoints = new Array()
for (let i = 0; i < points.length; i++) {
const qx = points[i].x * (size / B.w)
const qy = points[i].y * (size / B.h)
newpoints[newpoints.length] = { x: qx, y: qy }
}
return newpoints
}
function TranslateTo(points: VecLike[], pt: VecLike): VecLike[] {
// translates points' centroid
const c = Centroid(points)
const newpoints = new Array()
for (let i = 0; i < points.length; i++) {
const qx = points[i].x + pt.x - c.x
const qy = points[i].y + pt.y - c.y
newpoints[newpoints.length] = { x: qx, y: qy }
}
return newpoints
}
function Vectorize(points: VecLike[]): number[] {
let sum = 0.0
const vector = new Array()
for (let i = 0; i < points.length; i++) {
vector[vector.length] = points[i].x
vector[vector.length] = points[i].y
sum += points[i].x * points[i].x + points[i].y * points[i].y
}
const magnitude = Math.sqrt(sum)
for (let i = 0; i < vector.length; i++) vector[i] /= magnitude
return vector
}
function OptimalCosineDistance(v1: number[], v2: number[]): number {
let a = 0.0
let b = 0.0
for (let i = 0; i < v1.length; i += 2) {
a += v1[i] * v2[i] + v1[i + 1] * v2[i + 1]
b += v1[i] * v2[i + 1] - v1[i + 1] * v2[i]
}
const angle = Math.atan(b / a)
return Math.acos(a * Math.cos(angle) + b * Math.sin(angle))
}
function Centroid(points: VecLike[]): VecLike {
let x = 0.0
let y = 0.0
for (let i = 0; i < points.length; i++) {
x += points[i].x
y += points[i].y
}
x /= points.length
y /= points.length
return { x: x, y: y }
}
function BoundingBox(points: VecLike[]): BoxModel {
let minX = +Infinity
let maxX = -Infinity
let minY = +Infinity
let maxY = -Infinity
for (let i = 0; i < points.length; i++) {
minX = Math.min(minX, points[i].x)
minY = Math.min(minY, points[i].y)
maxX = Math.max(maxX, points[i].x)
maxY = Math.max(maxY, points[i].y)
}
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }
}
function PathLength(points: VecLike[]): number {
let d = 0.0
for (let i = 1; i < points.length; i++)
d += Distance(points[i - 1], points[i])
return d
}
function Distance(p1: VecLike, p2: VecLike): number {
const dx = p2.x - p1.x
const dy = p2.y - p1.y
return Math.sqrt(dx * dx + dy * dy)
}