chore(web): Merge branch 'feature-gestures' into change/web/floating-tablet-preview

This commit is contained in:
Joshua A. Horton 2023-11-30 13:45:20 +07:00
commit aa72c8d712
10 changed files with 30 additions and 3004 deletions

View file

@ -20,168 +20,19 @@ export type PathCoordAxisPair = 'tx' | 'ty' | 'xy';
*/
type StatAxis = PathCoordAxis | 'v';
/**
* Acts as a subtraction operation with a built-in, adaptive "significance" check.
* If the result * 2^30 (~ * 10^9) is still smaller in magnitude than an operand, we
* assume it to be a floating-point error that should have been 0 and act accordingly,
* returning 0.
*
* For reference, (32-bit) floats have 23 bits of significand precision, while (64-bit)
* doubles have 52. Therefore, we'll still be more precise than baseline floats.
*/
export function sigMinus(operand1: number, operand2: number) {
const diff = operand1 - operand2;
const magnitude = Math.max(Math.abs(operand1), Math.abs(operand2));
const logDiff = Math.log2(magnitude) - Math.log2(Math.abs(diff));
// If an operand is 2^30 (or ~10^9) larger than the result of the difference, it's
// nigh-certainly a floating-point error at play.
return logDiff < 30 ? diff : 0;
}
/**
* As the name suggests, this class facilitates tracking of cumulative mathematical values, etc
* necessary to perform the statistical operations necessary for path segmentation.
* useful for interpretation of a contact point's path as it relates to gestures.
*
* Instances of this class are immutable.
* Instances of this class may be considered immutable externally.
*
* A subclass with properties useful for path segmentation: `RegressiblePathStats`.
*/
export class CumulativePathStats<Type = any> {
/**
* Provides linear-regression statistics & fitting values based on the underlying `CumulativePathStats`
* object used to generate it. All operations are O(1).
*/
static readonly regression = class RegressionFromSums {
readonly independent: PathCoordAxis;
readonly dependent: PathCoordAxis;
readonly paired: PathCoordAxisPair;
readonly accumulator: CumulativePathStats;
/**
*
* @param mainStats The `CumulativePathStats` instance to base all regression data on.
* @param dependentAxis The 'output' axis / dimension; the axis whose behavior should be predicted based on
* existing data of its relationship with the independent axis.
* @param independentAxis The 'input' axis/dimension.
*/
constructor(mainStats: CumulativePathStats, dependentAxis: PathCoordAxis, independentAxis: PathCoordAxis) {
/* c8 ignore next 3 */
if(dependentAxis == independentAxis) {
throw new Error("Two different axes must be specified for the regression object.");
}
this.accumulator = mainStats;
this.dependent = dependentAxis;
this.independent = independentAxis;
if(dependentAxis < independentAxis) {
this.paired = dependentAxis.concat(independentAxis) as PathCoordAxisPair;
} else {
this.paired = independentAxis.concat(dependentAxis) as PathCoordAxisPair;
}
}
/**
* The 'slope' of the 'slope-intercept' form of the line that best fits the relationship between
* this regression's selected axes.
*/
get slope(): number {
// The technical definition is the commented-out line, but the denominator component of both
// cancels out - it's 'more efficient' to use the following line as a result.
// this.accumulator.covariance(this.paired) / this.accumulator.variance(this.independent);
const val = this.accumulator.crossSum(this.paired) / this.accumulator.squaredSum(this.independent);
return val;
}
/**
* The 'intercept' of the 'slope-intercept' form of the line that best fits the relationship between
* this regression's selected axes.
*/
get intercept(): number {
// Performing a regression based on these pre-summed values means that our obtained intercept is in
// the mapped coordinate system.
const mappedIntercept = this.accumulator.mappedMean(this.dependent) - this.slope * this.accumulator.mappedMean(this.independent);
const val = mappedIntercept + this.accumulator.mappingConstant(this.dependent) -
this.slope * this.accumulator.mappingConstant(this.independent);
return val;
}
/**
* The total summed squared-distances of the best fitting line from actually-observed values;
* in other words, the "sum of the squared errors".
*
* Statistically, this is the portion of the dependent variable's variance (un-normalized)
* that is unexplained by this regression.
*/
get sumOfSquaredError(): number {
return sigMinus(this.accumulator.squaredSum(this.dependent), this.sumOfSquaredModeled);
}
/**
* The portion of the dependent variable's variance that is successfully explained by this regression.
*/
get sumOfSquaredModeled(): number {
// If we have a perfectly straight vertical line, from the perspective of our independent axis,
// we get infinite slope. That's... not great for the math.
//
// Fortunately, it ALSO means that we can perfectly model the segment.
if(this.accumulator.squaredSum(this.independent) == 0) {
return this.accumulator.squaredSum(this.dependent);
} else {
return this.slope * this.accumulator.crossSum(this.paired);
}
}
/**
* A statistical term that signals how successful the regression is. Always has values on
* the interval [0, 1], with 1 being a perfect fit.
*/
get coefficientOfDetermination(): number {
if(this.accumulator.squaredSum(this.dependent) == 0 || this.accumulator.squaredSum(this.independent) == 0) {
return 1;
}
const acc = this.accumulator;
const num = acc.crossSum(this.paired) * acc.crossSum(this.paired);
const denom = acc.squaredSum(this.dependent) * acc.squaredSum(this.independent);
return num / denom;
}
/**
* Gets the value of the dependent axis that lies on the regression's fitted line
* for a specified independent axis value.
*
* @param value The input value to use for the independent axis's variable.
* @returns The predicted dependent axis value.
*/
predictFromValue(value: number) {
return this.slope * value + this.intercept;
}
}
private rawLinearSums = {'x': 0, 'y': 0, 't': 0, 'v': 0};
private rawSquaredSums = {'x': 0, 'y': 0, 't': 0, 'v': 0};
// Would 'tv' (time vs velocity) be worth it to track? And possibly even do a regression for?
// If so, maybe throw that in.
private rawCrossSums = {'tx': 0, 'ty': 0, 'xy': 0};
protected rawLinearSums = {'x': 0, 'y': 0, 't': 0, 'v': 0};
// Handles raw-distance stuff.
private coordArcSum: number = 0;
private arcSampleCount: number = 0;
// These two are kept separate because of their extreme interconnectedness - after all,
// they actually represent a SINGLE (polar) axis - the angle.
//
// Sadly, there's no straightforward, well-founded way to use these to give a proper
// statistical sense of 'fit' or 'regression' here, _especially_ in regard to segmentation.
// Proper angle-[other] cross-sums are pretty much impossible, at least as efficiently
// as the others are handled [O(1)].
private cosLinearSum: number = 0;
private sinLinearSum: number = 0;
/**
* The base sample used to transpose all other received samples. Use of this helps
@ -190,7 +41,7 @@ export class CumulativePathStats<Type = any> {
*
* Refer to https://en.wikipedia.org/wiki/Catastrophic_cancellation.
*/
private baseSample?: InputSample<Type>;
protected baseSample?: InputSample<Type>;
/**
* The initial sample included by this instance's computed stats. Needed for
@ -199,12 +50,13 @@ export class CumulativePathStats<Type = any> {
private _initialSample?: InputSample<Type>;
private _lastSample?: InputSample<Type>;
private followingSample?: InputSample<Type>;
protected followingSample?: InputSample<Type>;
private _sampleCount = 0;
constructor();
constructor(sample: InputSample<Type>);
constructor(instance: CumulativePathStats<Type>);
constructor(obj?: InputSample<Type> | CumulativePathStats<Type>)
constructor(obj?: InputSample<Type> | CumulativePathStats<Type>) {
if(!obj) {
return;
@ -215,8 +67,6 @@ export class CumulativePathStats<Type = any> {
Object.assign(this, obj);
this.rawLinearSums = {...obj.rawLinearSums};
this.rawCrossSums = {...obj.rawCrossSums};
this.rawSquaredSums = {...obj.rawSquaredSums};
} else if(isAnInputSample(obj)) {
Object.assign(this, this.extend(obj));
/* c8 ignore next 3 */
@ -233,33 +83,30 @@ export class CumulativePathStats<Type = any> {
* newly-sampled point.
*/
public extend(sample: InputSample<any>): CumulativePathStats<Type> {
if(!this._initialSample) {
this._initialSample = sample;
this.baseSample = sample;
return this._extend(new CumulativePathStats(this), sample);
}
protected _extend(result: CumulativePathStats<Type>, sample: InputSample<any>) {
if(!result._initialSample) {
result._initialSample = sample;
result.baseSample = sample;
}
const result = new CumulativePathStats(this);
const baseSample = result.baseSample;
// Set _after_ deep-copying this for the result.
this.followingSample = sample;
// Helps prevent "catastrophic cancellation" issues from floating-point computation
// for these statistical properties and properties based upon them.
const x = sample.targetX - this.baseSample.targetX;
const y = sample.targetY - this.baseSample.targetY;
const t = sample.t - this.baseSample.t;
const x = sample.targetX - baseSample.targetX;
const y = sample.targetY - baseSample.targetY;
const t = sample.t - baseSample.t;
result.rawLinearSums.x += x;
result.rawLinearSums.y += y;
result.rawLinearSums.t += t;
result.rawCrossSums.tx += t * x;
result.rawCrossSums.ty += t * y;
result.rawCrossSums.xy += x * y;
result.rawSquaredSums.x += x * x;
result.rawSquaredSums.y += y * y;
result.rawSquaredSums.t += t * t;
if(this.lastSample) {
// arc length stuff!
const xDelta = sample.targetX - this.lastSample.targetX;
@ -271,20 +118,8 @@ export class CumulativePathStats<Type = any> {
result.coordArcSum += coordArcDelta;
if(xDelta || yDelta) {
// We wish to measure angle clockwise from <0, -1> in the DOM. So, cos values should
// align with that axis, while sin values should align with the positive x-axis.
//
// This provides a mathematical 'transformation' to the axes used by `atan2` in the
// `angleMean` property.
result.cosLinearSum += -yDelta / coordArcDelta;
result.sinLinearSum += xDelta / coordArcDelta;
result.arcSampleCount += 1;
}
if(tDelta) {
result.rawLinearSums.v += coordArcDelta / tDelta;
result.rawSquaredSums.v += coordArcDeltaSq / (tDelta * tDelta);
}
}
@ -302,6 +137,12 @@ export class CumulativePathStats<Type = any> {
* @returns
*/
public deaccumulate(subsetStats?: CumulativePathStats<Type>): CumulativePathStats<Type> {
const result = new CumulativePathStats(this);
return this._deaccumulate(result, subsetStats);
}
public _deaccumulate(result: CumulativePathStats<Type>, subsetStats?: CumulativePathStats<Type>): CumulativePathStats<Type> {
// Possible addition: use `this.buildRenormalized` on the returned version
// if catastrophic cancellation effects (random, small floating point errors)
// are not sufficiently mitigated & handled by the measures currently in place.
@ -316,8 +157,6 @@ export class CumulativePathStats<Type = any> {
// the cumulative stats that follow. If any are still active, we can't
// recentralize.
const result = new CumulativePathStats(this);
// We actually WILL accept a `null` argument; makes some of the segmentation
// logic simpler.
if(!subsetStats) {
@ -336,16 +175,6 @@ export class CumulativePathStats<Type = any> {
result.rawLinearSums[d] -= subsetStats.rawLinearSums[d];
}
for(let dimPair in result.rawCrossSums) {
const d = dimPair as PathCoordAxisPair;
result.rawCrossSums[d] -= subsetStats.rawCrossSums[d];
}
for(let dim in result.rawSquaredSums) {
const d = dim as PathCoordAxis;
result.rawSquaredSums[d] -= subsetStats.rawSquaredSums[d];
}
// arc length stuff!
if(subsetStats.followingSample && subsetStats.lastSample) {
const xDelta = subsetStats.followingSample.targetX - subsetStats.lastSample.targetX;
@ -361,13 +190,8 @@ export class CumulativePathStats<Type = any> {
result.coordArcSum -= coordArcDelta;
result.coordArcSum -= subsetStats.coordArcSum;
result.cosLinearSum -= subsetStats.cosLinearSum;
result.sinLinearSum -= subsetStats.sinLinearSum;
result.arcSampleCount -= (subsetStats.arcSampleCount + 1);
if(tDelta) {
result.rawLinearSums.v -= coordArcDelta / tDelta;
result.rawSquaredSums.v -= coordArcDeltaSq / (tDelta * tDelta);
}
}
@ -412,7 +236,7 @@ export class CumulativePathStats<Type = any> {
* @param dim
* @returns
*/
private mappingConstant(dim: StatAxis) {
protected mappingConstant(dim: StatAxis) {
if(!this.baseSample) {
return undefined;
}
@ -428,16 +252,6 @@ export class CumulativePathStats<Type = any> {
}
}
/**
* Gets the statistical mean, utilizing the internal 'mapped' coordinate space.
* This is the version compatible with cross-sums and squared-sums.
* @param dim
* @returns
*/
private mappedMean(dim: StatAxis) {
return this.rawLinearSums[dim] / this.sampleCount;
}
/**
* Gets the statistical mean value of the samples observed during the represented
* interval on the specified axis.
@ -447,112 +261,7 @@ export class CumulativePathStats<Type = any> {
public mean(dim: StatAxis) {
// This external-facing version needs to provide values in 'external'-friendly
// coordinate space.
return this.mappedMean(dim) + this.mappingConstant(dim);
}
/**
* Gets the sum of the squared distance from the mean seen in samples observed
* during the represented interval on the specified axis.
* @param dim
* @returns
*/
public squaredSum(dim: StatAxis) {
const x2 = this.rawSquaredSums[dim];
const x1 = this.rawLinearSums[dim];
return sigMinus(x2, x1 * x1 / this.sampleCount);
}
/**
* Gets the sum of the statistical 'cross' term "distance" away from the mean
* observed during the represented interval on the specified axis.
* @param dimPair
* @returns
*/
public crossSum(dimPair: PathCoordAxisPair) {
const dim1 = dimPair.charAt(0) as PathCoordAxis;
const dim2 = dimPair.charAt(1) as PathCoordAxis;
let orderedDims = dimPair as PathCoordAxisPair;
const ab = this.rawCrossSums[orderedDims];
const a = this.rawLinearSums[dim1];
const b = this.rawLinearSums[dim2];
return sigMinus(ab, a * b / this.sampleCount);
}
/**
* Gets the unbiased covariance between the specified pair of axes for samples
* observed during the represented interval.
* @param dimPair
* @returns
*/
public covariance(dimPair: PathCoordAxisPair) {
return this.crossSum(dimPair) / (this.sampleCount - 1);
}
/**
* Gets the unbiased variance on the specified axis for samples observed
* during the represented interval.
*/
public variance(dim: StatAxis) {
return this.squaredSum(dim) / (this.sampleCount - 1);
}
/**
* Utilizing (and possibly abusing) statistical identities, this function produces
* an equivalent, but-recentered copy of this instance's statistical accumulations
* that will be less prone to catastrophic cancellation.
*
* In non-stats speak, the new instance will suffer smaller floating-point
* errors than the old instance whenever they do occur.
* @returns
*/
public buildRenormalized(): CumulativePathStats<Type> {
// Other (internal) notes: the internal mapping of the new instance will not
// match that of the old instance. This should not affect the practical
// results of any mapping to and from the external coordinate space, however.
let result = new CumulativePathStats(this);
let newBase: InputSample<any> = {
targetX: this.mappedMean('x') + this.baseSample.targetX,
targetY: this.mappedMean('y') + this.baseSample.targetY,
t: this.mappedMean('t') + this.baseSample.t
};
result.baseSample = newBase;
for(const dimPair in result.rawCrossSums) {
const d = dimPair as PathCoordAxisPair;
result.rawCrossSums[d] = this.crossSum(d);
}
for(const dim in result.rawSquaredSums) {
// 'v' does not need renormalization.
if(dim == 'v') {
break;
}
// The identity we're using to renormalize rawCrossSums and rawSquaredSums
// automatically guarantees a mean of 0 after the renormalization.
const d = dim as PathCoordAxis;
result.rawLinearSums[d] = 0;
result.rawSquaredSums[d] = this.squaredSum(d);
}
return result;
}
/**
* Provides a linear-regression perspective on two specified axes over the represented
* interval.
* @param dependent
* @param independent
* @returns
*/
public fitRegression(dependent: PathCoordAxis, independent: PathCoordAxis) {
return new CumulativePathStats.regression(this, dependent, independent);
return this.rawLinearSums[dim] / this.sampleCount + this.mappingConstant(dim);
}
/**
@ -645,77 +354,6 @@ export class CumulativePathStats<Type = any> {
return this.duration ? this.netDistance / this.duration : 0;
}
/**
* Returns the represented interval's 'mean angle' clockwise from the DOM's
* <0, -1> (the unit vector toward the top of the screen) in radians.
*
* Based upon the 'circular mean'. Refer to https://en.wikipedia.org/wiki/Circular_mean.
*
* Note that very slow-moving segments may be heavily affected by pixel aliasing
* effects; mouse and touch events usually do not provide sub-pixel resolution.
*/
public get angleMean() {
if(this.arcSampleCount == 0) {
return Number.NaN;
}
// Neato reference: https://rosettacode.org/wiki/Averages/Mean_angle
// But we don't actually need to divide by sample count; `atan2` handles that!
const sinMean = this.sinLinearSum;
const cosMean = this.cosLinearSum;
let angle = Math.atan2(sinMean, cosMean); // result: on the interval (-pi, pi]
// Convert to [0, 2*pi).
if(angle < 0) {
angle = angle + 2 * Math.PI;
}
return angle;
}
/**
* Provides the rSquared value needed internally for circular-statistic properties.
*
* Range: floating-point values on the interval [0, 1].
*/
private get angleRSquared() {
if(this.arcSampleCount == 0) {
return 1;
}
// Refer to https://en.wikipedia.org/wiki/Directional_statistics#Distribution_of_the_mean.
// We're computing the squared value of that page's R-bar stat.
//
// Now, why it's called that? ... good question. My best guess is that it's meant to
// correspond to linear regression's 'r' stat, which when squared serves as the
// coefficient of determination for the regression. Intuitively, that does seem to
// match what this represents - though for normal regressions, the c.o.d isn't normally
// used to compute deviation or variance!
const rSquaredBase = this.cosLinearSum * this.cosLinearSum + this.sinLinearSum * this.sinLinearSum;
return rSquaredBase / (this.arcSampleCount * this.arcSampleCount);
}
/**
* The **circular standard deviation** of the represented interval's angle observations.
*
* Refer to https://en.wikipedia.org/wiki/Directional_statistics#Standard_deviation.
*
* Note that very slow-moving segments may be heavily affected by pixel aliasing
* effects; mouse and touch events usually do not provide sub-pixel resolution.
* This can result in very high deviation values.
*
* In less-technical terms - the "stair-stepping" effect seen on high zoom levels means we don't
* get perfectly straight lines, and that can cause this value to be unexpectedly high.
*/
public get angleDeviation() {
if(this.arcSampleCount == 0) {
return Number.NaN;
}
const val = Math.sqrt(-Math.log(this.angleRSquared));
return isNaN(val) ? 0 : val;
}
/**
* Provides the actual, pixel-based distance actually traveled by the represented segment.
* May not be an integer (because diagonals are a thing).
@ -737,10 +375,7 @@ export class CumulativePathStats<Type = any> {
netDistance: this.netDistance,
duration: this.duration,
sampleCount: this.sampleCount,
angleMeanDegrees: this.angleMean * 180 / Math.PI,
angleDeviation: this.angleDeviation,
rawDistance: this.rawDistance,
speedVariance: this.variance('v')
rawDistance: this.rawDistance
}
}
/* c8 ignore end */

View file

@ -1,14 +0,0 @@
## About this subfolder
This folder contains code for an advanced touchpath sub-segmentation engine that would facilitate more complex gestures,
such as multi-segment flicks and/or swipe-style input. The decision was made to "ice" it for now in favor of preventing
further delays for the release of the feature.
To revisit a state when the system was fully connected, the following two commits may provide a useful reference:
- https://github.com/keymanapp/keyman/tree/0c7ac4ff3caf612674602fefb7ff71350b13964a
- The last feature-gestures commit with full subsegmentation integration
- https://github.com/keymanapp/keyman/tree/5c48cc5b9b127ab42a8055b7d960dbc4d39e4df7
- See #7440 - its description details the basic process for creation of a asynchronous FSM for recognizing & constructing gestures based on subsegments produced by the subsegmentation engine.
- The permalinked commit itself holds the code that said process would connect with.
- This was never fully committed to feature-gestures; #7440 was directly based upon the "last feature-gestures
commit..." mentioned above.

View file

@ -1,309 +0,0 @@
import { SegmentClassifier } from "../segmentClassifier.js";
import { CumulativePathStats } from "../cumulativePathStats.js";
import { SegmentationSplit, Subsegmentation } from "./pathSegmenter.js";
import { SegmentImplementation } from "./segment.js";
import { SubsegmentCompatibilityAnalyzer } from "./subsegmentCompatibilityAnalyzer.js";
/**
* This class is responsible for managing the construction of public-facing Segments while keeping
* all the internal parts... internal. As such, it includes state management for parts of
* PathSegmenter's operations. Note that it makes many assumptions based upon its usage within
* PathSegmenter.
*
* See also `SubsegmentCompatibilityAnalyzer`, which defines the criteria used within this class
* for determining when to recombine subsegments and when to uphold segmentation decisions.
*/
export class ConstructingSegment {
readonly classifier: SegmentClassifier;
/**
* Marks previously-accumulated stats on the touchpath for the portion that precedes
* the in-construction Segment.
*/
readonly baseAccumulation: CumulativePathStats;
/**
* Notes all subsegments identified as part of the overall segment being constructed.
*/
private subsegmentations: Subsegmentation[] = [];
/**
* Marks a potential follow-up subsegment. This portion is not automatically
* committed during finalization.
*/
private pendingSubsegmentation: Subsegmentation = null;
/**
* A flag that is only set once the in-construction segment is first recognized as a 'wait'.
* If set, this indicates that the current - and _only_ the current - `pendingSubsegmentation`
* may only be replaced by an update that would still be compatible if the field's current
* value were already committed. (Goal: prevent the 'locked' pending section from becoming
* not-committed in to the 'wait' role that triggered the 'lock'.)
*/
private _pendingLocked: boolean = false;
private _pathSegment: SegmentImplementation;
constructor(initialPendingSubsegment: Subsegmentation, classifier: SegmentClassifier) {
// Note: may be null! Occurs for the first processed subsegment.
this.baseAccumulation = initialPendingSubsegment.baseAccumulation;
this.classifier = classifier;
this.subsegmentations = [];
this.updatePendingSubsegment(initialPendingSubsegment);
}
/**
* Constructs a stats object for the interval ending with the specified accumulation
* state and beginning at the start of the in-construction Segment.
* @param accumulation
* @returns
*/
private buildIntervalFromBase(accumulation: CumulativePathStats) {
return accumulation.deaccumulate(this.baseAccumulation);
}
/**
* Returns the stats accumulation covering all currently-committed subsegments.
* May be `null` if there are no committed subsegments.
*/
public get committedInterval(): CumulativePathStats {
return this.committedIntervalAsSubsegmentation.stats;
}
/**
* Returns the number of already-committed subsegments that comprise the
* in-construction Segment thus far.
*/
public get subsegmentCount(): number {
return this.subsegmentations.length;
}
/**
* Returns a merged form of all currently-committed subsegments in
* Subsegmentation form.
*/
public get committedIntervalAsSubsegmentation(): Subsegmentation {
if(this.subsegmentations.length == 0) {
return {
stats: null,
endingAccumulation: null,
baseAccumulation: this.baseAccumulation
};
} else {
const tailSub = this.subsegmentations[this.subsegmentations.length - 1];
const tail = tailSub.endingAccumulation;
return {
stats: this.buildIntervalFromBase(tail),
endingAccumulation: tail,
baseAccumulation: this.baseAccumulation
};
}
}
/**
* Checks if the committed portion of the constructing segment are "compatible" with
* a potential following subsegment. Appending said subsegment may not change the
* 'type' of segment being constructed or certain properties of it.
*
* @returns `true` if compatible, `false` if not.
*/
public isCompatible(subsegmentation: Subsegmentation): boolean {
const pendingAnalyzer = this.analyzeCompatibility(subsegmentation);
return pendingAnalyzer.isCompatible;
}
/**
* Returns the analyzer instance used for the `isCompatible` check. The returned
* object provides additional helper properties useful for inspecting compatibility
* logic.
*/
public analyzeCompatibility(subsegmentation: Subsegmentation): SubsegmentCompatibilityAnalyzer {
const committed = this.committedIntervalAsSubsegmentation;
const segmentationSplit = new SegmentationSplit(committed, subsegmentation);
return new SubsegmentCompatibilityAnalyzer(segmentationSplit, this.classifier);
}
/**
* Returns the stats accumulation covering all currently-committed subsegments +
* the submitted `Subsegmentation` object (as if it were committed).
*
* Assumes that its parameter immediately follows any previously-committed
* subsegments.
*/
private wholeSegmentation(subsegmentation: Subsegmentation): Subsegmentation {
const whole: Subsegmentation = {
stats: subsegmentation.endingAccumulation.deaccumulate(this.baseAccumulation),
baseAccumulation: this.baseAccumulation,
endingAccumulation: subsegmentation.endingAccumulation
};
return whole;
}
/**
* A subsegment is pre-committed when it is required to successfully classify the in-construction
* segment after reaching the configured time threshold for segment recognition - that is,
* when previously-committed subsegments are insufficient to support the classification of a
* 'recognized' Segment.
*
* In such a case, the precommitted portion must be maintained. In the event that extending the
* subsegment (on future updates) would render it incompatible, the subsegment must be forceably
* segmented in order to commit the precommitted portion.
*/
public get hasPrecommittedSubsegment() {
return this._pendingLocked;
}
private set hasPrecommittedSubsegment(flag: boolean) {
this._pendingLocked = flag;
}
public get hasPendingSubsegment() {
return !!this.pendingSubsegmentation;
}
/**
* Call this to denote the current state of an in-construction subsegment that **will** be included
* in the final Segment once completed if the pending subsegment does not diverge in a later update.
*
* If the pending subsegment appears to belong to the current Segment, it contains valuable info
* about the state of the as-of-yet unresolved Segment, including the most recent location
* of the corresponding touchpoint.
*
* NOTE: Assumes that .isCompatible has been checked first! This method (currently) does not
* perform the related check!
* - It is currently called when and where relevant in PathSegmenter, the only thing currently
* calling this method.
*
* @param subsegmentation
* @returns `true` unless the subsegment
*/
public updatePendingSubsegment(subsegmentation: Subsegmentation): boolean {
const isFirstUpdate = !this.pendingSubsegmentation && this.subsegmentations.length == 0;
const fullStatsWithIncoming = this.wholeSegmentation(subsegmentation).stats;
// Do we have a locked pending section? If so, check to be sure that an update won't break things.
if(this.hasPrecommittedSubsegment) {
const classification = this.classifier.classifySegment(fullStatsWithIncoming);
// If the classification would change after updating the pending subsegment, block the update &
// report update failure.
if(this.pathSegment.type != classification) {
return false;
}
}
this.pendingSubsegmentation = subsegmentation;
if(isFirstUpdate) {
this.pathSegment = new SegmentImplementation();
}
this.pathSegment.updateStats(fullStatsWithIncoming);
// Check the length of time that's elapsed. If we've surpassed the recognition threshold,
// it's time to commit to classifying the in-construction Segment.
const alreadyElapsed = fullStatsWithIncoming.duration;
const recognitionWaitTime = this.classifier.config.holdMinimumDuration - alreadyElapsed;
const recognitionFromMove = (this.pathSegment.distance > this.classifier.config.holdMoveTolerance);
// `undefined` if and only if still unrecognized.
if((recognitionFromMove || recognitionWaitTime <= 0) && !this._pathSegment.isRecognized) {
const classification = this.classifier.classifySegment(fullStatsWithIncoming);
// Based on the specification for segment classification, there WILL be a classification
// assigned here. It's an implementation error if not.
if(!classification) {
throw new Error("Implementation error - segment was not properly recognized");
}
// auto-resolve the recognition promise & 'lock' the classification (and also the portion
// of the touchpath that triggered it).
this.pathSegment.classifyType(classification);
this.hasPrecommittedSubsegment = true;
}
return true;
}
/**
* Call to clear a previously-tracked in-construction subsegment that has diverged and is no
* longer compatible.
*/
public clearPendingSubsegment() {
if(this.hasPrecommittedSubsegment) {
throw new Error("Invalid state: must fully commit a subsection due to a precommitted portion!");
}
// Probably does not need to trigger an event; if called, there's a new, follow-up segment
// coming that will maintain the current coordinate with its events instead. Assuming
// the need to rely on Segment events for that; even that's better off handled with path.coords
// events instead.
this.pendingSubsegmentation = null;
// If there was a pending subsegment, we need to remove its components from the published segment stats.
this.pathSegment.updateStats(this.committedInterval);
}
/**
* Commits a currently-pending subsegment as part of the in-construction Segment.
*/
public commitPendingSubsegment() {
if(this.pendingSubsegmentation) {
this.subsegmentations.push(this.pendingSubsegmentation);
// Check the peak speed & update if needed.
const commitStats = this.pendingSubsegmentation.stats;
let peakSpeed = commitStats.mean('v') + Math.sqrt(commitStats.variance('v'));
if(peakSpeed > this.pathSegment.peakSpeed) {
this.pathSegment.setPeakSpeed(peakSpeed);
}
// Clear 'pending'-related fields.
this.pendingSubsegmentation = null;
this.hasPrecommittedSubsegment = false;
// Recognition check!
if(!this.pathSegment.isRecognized) {
const classification = this.classifier.classifySegment(this.committedInterval);
if(classification) {
this.pathSegment.classifyType(classification);
}
}
} else {
throw new Error("Illegal state - `commitPendingPortion` should never be called with nothing pending.");
}
}
/**
* Finalizes the Segment, committing any pending portions, classifying ('recognizing') it if
* necessary and resolving it.
*/
public finalize() {
if(this.pendingSubsegmentation) {
this.commitPendingSubsegment();
}
// Fully 'recognize' the Segment if it somehow hasn't yet been recognized.
if(!this.pathSegment.isRecognized) {
this.pathSegment.classifyType(this.classifier.classifySegment(this.committedInterval));
}
this.pathSegment.resolve();
}
/**
* The in-construction Segment, as published to `GesturePath.segments` & `GesturePath`'s
* 'segmentation' event.
*/
public get pathSegment() {
return this._pathSegment;
}
private set pathSegment(segment: SegmentImplementation) {
this._pathSegment = segment;
}
}

View file

@ -1,983 +0,0 @@
import { ConstructingSegment } from "./constructingSegment.js";
import { CumulativePathStats, PathCoordAxis, sigMinus } from "../cumulativePathStats.js";
import { InputSample } from "../inputSample.js";
import { Segment, SegmentImplementation } from "./segment.js";
import { SegmentClass, SegmentClassifier, SegmentClassifierConfig } from "../segmentClassifier.js";
// Mostly used here to compare the sum-squared error components of segmented regressions
// to the sum-squared "modeled" components of their overall variance. Those are the two
// "independent random variables" we're examining. Variances (which are sums of squared
// values themselves) tend to be chi-squared distributed, fulfilling the conditions.
// That's stats-speak for "what this module does with it has a solid theoretical basis."
//
// We do NOT want to be computing this thing at run-time. (Just take one look at the
// equations found in that Wikipedia article!) Fortunately, it's very common in stats
// circles to just... use a lookup table for finding p-values for things following
// the f-distribution.
//
// For future non-stats-inclined maintainers:
// One could say that the p-value = the chance that what we have just "randomly" happened
// to occur without our expectations actually being met. It's a super-common
// stats term; they'd then say that if the p-value is sufficiently low, then we\
// "reject the null hypothesis" - the theory that it actually DID happen randomly - in
// favor of the high likelihood that we really are onto something.
/**
* Acts as a lookup table for null-hypothesis tests utilizing the F-distribution from the
* domain of statistics. Refer to https://en.wikipedia.org/wiki/F-distribution for details
* on its properties.
*
* This is only a partial implementation; only a couple of numerator degrees-of-freedom are
* worth our consideration, and there's only marginal gain to be found supporting more
* denominator degrees-of-freedom than are already supported here. Keeping it small also
* helps with maintenance and readability.
*/
class FDistribution {
/**
* Indexing: [threshold-index][num-2][denom-1].
*
* If numerator has only 1 degree of freedom, that means we don't really have
* segments so much as different averages between the two segments - there's
* no slope on either side. In other words, "just a bump". No segmentation
* there.
*
* 2 degrees of freedom: no movement on the axis on only one side.
*
* threshold-index:
* - p-value of .100: 0
* - p-value of .050: 1
*
* The f-statistic value must match or exceed its corresponding entry in
* the table below, based on the detected DoF (degrees of freedom) for the
* 'numerator' and 'denominator' components. Exceeding the threshold specified
* for the p = .100 section indicates less than a 10% probability of the statistic
* being tested having arisen by purely random chance. Failure to exceed even
* that implies an unacceptably high chance of being "convenient but meaningless" -
* the "null hypothesis", in stats speak.
*/
private static readonly table = [
// p = .100
[
// numerator: 2
[
// denom: 1-10
49.50, 9.00, 5.46, 4.32, 3.78, 3.46, 3.26, 3.11, 3.01, 2.92,
// denom: 11-20
2.86, 2.81, 2.76, 2.73, 2.70, 2.67, 2.64, 2.62, 2.61, 2.59
// we COULD continue with threshold-dropping after that,
// but I doubt we'll need it in practicality.
// At 30: 2.49
// limit -> infinity: 2.30
],
// numerator: 3
[
// denom: 1-10
53.59, 9.16, 5.39, 4.19, 3.62, 3.29, 3.07, 2.92, 2.81, 2.73,
// denom: 11-20
2.66, 2.61, 2.56, 2.52, 2.49, 2.46, 2.44, 2.42, 2.40, 2.38
// Likewise, from above.
// At 30: 2.28
// limit -> infinity: 2.08
]
],
// p = .050
[
// numerator: 2
[
// denom: 1-10
199.5, 19.0, 9.55, 6.94, 5.79, 5.14, 4.74, 4.46, 4.26, 4.10,
// denom: 10-20
3.98, 3.89, 3.81, 3.74, 3.68, 3.63, 3.59, 3.55, 3.52, 3.49
// At 30: 3.32
// limit -> infinity: 3.00
],
// numerator: 3
[
// denom: 1-10
215.7,19.16, 9.28, 6.59, 5.41, 4.76, 4.35, 4.07, 3.86, 3.71,
// denom: 10-20
3.59, 3.49, 3.41, 3.34, 3.29, 3.24, 3.20, 3.16, 3.13, 3.10
// At 30: 2.92
// limit -> infinity: 2.60
]
]
]
/**
* Determines a threshold tier for segmentation based on the segmented
* regression f-statistic.
* @param statistic
* @param numDoF
* @param denomDoF
*
* Returns 0.050 if the statistic indicates a p-value of 0.050 or lower.
* Failing that, returns 0.100 if a p-value of 0.100 or lower is indicated.
* Otherwise, returns 1.0 - we have no basis to "reject the null hypothesis".
*/
static thresholdTier(statistic: number, numDoF: number, denomDoF: number) {
// Former case: we'd never segment anyway
// Latter case: it's currently wrong to statistically test.
// The F-distribution is not defined for this case.
if(numDoF < 2 || denomDoF < 1) {
return 1;
}
const numIndex = (numDoF > 3 ? 3 : numDoF) - 2;
const denomIndex = (denomDoF > 20 ? 20 : denomDoF) - 1;
const tier2Threshold = FDistribution.table[1][numIndex][denomIndex];
if(statistic >= tier2Threshold) {
return 0.05;
}
const tier1Threshold = FDistribution.table[0][numIndex][denomIndex];
// If arising purely randomly isn't at least less than 10% likely,
// we'll categorically say it happened randomly with full certainty (1).
// Obviously not actually true, but it works for our thresholding.
return statistic >= tier1Threshold ? 0.10 : 1;
}
}
/**
* Represents a "subsegmentation" - one part of a phase 1 segmentation split.
*/
export interface Subsegmentation {
/**
* The stats for observations comprising the subsegment.
*/
stats: CumulativePathStats;
/**
* The cumulative stats up to the subsegment's endpoint, including
* accumulated components that precede the subsegment entirely.
*/
endingAccumulation: CumulativePathStats;
/**
* The cumulative stats up to the point before the subsegment's start
* point, not including any accumulation for components within the subsegment.
*/
baseAccumulation: CumulativePathStats;
}
/**
* Represents the result of a segmentation of the search path and the related
* statistical accumulations needed to properly test the segmentation's
* validity.
*/
export class SegmentationSplit {
public static readonly SPLIT_CRITERION_THRESHOLD = 1.5;
/**
* The properties of the "left-hand" / earlier half of the time interval
* being segmented.
*/
readonly pre: Subsegmentation;
/**
* The properties of the "right-hand" / later half of the time interval
* being segmented.
*/
readonly post: Subsegmentation;
/**
* The properties of the full interval being segmented, before the split.
*/
readonly union: CumulativePathStats;
/**
* Provides segmented-regression statistics & fitting values on the two specified axes /
* dimensions based on the subsegments and their corresponding linear-regression
* statistics. All operations are O(1).
*/
static readonly segmentationComparison = class SegmentedRegression {
host: SegmentationSplit;
readonly independent: PathCoordAxis;
readonly dependent: PathCoordAxis;
readonly paired: 'tx' | 'ty' | 'xy';
/**
* The linear regression for the underlying `pre` (earlier) subsegment.
*/
pre: typeof CumulativePathStats.regression.prototype;
/**
* The linear regression for the underlying `post` (later) subsegment.
*/
post: typeof CumulativePathStats.regression.prototype;
/**
* The linear, unsegmented regression for the underlying `union` (combined) subsegment.
*/
union: typeof CumulativePathStats.regression.prototype;
constructor(host: SegmentationSplit, dependentAxis: PathCoordAxis, independentAxis: PathCoordAxis) {
if(dependentAxis == independentAxis) {
throw new Error("Two different axes must be specified for the regression object.");
}
this.host = host;
this.dependent = dependentAxis;
this.independent = independentAxis;
if(dependentAxis < independentAxis) {
this.paired = dependentAxis.concat(independentAxis) as 'tx' | 'ty' | 'xy';
} else {
this.paired = independentAxis.concat(dependentAxis) as 'tx' | 'ty' | 'xy';
}
this.pre = this.host.pre .stats.fitRegression(dependentAxis, independentAxis);
this.post = this.host.post .stats.fitRegression(dependentAxis, independentAxis);
this.union = this.host.union.fitRegression(dependentAxis, independentAxis);
}
/**
* The total summed squared-distances of the best fitting line from actually-observed values
* for their corresponding subsegment; in other words, the "sum of the squared errors" when
* utilizing segmented regression.
*
* Statistically, this is the portion of the dependent variable's variance (un-normalized)
* that is unexplained even after dividing the interval into separate subsegments.
*/
get remainingSumSquaredError(): number {
return this.pre.sumOfSquaredError + this.post.sumOfSquaredError;
}
/**
* Provides the coordinate that belongs to BOTH subsegments, as it ends one and begins
* the other.
*/
get splitPoint() {
let splitPoint = this.host.pre.stats.lastSample;
let indep = splitPoint.t;
if(this.independent == 'x') {
indep = splitPoint.targetX;
} else if(this.independent == 'y') {
indep = splitPoint.targetY;
}
let dep = splitPoint.t;
if(this.dependent == 'x') {
dep = splitPoint.targetX;
} else if(this.dependent == 'y') {
dep = splitPoint.targetY;
}
return {
dep: dep,
indep: indep
};
}
/**
* The total summed squared-distances of the best fitting line from actually-observed values
* when **not** utilizing segmented regression. Double-counts the subsegment's common point, as
* it is counted within both subsegments' sum-of-squared error term.
*/
get unsegmentedSumSquaredError(): number {
// What was our remaining error for the unsegmented regression?
let baseSSE = this.union.sumOfSquaredError;
// We double-count the split point when segmenting, so we should add an
// extra copy of its residual.
const splitPoint = this.splitPoint;
const splitPointError = this.splitPoint.dep - this.union.predictFromValue(splitPoint.indep);
// This is the total sum-squared error to be handled by segmenting.
return baseSSE + splitPointError * splitPointError;
}
/**
* Gets the amount of (unnormalized) variance not explained by a standard regression on
* the full interval but succesfully explained by splitting the interval in twain via
* segmented regression.
*/
get sumSquaredGainFromSegmentation(): number {
return sigMinus(this.unsegmentedSumSquaredError, this.remainingSumSquaredError);
}
/**
* A statistical term that signals how successful the segmented regression is. Always
* has values on the interval [0, 1], with 1 being a perfect fit.
*/
get coefficientOfDetermination(): number {
if(this.host.union.squaredSum(this.dependent) == 0 || this.host.union.squaredSum(this.independent) == 0) {
return 1;
}
return 1 - this.remainingSumSquaredError / this.host.union.squaredSum(this.dependent);
}
/**
* Gets the raw statistic value needed to test for validity of segmentation based on the axes
* under examination. The higher the proportion of newly-explained variance to still-unexplained
* variance is, the more confident we can _formally_ be in our segmentation.
*/
get fStat(): number {
const val = this.sumSquaredGainFromSegmentation / this.remainingSumSquaredError;
// We're fine with Infinity. Just... not so much NaN.
return isNaN(val) ? 0 : val;
}
/**
* Gets the degrees-of-freedom to be used for the numerator DoF parameter of
* the F-distribution needed for validity testing.
*/
get fDoF1(): number {
let numDoF = 3;
// Cases where there clearly may as well be 'no slope' at all.
// This saves us a degree of freedom, which is VERY useful for segmenting
// the boundary between a 'hold' and a 'move'.
if(this.host.pre.stats.squaredSum(this.dependent) < 1e-8) {
numDoF--;
}
if(this.host.post.stats.squaredSum(this.dependent) < 1e-8) {
numDoF--;
}
return numDoF;
}
/**
* Gets the degrees-of-freedom to be used for the denominator DoF parameter of
* the F-distribution needed for validity testing.
*/
get fDoF2(): number {
return this.host.union.sampleCount - 2 - this.fDoF1;
}
/**
* States a statistically-founded level of confidence we may place in upholding
* the represented segmentation, as based on the regressions and axes under examination.
*
* If we cannot hold any confidence whatsoever (due to being unable to "reject the
* null hypothesis" from this specific regression), will return 0.
*/
get certaintyThreshold() {
return 1 - FDistribution.thresholdTier(this.fStat, this.fDoF1, this.fDoF2);
}
get prettyPrint(): string {
return `F_(${this.fDoF1}, ${this.fDoF2}) = ${this.fStat} @ ${this.certaintyThreshold}`
}
}
constructor(pre: Subsegmentation,
post: Subsegmentation) {
this.pre = pre;
this.post = post;
this.union = post.endingAccumulation.deaccumulate(pre.baseAccumulation);
}
static fromTrackedStats(steppedStats: CumulativePathStats[],
baseAccumulation: CumulativePathStats,
splitIndex: number) {
let intervalEndStats = steppedStats[splitIndex];
const pre: Subsegmentation = {
stats: intervalEndStats.deaccumulate(baseAccumulation),
endingAccumulation: intervalEndStats,
baseAccumulation: baseAccumulation
};
// Keep stats value components based on the final point of the 'pre' segment.
intervalEndStats = steppedStats[steppedStats.length-1];
const postBaseAccumulation = steppedStats[splitIndex-1];
const post: Subsegmentation = {
stats: intervalEndStats.deaccumulate(postBaseAccumulation),
endingAccumulation: intervalEndStats,
baseAccumulation: postBaseAccumulation
}
return new SegmentationSplit(pre, post);
}
/**
* Provides a segmented-regression perspective for the represented interval and proposed
* split point based upon the two specified axes.
* @param dependent
* @param independent
* @returns
*/
public segReg(dependentAxis: PathCoordAxis, independentAxis: PathCoordAxis) {
return new SegmentationSplit.segmentationComparison(this, dependentAxis, independentAxis);
}
/**
* Defines our test for upholding a minimum of sub-segmentation based upon changes in the
* interval's coordinates over time. This may occur even with no change in spacial direction
* if there is significant enough change in _speed_, as that "curves the line" when one axis
* is time.
*/
get segmentationMerited(): boolean {
let totalThreshold = 0;
const xTest = new SegmentationSplit.segmentationComparison(this, 'x', 't');
const yTest = new SegmentationSplit.segmentationComparison(this, 'y', 't');
// Our testing thresholds are for p=0.05 and p=0.10, which correspond to certainties of 95% and
// 90% that our segmentation did not arrive from random chance based on the axis being tested.
const p05 = 0.95; // 1 - 0.05
const p10 = 0.9; // 1 - 0.10
totalThreshold += xTest.certaintyThreshold >= p05 ? 2 : (xTest.certaintyThreshold >= p10 ? 1 : 0) ;
totalThreshold += yTest.certaintyThreshold >= p05 ? 2 : (yTest.certaintyThreshold >= p10 ? 1 : 0) ;
return totalThreshold >= 2;
}
/**
* Defines our test for considering two (or more) sub-segments as part of the same overall
* segment. This seeks to 'link' geometrically-related subsegments - especially those that
* maintain the same direction but differ only in observed speed.
*/
get mergeMerited(): boolean {
// Because of caret-like motions (as in, in the '^' shape), we need to text for
// regression on both axes. One may have notably higher variance than the other.
//
// These tests ignore time, and therefore speed. Only the raw geometry of the motion
// matters for this test.
const xTest = new SegmentationSplit.segmentationComparison(this, 'x', 'y');
const yTest = new SegmentationSplit.segmentationComparison(this, 'y', 'x');
// If we don't get a p-value less than .100, then as far as x & y are concerned - and thus the user
// is concerned - it's the same segment. Speed may be different, but not the direction.
if(xTest.certaintyThreshold > 0) {
return false;
}
return yTest.certaintyThreshold == 0;
}
public _debugLogSplitReport(demarcate?: boolean) {
if(demarcate) {
console.log("------------------------------------------------------------------");
}
console.log("Split object: ");
console.log(this);
console.log();
const xF = this.segReg('x', 't');
const yF = this.segReg('y', 't');
console.log(`x F-test: ${xF.prettyPrint}`);
console.log(`y F-test: ${yF.prettyPrint}`);
console.log();
if(demarcate) {
console.log("------------------------------------------------------------------");
}
}
public _debugLogAlignmentReport() {
console.log("Desegmentation under consideration: ");
console.log(this);
console.log();
const xF = this.segReg('x', 'y');
const yF = this.segReg('y', 'x');
console.log(`merger F-test (xy): ${xF.prettyPrint}`);
console.log(`merger F-test (yx): ${yF.prettyPrint}`);
console.log(`will remerge: ${this.mergeMerited}`);
}
}
export interface SegmentationConfiguration extends SegmentClassifierConfig {
// See `PathSegmenter.segmentationConfig`'s comment; we may wish to
// define & provide extra configuration parameters here... or wherever
// this type's final location ends up.
}
/**
* The core logic, algorithm, and manager for touchpath segmentation and
* subsegmentation.
*
* This class itself directly handles 'subsegmentation', as the first pass of
* our algorithm errs on the side of segmenting too much in order to never
* 'undersegment'. It then delegates to other classes to recombine the
* results as appropriate before producing completed `Segment`s.
*/
export class PathSegmenter {
/**
* The minimum amount of time (in ms) to wait between sample repetitions
* once inputs stop arriving, so long as the path is still active.
*/
private readonly REPEAT_INTERVAL = 33;
/**
* The time-interval length (in ms) at the end of the path to consider
* when determining whether or not to trigger path segmentation.
*/
private readonly SLIDING_WINDOW_INTERVAL = 50;
/**
* Tracks the mathematical values used to provide path segment stats
* at each point on the path. Individual steps may be removed (and
* batched into `choppedStats`) once their respective points on the
* path have been fully processed.
*/
private steppedCumulativeStats: CumulativePathStats[];
/**
* Tracks the segment currently under construction.
*
* May also model an "empty" tail on the touchpath in order to track
* which part of the path's accumulation is no longer under consideration.
*/
private constructingSegment: ConstructingSegment;
/**
* Used to 'repeat' the most-recently observed incoming sample if no
* other replaces it before it triggers.
*
* A repeating sample indicates lack of motion, which is valuable
* information for stats-based segmentation.
*/
private repeatTimer: number | NodeJS.Timeout;
/**
* The timestamp of observation of the most recently observed sample's
* most recent repetition - even if it's only the first evaluation.
*/
private repeatTimestamp: number;
/**
* Represents the cumulative statistics of all points on the path
* that lie on already-fully-segmented parts of it.
*/
private choppedStats: CumulativePathStats = null;
/**
* A closure used to 'forward' generated Segments, generally to their public-facing
* location on GesturePath.segments.
*/
private readonly segmentForwarder: (segment: Segment) => void;
/**
* Denotes whether or not a first touchpath sample has been provided.
*/
private hasStarted: boolean = false;
// For consideration: should REPEAT_INTERVAL, SLIDING_WINDOW_INTERVAL, and other
// related values be defined here?
//
// Note:
// - hardcoded 2 * SLIDING_WINDOW_INTERVAL: minimum interval required for a
// subsegmentation attempt (to ensure sufficient observations on each side)
// - hardcoded SLIDING_WINDOW_INTERVAL / 2: minimum interval that must
// remain on each side after the sliding "optimum split point" search.
//
// NOTE: this will likely (eventually) be defined elsewhere, at a "higher level"
// within this module / package.
private segmentationConfig: SegmentationConfiguration;
public static readonly DEFAULT_CONFIG: SegmentationConfiguration = {
holdMinimumDuration: 100,
holdMoveTolerance: 5
}
constructor(segmentationConfig: SegmentationConfiguration, segmentForwarder: (segment: Segment) => void) {
this.steppedCumulativeStats = [];
this.segmentForwarder = segmentForwarder;
this.segmentationConfig = segmentationConfig;
}
/**
* Appends a new coordinate to the touch path and also kick-starts a timer for replicating it
* should neither further inputs be received nor termination of the touchpoint be indicated.
* @param sample
*/
public add(sample: InputSample<any>) {
// If this is the first received input sample, generate & publish a "start" segment.
// As ConstructingSegment is designed to work with -sequences- of samples, it's less
// useful here... and unnecessary, as we already have all the info we need.
if(!this.hasStarted) {
this.hasStarted = true;
const startSegment = new SegmentImplementation();
startSegment.updateStats(new CumulativePathStats(sample));
startSegment.classifyType(SegmentClass.START);
startSegment.resolve();
this.segmentForwarder(startSegment);
}
// Set up the input-repeater (in case we don't get further feedback but remain active)
const repeater = (timeDelta: number) => {
this.observe(sample, timeDelta);
}
// If we previously set up an input-repeater, cancel it. We've got a more up-to-date
// coordinate on the touchpath now.
if(this.repeatTimer) {
// @ts-ignore
clearInterval(this.repeatTimer);
this.repeatTimer = null;
}
this.repeatTimer = setInterval(() => {
const timeDelta = Date.now() - this.repeatTimestamp;
repeater(timeDelta);
}, this.REPEAT_INTERVAL);
this.repeatTimestamp = Date.now();
repeater(0);
}
/**
* Used to finalize all segmentation for the touchpath whenever termination of the corresponding
* touchpoint has been terminated (mouse-up, touch-end).
*/
public close() {
// The Node clearTimeout & DOM clearTimeout appear to TS as overloads of each other,
// and their type definitions will conflict. A simple @ts-ignore will bypass this issue.
// @ts-ignore
clearInterval(this.repeatTimer); // Cancels the input-repeater.
this.repeatTimer = null;
if(this.steppedCumulativeStats.length == 0) {
return;
}
// Make sure that the final part of the touchpath is given a subsegment & then handled.
const finalAccumulation = this.steppedCumulativeStats[this.steppedCumulativeStats.length - 1]
let finalSubsegment: Subsegmentation = {
stats: finalAccumulation.deaccumulate(this.choppedStats),
endingAccumulation: finalAccumulation,
baseAccumulation: this.choppedStats
}
// No need to check if this matches any predecessor subsegments; that already happened during
// the last `performSubsegmentation` call. There's no new data since then.
// (Actually... this should already be in place now, after recent changes!)
this.constructingSegment?.updatePendingSubsegment(finalSubsegment);
this.finalizeSegment(); // also commits pending subsegment
// Using the last-received sample, generate & publish an "end" segment.
// As ConstructingSegment is designed to work with -sequences- of samples, it's less
// useful here... and unnecessary, as we already have all the info we need.
const endSegment = new SegmentImplementation();
endSegment.updateStats(new CumulativePathStats(finalAccumulation.lastSample));
endSegment.classifyType(SegmentClass.END);
endSegment.resolve();
this.segmentForwarder(endSegment);
}
/**
* Adds a statistic 'observation' of the state of the touchpath.
*
* This is either to be called directly upon reception of a new input-coordinate or
* upon replication of a prior input should the touchpath still be active without any
* indication of motion.
* @param sample
* @param timeDelta
*/
private observe(sample: InputSample<any>, timeDelta: number) {
let cumulativeStats: CumulativePathStats;
if(this.steppedCumulativeStats.length) {
cumulativeStats = this.steppedCumulativeStats[this.steppedCumulativeStats.length-1];
} else {
cumulativeStats = new CumulativePathStats();
}
sample = {... sample, t: sample.t + timeDelta};
const extendedStats = cumulativeStats.extend(sample);
this.steppedCumulativeStats.push(extendedStats);
this.performSubsegmentation();
}
// The "reported via event" aspect mentioned below is necessary because this may be
// called via setTimeout callback on a held touchpoint. We need that `setTimeout`
// (which replicates the most recently-seen input coordinate) in order to have good
// sample-data for detecting the boundary between motion and lack thereof during
// segmentation.
/**
* This is the "main method" for touchpath segmentation. Should a new segment result
* from its analysis, it will be reported via event.
* @returns
*/
private performSubsegmentation() {
const cumulativeStats = this.steppedCumulativeStats[this.steppedCumulativeStats.length - 1];
const unsegmentedDuration = cumulativeStats.lastTimestamp - this.steppedCumulativeStats[0].lastTimestamp;
const unsegmentedForm: Subsegmentation = {
stats: cumulativeStats.deaccumulate(this.choppedStats),
endingAccumulation: cumulativeStats,
baseAccumulation: this.choppedStats
}
// STEP 1: Determine the range of the initial sliding time window for the most recent samples.
if(unsegmentedDuration < this.SLIDING_WINDOW_INTERVAL * 2) {
// If it returns false, that only makes the interval _even shorter_.
if(!this.updateSegmentConstruction(unsegmentedForm)) {
// Updates our internal state tracking; no infinite loop will result.
this.performSubsegmentation();
// return will automatically happen via fall-through.
}
return;
}
let splitPoint = 0;
// Do not consider the just-added `extendedStats` entry when building the sliding
// time window.
for(let i = this.steppedCumulativeStats.length-2; i >=0; i--) {
if(this.steppedCumulativeStats[i].lastTimestamp < cumulativeStats.lastTimestamp - this.SLIDING_WINDOW_INTERVAL) {
splitPoint = i+1;
break;
}
}
// We split the cumulative stats on a specific point, which then resides on the edge
// of both of the resulting intervals. This completes "step 1".
let candidateSplit = SegmentationSplit.fromTrackedStats(this.steppedCumulativeStats, this.choppedStats, splitPoint);
// STEP 2: given the proposed time window, do we have a basis for segmentation? And is there a better
// candidate split point nearby?
// We either have the conditions to trigger segmentation or just became long enough to consider it.
// If we're only just long enough to consider it, there may be a better segmentation point to start with.
// Now... is there a better segmentation point? But first... how do we facilitate searching for one?
// Start: split-point search helper local-class.
class SplitSearchState {
readonly xTest: typeof SegmentationSplit.segmentationComparison.prototype;
readonly yTest: typeof SegmentationSplit.segmentationComparison.prototype;
readonly candidate: SegmentationSplit;
constructor(candidate?: SegmentationSplit) {
this.candidate = candidate;
if(candidate == null) {
return;
}
this.xTest = candidate.segReg('x', 't');
this.yTest = candidate.segReg('y', 't');
}
// The value for an 'objective function' to optimize in our search for a better candidate.
// The higher this is, the more we like its potential as the segmentation point.
get segRating() {
if(this.candidate) {
return Math.max(this.xTest.coefficientOfDetermination, this.yTest.coefficientOfDetermination);
} else {
return 0;
}
}
// But, if it turns out this potential point wouldn't actually result in segmentation, well...
// "abandon ship".
get segmentationMerited() {
return this.candidate?.segmentationMerited ?? false;
}
}
// Start: split-point search
// NOTE: During initial development, I actually had a hard rule about not even searching if segmentation
// failed on the initially-constructed sliding window. Some analysis may be wise here, as it'd be nice
// to optimize away the 'need to search' if we can find hard & fast rules about when we'll never need
// to go looking. But... being too aggressive can cause nasty problems.
/* We'll use our initial sliding window as a starting point. It's close to the active location
* of the touch, so we're unlikely to miss a proper segmentation point if it lies close.
* We'll find the local maximum (best split point for the local part of the path)
* via gradient descent.
*
* Note: binary search is a 'bad idea', as the touchpath is likely not following a
* monotonous path, mathematically speaking. (It'd be possible to overshoot a "peak" or
* "valley" quite easily.)
*/
let currentSplit = new SplitSearchState(candidateSplit);
let leftSplit = new SplitSearchState(SegmentationSplit.fromTrackedStats(this.steppedCumulativeStats, this.choppedStats, splitPoint-1));
let rightCandidate: SegmentationSplit = null;
if(splitPoint+1 < this.steppedCumulativeStats.length) {
rightCandidate = SegmentationSplit.fromTrackedStats(this.steppedCumulativeStats, this.choppedStats, splitPoint+1);
}
let rightSplit = new SplitSearchState(rightCandidate);
// Step 2a: detect which direction gives the best improvement in segmentation potential.
const criteria = [leftSplit.segRating, currentSplit.segRating, rightSplit.segRating];
let sortedCriteria = [].concat(criteria).sort();
const delta = criteria.indexOf(sortedCriteria[2])-1; // -1 if 'left' is best, 1 if 'right' is best.
// Step 2b: we've found the direction: go searching!
if(delta != 0) {
// We can get better segmentation by shifting. Proceed in the optimal direction.
do {
const nextSplitIndex = splitPoint + delta;
if(nextSplitIndex >= this.steppedCumulativeStats.length || nextSplitIndex < 0) {
break;
}
let nextCandidate = SegmentationSplit.fromTrackedStats(this.steppedCumulativeStats, this.choppedStats, splitPoint + delta);
let nextSplit = new SplitSearchState(nextCandidate);
// Prevent overly-short intervals / over-segmentation.
if(nextCandidate.pre.stats.duration < this.SLIDING_WINDOW_INTERVAL / 2) {
break;
} else if(nextCandidate.post.stats.duration < this.SLIDING_WINDOW_INTERVAL / 2) {
break;
}
// Not an improvement? Guess we found the best spot.
if(nextSplit.segRating <= currentSplit.segRating) {
break;
} else if(!nextSplit.segmentationMerited && currentSplit.segmentationMerited) {
// Note: this can happen if segmentation is triggered due to divergence on both axes
// if one of them drops a threshold tier and the other fails to improve sufficiently.
break;
} else {
splitPoint += delta;
currentSplit = nextSplit;
candidateSplit = nextCandidate;
}
// If we found a new best segmentation point, we then ask if we can get even better by shifting further.
} while(true);
}
// Step 2 complete.
// Step 3: evaluate validity of the left-hand subsegment reasonably continuing the in-construction segment.
// (In sort, subsegment "compatibility" with what came before.)
// A classic example case: same direction, different speeds.
//
if(!this.updateSegmentConstruction(candidateSplit.pre)) {
// It's quite possible that segmentation is possible in the leftover section post-reversion.
// There is a pretty good chance that it'll instantly abort, but no guarantee.
this.performSubsegmentation();
return;
}
// First phase of segmentation: complete!
// Step 4: basic bookkeeping.
/*
* NOTE: this marks a very good location to call _debugLogSplitReport() if a deep-dive
* inspection of the subsegmentation algorithm and its decisions is needed.
*/
if(!candidateSplit.segmentationMerited) {
if(!this.updateSegmentConstruction(unsegmentedForm)) {
this.performSubsegmentation();
//return will happen via fall-through here.
}
return;
}
// A successful update will ensure a valid .constructingSegment instance exists.
this.commitSubsegmentation();
// Step 5: now that subsegmentation & its bookkeeping is done... process the implications.
// Does the right-hand (still-constructing) subsegment appear reasonable to
// 'link' with its predecessors? If not, finalize the predecessors.
// As this occurs after a `commitPending`, there is currently no pending subsegment.
// As such, this update will either:
// - initialize a new pending subsegment for the in-construction Segment if compatible
// - or finalize the Segment if incompatible, then use this subsegment to start a new Segment.
// As a result, this update call will always succeed; there's no prior pending state that may be reverted to.
this.updateSegmentConstruction(candidateSplit.post);
}
private finalizeSegment() {
if(this.constructingSegment) {
if(this.constructingSegment.subsegmentCount == 0 && !this.constructingSegment.hasPendingSubsegment) {
throw new Error("Implementation error!");
}
this.constructingSegment.finalize();
/*
* NOTE: if a deep-dive investigation is needed, it may prove helpful to emit each finalized
* `constructingSegment` instance to the console here. Like, _**precisely**_ here, immediately
* after this multiline comment.
*
* From there, note that in many modern browsers, you can right-click a logged object and say
* to "Store object as global variable", giving you console access to any such logged instances.
*
* `SubsegmentCompatibilityAnalyzer` is designed for ease-of-use with the members of
* `ConstructingSegment.subsegmentations` via `SegmentationSplit`, facilitating interactive
* inspection of which subsegments are and are not recombined into `Segment`s and why.
*/
this.constructingSegment = null;
}
}
/**
* Updates the in-construction Segment with the specified subsegment's accumulation
* data. If no Segment is currently under construction, it also creates a new one.
*
* In the case that an update must be blocked due to a "pending commit" (from having
* recognized the Segment while depending on the currently-constructing subsegment),
* it will return `false` to signal that it has overriden the subsegmentation with
* the most recently-valid prior version, committed that, and that the algorithm's
* current analysis is no longer valid.
* @param subsegment
* @returns `false` if the caller should restart due to forced change of segmentation
* state.
*/
private updateSegmentConstruction(subsegment: Subsegmentation): boolean {
let updateFlag: boolean;
if(this.constructingSegment) {
if(!this.constructingSegment.isCompatible(subsegment)) {
// unknown: is pending locked or not?
updateFlag = false;
} else {
// Note that this call may semi-silently precommit the subsegment if this update is the
// first to surpass the configured segment recognition timer threshold.
//
// Alternatively, if updating the pending subsegment results in incompatibility while
// a pre-existing precomitted version does not, the update will fail.
updateFlag = this.constructingSegment.updatePendingSubsegment(subsegment);
if(updateFlag) {
return true;
}
}
}
// If the segment under construction was registered as a 'hold' segment in the
// middle of the subsegment, we now require the 'pending subsegment' to maintain
// the same type. But, reaching here means it's no longer compatible - so we
// use the previously-registered subsegmentation here instead.
if(updateFlag === false && this.constructingSegment.hasPrecommittedSubsegment) {
this.commitSubsegmentation();
// Signal our caller to refresh itself and restart segmentation for the round.
// Kinda dirty, but it's 100% internal to this class, at least.
return false;
}
this.constructingSegment?.clearPendingSubsegment();
this.finalizeSegment();
this.constructingSegment = new ConstructingSegment(subsegment, new SegmentClassifier(this.segmentationConfig));
this.segmentForwarder(this.constructingSegment.pathSegment);
return true;
}
private commitSubsegmentation() {
this.constructingSegment.commitPendingSubsegment();
// Based on what we just committed, we can find the split point that led to the subsegmentation commit:
const splitPointAccumulation = this.constructingSegment.committedIntervalAsSubsegmentation.endingAccumulation;
const splitPoint = this.steppedCumulativeStats.indexOf(splitPointAccumulation);
// And given that split point, we can maintain our internal state accordingly.
// The exact point of the split is duplicated; we remove accumulation from everything before it.
this.choppedStats = this.steppedCumulativeStats[splitPoint-1];
this.steppedCumulativeStats = this.steppedCumulativeStats.slice(splitPoint);
}
}

View file

@ -1,287 +0,0 @@
import { CumulativePathStats } from "../cumulativePathStats.js";
import { InputSample } from "../inputSample.js";
import { type SegmentClass } from "../segmentClassifier.js";
export interface JSONSegment {
type: SegmentClass,
duration: number,
distance: number,
speed: number,
peakSpeed: number
angle: number,
cardinalDirection: string,
initialCoord: InputSample<any>,
lastCoord: InputSample<any>
}
/**
* Denotes one "segment" of the ongoing touchpath. These segments are then utilized as the basis
* for detecting and synthesizing gestures.
*
* By first breaking the touchpath into gestures, we gain the ability to define something of a
* finite-state-machine (FSM) model for each type of gesture we wish to support. While technically
* possible to do even without this step... we find that this makes such a goal far less complex.
*/
export class Segment {
/**
* Denotes the highest (mean + 1-sigma) speed seen among all subsegments comprising
* this segment.
*
* This is likely not the maximum speed observed, as that observation is likely an outlier
* and is not internally recorded. We rebuild this from stats observations.
*
* Think of it as acting like a "smoothed peak speed".
*/
private _peakSpeed: number;
private _type?: SegmentClass;
private _stats: CumulativePathStats | JSONSegment;
private _recognitionPromise: Promise<SegmentClass>;
private _recognitionPromiseResolver: (type: SegmentClass | PromiseLike<SegmentClass>) => void;
private _resolutionPromise: Promise<void>;
private _resolutionPromiseResolver: () => void;
private _isResolved: boolean = false;
/**
* Intended for internal-use only; this is utilized during the segmentation process.
*/
public constructor();
/**
* Reconstructs an instance based upon a previously-serialized `.toJSON()` call on this
* object.
* @param serializedObj
*/
public constructor(serializedObj: JSONSegment);
public constructor(serializedObj?: JSONSegment) {
if(serializedObj) {
// We'll use the loaded object as our core reference.
this._stats = serializedObj;
this._peakSpeed = serializedObj.peakSpeed; // Is retrieved from `this`, not `this._stats`.
this._type = serializedObj.type; // This one too.
// Ensure that the two promises are properly initialized for this construction type.
this._recognitionPromise = Promise.resolve(serializedObj.type);
this._resolutionPromise = Promise.resolve();
} else {
this._peakSpeed = 0;
this._recognitionPromise = new Promise<SegmentClass>((resolve) => {
this._recognitionPromiseResolver = resolve;
});
this._resolutionPromise = new Promise<void>((resolve) => {
this._resolutionPromiseResolver = () => {
this._isResolved = true;
resolve();
}
})
}
}
/**
* Indicates the likely "highest sustained" speed observed within the `Segment`'s path.
*
* To be more precise, the mean + 1-sigma (average + one standard deviation) of the speed
* for the fastest component of the `Segment`.
*/
get peakSpeed(): number {
return this._peakSpeed;
}
protected setPeakSpeed(speed: number) {
if(this._isResolved) {
throw new Error("May not modify a resolved segment!");
}
this._peakSpeed = speed;
}
protected updateStats(totalStats: CumulativePathStats) {
if(this._isResolved) {
throw new Error("May not modify a resolved segment!");
}
this._stats = totalStats;
}
protected classifyType(type: SegmentClass) {
if(!this._stats) {
throw new Error("Cannot recognize the segment - lacking critical metadata");
}
if(this._isResolved) {
throw new Error("May not modify a resolved segment!");
}
if(this._type === undefined) {
this._type = type;
this._recognitionPromiseResolver(type);
} else if(this._type != type) {
throw new Error("May not change segment type once set!");
}
}
/**
* The starting point of this touchpath segment.
*/
get initialCoord(): InputSample<any> {
if(this._stats instanceof CumulativePathStats) {
return this._stats.initialSample;
} else {
return this._stats.initialCoord;
}
};
/**
* The ending point of this touchpath segment.
*/
get lastCoord(): InputSample<any> {
if(this._stats instanceof CumulativePathStats) {
return this._stats.lastSample;
} else {
return this._stats.lastCoord;
}
};
/**
* The duration of this touchpath segment, measured in ms.
*/
get duration(): number {
return this._stats.duration;
}
/**
* The net distance traveled by this touchpath segment. In other words,
* the distance between `initialCoord` and `lastCoord` (ignoring time).
*/
get distance(): number {
if(this._stats instanceof CumulativePathStats) {
return this._stats.netDistance;
} else {
return this._stats.distance;
}
}
/**
* The average speed of this touchpath segment - distance / duration.
*/
get speed(): number {
return this._stats.speed;
};
/**
* The angle traveled by this touchpath segment. Measured in radians.
*/
get angle(): number {
return this._stats.angle;
};
/**
* The 'directional bucket' for the detected angle. This will
* correspond to a cardinal or an intercardinal ('n', 'ne', etc.)
*/
get direction(): string {
return this._stats.cardinalDirection;
};
/**
* The classification of this Segment: 'start', 'hold', 'move', or 'end'.
*
* May be null if the Segment has not yet been `recognized`.
*
* @see `SegmentClass`
*/
get type(): SegmentClass {
return this._type || null;
}
protected _isRecognized(): boolean {
return this._type !== undefined;
}
/**
* A `Promise` that resolves when the segment's classification is determined
* - when we "recognize" its role in the touchpath.
*/
get whenRecognized(): Promise<SegmentClass> {
return this._recognitionPromise;
}
/**
* A `Promise` that resolves when the segment and its role within the touchpath
* are fully determined, as segmentation has noted the need for a new boundary -
* and thus, distinct and different behavior after the endpoint of this segment
* of the touchpath.
*/
get whenResolved(): Promise<void> {
return this._resolutionPromise;
}
protected resolve() {
if(!this._stats) {
throw new Error("Cannot resolve the segment - illegal state!");
}
this._resolutionPromiseResolver();
}
/**
* Provides a human-readable yet serialization-friendly version of this instance.
* @returns
*/
public toJSON(): JSONSegment {
const cleanSample: (sample: InputSample<any>) => InputSample<any> = (sample) => {
const clone = {... sample};
delete clone.clientX;
delete clone.clientY;
return clone;
}
return {
type: this.type,
duration: this.duration,
cardinalDirection: this.direction,
speed: this.speed,
distance: this.distance,
angle: this.angle,
peakSpeed: this.peakSpeed,
initialCoord: cleanSample(this.initialCoord),
lastCoord: cleanSample(this.lastCoord)
};
}
}
/**
* Provides an editable typing for Segment utilized during the segmentation process;
* casting to the superclass provides an uneditable view into the Segment instead.
*
* This is intended for internal use only.
*/
export class SegmentImplementation extends Segment {
constructor() {
super();
}
public setPeakSpeed(speed: number) {
super.setPeakSpeed(speed);
}
public updateStats(stats: CumulativePathStats) {
super.updateStats(stats);
}
public classifyType(type: SegmentClass) {
super.classifyType(type);
}
public resolve() {
super.resolve();
}
public get isRecognized() {
return super._isRecognized();
}
}

View file

@ -1,159 +0,0 @@
import { SegmentationSplit } from "./pathSegmenter.js";
import { SegmentClass, SegmentClassifier } from "../segmentClassifier.js";
/**
* This class fulfills two roles:
*
* 1. The `.isCompatible()` function provides the core definition used by
* `ConstructingSegment` to re-merge compatible subsegments, 'correcting' any
* 'oversegmentation' that may have resulted from `PathSegmenter`'s algorithm.
*
* 2. This class's other properties and methods facilitate inspection of
* the algorithm and its decision-making process during interactive debugging
* sessions.
*
* `SegmentationSplit` objects are constructed from Subsegmentation objects,
* which are made in abundance throughout the segmentation algorithms. So, it
* shouldn't be difficult to construct the necessary parameters to dynamically
* construct instances of this class during an interactive debugging session.
*/
export class SubsegmentCompatibilityAnalyzer {
readonly classifier: SegmentClassifier;
private split: SegmentationSplit;
constructor(subsegmentationSplit: SegmentationSplit, classifier: SegmentClassifier) {
this.split = subsegmentationSplit;
this.classifier = classifier;
}
private get preStats() {
return this.split.pre.stats;
}
private get postStats() {
return this.split.post.stats;
}
private get unionStats() {
return this.split.union;
}
/**
* This property summarizes subsegment compatibility on the basis of direction.
*
* 1. If both subsegments are compatible as determined by geometry-based regression, we consider
* them compatible. (The first phase considers both geometry _and time_; we leave the "time"
* part out here.)
*
* 2. If regression upholds the segmentation, but both subsegments are classified as
* 'move'-compatible and their directions both fall within the same "direction bucket", we
* consider them compatible.
*
* 3. If both subsegments are classified as 'hold's, angle doesn't matter - the user's intent
* is "no motion".
*/
get directionCompatible(): boolean {
if(!this.preStats || this.regressionDirectionCompatible) {
return true;
}
let preMatchesMove = this.classifier.classifySubsegment(this.preStats) != SegmentClass.HOLD;
let postMatchesMove = this.classifier.classifySubsegment(this.postStats) != SegmentClass.HOLD;
if(preMatchesMove != postMatchesMove) {
// If one subsegment appears to be a 'hold' while the other does not, well... holds
// don't (practically) have a direction, which mismatches with the 'move' that does
// have a direction.
return false;
} else if(!preMatchesMove) {
// If both are 'hold' subsegments, both should be treated as directionless.
return true;
} else {
// If both are 'move' / 'move'-like subsegments, only merge if their directional
// classification falls into the same 'direction bucket'.
return this.cardinalDirectionCompatible;
}
}
/**
* Indicates whether or not the two halves of the represented segmentation fall within
* the same cardinal-direction "bucket".
*/
get cardinalDirectionCompatible(): boolean {
return !this.preStats || this.preStats.cardinalDirection == this.postStats.cardinalDirection;
}
/**
* Indicates whether or not geometry-based regression of the two subsegments (without
* respect to speed and/or time) provides sufficient evidence to uphold the segmentation.
*/
get regressionDirectionCompatible(): boolean {
return !this.preStats || this.split.mergeMerited;
}
/**
* Indicates whether or not the classifications of the two subsegments is compatible;
* if so, it returns the corresponding `SegmentClass` (or `null`, if no classification
* may be committed yet).
*
* If the classifications are incompatible, ths function will return `undefined`
* instead.
*/
get classificationIfCompatible(): SegmentClass {
// Get the baseline subsegment classification for each subsegment.
let leftClass = this.classifier.classifySubsegment(this.preStats);
let rightClass = this.classifier.classifySubsegment(this.postStats);
let unionClass = this.classifier.classifySubsegment(this.unionStats);
// Choose the first non-null one as a fallback, then apply it.
let fallbackClass = leftClass || rightClass || unionClass;
leftClass = leftClass || fallbackClass;
rightClass = rightClass || fallbackClass;
unionClass = unionClass || fallbackClass;
// If all classes (post-fallback) match, that's the class if compatible.
if(leftClass == rightClass && leftClass == unionClass) {
return fallbackClass; // can technically still be null.
} else {
return undefined;
}
}
/**
* The full, complete test for subsegment 'compatibility'. If `true`, our
* criteria indicate no merit in upholding the segmentation under review.
* `false` indicates that the segmentation should be upheld instead.
*/
get isCompatible(): boolean {
const commonClass = this.classificationIfCompatible;
// If two adjacent hold subsegments also make a hold when combined...
// just merge the two holds & call 'em compatible.
if(!this.preStats || commonClass == 'hold') {
return true;
} else if(commonClass === undefined) { // `null`: pending; `undefined`: incompatible
return false;
}
// if(null || "move"): as `null` "looks like" a not-quite-there-yet "move",
// we treat it as such here. Such subsegments are only compatible if
// their directions are compatible.
return this.directionCompatible;
}
/**
* Intended only for use during interactive debugging sessions; prints useful logging
* information to the developer console.
*/
public debugLogCompabilityReport() {
if(!this.preStats) {
console.log("No prior subsegments - thus, no compatibility conflicts are possible.");
return;
}
console.log("Regression-based compatibility check:")
this.split._debugLogAlignmentReport();
}
}

View file

@ -1,6 +1,5 @@
import { validateModelDefs } from './headless/gestures/specs/modelDefValidator.js';
export { ConstructingSegment } from './headless/subsegmentation/constructingSegment.js';
export { CumulativePathStats } from './headless/cumulativePathStats.js';
export { GestureModelDefs } from './headless/gestures/specs/gestureModelDefs.js';
export { GestureRecognizer } from "./gestureRecognizer.js";
@ -11,10 +10,8 @@ export { SerializedGesturePath, GesturePath } from "./headless/gesturePath.js";
export { ConfigChangeClosure, GestureStageReport, GestureSequence } from "./headless/gestures/matchers/gestureSequence.js";
export { SerializedGestureSource, GestureSource, buildGestureMatchInspector } from "./headless/gestureSource.js";
export { MouseEventEngine } from "./mouseEventEngine.js";
export { PathSegmenter, Subsegmentation } from "./headless/subsegmentation/pathSegmenter.js";
export { PaddedZoneSource } from './configuration/paddedZoneSource.js';
export { RecognitionZoneSource } from './configuration/recognitionZoneSource.js';
export { Segment } from "./headless/subsegmentation/segment.js";
export { SegmentClassifier } from "./headless/segmentClassifier.js";
export { TouchEventEngine } from "./touchEventEngine.js";
export { TouchpointCoordinator } from "./headless/touchpointCoordinator.js";

View file

@ -1,390 +0,0 @@
import { assert } from 'chai'
import sinon from 'sinon';
import * as PromiseStatusModule from 'promise-status-async';
const promiseStatus = PromiseStatusModule.promiseStatus;
const PromiseStatuses = PromiseStatusModule.PromiseStatuses;
import { PathSegmenter } from '@keymanapp/gesture-recognizer';
import { timedPromise } from '@keymanapp/web-utils';
describe("Basic segmentation cases", function() {
describe("Single-sample 'sequence'", function() {
it("expected segment types", function() {
let spy = sinon.fake();
const segmenter = new PathSegmenter(PathSegmenter.DEFAULT_CONFIG, spy);
const sample = {
targetX: 1,
targetY: 1,
t: 100
};
// Expected sequence:
// 1: on `segmenter.add(sample)`:
// a. 'start' segment
// b. unrecognized (null-type) segment.
// 2: on `segmenter.close()`:
// b. 'end' segment
segmenter.add(sample);
try {
assert.isTrue(spy.calledTwice, "Segmenter callback was not called exactly twice before the .close().");
} finally {
segmenter.close();
}
assert.isTrue(spy.calledThrice, "Segmenter callback was not called exactly once after the .close().");
for(let i=0; i < 3; i++) {
assert.equal(spy.args[i].length, 1, "Segmenter callback received an unexpected number of arguments");
}
// Is the first argument of each call's argument set.
assert.equal(spy.firstCall .args[0].type, 'start', "First call should receive a 'start'-type segment.");
assert.equal(spy.secondCall.args[0].type, undefined, "Second call's segment should not be classified.");
assert.equal(spy.thirdCall .args[0].type, 'end', "Third call should receive an 'end'-type segment.");
});
it("segment recognition + resolution", async function() {
let spy = sinon.fake();
const segmenter = new PathSegmenter(PathSegmenter.DEFAULT_CONFIG, spy);
const sample = {
targetX: 1,
targetY: 1,
t: 100
};
segmenter.add(sample);
try {
// It's still best to verify this before proceeding.
assert.isTrue(spy.calledTwice, "Segmenter callback was not called exactly twice before the .close().");
// 'start': should be fully resolved, right out of the gate.
assert.equal(await promiseStatus(spy.firstCall.args[0].whenRecognized), PromiseStatuses.PROMISE_RESOLVED);
assert.equal(await promiseStatus(spy.firstCall.args[0].whenResolved), PromiseStatuses.PROMISE_RESOLVED);
// null-typed, just-starting segment: neither recognized nor resolved.
assert.equal(await promiseStatus(spy.secondCall.args[0].whenRecognized), PromiseStatuses.PROMISE_PENDING);
assert.equal(await promiseStatus(spy.secondCall.args[0].whenResolved), PromiseStatuses.PROMISE_PENDING);
} finally {
segmenter.close();
}
assert.isTrue(spy.calledThrice, "Segmenter callback was not called exactly once after the .close().");
// null-typed segment should now be recognized and resolved.
assert.equal(await promiseStatus(spy.secondCall.args[0].whenRecognized), PromiseStatuses.PROMISE_RESOLVED);
assert.equal(await promiseStatus(spy.secondCall.args[0].whenResolved), PromiseStatuses.PROMISE_RESOLVED);
// 'end': should be fully resolved, right out of the gate.
assert.equal(await promiseStatus(spy.thirdCall.args[0].whenRecognized), PromiseStatuses.PROMISE_RESOLVED);
assert.equal(await promiseStatus(spy.thirdCall.args[0].whenResolved), PromiseStatuses.PROMISE_RESOLVED);
});
});
describe("Single held point", function() {
beforeEach(function() {
this.fakeClock = sinon.useFakeTimers();
})
afterEach(function() {
this.fakeClock.restore();
})
it("'hold' segment recognition + resolution", async function() {
let spy = sinon.fake();
const segmenter = new PathSegmenter(PathSegmenter.DEFAULT_CONFIG, spy);
const startSample = {
targetX: 1,
targetY: 1,
t: 100
};
const endSample = {
targetX: 1,
targetY: 1,
t: 1100 // total duration: 1 sec.
};
const segment2Recognition = sinon.fake();
const segment2Resolution = sinon.fake();
// Timestamp 1: segmentation begins, with an initial Sample recorded.
const firstPromise = timedPromise(0).then(() => {
segmenter.add(startSample);
assert.isTrue(spy.calledTwice, "Segmenter callback was not called exactly twice upon adding the first point.");
// The focus of this unit test is the two `Promise`s provided by this specific `Segment`.
const pendingSegment = spy.secondCall.args[0];
assert.exists(pendingSegment);
pendingSegment.whenRecognized.then(segment2Recognition);
pendingSegment.whenResolved.then(segment2Resolution);
}).then(() => {
// At time = 0, neither recognition nor resolution should have triggered.
assert.isFalse(segment2Recognition.called);
const pendingSegment = spy.secondCall.args[0];
assert.isNull(pendingSegment.type); // not yet recognized.
assert.isFalse(segment2Resolution.called);
});
// Timestamp 2: segmentation continues... a second later. A second Sample is recorded.
const secondSampleTestPromise = firstPromise.then(() => {
return timedPromise(1000).then(() => {
// This should have occurred already, despite no new sample having been provided
// to the segmenter.
assert.isTrue(segment2Recognition.called);
const pendingSegment = spy.secondCall.args[0];
assert.equal(pendingSegment.type, 'hold'); // now has a type, as it's been recognized.
// And now to update with a new sample.
segmenter.add(endSample);
}).then(() => {
assert.isFalse(segment2Resolution.called);
const pendingSegment = spy.secondCall.args[0];
assert.isAtLeast(pendingSegment.duration, 1000); // Latest sample's timestamp gives exactly 1000.
});
});
// Timestamp 3: segmentation is then ended via a followup event.
const segmentationEndPromise = secondSampleTestPromise.then(() => {
assert.isFalse(segment2Resolution.called);
segmenter.close();
// Fun fact: assert.isFalse(segment2Resolution.called) still holds here
// (after `segmenter.close()`) because Promises are async.
}).then(() => {
assert.isTrue(segment2Resolution.called);
assert.isTrue(spy.calledThrice);
})
const finalPromise = segmentationEndPromise.catch((reason) => {
segmenter.close();
throw reason;
});
this.fakeClock.runAllAsync();
// This is the one that reports all of our async assertion failures.
return finalPromise;
});
it("motionless 'hold' segment properties", function() {
const spy = sinon.fake();
const segmenter = new PathSegmenter(PathSegmenter.DEFAULT_CONFIG, spy);
const startSample = {
targetX: 1,
targetY: 1,
t: 100
};
const endSample = {
targetX: 1,
targetY: 1,
t: 1100 // total duration: 1 sec.
};
// Sample 'playback' Promise setup
const samples = [startSample, endSample];
const samplePromises = samples.map((sample) => {
return timedPromise(sample.t - startSample.t).then(() => {
segmenter.add(sample);
});
});
// Timestamp 3: segmentation is then ended via a followup event.
const segmentationEndPromise = Promise.all(samplePromises).then(() => {
segmenter.close();
});
const finalPromise = segmentationEndPromise.catch((reason) => {
segmenter.close();
throw reason;
});
this.fakeClock.runAllAsync();
// This is the one that reports all of our async assertion failures.
return finalPromise.then(() => {
const holdSegment = spy.secondCall.args[0];
assert.equal(holdSegment.type, 'hold');
assert.isAtLeast(holdSegment.duration, endSample.t - startSample.t);
assert.equal(holdSegment.speed, 0);
assert.isUndefined(holdSegment.angle);
assert.isUndefined(holdSegment.direction);
assert.equal(holdSegment.peakSpeed, 0);
assert.equal(holdSegment.distance, 0);
// Samples may be cloned when passed through the segmenter!
assert.deepEqual(holdSegment.initialCoord, startSample);
assert.deepEqual(holdSegment.lastCoord, endSample);
});
});
});
describe("Idealized 'move'", function() {
beforeEach(function() {
this.fakeClock = sinon.useFakeTimers();
})
afterEach(function() {
this.fakeClock.restore();
})
/**
* Builds a diagonal sequence traveling 40 pixels 's', 40 pixels 'e' in a perfect diagonal lock-step.
*/
const buildSampleSequence = () => {
const startSample = {
targetX: 1,
targetY: 1,
t: 100
};
let samples = [startSample];
for(let i=1; i <=20; i++) {
samples.push({
targetX: startSample.targetX + 2 * i,
targetY: startSample.targetY + 2 * i,
t: startSample.t + 10 * i
});
}
return samples;
}
it("'move' segment recognition + resolution", async function() {
let spy = sinon.fake();
const segmenter = new PathSegmenter(PathSegmenter.DEFAULT_CONFIG, spy);
const samples = buildSampleSequence();
const samplePromises = samples.map((sample) => {
return timedPromise(sample.t - samples[0].t).then(() => {
segmenter.add(sample);
});
});
// Find and replace select promises with then'd versions of themselves to 'hook in' as needed.
// We know the 'move' segment will appear upon completion of the first sample's Promise.
const segment2Recognition = sinon.fake();
const segment2Resolution = sinon.fake();
samplePromises[0] = samplePromises[0].then(async () => {
assert.isAtLeast(spy.callCount, 2);
const moveSegment = spy.secondCall.args[0];
assert.equal(await promiseStatus(moveSegment.whenRecognized), PromiseStatuses.PROMISE_PENDING);
assert.equal(await promiseStatus(moveSegment.whenResolved), PromiseStatuses.PROMISE_PENDING);
// Now that we're sure they haven't already been called, let's set the `fake` calls in place.
moveSegment.whenRecognized.then(segment2Recognition);
moveSegment.whenResolved.then(segment2Resolution);
assert.isNotNull(moveSegment);
});
// Given the current engine setup, we know that after 5 samples, we pass the movement-based
// recognition threshold and thus should be classified as a 'move'.
samplePromises[5] = samplePromises[5].then(() => {
const moveSegment = spy.secondCall.args[0];
assert.isTrue(segment2Recognition.called);
assert.isFalse(segment2Resolution.called);
assert.equal(moveSegment.type, 'move');
});
samplePromises[20] = samplePromises[20].then(() => {
const moveSegment = spy.secondCall.args[0];
assert.isTrue(segment2Recognition.called);
assert.isFalse(segment2Resolution.called);
assert.isAtLeast(moveSegment.duration, samples[20].t - samples[0].t);
});
// Timestamp 3: segmentation is then ended via a followup event.
const segmentationEndPromise = Promise.all(samplePromises).then(() => {
assert.isFalse(segment2Resolution.called);
segmenter.close();
}).then(() => {
assert.isTrue(segment2Resolution.called);
assert.equal(spy.callCount, 3);
});
const finalPromise = segmentationEndPromise.catch((reason) => {
segmenter.close();
throw reason;
});
this.fakeClock.runAllAsync();
// This is the one that reports all of our async assertion failures.
return finalPromise;
});
it("idealized 'move' segment properties", function() {
let spy = sinon.fake();
const segmenter = new PathSegmenter(PathSegmenter.DEFAULT_CONFIG, spy);
const samples = buildSampleSequence();
const samplePromises = samples.map((sample) => {
return timedPromise(sample.t - samples[0].t).then(() => {
segmenter.add(sample);
});
});
// Timestamp 3: segmentation is then ended via a followup event.
const segmentationEndPromise = Promise.all(samplePromises).then(() => {
segmenter.close();
});
const finalPromise = segmentationEndPromise.catch((reason) => {
segmenter.close();
throw reason;
});
this.fakeClock.runAllAsync();
// This is the one that reports all of our async assertion failures.
return finalPromise.then(() => {
// The real checks.
const moveSegment = spy.secondCall.args[0];
assert.equal(moveSegment.type, 'move');
assert.isAtLeast(moveSegment.duration, samples[20].t - samples[0].t);
assert.equal(moveSegment.direction, 'se');
assert.equal(moveSegment.distance, 40 * Math.SQRT2);
assert.equal(moveSegment.angle, (135 / 180) * Math.PI);
// Speed: our idealized sequence moves 2*sqrt(2) px distance every 10ms.
const idealSpeed = 2 * Math.sqrt(2) / 10;
assert.isAtLeast(moveSegment.speed, 0.99 * idealSpeed);
assert.isAtLeast(moveSegment.peakSpeed, 0.99 * idealSpeed);
// Samples may be cloned when passed through the segmenter!
assert.deepEqual(moveSegment.initialCoord, samples[0]);
assert.deepEqual(moveSegment.lastCoord, samples[20]);
});
});
});
});

View file

@ -9,7 +9,6 @@ describe("CumulativePathStats", function() {
assert.equal(stats.duration, 0);
assert.equal(stats.angle, undefined);
assert.isNaN(stats.angleDeviation);
assert.equal(stats.cardinalDirection, undefined);
assert.equal(stats.rawDistance, 0);
assert.equal(stats.netDistance, 0);
@ -27,10 +26,7 @@ describe("CumulativePathStats", function() {
assert.equal(stats.duration, 0);
assert.equal(stats.angle, undefined);
assert.isNaN(stats.angleDeviation);
assert.equal(stats.cardinalDirection, undefined);
assert.isNaN(stats.variance('x'));
assert.isNaN(stats.variance('y'));
assert.equal(stats.mean('x'), 4);
assert.equal(stats.mean('y'), 8);
});
@ -58,14 +54,6 @@ describe("CumulativePathStats", function() {
assert.equal(stats.mean('y'), samples[2].targetY);
assert.equal(stats.mean('t'), samples[2].t);
// The two are perfectly correlated and have the same variance.
// Since we renormalize samples, the base 'intercept' of each variable's linear form should have no effect..
assert.equal(stats.squaredSum('x'), stats.crossSum('xy'));
assert.equal(stats.squaredSum('y'), stats.crossSum('xy'));
// As x and y are perfectly correlated with the same variance, their cross-sum with t should match.
assert.equal(stats.crossSum('tx'), stats.crossSum('ty'));
assert.equal(stats.angle, 135 * Math.PI / 180);
assert.equal(stats.rawDistance, 16 * Math.SQRT2); // 4 intervals of length 4 * sqrt(2)
assert.equal(stats.cardinalDirection, 'se');
@ -111,44 +99,6 @@ describe("CumulativePathStats", function() {
assert.sameDeepOrderedMembers(postStats, preStats);
});
it("Basic regressions (perfect correlation)", function() {
const samples: InputSample<any>[] = [];
// Exactly 5 points, evenly spaced and linear.
// So, the arithmetic mean should be very obvious - it's the middle sample.
for(let i = 0; i < 5; i++) {
samples.push({
targetX: 4 * i + 10,
targetY: 4 * i + 20,
t: 100 * i // since it makes head-math simpler for the regressions we'll be doing.
});
}
let stats = new CumulativePathStats();
for(const sample of samples) {
stats = stats.extend(sample);
}
const xtRegression = stats.fitRegression('x', 't');
assert.equal(xtRegression.sumOfSquaredError, 0);
assert.equal(xtRegression.slope, 4 / 100);
assert.equal(xtRegression.intercept, 10);
assert.equal(xtRegression.predictFromValue(1000), 40 + 10);
const ytRegression = stats.fitRegression('y', 't');
assert.equal(ytRegression.sumOfSquaredError, 0);
assert.equal(ytRegression.slope, 4 / 100);
assert.equal(ytRegression.intercept, 20);
assert.equal(ytRegression.predictFromValue(1000), 40 + 20);
const xyRegression = stats.fitRegression('y', 'x');
assert.equal(xyRegression.sumOfSquaredError, 0);
assert.equal(xyRegression.slope, 1);
assert.equal(xyRegression.intercept, 10);
assert.equal(xyRegression.predictFromValue(50), 60);
});
it("Deaccumulation", function() {
const sampleSet1: InputSample<any>[] = [];
@ -209,14 +159,6 @@ describe("CumulativePathStats", function() {
assert.equal(deaccumulatedSecondHalfStats.mean('y'), secondHalfStats.mean('y'));
assert.equal(deaccumulatedSecondHalfStats.mean('t'), secondHalfStats.mean('t'));
assert.equal(deaccumulatedSecondHalfStats.variance('x'), secondHalfStats.variance('x'));
assert.equal(deaccumulatedSecondHalfStats.variance('y'), secondHalfStats.variance('y'));
assert.equal(deaccumulatedSecondHalfStats.variance('t'), secondHalfStats.variance('t'));
assert.equal(deaccumulatedSecondHalfStats.covariance('tx'), secondHalfStats.covariance('tx'));
assert.equal(deaccumulatedSecondHalfStats.covariance('ty'), secondHalfStats.covariance('ty'));
assert.equal(deaccumulatedSecondHalfStats.covariance('xy'), secondHalfStats.covariance('xy'));
// Floating-point "equality".
assert.closeTo(deaccumulatedSecondHalfStats.netDistance, secondHalfStats.netDistance, 1e-8);
assert.closeTo(deaccumulatedSecondHalfStats.rawDistance, secondHalfStats.rawDistance, 1e-8);
@ -226,69 +168,5 @@ describe("CumulativePathStats", function() {
assert.equal(deaccumulatedSecondHalfStats.initialSample, secondHalfStats.initialSample);
assert.equal(deaccumulatedSecondHalfStats.lastSample, secondHalfStats.lastSample);
// Angle deviation stuff doesn't currently have the same level of catastrophic cancellation protection
// as the main statistical properties. But... even still, we can add a reasonable test.
assert.isBelow(Math.abs(deaccumulatedSecondHalfStats.angleDeviation - secondHalfStats.angleDeviation), 1e-7);
});
it("Renormalization", () => {
let stats = new CumulativePathStats();
const turtle = new TouchpathTurtle({
targetX: 50,
targetY: -25,
t: 500,
item: 'a'
});
turtle.on('sample', (sample) => {
stats = stats.extend(sample);
});
function assertMatchingStats(actual: CumulativePathStats, expected: CumulativePathStats) {
assert.equal(actual.duration, expected.duration);
assert.equal(actual.sampleCount, expected.sampleCount);
assert.closeTo(actual.angle, expected.angle, 1e-8);
assert.closeTo(actual.mean('x'), expected.mean('x'), 1e-8);
assert.closeTo(actual.mean('y'), expected.mean('y'), 1e-8);
assert.closeTo(actual.mean('t'), expected.mean('t'), 1e-8);
assert.closeTo(actual.variance('x'), expected.variance('x'), 1e-8);
assert.closeTo(actual.variance('y'), expected.variance('y'), 1e-8);
assert.closeTo(actual.variance('t'), expected.variance('t'), 1e-8);
assert.closeTo(actual.rawDistance, expected.rawDistance, 1e-8);
assert.closeTo(actual.netDistance, expected.netDistance, 1e-8);
const actualRegression = actual.fitRegression('x', 'y');
const expectedRegression = expected.fitRegression('x', 'y');
assert.closeTo(
actualRegression.coefficientOfDetermination,
expectedRegression.coefficientOfDetermination,
1e-8
);
assert.closeTo(actualRegression.slope, expectedRegression.slope, 1e-8);
}
turtle.move(90, 4, 20, 2);
turtle.move(60, 5, 20, 2); // net move: <4, -3>.
turtle.commitPending();
let renormalizedStats = stats.buildRenormalized();
assertMatchingStats(renormalizedStats, stats);
// Continue to accumulate stats for both.
turtle.on('sample', (sample) => {
renormalizedStats = renormalizedStats.extend(sample);
})
turtle.move(0, 10, 40, 4);
turtle.move(45, 3 * Math.sqrt(2), 40, 4);
turtle.commitPending();
// After the renormalization, there should be no externally-visible distinction between the two stats objects.
assertMatchingStats(renormalizedStats, stats);
});
});

View file

@ -1,342 +0,0 @@
import { assert } from 'chai';
import sinon from 'sinon';
import fs from 'fs';
import path from 'path';
import url from 'url';
import * as PromiseStatusModule from 'promise-status-async';
const promiseStatus = PromiseStatusModule.promiseStatus;
const PromiseStatuses = PromiseStatusModule.PromiseStatuses;
import { PathSegmenter } from '@keymanapp/gesture-recognizer';
import { HeadlessInputEngine } from '#tools';
// Ensures that the resources are resolved relative to this script, not to the cwd when the test
// runner was launched.
const scriptFolder = path.dirname(url.fileURLToPath(import.meta.url));
const SEGMENT_TEST_JSON_FOLDER = path.resolve(`${scriptFolder}/../../resources/json/segmentation`);
import { assertSegmentSimilarity } from '../../resources/assertSegmentSimilarity.js';
/**
* Since we're disconnecting subsegmentation stuff for the first gestures release, our
* previously-recorded subsegmentations aren't retrievable in the same spot as before.
*
* This manually retrieves them from the originally-recorded version in order to preserve
* the unit tests. Makes a few assumptions, but they're valid for the original recordings.
* @param {*} jsonObj
*/
function retrieveSubsegmentations(jsonObj) {
return jsonObj.inputs[0].path.segments;
}
// ---------------------------------------
// NOTE: this suite of tests is for a disconnected subsystem - subsegmentation - designed
// to handle more complicated gestures than supported in our initial release. We had to
// triage it to prevent further delays.
//
// The tests themselves should still be functional.
// ---------------------------------------
describe("Segmentation - from recorded sequences", function() {
beforeEach(function() {
this.fakeClock = sinon.useFakeTimers();
})
afterEach(function() {
// NOTE: for debugging investigations, it may be necessary to use .only on
// the test under investigation and to disable the `this.fakeClock.restore()` line.
//
// Tests tend to timeout when interactively debugging, and having unmocked timers
// suddenly restored during investigation can cause some very confusing behavior.
this.fakeClock.restore();
})
it("simple_ne_move.json", async function() {
// NOTE: this recording's final 'hold' segment is somewhat tightly attuned to the DEFAULT_CONFIG
// hold time setting. Changing default values there may necessitate a hand-edit tweak to the
// test recording's data in order for this test to continue passing as-is.
let testJSONtext = fs.readFileSync(`${SEGMENT_TEST_JSON_FOLDER}/simple_ne_move.json`);
let jsonObj = JSON.parse(testJSONtext);
// Some special setup - we're going to capture the 'move' segment in-process and run a check that way.
const recognitionTestCapturer = {
recognizedPromise: null
}
// Spy: the method we pass in is _actually called_ while _also_ capturing metadata for every call.
let spy = sinon.spy((segment) => {
if(segment.type == 'move' && !recognitionTestCapturer.recognizedPromise) {
segment.whenRecognized.then(() => {
recognitionTestCapturer.recognizedPromise = new Promise((resolve, reject) => {
// Verify that we resolved because of distance, not time.
assert.isAtLeast(segment.distance, PathSegmenter.DEFAULT_CONFIG.holdMoveTolerance);
assert.isBelow(segment.duration, PathSegmenter.DEFAULT_CONFIG.holdMinimumDuration);
// Makes sure the segment isn't resolved at the same time it is recognized.
promiseStatus(segment.whenResolved).then((status) => {
assert.equal(status, PromiseStatuses.PROMISE_PENDING);
resolve();
}).catch((err) => {
reject(err);
});
});
return recognitionTestCapturer.recognizedPromise;
});
}
});
// Now the normal basic setup.
const segmenter = new PathSegmenter(PathSegmenter.DEFAULT_CONFIG, spy);
let engine = new HeadlessInputEngine();
engine.once('pointstart', (point) => {
// The first point is always available initially, rather than via event.
segmenter.add(point.path.coords[0]);
point.path.on('step', (val) => {
segmenter.add(val);
});
});
const testPromise = engine.playbackRecording(jsonObj).then(() => segmenter.close());
await this.fakeClock.runAllAsync();
await testPromise;
await recognitionTestCapturer.recognizedPromise;
// Any post-sequence tests to run.
const originalSegments = retrieveSubsegmentations(jsonObj);
const originalSegmentTypeSequence = originalSegments.map((segment) => segment.type);
const reproedSegments = spy.getCalls().map((call) => call.args[0]);
const reproedSegmentTypeSequence = reproedSegments.map((segment) => segment.type);
assert.sameOrderedMembers(reproedSegmentTypeSequence, originalSegmentTypeSequence);
// Ensure all relevant Promises resolved.
for(let segment of reproedSegments) {
assert.isTrue(await promiseStatus(segment.whenRecognized) == PromiseStatuses.PROMISE_RESOLVED);
assert.isTrue(await promiseStatus(segment.whenResolved) == PromiseStatuses.PROMISE_RESOLVED);
}
assertSegmentSimilarity(reproedSegments[1], originalSegments[1], 'hold'); // ~200ms
assertSegmentSimilarity(reproedSegments[2], originalSegments[2], 'move'); // 'ne'
});
it("nonstationary_hold.json", async function() {
let testJSONtext = fs.readFileSync(`${SEGMENT_TEST_JSON_FOLDER}/nonstationary_hold.json`);
let jsonObj = JSON.parse(testJSONtext);
// Some special setup - we're going to capture the 'hold' segment in-process and run a check that way.
const recognitionTestCapturer = {
recognizedPromise: null
}
// Spy: the method we pass in is _actually called_ while _also_ capturing metadata for every call.
let spy = sinon.spy((segment) => {
if(segment.type == 'hold' && !recognitionTestCapturer.recognizedPromise) {
segment.whenRecognized.then(() => {
recognitionTestCapturer.recognizedPromise = new Promise((resolve, reject) => {
// Verify that we resolved because of time, not distance.
assert.isBelow(segment.distance, PathSegmenter.DEFAULT_CONFIG.holdMoveTolerance);
assert.isAtLeast(segment.duration, PathSegmenter.DEFAULT_CONFIG.holdMinimumDuration);
// Makes sure the segment isn't resolved at the same time it is recognized.
promiseStatus(segment.whenResolved).then((status) => {
assert.equal(status, PromiseStatuses.PROMISE_PENDING);
resolve();
}).catch((err) => {
reject(err);
});
});
return recognitionTestCapturer.recognizedPromise;
});
}
});
// Now the normal basic setup.
const segmenter = new PathSegmenter(PathSegmenter.DEFAULT_CONFIG, spy);
let engine = new HeadlessInputEngine();
engine.once('pointstart', (point) => {
// The first point is always available initially, rather than via event.
segmenter.add(point.path.coords[0]);
point.path.on('step', (val) => {
segmenter.add(val);
});
});
const testPromise = engine.playbackRecording(jsonObj).then(() => segmenter.close());
await this.fakeClock.runAllAsync();
await testPromise;
await recognitionTestCapturer.recognizedPromise;
// Any post-sequence tests to run.
const originalSegments = retrieveSubsegmentations(jsonObj);
const originalSegmentTypeSequence = originalSegments.map((segment) => segment.type);
const reproedSegments = spy.getCalls().map((call) => call.args[0]);
const reproedSegmentTypeSequence = reproedSegments.map((segment) => segment.type);
assert.sameOrderedMembers(reproedSegmentTypeSequence, originalSegmentTypeSequence);
// Ensure all relevant Promises resolved.
for(let segment of reproedSegments) {
assert.isTrue(await promiseStatus(segment.whenRecognized) == PromiseStatuses.PROMISE_RESOLVED);
assert.isTrue(await promiseStatus(segment.whenResolved) == PromiseStatuses.PROMISE_RESOLVED);
}
assertSegmentSimilarity(reproedSegments[1], originalSegments[1], 'hold'); // ~1200ms
});
it("flick_ne_se.json", async function() {
let testJSONtext = fs.readFileSync(`${SEGMENT_TEST_JSON_FOLDER}/flick_ne_se.json`);
let jsonObj = JSON.parse(testJSONtext);
// Prepares some basic setup - we won't set up segment-specific recognition tests here.
let spy = sinon.fake();
const segmenter = new PathSegmenter(PathSegmenter.DEFAULT_CONFIG, spy);
let engine = new HeadlessInputEngine();
engine.once('pointstart', (point) => {
// The first point is always available initially, rather than via event.
segmenter.add(point.path.coords[0]);
point.path.on('step', (val) => {
segmenter.add(val);
});
});
const testPromise = engine.playbackRecording(jsonObj).then(() => segmenter.close());
await this.fakeClock.runAllAsync();
await testPromise;
// Any post-sequence tests to run.
const originalSegments = retrieveSubsegmentations(jsonObj);
const originalSegmentTypeSequence = originalSegments.map((segment) => segment.type);
const reproedSegments = spy.getCalls().map((call) => call.args[0]);
const reproedSegmentTypeSequence = reproedSegments.map((segment) => segment.type);
assert.sameOrderedMembers(reproedSegmentTypeSequence, originalSegmentTypeSequence);
// Ensure all relevant Promises resolved.
for(let segment of reproedSegments) {
assert.isTrue(await promiseStatus(segment.whenRecognized) == PromiseStatuses.PROMISE_RESOLVED);
assert.isTrue(await promiseStatus(segment.whenResolved) == PromiseStatuses.PROMISE_RESOLVED);
}
assertSegmentSimilarity(reproedSegments[1], originalSegments[1], 'hold'); // ~820ms
assertSegmentSimilarity(reproedSegments[2], originalSegments[2], 'move'); // 'ne'
assertSegmentSimilarity(reproedSegments[3], originalSegments[3], 'hold'); // ~580ms
assertSegmentSimilarity(reproedSegments[4], originalSegments[4], 'move'); // 'se'
assertSegmentSimilarity(reproedSegments[5], originalSegments[5], 'hold'); // ~300ms
});
it("longpress_to_ne.json", async function() {
let testJSONtext = fs.readFileSync(`${SEGMENT_TEST_JSON_FOLDER}/longpress_to_ne.json`);
let jsonObj = JSON.parse(testJSONtext);
// Prepares some basic setup - we won't set up segment-specific recognition tests here.
let spy = sinon.fake();
const segmenter = new PathSegmenter(PathSegmenter.DEFAULT_CONFIG, spy);
let engine = new HeadlessInputEngine();
engine.once('pointstart', (point) => {
// The first point is always available initially, rather than via event.
segmenter.add(point.path.coords[0]);
point.path.on('step', (val) => {
segmenter.add(val);
});
});
const testPromise = engine.playbackRecording(jsonObj).then(() => segmenter.close());
await this.fakeClock.runAllAsync();
await testPromise;
// Any post-sequence tests to run.
const originalSegments = retrieveSubsegmentations(jsonObj);
const reproedSegments = spy.getCalls().map((call) => call.args[0]);
// Because of the sweeping arc motion, we won't assume a perfect match to the segmentation here.
// Ensure all relevant Promises resolved.
for(let segment of reproedSegments) {
assert.isTrue(await promiseStatus(segment.whenRecognized) == PromiseStatuses.PROMISE_RESOLVED);
assert.isTrue(await promiseStatus(segment.whenResolved) == PromiseStatuses.PROMISE_RESOLVED);
}
assertSegmentSimilarity(reproedSegments[1], originalSegments[1], 'hold'); // ~1460ms
// there are actually a LOT of move segments here; the motion was in an arc.
// will make a good test case for the eventual longpress gesture.
assert.equal(reproedSegments[2].type, 'move');
assert.equal(reproedSegments[2].direction, 'n'); // the initial direction once motion started.
});
it("quick_small_square.json", async function() {
// NOTE: this recording has a number of borderline-duration 'hold' segments between its moves.
// Naturally, there has to be SOME kind of transition when sharply changing direction.
// NOTE: We wish to ensure that each 'edge' of the 'square' remains reasonably distinct.
let testJSONtext = fs.readFileSync(`${SEGMENT_TEST_JSON_FOLDER}/quick_small_square.json`);
let jsonObj = JSON.parse(testJSONtext);
// Prepares some basic setup - we won't set up segment-specific recognition tests here.
let spy = sinon.fake();
const segmenter = new PathSegmenter(PathSegmenter.DEFAULT_CONFIG, spy);
let engine = new HeadlessInputEngine();
engine.once('pointstart', (point) => {
// The first point is always available initially, rather than via event.
segmenter.add(point.path.coords[0]);
point.path.on('step', (val) => {
segmenter.add(val);
});
});
const testPromise = engine.playbackRecording(jsonObj).then(() => segmenter.close());
await this.fakeClock.runAllAsync();
await testPromise;
// Any post-sequence tests to run.
const reproedSegments = spy.getCalls().map((call) => call.args[0]);
// Ensure all relevant Promises resolved.
for(let segment of reproedSegments) {
assert.isTrue(await promiseStatus(segment.whenRecognized) == PromiseStatuses.PROMISE_RESOLVED);
assert.isTrue(await promiseStatus(segment.whenResolved) == PromiseStatuses.PROMISE_RESOLVED);
}
const holds = reproedSegments.filter((segment) => segment.type == 'hold');
const moves = reproedSegments.filter((segment) => segment.type == 'move');
const nulls = reproedSegments.filter((segment) => segment.type === null);
assert.isEmpty(nulls);
assert.isEmpty(holds.filter((hold) => hold.duration > 300)); // all motions were very quick.
// True (intended) motions were 'e' -> 's' -> 'w' -> 'n', but the motions weren't that precise
// due to prioritizing speed.
const eMoveIndex = moves.findIndex((move) => move.direction.includes('e'));
const sMoveIndex = moves.findIndex((move) => move.direction.includes('s'));
const wMoveIndex = moves.findIndex((move) => move.direction.includes('w'));
const nMoveIndex = moves.findIndex((move) => move.direction.includes('n'));
assert.notEqual(eMoveIndex, -1);
assert.notEqual(sMoveIndex, -1);
assert.notEqual(wMoveIndex, -1);
assert.notEqual(nMoveIndex, -1);
// Again, the true (intended) motions were 'e' -> 's' -> 'w' -> 'n'. This verifies the relative ordering.
assert.isBelow(eMoveIndex, sMoveIndex);
assert.isBelow(sMoveIndex, wMoveIndex);
assert.isBelow(wMoveIndex, nMoveIndex);
// Original's length: 12, but there are some borderline holds there & we don't want this test
// to be too rigid.
assert.isAtLeast(reproedSegments.length, 9); // 2 = 'start' + 'end'
// 2 = 1 hold after start, 1 hold before end
// 4 cardinal directions
// At least one notable hold during direction changes
});
});