Skip to content

Instantly share code, notes, and snippets.

@thomaswilburn
Created April 2, 2026 17:02
Show Gist options
  • Select an option

  • Save thomaswilburn/11f5d9196e37dee8baf5600a2c4733b8 to your computer and use it in GitHub Desktop.

Select an option

Save thomaswilburn/11f5d9196e37dee8baf5600a2c4733b8 to your computer and use it in GitHub Desktop.
Graffiti-style unistroke recognizer
const LETTERS = [
["a", "URD"],
["b", "RDLRDL"],
["c", "LDR"],
["d", "DURDL"],
["e", "LDRLDR"],
["f", "LD"],
["g", "LDRULR"],
["h", "DURD"],
["i", "D"],
["j", "DL"],
["k", "DLURDR"],
["l", "DR"],
["m", "URDRD"],
["n", "URDRU"],
["o", "LDRUL"],
["p", "DURDL"],
["q", "LDRULR"],
["r", "DURDLDR"],
["s", "LDRDL"],
["t", "RD"],
["u", "DRU"],
["v", "DLU"],
["w", "DRUDRU"],
["x", "DRUL"],
["y", "DRUDLUR"],
["z", "RDLDR"]
].sort((a, b) => b[1].length - a[1].length);
const NUMBERS = [
];
const PUNCTUATION = [
];
class Recognizer extends EventTarget {
constructor() {
super();
}
decimate(points) {
let thinned = this.thin(points);
let smoothed = this.smooth(thinned);
return smoothed;
}
recognize(points, candidates) {
let decimated = this.decimate(points);
let directions = this.findDirections(decimated);
console.log(directions);
for (let [character, ...shapes] of candidates) {
// step through each shape
for (let shape of shapes) {
// for each shape, see whether we can encounter all its directions in order
let i = 0;
let seeking = shape[i];
for (let direction of directions) {
if (direction == seeking) {
seeking = shape[++i];
}
if (i >= shape.length) {
// we've found all the required directions, return our candidate
return character;
}
}
}
}
}
smooth(points) {
let last = points[0];
let smoothed = [last];
for (let i = 1; i < points.length; i++) {
let current = points[i];
let midpoint = {
x: (last.x + current.x) / 2,
y: (last.y + current.y) / 2
};
smoothed.push(midpoint);
last = midpoint;
}
smoothed.push(points.at(-1));
return smoothed;
}
thin(points) {
let last = points[0];
let thinned = [last];
let threshold = 2;
for (let i = 1; i < points.length; i++) {
let point = points[i];
if (Math.abs(last.x - point.x) < threshold) continue;
if (Math.abs(last.y - point.y) < threshold) continue;
thinned.push(point);
last = point;
}
thinned.push(points.at(-1));
return thinned;
}
findDirections(points) {
let from = points[0];
let directions = [];
let lastDirection = null;
for (let i = 1; i < points.length; i++) {
let point = points[i];
let dx = point.x - from.x;
let dy = point.y - from.y;
let d = null;
if (Math.abs(dx) > Math.abs(dy)) {
d = dx < 0 ? "L" : "R";
} else {
d = dy < 0 ? "U" : "D";
}
from = point;
if (d == lastDirection) continue;
directions.push(d);
lastDirection = d;
}
return directions;
}
}
export class RecognizerElement extends HTMLElement {
points = null;
constructor() {
super();
this.recognizer = new Recognizer();
this.canvas = document.createElement("canvas");
this.canvas.style = "background: #EEE";
this.context = this.canvas.getContext("2d");
this.attachShadow({ mode: "open" });
this.shadowRoot.append(this.canvas);
this.canvas.addEventListener("mousedown", this);
this.canvas.addEventListener("mouseup", this);
this.canvas.addEventListener("mousemove", this);
}
handleEvent(e) {
switch(e.type) {
case "mousedown":
this.points = [];
this.points.push({ x: e.offsetX, y: e.offsetY });
break;
case "mouseup":
let points = this.points;
this.points = null;
this.canvas.width = this.canvas.width;
this.context.beginPath();
this.context.moveTo(points[0].x, points[0].y);
for (let point of points) {
this.context.lineTo(point.x, point.y);
}
this.context.strokeStyle = "#888";
this.context.stroke();
let decimated = this.recognizer.decimate(points);
this.context.beginPath();
this.context.moveTo(decimated[0].x, decimated[0].y);
for (let point of decimated) {
this.context.lineTo(point.x, point.y);
}
this.context.strokeStyle = "black";
this.context.stroke();
let gesture = this.recognizer.recognize(points, LETTERS);
console.log(gesture);
case "mousemove":
if (this.points) {
this.points.push({ x: e.offsetX, y: e.offsetY });
}
break;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment