Created
April 2, 2026 17:02
-
-
Save thomaswilburn/11f5d9196e37dee8baf5600a2c4733b8 to your computer and use it in GitHub Desktop.
Graffiti-style unistroke recognizer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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