mirror of
https://github.com/keymanapp/keyman.git
synced 2026-08-31 04:37:41 +00:00
chore(web): adjustments per PR review
This commit is contained in:
parent
af2c803d6b
commit
1d4de3f2a0
2 changed files with 76 additions and 52 deletions
|
|
@ -1,4 +1,24 @@
|
|||
namespace com.keyman.osk {
|
||||
/**
|
||||
* Denotes one dimension utilized by touchpath input coordinates - 'x' and y' for space,
|
||||
* 't' for time.
|
||||
*/
|
||||
export type PathCoordAxis = 'x' | 'y' | 't';
|
||||
|
||||
/**
|
||||
* Denotes a pair of dimensions utilized by touchpath input coordinates. The two axes
|
||||
* (see `PathCoordAxis`) must be specified in alphabetical order.
|
||||
*/
|
||||
export type PathCoordAxisPair = 'tx' | 'ty' | 'xy';
|
||||
|
||||
/**
|
||||
* Denotes one dimension or feature (velocity) that this class tracks statistics for.
|
||||
*
|
||||
* Sine and Cosine stats are currently excluded due to their necessary lack of statistical
|
||||
* independence.
|
||||
*/
|
||||
type StatAxis = PathCoordAxis | 'v';
|
||||
|
||||
/**
|
||||
* As the name suggests, this class facilitates tracking of cumulative mathematical values, etc
|
||||
* necessary to perform the statistical operations necessary for path segmentation.
|
||||
|
|
@ -6,15 +26,14 @@ namespace com.keyman.osk {
|
|||
* Instances of this class are immutable.
|
||||
*/
|
||||
export class CumulativePathStats {
|
||||
// So... class-level "inner classes" are possible in TS... if defined via assignment to a field.
|
||||
/**
|
||||
* 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: 'x' | 'y' | 't';
|
||||
readonly dependent: 'x' | 'y' | 't';
|
||||
readonly paired: 'tx' | 'ty' | 'xy';
|
||||
readonly independent: PathCoordAxis;
|
||||
readonly dependent: PathCoordAxis;
|
||||
readonly paired: PathCoordAxisPair;
|
||||
|
||||
readonly accumulator: CumulativePathStats;
|
||||
|
||||
|
|
@ -25,7 +44,7 @@ namespace com.keyman.osk {
|
|||
* existing data of its relationship with the independent axis.
|
||||
* @param independentAxis The 'input' axis/dimension.
|
||||
*/
|
||||
constructor(mainStats: CumulativePathStats, dependentAxis: 'x' | 'y' | 't', independentAxis: 'x' | 'y' | 't') {
|
||||
constructor(mainStats: CumulativePathStats, dependentAxis: PathCoordAxis, independentAxis: PathCoordAxis) {
|
||||
if(dependentAxis == independentAxis) {
|
||||
throw "Two different axes must be specified for the regression object.";
|
||||
}
|
||||
|
|
@ -36,9 +55,9 @@ namespace com.keyman.osk {
|
|||
this.independent = independentAxis;
|
||||
|
||||
if(dependentAxis < independentAxis) {
|
||||
this.paired = dependentAxis.concat(independentAxis) as 'tx' | 'ty' | 'xy';
|
||||
this.paired = dependentAxis.concat(independentAxis) as PathCoordAxisPair;
|
||||
} else {
|
||||
this.paired = independentAxis.concat(dependentAxis) as 'tx' | 'ty' | 'xy';
|
||||
this.paired = independentAxis.concat(dependentAxis) as PathCoordAxisPair;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -124,6 +143,12 @@ namespace com.keyman.osk {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating-point errors may result from cross-sum calculations, and they may be slightly larger than
|
||||
* Number.EPSILON as the sums grow. (Taking the difference of cross-sums)
|
||||
*/
|
||||
private static readonly CANCELLATION_EPSILON = Math.sqrt(Number.EPSILON);
|
||||
|
||||
private rawLinearSums: {'x': number, 'y': number, 't': number, 'v': number} = {'x': 0, 'y': 0, 't': 0, 'v': 0};
|
||||
private rawSquaredSums: {'x': number, 'y': number, 't': number, 'v': number} = {'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?
|
||||
|
|
@ -179,6 +204,8 @@ namespace com.keyman.osk {
|
|||
this.rawSquaredSums = {...obj.rawSquaredSums};
|
||||
} else if(isAnInputSample(obj)) {
|
||||
Object.assign(this, this.extend(obj));
|
||||
} else {
|
||||
throw "A constructor for this input pattern has not yet been implemented";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -305,12 +332,13 @@ namespace com.keyman.osk {
|
|||
const tDelta = subsetStats.followingSample.t - subsetStats.lastSample.t;
|
||||
const tDeltaInSec = tDelta / 1000;
|
||||
|
||||
const coordArcSq = xDelta * xDelta + yDelta * yDelta;
|
||||
const coordArcDeltaSq = xDelta * xDelta + yDelta * yDelta;
|
||||
const coordArcDelta = Math.sqrt(coordArcDeltaSq);
|
||||
|
||||
// Due to how arc length stuff gets segmented.
|
||||
// There's the arc length within the prefix subset (operand 2 below) AND the part connecting it to the
|
||||
// 'remaining' subset (operand 1 below) before the portion wholly within what remains (the result)
|
||||
result.coordArcSum -= Math.sqrt(coordArcSq);
|
||||
result.coordArcSum -= coordArcDelta;
|
||||
result.coordArcSum -= subsetStats.coordArcSum;
|
||||
|
||||
result.cosLinearSum -= subsetStats.cosLinearSum;
|
||||
|
|
@ -318,8 +346,8 @@ namespace com.keyman.osk {
|
|||
result.arcSampleCount -= subsetStats.arcSampleCount;
|
||||
|
||||
if(tDeltaInSec) {
|
||||
result.rawLinearSums['v'] -= Math.sqrt(coordArcSq) / tDeltaInSec;
|
||||
result.rawSquaredSums['v'] -= coordArcSq / (tDeltaInSec * tDeltaInSec);
|
||||
result.rawLinearSums['v'] -= coordArcDelta / tDeltaInSec;
|
||||
result.rawSquaredSums['v'] -= coordArcDeltaSq / (tDeltaInSec * tDeltaInSec);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -360,7 +388,7 @@ namespace com.keyman.osk {
|
|||
* @param dim
|
||||
* @returns
|
||||
*/
|
||||
private mappingConstant(dim: 'x' | 'y' | 't' | 'v') {
|
||||
private mappingConstant(dim: StatAxis) {
|
||||
if(!this.baseSample) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -382,7 +410,7 @@ namespace com.keyman.osk {
|
|||
* @param dim
|
||||
* @returns
|
||||
*/
|
||||
private mappedMean(dim: 'x' | 'y' | 't' |'v') {
|
||||
private mappedMean(dim: StatAxis) {
|
||||
return this.rawLinearSums[dim] / this.sampleCount;
|
||||
}
|
||||
|
||||
|
|
@ -392,7 +420,7 @@ namespace com.keyman.osk {
|
|||
* @param dim
|
||||
* @returns
|
||||
*/
|
||||
public mean(dim: 'x' | 'y' | 't' | 'v') {
|
||||
public mean(dim: StatAxis) {
|
||||
// This external-facing version needs to provide values in 'external'-friendly
|
||||
// coordinate space.
|
||||
return this.mappedMean(dim) + this.mappingConstant(dim);
|
||||
|
|
@ -404,7 +432,7 @@ namespace com.keyman.osk {
|
|||
* @param dim
|
||||
* @returns
|
||||
*/
|
||||
public squaredSum(dim: 'x' | 'y' | 't' | 'v') {
|
||||
public squaredSum(dim: StatAxis) {
|
||||
const x2 = this.rawSquaredSums[dim];
|
||||
const x1 = this.rawLinearSums[dim];
|
||||
|
||||
|
|
@ -419,14 +447,11 @@ namespace com.keyman.osk {
|
|||
* @param dimPair
|
||||
* @returns
|
||||
*/
|
||||
public crossSum(dimPair: 'tx' | 'ty' | 'xy') {
|
||||
public crossSum(dimPair: PathCoordAxisPair) {
|
||||
const dim1 = dimPair.charAt(0);
|
||||
const dim2 = dimPair.charAt(1);
|
||||
|
||||
let orderedDims: string = dimPair;
|
||||
if(dim2 < dim1) {
|
||||
orderedDims = dim2.concat(dim1);
|
||||
}
|
||||
|
||||
const ab = this.rawCrossSums[orderedDims];
|
||||
const a = this.rawLinearSums[dim1];
|
||||
|
|
@ -444,7 +469,7 @@ namespace com.keyman.osk {
|
|||
* @param dimPair
|
||||
* @returns
|
||||
*/
|
||||
public covariance(dimPair: 'tx' | 'ty' | 'xy') {
|
||||
public covariance(dimPair: PathCoordAxisPair) {
|
||||
return this.crossSum(dimPair) / (this.sampleCount - 1);
|
||||
}
|
||||
|
||||
|
|
@ -452,7 +477,7 @@ namespace com.keyman.osk {
|
|||
* Gets the unbiased variance on the specified axis for samples observed
|
||||
* during the represented interval.
|
||||
*/
|
||||
public variance(dim: 'x' | 'y' | 't' | 'v') {
|
||||
public variance(dim: StatAxis) {
|
||||
return this.squaredSum(dim) / (this.sampleCount - 1);
|
||||
}
|
||||
|
||||
|
|
@ -480,11 +505,11 @@ namespace com.keyman.osk {
|
|||
result.baseSample = newBase;
|
||||
|
||||
for(const dimPair in result.rawCrossSums) {
|
||||
result.rawCrossSums[dimPair] = this.crossSum(dimPair as 'tx' | 'ty' | 'xy');
|
||||
result.rawCrossSums[dimPair] = this.crossSum(dimPair as PathCoordAxisPair);
|
||||
}
|
||||
|
||||
for(const dim in result.rawSquaredSums) {
|
||||
result.rawSquaredSums[dim] = this.squaredSum(dim as 'x' | 'y' | 't');
|
||||
result.rawSquaredSums[dim] = this.squaredSum(dim as PathCoordAxis);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
@ -497,7 +522,7 @@ namespace com.keyman.osk {
|
|||
* @param independent
|
||||
* @returns
|
||||
*/
|
||||
public fitRegression(dependent: 'x' | 'y' | 't', independent: 'x' | 'y' | 't') {
|
||||
public fitRegression(dependent: PathCoordAxis, independent: PathCoordAxis) {
|
||||
return new CumulativePathStats.regression(this, dependent, independent);
|
||||
}
|
||||
|
||||
|
|
@ -510,7 +535,7 @@ namespace com.keyman.osk {
|
|||
public get netDistance() {
|
||||
// No issue with a net distance of 0 due to a single point.
|
||||
if(!this.lastSample || !this.initialSample) {
|
||||
return Number.NaN;
|
||||
return 0;
|
||||
}
|
||||
|
||||
const xDelta = this.lastSample.targetX - this.initialSample.targetX;
|
||||
|
|
@ -527,7 +552,7 @@ namespace com.keyman.osk {
|
|||
public get duration() {
|
||||
// no issue with a duration of zero from just one sample.
|
||||
if(!this.lastSample || !this.initialSample) {
|
||||
return Number.NaN;
|
||||
return 0;
|
||||
}
|
||||
return (this.lastSample.t - this.initialSample.t) * 0.001;
|
||||
}
|
||||
|
|
@ -572,16 +597,12 @@ namespace com.keyman.osk {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const angle = this.angleInDegrees;
|
||||
const buckets = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw'];
|
||||
const buckets = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw', 'n'];
|
||||
|
||||
for(let threshold = 22.5, bucketIndex = 0; threshold < 360; threshold += 45, bucketIndex += 1) {
|
||||
if(angle < threshold) {
|
||||
return buckets[bucketIndex];
|
||||
}
|
||||
}
|
||||
|
||||
return 'n';
|
||||
// We could be 'more efficient' and use radians here instead, but this
|
||||
// version helps a bit more with easy maintainability.
|
||||
const bucketIndex = Math.ceil((this.angleInDegrees - 22.5)/45);
|
||||
return buckets[bucketIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -627,9 +648,14 @@ namespace com.keyman.osk {
|
|||
* Range: floating-point values on the interval [0, 1].
|
||||
*/
|
||||
private get angleRSquared() {
|
||||
// https://www.ebi.ac.uk/thornton-srv/software/PROCHECK/nmr_manual/man_cv.html may be a useful
|
||||
// reference for this tidbit. The Wikipedia article's more dense... not that this link isn't
|
||||
// a bit dense itself.
|
||||
// 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);
|
||||
}
|
||||
|
|
@ -667,22 +693,22 @@ namespace com.keyman.osk {
|
|||
// This `likelyState` value is extremely prototyped & just here for reviewer/tester convenience.
|
||||
// It'll need to be developed a bit more fully, but follows my intuitions from development &
|
||||
// testing.
|
||||
let likelyState = 'unknown';
|
||||
let likelyType = 'unknown';
|
||||
|
||||
if(this.mean('v') < 80 && this.rawDistance < 12 && this.duration > 0.1) {
|
||||
likelyState = 'hold';
|
||||
likelyType = 'hold';
|
||||
} else if(this.mean('v') < 80 && this.rawDistance < 6) {
|
||||
likelyState = 'hold';
|
||||
likelyType = 'hold';
|
||||
}
|
||||
|
||||
if(this.mean('v') > 400 || (this.mean('v') > 200 && this.duration > 0.1) || this.netDistance > 20) {
|
||||
likelyState = 'move';
|
||||
likelyType = 'move';
|
||||
}
|
||||
|
||||
return {
|
||||
angle: this.angle,
|
||||
cardinal: this.cardinalDirection,
|
||||
likelyType: likelyState,
|
||||
likelyType: likelyType,
|
||||
speedMean: this.mean('v'),
|
||||
rawDistance: this.rawDistance,
|
||||
duration: this.duration,
|
||||
|
|
|
|||
|
|
@ -133,10 +133,6 @@ namespace com.keyman.osk {
|
|||
return 1;
|
||||
}
|
||||
|
||||
if(numDoF > 3) {
|
||||
numDoF = 3;
|
||||
}
|
||||
|
||||
const numIndex = (numDoF > 3 ? 3 : numDoF) - 2;
|
||||
const denomIndex = (denomDoF > 20 ? 20 : denomDoF) - 1;
|
||||
|
||||
|
|
@ -191,8 +187,8 @@ namespace com.keyman.osk {
|
|||
*/
|
||||
static readonly segmentationComparison = class SegmentedRegression {
|
||||
host: Segmentation;
|
||||
readonly independent: 'x' | 'y' | 't';
|
||||
readonly dependent: 'x' | 'y' | 't';
|
||||
readonly independent: PathCoordAxis;
|
||||
readonly dependent: PathCoordAxis;
|
||||
readonly paired: 'tx' | 'ty' | 'xy';
|
||||
|
||||
/**
|
||||
|
|
@ -210,7 +206,7 @@ namespace com.keyman.osk {
|
|||
*/
|
||||
union: typeof CumulativePathStats.regression.prototype;
|
||||
|
||||
constructor(host: Segmentation, dependentAxis: 'x' | 'y' | 't', independentAxis: 'x' | 'y' | 't') {
|
||||
constructor(host: Segmentation, dependentAxis: PathCoordAxis, independentAxis: PathCoordAxis) {
|
||||
if(dependentAxis == independentAxis) {
|
||||
throw "Two different axes must be specified for the regression object.";
|
||||
}
|
||||
|
|
@ -377,7 +373,7 @@ namespace com.keyman.osk {
|
|||
* @param independent
|
||||
* @returns
|
||||
*/
|
||||
public segReg(dependentAxis: 'x' | 'y' | 't', independentAxis: 'x' | 'y' | 't') {
|
||||
public segReg(dependentAxis: PathCoordAxis, independentAxis: PathCoordAxis) {
|
||||
return new Segmentation.segmentationComparison(this, dependentAxis, independentAxis);
|
||||
}
|
||||
|
||||
|
|
@ -392,6 +388,8 @@ namespace com.keyman.osk {
|
|||
const xTest = new Segmentation.segmentationComparison(this, 'x', 't');
|
||||
const yTest = new Segmentation.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.
|
||||
totalThreshold += xTest.certaintyThreshold >= 0.95 ? 2 : (xTest.certaintyThreshold >= 0.90 ? 1 : 0) ;
|
||||
totalThreshold += yTest.certaintyThreshold >= 0.95 ? 2 : (yTest.certaintyThreshold >= 0.90 ? 1 : 0) ;
|
||||
|
||||
|
|
@ -404,7 +402,7 @@ namespace com.keyman.osk {
|
|||
* 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 test for
|
||||
// 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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue