@altnovatrail/canvas 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +59 -2
- package/lib/core/boxes.d.ts +54 -0
- package/lib/core/boxes.js +116 -0
- package/lib/core/clock.d.ts +25 -0
- package/lib/core/clock.js +39 -0
- package/lib/core/crop.d.ts +87 -0
- package/lib/core/crop.js +227 -0
- package/lib/core/eraser.d.ts +42 -0
- package/lib/core/eraser.js +127 -0
- package/lib/core/example.d.ts +66 -0
- package/lib/core/example.js +116 -0
- package/lib/core/fields.d.ts +29 -0
- package/lib/core/fields.js +78 -0
- package/lib/core/geometry.d.ts +38 -0
- package/lib/core/geometry.js +107 -0
- package/lib/core/grid.d.ts +30 -0
- package/lib/core/grid.js +47 -0
- package/lib/core/groups.d.ts +55 -0
- package/lib/core/groups.js +172 -0
- package/lib/core/hands.d.ts +120 -0
- package/lib/core/hands.js +273 -0
- package/lib/core/history.d.ts +42 -0
- package/lib/core/history.js +228 -0
- package/lib/core/index.d.ts +12 -0
- package/lib/core/index.js +15 -0
- package/lib/core/ink.d.ts +1 -0
- package/lib/core/ink.js +5 -0
- package/lib/core/manifest.d.ts +22 -0
- package/lib/core/manifest.js +50 -0
- package/lib/core/marks.d.ts +145 -0
- package/lib/core/marks.js +454 -0
- package/lib/core/outline.d.ts +22 -0
- package/lib/core/outline.js +50 -0
- package/lib/core/page.d.ts +62 -0
- package/lib/core/page.js +309 -0
- package/lib/core/png.d.ts +13 -0
- package/lib/core/png.js +231 -0
- package/lib/core/raster.d.ts +14 -0
- package/lib/core/raster.js +72 -0
- package/lib/core/recorder.d.ts +73 -0
- package/lib/core/recorder.js +127 -0
- package/lib/core/screen.d.ts +35 -0
- package/lib/core/screen.js +36 -0
- package/lib/core/sets.d.ts +10 -0
- package/lib/core/sets.js +31 -0
- package/lib/core/sheet.d.ts +9 -0
- package/lib/core/sheet.js +6 -0
- package/lib/core/submission.d.ts +41 -0
- package/lib/core/submission.js +63 -0
- package/lib/core/task.d.ts +214 -0
- package/lib/core/task.js +343 -0
- package/lib/core/widths.d.ts +57 -0
- package/lib/core/widths.js +218 -0
- package/lib/core/work.d.ts +77 -0
- package/lib/core/work.js +293 -0
- package/lib/element/attributes.d.ts +11 -0
- package/lib/element/attributes.js +22 -0
- package/lib/element/index.d.ts +219 -0
- package/lib/element/index.js +1898 -0
- package/lib/element/labels.d.ts +41 -0
- package/lib/element/labels.js +74 -0
- package/lib/element/parts.d.ts +63 -0
- package/lib/element/parts.js +191 -0
- package/lib/element/pictures.d.ts +45 -0
- package/lib/element/pictures.js +167 -0
- package/lib/element/probe.d.ts +33 -0
- package/lib/element/probe.js +18 -0
- package/lib/element/session.d.ts +3 -0
- package/lib/element/session.js +7 -0
- package/lib/element/statement.d.ts +35 -0
- package/lib/element/statement.js +116 -0
- package/lib/element/tools.d.ts +64 -0
- package/lib/element/tools.js +254 -0
- package/package.json +41 -4
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What reads a value a consumer gives the element, such as a task or the tutor's marks: its fields,
|
|
3
|
+
* and the value itself as an error message shows it. A value the element cannot take is a
|
|
4
|
+
* TypeError that names the field by its path.
|
|
5
|
+
*/
|
|
6
|
+
/** The fields of an object; anything else is a TypeError at `path`. */
|
|
7
|
+
export function record(value, path) {
|
|
8
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
9
|
+
throw new TypeError(`${path}: an object, not ${shown(value)}`);
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The fields of an object closed as the module format closes its objects (R32), read once into a
|
|
15
|
+
* record of their own: a field it does not name is a TypeError at that field's path. A field left
|
|
16
|
+
* out by `undefined` is no field, and is not in the record.
|
|
17
|
+
*/
|
|
18
|
+
export function closed(value, path, names, what) {
|
|
19
|
+
const fields = {};
|
|
20
|
+
for (const [name, field] of Object.entries(record(value, path))) {
|
|
21
|
+
if (field === undefined)
|
|
22
|
+
continue;
|
|
23
|
+
if (!names.includes(name))
|
|
24
|
+
throw new TypeError(`${path}.${name}: no field of ${what}`);
|
|
25
|
+
fields[name] = field;
|
|
26
|
+
}
|
|
27
|
+
return fields;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A list of at least `least` items, each read by `each` at its own path. Array.from visits every
|
|
31
|
+
* index, a hole too, which map would pass over unchecked.
|
|
32
|
+
*/
|
|
33
|
+
export function listOf(value, path, least, what, each) {
|
|
34
|
+
if (!Array.isArray(value))
|
|
35
|
+
throw new TypeError(`${path}: a list of ${what}, not ${shown(value)}`);
|
|
36
|
+
if (value.length < least) {
|
|
37
|
+
const count = ["one", "two"][least - 1] ?? String(least);
|
|
38
|
+
throw new TypeError(`${path}: a list of ${what}, ${count} at least, not ${shown(value)}`);
|
|
39
|
+
}
|
|
40
|
+
return Array.from(value, (item, i) => each(item, `${path}[${i}]`));
|
|
41
|
+
}
|
|
42
|
+
/** A text for people to read: a string, not empty. */
|
|
43
|
+
export function text(value, path, what) {
|
|
44
|
+
if (typeof value !== "string" || value === "") {
|
|
45
|
+
throw new TypeError(`${path}: ${what}, a string not empty, not ${shown(value)}`);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A whole number from `least` to `most`, or from `least` up, short of 2^53, where a number stops
|
|
51
|
+
* being exact.
|
|
52
|
+
*/
|
|
53
|
+
export function whole(value, path, least, most = Number.MAX_SAFE_INTEGER) {
|
|
54
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < least || value > most) {
|
|
55
|
+
const range = most === Number.MAX_SAFE_INTEGER ? `from ${least}` : `from ${least} to ${most}`;
|
|
56
|
+
throw new TypeError(`${path}: a whole number ${range}, not ${shown(value)}`);
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
/** A value as an error message shows it. */
|
|
61
|
+
export function shown(value) {
|
|
62
|
+
if (typeof value === "number")
|
|
63
|
+
return String(value);
|
|
64
|
+
if (value === undefined)
|
|
65
|
+
return "undefined";
|
|
66
|
+
if (typeof value === "function")
|
|
67
|
+
return "a function";
|
|
68
|
+
try {
|
|
69
|
+
return JSON.stringify(value) ?? String(value);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return String(value);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Whether two lists are the same, item by item, by `same`: by identity unless given. */
|
|
76
|
+
export function sameItems(a, b, same = Object.is) {
|
|
77
|
+
return a.length === b.length && a.every((item, n) => n in b && same(item, b[n]));
|
|
78
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Stroke } from "./ink.js";
|
|
2
|
+
import type { Point } from "./screen.js";
|
|
3
|
+
/**
|
|
4
|
+
* A rectangle on the sheet, as rbush takes one (R05): in units, or in tenths of a unit where the
|
|
5
|
+
* rules count in whole numbers. The contract's Box, spec's, is one in tenths: of a crop's numbers.
|
|
6
|
+
*/
|
|
7
|
+
export interface Box {
|
|
8
|
+
readonly minX: number;
|
|
9
|
+
readonly minY: number;
|
|
10
|
+
readonly maxX: number;
|
|
11
|
+
readonly maxY: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A length or a place in whole tenths of a unit, as a sample file keeps them (R09), rounded half
|
|
15
|
+
* up: 100.05 units, 1000.5 tenths, are 1001. The groups and the crop count in them (R24, R25).
|
|
16
|
+
*/
|
|
17
|
+
export declare function tenths(value: number): number;
|
|
18
|
+
/** A stroke's box in whole tenths, with its ID: what the crop and the boxes' rule count by. */
|
|
19
|
+
export interface Extent extends Box {
|
|
20
|
+
readonly id: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The box of a stroke's points in whole tenths (R25, R37): rounding keeps order, so its edges are
|
|
24
|
+
* the rounded least and most of the points. A stroke with no points is a TypeError, and one with a
|
|
25
|
+
* point off the sheet, or no number, a RangeError: the rules' whole numbers hold on the sheet alone.
|
|
26
|
+
*/
|
|
27
|
+
export declare function extentOf({ id, points }: Stroke): Extent;
|
|
28
|
+
/** The box around a stroke's points, the raw points rather than the ink's outline. */
|
|
29
|
+
export declare function boxOf(points: readonly Point[]): Box;
|
|
30
|
+
/** The box around boxes. */
|
|
31
|
+
export declare function around(boxes: readonly Box[]): Box;
|
|
32
|
+
/** The box around a segment, wider by `margin` on every side. */
|
|
33
|
+
export declare function segmentBox(a: Point, b: Point, margin: number): Box;
|
|
34
|
+
/**
|
|
35
|
+
* How near two segments come, ab and cd: 0 if they cross or touch. A segment whose ends are one
|
|
36
|
+
* point is that point.
|
|
37
|
+
*/
|
|
38
|
+
export declare function segmentDistance(a: Point, b: Point, c: Point, d: Point): number;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { SHEET } from "./sheet.js";
|
|
2
|
+
/**
|
|
3
|
+
* A length or a place in whole tenths of a unit, as a sample file keeps them (R09), rounded half
|
|
4
|
+
* up: 100.05 units, 1000.5 tenths, are 1001. The groups and the crop count in them (R24, R25).
|
|
5
|
+
*/
|
|
6
|
+
export function tenths(value) {
|
|
7
|
+
return Math.round(value * 10);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The box of a stroke's points in whole tenths (R25, R37): rounding keeps order, so its edges are
|
|
11
|
+
* the rounded least and most of the points. A stroke with no points is a TypeError, and one with a
|
|
12
|
+
* point off the sheet, or no number, a RangeError: the rules' whole numbers hold on the sheet alone.
|
|
13
|
+
*/
|
|
14
|
+
export function extentOf({ id, points }) {
|
|
15
|
+
if (points.length === 0)
|
|
16
|
+
throw new TypeError(`the stroke ${id} has no points`);
|
|
17
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
18
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
19
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
20
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
21
|
+
for (const { x, y } of points) {
|
|
22
|
+
if (!onSheet(x, SHEET.width) || !onSheet(y, SHEET.height)) {
|
|
23
|
+
throw new RangeError(`the stroke ${id} has a point (${x}, ${y}) off the sheet`);
|
|
24
|
+
}
|
|
25
|
+
minX = Math.min(minX, tenths(x));
|
|
26
|
+
minY = Math.min(minY, tenths(y));
|
|
27
|
+
maxX = Math.max(maxX, tenths(x));
|
|
28
|
+
maxY = Math.max(maxY, tenths(y));
|
|
29
|
+
}
|
|
30
|
+
return { id, minX, minY, maxX, maxY };
|
|
31
|
+
}
|
|
32
|
+
function onSheet(value, size) {
|
|
33
|
+
return Number.isFinite(value) && value >= 0 && value <= size;
|
|
34
|
+
}
|
|
35
|
+
/** The box around a stroke's points, the raw points rather than the ink's outline. */
|
|
36
|
+
export function boxOf(points) {
|
|
37
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
38
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
39
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
40
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
41
|
+
for (const { x, y } of points) {
|
|
42
|
+
minX = Math.min(minX, x);
|
|
43
|
+
minY = Math.min(minY, y);
|
|
44
|
+
maxX = Math.max(maxX, x);
|
|
45
|
+
maxY = Math.max(maxY, y);
|
|
46
|
+
}
|
|
47
|
+
return { minX, minY, maxX, maxY };
|
|
48
|
+
}
|
|
49
|
+
/** The box around boxes. */
|
|
50
|
+
export function around(boxes) {
|
|
51
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
52
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
53
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
54
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
55
|
+
for (const box of boxes) {
|
|
56
|
+
minX = Math.min(minX, box.minX);
|
|
57
|
+
minY = Math.min(minY, box.minY);
|
|
58
|
+
maxX = Math.max(maxX, box.maxX);
|
|
59
|
+
maxY = Math.max(maxY, box.maxY);
|
|
60
|
+
}
|
|
61
|
+
return { minX, minY, maxX, maxY };
|
|
62
|
+
}
|
|
63
|
+
/** The box around a segment, wider by `margin` on every side. */
|
|
64
|
+
export function segmentBox(a, b, margin) {
|
|
65
|
+
return {
|
|
66
|
+
minX: Math.min(a.x, b.x) - margin,
|
|
67
|
+
minY: Math.min(a.y, b.y) - margin,
|
|
68
|
+
maxX: Math.max(a.x, b.x) + margin,
|
|
69
|
+
maxY: Math.max(a.y, b.y) + margin,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* How near two segments come, ab and cd: 0 if they cross or touch. A segment whose ends are one
|
|
74
|
+
* point is that point.
|
|
75
|
+
*/
|
|
76
|
+
export function segmentDistance(a, b, c, d) {
|
|
77
|
+
if (crosses(a, b, c, d))
|
|
78
|
+
return 0;
|
|
79
|
+
return Math.min(pointDistance(a, c, d), pointDistance(b, c, d), pointDistance(c, a, b), pointDistance(d, a, b));
|
|
80
|
+
}
|
|
81
|
+
/** How near a point comes to the segment ab. */
|
|
82
|
+
function pointDistance(p, a, b) {
|
|
83
|
+
const dx = b.x - a.x;
|
|
84
|
+
const dy = b.y - a.y;
|
|
85
|
+
const length = dx * dx + dy * dy;
|
|
86
|
+
// The point of the segment nearest p, as a fraction of the way from a to b.
|
|
87
|
+
const along = length === 0 ? 0 : clamp(((p.x - a.x) * dx + (p.y - a.y) * dy) / length, 0, 1);
|
|
88
|
+
return Math.hypot(p.x - (a.x + along * dx), p.y - (a.y + along * dy));
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Whether ab and cd cross: each segment's ends lie on either side of the other's line. Segments that
|
|
92
|
+
* only touch, or lie along one line, are left to the distances of their ends, which are 0 then.
|
|
93
|
+
*/
|
|
94
|
+
function crosses(a, b, c, d) {
|
|
95
|
+
const abc = turn(a, b, c);
|
|
96
|
+
const abd = turn(a, b, d);
|
|
97
|
+
const cda = turn(c, d, a);
|
|
98
|
+
const cdb = turn(c, d, b);
|
|
99
|
+
return abc * abd < 0 && cda * cdb < 0;
|
|
100
|
+
}
|
|
101
|
+
/** Which way a, b, c turn: above 0 one way, below the other, 0 on one line. */
|
|
102
|
+
function turn(a, b, c) {
|
|
103
|
+
return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
|
104
|
+
}
|
|
105
|
+
function clamp(value, low, high) {
|
|
106
|
+
return Math.min(Math.max(value, low), high);
|
|
107
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a task rules the sheet (concepts 8 spec): in squares, in lines or in dots, as concepts 16.1
|
|
3
|
+
* spec's illustration names them.
|
|
4
|
+
*/
|
|
5
|
+
export type GridKind = "squares" | "lines" | "dots";
|
|
6
|
+
/** The grid of a sheet: its kind and its step, in sheet units. */
|
|
7
|
+
export interface Grid {
|
|
8
|
+
readonly kind: GridKind;
|
|
9
|
+
readonly step: number;
|
|
10
|
+
}
|
|
11
|
+
export declare const GRID_KINDS: readonly GridKind[];
|
|
12
|
+
/** The step of a grid whose task gives none, in units: canvas-api's square (R06). */
|
|
13
|
+
export declare const GRID_STEP = 50;
|
|
14
|
+
/**
|
|
15
|
+
* The steps a task may give, in units. A finer grid would be thousands of dots, and one wider than
|
|
16
|
+
* half the sheet rules nothing much.
|
|
17
|
+
*/
|
|
18
|
+
export declare const GRID_STEP_MIN = 20;
|
|
19
|
+
export declare const GRID_STEP_MAX = 500;
|
|
20
|
+
/**
|
|
21
|
+
* The grid of a sheet whose task gives none, or that has no task: squares of 50 units. concepts 8
|
|
22
|
+
* spec knows no sheet without a grid (R23).
|
|
23
|
+
*/
|
|
24
|
+
export declare const DEFAULT_GRID: Grid;
|
|
25
|
+
/**
|
|
26
|
+
* A grid's path in SVG, in sheet units: a line along every ruling, strictly inside the sheet, or,
|
|
27
|
+
* for dots, a dot where two rulings cross — a subpath of no length, which a round cap draws as a
|
|
28
|
+
* dot.
|
|
29
|
+
*/
|
|
30
|
+
export declare function gridPath(grid: Grid): string;
|
package/lib/core/grid.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { SHEET } from "./sheet.js";
|
|
2
|
+
export const GRID_KINDS = ["squares", "lines", "dots"];
|
|
3
|
+
/** The step of a grid whose task gives none, in units: canvas-api's square (R06). */
|
|
4
|
+
export const GRID_STEP = 50;
|
|
5
|
+
/**
|
|
6
|
+
* The steps a task may give, in units. A finer grid would be thousands of dots, and one wider than
|
|
7
|
+
* half the sheet rules nothing much.
|
|
8
|
+
*/
|
|
9
|
+
export const GRID_STEP_MIN = 20;
|
|
10
|
+
export const GRID_STEP_MAX = 500;
|
|
11
|
+
/**
|
|
12
|
+
* The grid of a sheet whose task gives none, or that has no task: squares of 50 units. concepts 8
|
|
13
|
+
* spec knows no sheet without a grid (R23).
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULT_GRID = Object.freeze({ kind: "squares", step: GRID_STEP });
|
|
16
|
+
/**
|
|
17
|
+
* A grid's path in SVG, in sheet units: a line along every ruling, strictly inside the sheet, or,
|
|
18
|
+
* for dots, a dot where two rulings cross — a subpath of no length, which a round cap draws as a
|
|
19
|
+
* dot.
|
|
20
|
+
*/
|
|
21
|
+
export function gridPath(grid) {
|
|
22
|
+
const xs = rulings(grid.step, SHEET.width);
|
|
23
|
+
const ys = rulings(grid.step, SHEET.height);
|
|
24
|
+
const down = xs.map((x) => `M${x} 0V${SHEET.height}`);
|
|
25
|
+
const across = ys.map((y) => `M0 ${y}H${SHEET.width}`);
|
|
26
|
+
switch (grid.kind) {
|
|
27
|
+
case "squares":
|
|
28
|
+
return [...down, ...across].join("");
|
|
29
|
+
case "lines":
|
|
30
|
+
return across.join("");
|
|
31
|
+
case "dots":
|
|
32
|
+
return ys.flatMap((y) => xs.map((x) => `M${x} ${y}h0`)).join("");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Where the rulings of a step lie along a side of the sheet, inside it: each one i × step, worked
|
|
37
|
+
* out apart rather than summed, so that no error builds up, and rounded to a thousandth of a unit.
|
|
38
|
+
*/
|
|
39
|
+
function rulings(step, length) {
|
|
40
|
+
const places = [];
|
|
41
|
+
for (let i = 1;; i++) {
|
|
42
|
+
const place = Math.round(i * step * 1000) / 1000;
|
|
43
|
+
if (place >= length)
|
|
44
|
+
return places;
|
|
45
|
+
places.push(place);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Group } from "@altnovatrail/spec/canvas";
|
|
2
|
+
import { type Box } from "./geometry.js";
|
|
3
|
+
import type { Stroke } from "./ink.js";
|
|
4
|
+
/**
|
|
5
|
+
* The rule of the groups of strokes (R24): two strokes are neighbours if no more than `pause`
|
|
6
|
+
* milliseconds lie between them and their boxes come within `across` sheet units of each other
|
|
7
|
+
* across the sheet and within `down` units down it. A group is the strokes a chain of neighbours
|
|
8
|
+
* joins. The rule counts in whole numbers — tenths of a unit and milliseconds, the precision of a
|
|
9
|
+
* sample file (R09), rounded half up as `Math.round` rounds: 1000.5 tenths are 1001 — and only
|
|
10
|
+
* subtracts and compares them, so that tutor's runner, which groups by the same rule, gets the same
|
|
11
|
+
* groups to the bit.
|
|
12
|
+
*/
|
|
13
|
+
export interface GroupRule {
|
|
14
|
+
/** The longest pause between two neighbours, in milliseconds. */
|
|
15
|
+
readonly pause: number;
|
|
16
|
+
/** How far apart across the sheet the boxes of two neighbours may be, in sheet units. */
|
|
17
|
+
readonly across: number;
|
|
18
|
+
/** How far apart down the sheet, in sheet units. */
|
|
19
|
+
readonly down: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The rule the sheet groups by (R24): 2 seconds, the threshold by which studies of writing tell a
|
|
23
|
+
* pause of writing from one of thinking, and a cell of the default grid, across and down.
|
|
24
|
+
*/
|
|
25
|
+
export declare const GROUP_RULE: GroupRule;
|
|
26
|
+
/**
|
|
27
|
+
* A group of the strokes on a sheet: its label, by place, and the IDs of its strokes, in ascending
|
|
28
|
+
* order. The labels and the IDs are the table «label → stroke IDs» of one submission (R06). spec's
|
|
29
|
+
* type (R74 spec).
|
|
30
|
+
*/
|
|
31
|
+
export type { Group };
|
|
32
|
+
/**
|
|
33
|
+
* The groups of the strokes on a sheet (R24): the strokes it shows, with the times of their points
|
|
34
|
+
* as they were written, in any order. Erased strokes are none of it: whoever groups a history
|
|
35
|
+
* replays it first, and the groups come from what the sheet shows. Labelled by place, from the top
|
|
36
|
+
* down (R09): A, B, … Z, then AA, AB, … — by the top of a group's rectangle, then its left edge.
|
|
37
|
+
*/
|
|
38
|
+
export declare function groupsOf(strokes: readonly Stroke[], rule?: GroupRule): Group[];
|
|
39
|
+
/**
|
|
40
|
+
* The pairs of neighbours among the strokes on a sheet, by ID (R24): each pair once, the smaller
|
|
41
|
+
* ID first, in ascending order. Its groups join these pairs; the test vectors give them too, for a
|
|
42
|
+
* runner to see where its grouping parts from the sheet's.
|
|
43
|
+
*/
|
|
44
|
+
export declare function neighboursOf(strokes: readonly Stroke[], rule?: GroupRule): [number, number][];
|
|
45
|
+
/**
|
|
46
|
+
* The rectangle of a group on the sheet: the box around the points of its strokes, in sheet units,
|
|
47
|
+
* of the geometry it is given — the raw strokes on the device, the recorded ones in a sample (R09).
|
|
48
|
+
* Its box in the fractions of a cut-out is the cut-out's (T13).
|
|
49
|
+
*/
|
|
50
|
+
export declare function rectOf(ids: readonly number[], strokes: readonly Stroke[]): Box;
|
|
51
|
+
/**
|
|
52
|
+
* The label of the group in the place `n`, from 0: A to Z, then AA to AZ, BA to ZZ, AAA… — letters
|
|
53
|
+
* in base 26 without a zero, as the columns of a spreadsheet.
|
|
54
|
+
*/
|
|
55
|
+
export declare function labelOf(n: number): string;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import RBush from "rbush";
|
|
2
|
+
import { boxOf, tenths } from "./geometry.js";
|
|
3
|
+
import { Sets } from "./sets.js";
|
|
4
|
+
/**
|
|
5
|
+
* The rule the sheet groups by (R24): 2 seconds, the threshold by which studies of writing tell a
|
|
6
|
+
* pause of writing from one of thinking, and a cell of the default grid, across and down.
|
|
7
|
+
*/
|
|
8
|
+
export const GROUP_RULE = Object.freeze({ pause: 2000, across: 50, down: 50 });
|
|
9
|
+
/**
|
|
10
|
+
* The groups of the strokes on a sheet (R24): the strokes it shows, with the times of their points
|
|
11
|
+
* as they were written, in any order. Erased strokes are none of it: whoever groups a history
|
|
12
|
+
* replays it first, and the groups come from what the sheet shows. Labelled by place, from the top
|
|
13
|
+
* down (R09): A, B, … Z, then AA, AB, … — by the top of a group's rectangle, then its left edge.
|
|
14
|
+
*/
|
|
15
|
+
export function groupsOf(strokes, rule = GROUP_RULE) {
|
|
16
|
+
const pieces = piecesOf(strokes);
|
|
17
|
+
const sets = new Sets(pieces.length);
|
|
18
|
+
for (const [a, b] of pairsOf(pieces, rule))
|
|
19
|
+
sets.join(a.at, b.at);
|
|
20
|
+
const members = new Map();
|
|
21
|
+
for (const piece of pieces) {
|
|
22
|
+
const root = sets.root(piece.at);
|
|
23
|
+
const group = members.get(root);
|
|
24
|
+
if (group === undefined)
|
|
25
|
+
members.set(root, [piece]);
|
|
26
|
+
else
|
|
27
|
+
group.push(piece);
|
|
28
|
+
}
|
|
29
|
+
return [...members.values()]
|
|
30
|
+
.map((group) => ({ place: placeOf(group), ids: group.map(({ id }) => id).sort(byNumber) }))
|
|
31
|
+
.sort((a, b) => byPlace(a.place, b.place) || (a.ids[0] ?? 0) - (b.ids[0] ?? 0))
|
|
32
|
+
.map(({ ids }, n) => ({ label: labelOf(n), ids }));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The pairs of neighbours among the strokes on a sheet, by ID (R24): each pair once, the smaller
|
|
36
|
+
* ID first, in ascending order. Its groups join these pairs; the test vectors give them too, for a
|
|
37
|
+
* runner to see where its grouping parts from the sheet's.
|
|
38
|
+
*/
|
|
39
|
+
export function neighboursOf(strokes, rule = GROUP_RULE) {
|
|
40
|
+
return pairsOf(piecesOf(strokes), rule)
|
|
41
|
+
.map(([a, b]) => (a.id < b.id ? [a.id, b.id] : [b.id, a.id]))
|
|
42
|
+
.sort(([a1, a2], [b1, b2]) => a1 - b1 || a2 - b2);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The rectangle of a group on the sheet: the box around the points of its strokes, in sheet units,
|
|
46
|
+
* of the geometry it is given — the raw strokes on the device, the recorded ones in a sample (R09).
|
|
47
|
+
* Its box in the fractions of a cut-out is the cut-out's (T13).
|
|
48
|
+
*/
|
|
49
|
+
export function rectOf(ids, strokes) {
|
|
50
|
+
const wanted = new Set(ids);
|
|
51
|
+
const points = strokes.filter(({ id }) => wanted.has(id)).flatMap(({ points }) => points);
|
|
52
|
+
if (points.length === 0)
|
|
53
|
+
throw new RangeError("no stroke of the group is on the sheet");
|
|
54
|
+
return boxOf(points);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The label of the group in the place `n`, from 0: A to Z, then AA to AZ, BA to ZZ, AAA… — letters
|
|
58
|
+
* in base 26 without a zero, as the columns of a spreadsheet.
|
|
59
|
+
*/
|
|
60
|
+
export function labelOf(n) {
|
|
61
|
+
let label = "";
|
|
62
|
+
for (let rest = n + 1; rest > 0; rest = Math.floor((rest - 1) / 26)) {
|
|
63
|
+
label = String.fromCharCode(65 + ((rest - 1) % 26)) + label;
|
|
64
|
+
}
|
|
65
|
+
return label;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The strokes as the rule sees them, each from one pass over its points. Rounding keeps the order
|
|
69
|
+
* of numbers, so the rounded least and greatest are the least and greatest rounded. A stroke
|
|
70
|
+
* without points, or an ID twice, is no sheet's.
|
|
71
|
+
*/
|
|
72
|
+
function piecesOf(strokes) {
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
return strokes.map((stroke, at) => {
|
|
75
|
+
if (seen.has(stroke.id))
|
|
76
|
+
throw new TypeError(`the stroke ${stroke.id} is on the sheet twice`);
|
|
77
|
+
seen.add(stroke.id);
|
|
78
|
+
const [first] = stroke.points;
|
|
79
|
+
if (first === undefined)
|
|
80
|
+
throw new TypeError(`the stroke ${stroke.id} has no points`);
|
|
81
|
+
let { x: minX, y: minY, t: start } = first;
|
|
82
|
+
let { x: maxX, y: maxY, t: end } = first;
|
|
83
|
+
for (const { x, y, t } of stroke.points) {
|
|
84
|
+
minX = Math.min(minX, x);
|
|
85
|
+
minY = Math.min(minY, y);
|
|
86
|
+
maxX = Math.max(maxX, x);
|
|
87
|
+
maxY = Math.max(maxY, y);
|
|
88
|
+
start = Math.min(start, t);
|
|
89
|
+
end = Math.max(end, t);
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
id: stroke.id,
|
|
93
|
+
at,
|
|
94
|
+
minX: tenths(minX),
|
|
95
|
+
minY: tenths(minY),
|
|
96
|
+
maxX: tenths(maxX),
|
|
97
|
+
maxY: tenths(maxY),
|
|
98
|
+
start: Math.round(start),
|
|
99
|
+
end: Math.round(end),
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Every pair of neighbours, each once. The index finds the pieces whose boxes come near enough —
|
|
105
|
+
* in whole tenths that is the rule's own test — and the time decides.
|
|
106
|
+
*/
|
|
107
|
+
function pairsOf(pieces, rule) {
|
|
108
|
+
const { pause, across, down } = limitsOf(rule);
|
|
109
|
+
const index = new RBush().load(pieces);
|
|
110
|
+
const pairs = [];
|
|
111
|
+
for (const piece of pieces) {
|
|
112
|
+
const near = index.search({
|
|
113
|
+
minX: piece.minX - across,
|
|
114
|
+
minY: piece.minY - down,
|
|
115
|
+
maxX: piece.maxX + across,
|
|
116
|
+
maxY: piece.maxY + down,
|
|
117
|
+
});
|
|
118
|
+
for (const other of near) {
|
|
119
|
+
if (other.at <= piece.at)
|
|
120
|
+
continue;
|
|
121
|
+
if (gap(piece.minX, piece.maxX, other.minX, other.maxX) <= across &&
|
|
122
|
+
gap(piece.minY, piece.maxY, other.minY, other.maxY) <= down &&
|
|
123
|
+
gap(piece.start, piece.end, other.start, other.end) <= pause) {
|
|
124
|
+
pairs.push([piece, other]);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return pairs;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* A rule's limits in the whole numbers the rule counts in. A limit that is not a finite number of
|
|
132
|
+
* 0 or more is no rule's: it would group nothing, or every pair, without a word.
|
|
133
|
+
*/
|
|
134
|
+
function limitsOf(rule) {
|
|
135
|
+
for (const name of ["pause", "across", "down"]) {
|
|
136
|
+
const value = rule[name];
|
|
137
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
138
|
+
throw new RangeError(`the rule's ${name} is ${value}, not a finite number of 0 or more`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { pause: Math.round(rule.pause), across: tenths(rule.across), down: tenths(rule.down) };
|
|
142
|
+
}
|
|
143
|
+
/** How far apart two spans are: 0 if they overlap or touch. */
|
|
144
|
+
function gap(low, high, otherLow, otherHigh) {
|
|
145
|
+
return Math.max(0, low - otherHigh, otherLow - high);
|
|
146
|
+
}
|
|
147
|
+
function placeOf(group) {
|
|
148
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
149
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
150
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
151
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
152
|
+
let start = Number.POSITIVE_INFINITY;
|
|
153
|
+
for (const piece of group) {
|
|
154
|
+
minX = Math.min(minX, piece.minX);
|
|
155
|
+
minY = Math.min(minY, piece.minY);
|
|
156
|
+
maxX = Math.max(maxX, piece.maxX);
|
|
157
|
+
maxY = Math.max(maxY, piece.maxY);
|
|
158
|
+
start = Math.min(start, piece.start);
|
|
159
|
+
}
|
|
160
|
+
return { minX, minY, maxX, maxY, start };
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* From the top down, then from the left; rectangles alike in both go by their bottom, their right
|
|
164
|
+
* and then by which was begun first. The IDs, which say the order of writing, settle nothing short
|
|
165
|
+
* of two groups alike in all of that.
|
|
166
|
+
*/
|
|
167
|
+
function byPlace(a, b) {
|
|
168
|
+
return (a.minY - b.minY || a.minX - b.minX || a.maxY - b.maxY || a.maxX - b.maxX || a.start - b.start);
|
|
169
|
+
}
|
|
170
|
+
function byNumber(a, b) {
|
|
171
|
+
return a - b;
|
|
172
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A pointer as the hands on the sheet see it (R29): where it is on the screen, how big its contact
|
|
3
|
+
* is, and when. The DOM-free part sees no DOM (R12), so the element hands it this much of each
|
|
4
|
+
* pointer event. The contact's size decides only what the pointer may do: it goes into no stroke,
|
|
5
|
+
* no history and no sample (R09).
|
|
6
|
+
*/
|
|
7
|
+
export interface Contact {
|
|
8
|
+
/** The event's `pointerId`. */
|
|
9
|
+
readonly pointerId: number;
|
|
10
|
+
/** The event's `pointerType`, as it came: "pen", "touch", "mouse" or another. */
|
|
11
|
+
readonly kind: string;
|
|
12
|
+
/** Where on the screen, in CSS pixels: the event's `clientX` and `clientY`. */
|
|
13
|
+
readonly x: number;
|
|
14
|
+
readonly y: number;
|
|
15
|
+
/**
|
|
16
|
+
* The contact's size on the screen, in CSS pixels: the event's `width` and `height`, 1 where the
|
|
17
|
+
* hardware does not measure it (Pointer Events Level 3).
|
|
18
|
+
*/
|
|
19
|
+
readonly width: number;
|
|
20
|
+
readonly height: number;
|
|
21
|
+
/** When: the event's `timeStamp`, in milliseconds. */
|
|
22
|
+
readonly time: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A touch wider or taller than this, in CSS pixels, is a palm (R29). WebKit on iPadOS gives a touch
|
|
26
|
+
* twice its contact's radius, a fingertip's 40–60 px by one report, so canvas-api's 30 px would
|
|
27
|
+
* take most fingers there for palms. T22 measures it on the devices.
|
|
28
|
+
*/
|
|
29
|
+
export declare const PALM_PX = 100;
|
|
30
|
+
/**
|
|
31
|
+
* Two touches that come down at most this many milliseconds apart are two fingers of one gesture
|
|
32
|
+
* (R29): Android's tap timeout, 100 ms, with a margin for children's fingers.
|
|
33
|
+
*/
|
|
34
|
+
export declare const PAIR_MS = 150;
|
|
35
|
+
/**
|
|
36
|
+
* How far a touch may wander, in CSS pixels, before it is going somewhere (R29): Android's touch
|
|
37
|
+
* slop, 8 dp. A finger that has gone further is writing, and two fingers scroll once their middle
|
|
38
|
+
* has gone further.
|
|
39
|
+
*/
|
|
40
|
+
export declare const SLOP_PX = 8;
|
|
41
|
+
/**
|
|
42
|
+
* The pen mode (R29): on from a pen's touch on a sheet, off when the learner asks for the finger.
|
|
43
|
+
* While it is on, a finger neither writes nor erases. One is shared by every sheet of a page, and
|
|
44
|
+
* it lives in memory alone: nothing is stored or sent, and a new page starts without it.
|
|
45
|
+
*
|
|
46
|
+
* It knows, too, whether any sheet of the page is written on: a row of buttons changes between the
|
|
47
|
+
* strokes of the whole page alone, for a row that wraps moves the sheets below it as well.
|
|
48
|
+
*/
|
|
49
|
+
export declare class PenMode {
|
|
50
|
+
#private;
|
|
51
|
+
get on(): boolean;
|
|
52
|
+
/** Every watcher hears of a change. */
|
|
53
|
+
set on(on: boolean);
|
|
54
|
+
/** Whether a pointer writes or erases now on a sheet of the page. */
|
|
55
|
+
get writing(): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* `watcher` hears of every change from now on, and of every stroke that ends, until the function
|
|
58
|
+
* this gives is called.
|
|
59
|
+
*/
|
|
60
|
+
watch(watcher: () => void): () => void;
|
|
61
|
+
/** A sheet tells whether it is written on, until the function this gives is called. */
|
|
62
|
+
enlist(writes: () => boolean): () => void;
|
|
63
|
+
/** A pointer lifted off a sheet: once no sheet is written on, every watcher hears of it. */
|
|
64
|
+
settle(): void;
|
|
65
|
+
}
|
|
66
|
+
/** What the hands ask of the sheet they serve. */
|
|
67
|
+
export interface HandsSheet {
|
|
68
|
+
/** The pointer that writes or erases now, if one does: the recorder's or the eraser's. */
|
|
69
|
+
writer(): number | undefined;
|
|
70
|
+
/** The stroke or the erasing of a pointer goes, as on `pointercancel`. */
|
|
71
|
+
drop(pointerId: number): void;
|
|
72
|
+
/**
|
|
73
|
+
* Two fingers moved down the screen by `dy` CSS pixels, or up for a negative one: the sheet goes
|
|
74
|
+
* with them. `fresh` marks a gesture's first step.
|
|
75
|
+
*/
|
|
76
|
+
scroll(dy: number, fresh: boolean): void;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* What a pointer down on the sheet does:
|
|
80
|
+
* - "writes": it may write or erase, and does while the sheet says it is the writer;
|
|
81
|
+
* - "waits": a finger in pen mode, waiting for a second to scroll with;
|
|
82
|
+
* - "scrolls": one of the two fingers of a gesture;
|
|
83
|
+
* - "palm": too big for a finger, it does nothing until it lifts;
|
|
84
|
+
* - "idle": it does nothing until it lifts.
|
|
85
|
+
*/
|
|
86
|
+
export type Role = "writes" | "waits" | "scrolls" | "palm" | "idle";
|
|
87
|
+
/**
|
|
88
|
+
* The hands on a sheet (R29): which pointer may write, which is a palm, and which two fingers
|
|
89
|
+
* scroll, for a pen, fingers and a mouse on one screen.
|
|
90
|
+
* - One pointer writes at a time, as before (R18), and the sheet's recorder and eraser say which.
|
|
91
|
+
* - A pen's touch puts the page in pen mode and takes away the stroke of a touch under way: it was
|
|
92
|
+
* a palm. While a pen or a mouse writes, a touch does nothing until it lifts.
|
|
93
|
+
* - A touch wider or taller than `PALM_PX`, at its `pointerdown` or at any move, is a palm: it does
|
|
94
|
+
* nothing until it lifts, and what it was writing goes.
|
|
95
|
+
* - Without pen mode the first finger writes. A second that comes down within `PAIR_MS`, before the
|
|
96
|
+
* first has gone `SLOP_PX`, takes the first one's stroke away, and the two scroll. A second that
|
|
97
|
+
* comes later does nothing.
|
|
98
|
+
* - In pen mode a finger writes nothing, and two that come down within `PAIR_MS` scroll.
|
|
99
|
+
* - A third finger does nothing. When one of the two lifts, the other does nothing until it lifts.
|
|
100
|
+
* - Two fingers scroll by the travel of their middle down the screen, past the first `SLOP_PX`.
|
|
101
|
+
* - Any other pointer, a mouse or one of no type, writes when nothing does, whatever the mode.
|
|
102
|
+
*/
|
|
103
|
+
export declare class Hands {
|
|
104
|
+
#private;
|
|
105
|
+
constructor(sheet: HandsSheet, mode: PenMode);
|
|
106
|
+
/** What each pointer down on the sheet does now. */
|
|
107
|
+
get roles(): ReadonlyMap<number, Role>;
|
|
108
|
+
/** Whether a pointer is down on the sheet: the hands saw it come down, and not yet lift. */
|
|
109
|
+
knows(pointerId: number): boolean;
|
|
110
|
+
/** Whether two fingers make a gesture now: they wait for their slop, or scroll. */
|
|
111
|
+
get scrolling(): boolean;
|
|
112
|
+
/** `pointerdown`: whether this pointer may write or erase. */
|
|
113
|
+
down(contact: Contact): boolean;
|
|
114
|
+
/** `pointermove`, once for each coalesced event: whether this pointer writes on. */
|
|
115
|
+
move(contact: Contact): boolean;
|
|
116
|
+
/** `pointerup`, `pointercancel` or a lost capture: the pointer has gone, and its gesture too. */
|
|
117
|
+
lift(pointerId: number): void;
|
|
118
|
+
/** The sheet has left the page, and no pointer is down on it now. */
|
|
119
|
+
reset(): void;
|
|
120
|
+
}
|