@altnovatrail/canvas 0.0.0-stage → 0.1.1
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
package/lib/core/crop.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import RBush from "rbush";
|
|
2
|
+
import { around, extentOf } from "./geometry.js";
|
|
3
|
+
import { Sets } from "./sets.js";
|
|
4
|
+
/**
|
|
5
|
+
* The crop's rule in its numbers (R25): sizes on the crop in pixels, heights on the sheet in units.
|
|
6
|
+
* The rule counts in whole numbers — places in tenths of a unit, as the groups do (R24) — and each
|
|
7
|
+
* number it hands out that is not whole is one division of two whole numbers, so that tutor's
|
|
8
|
+
* runner, which crops by the same rule, gets the same numbers to the bit.
|
|
9
|
+
*/
|
|
10
|
+
export const CROP = Object.freeze({
|
|
11
|
+
/** The margin around the work, px (R06). */
|
|
12
|
+
margin: 12,
|
|
13
|
+
/**
|
|
14
|
+
* A stroke lower than this, in units, is a line, not writing — a dot, a minus, a bar, an
|
|
15
|
+
* underline — and is in no bundle, nor joins any.
|
|
16
|
+
*/
|
|
17
|
+
flat: 15,
|
|
18
|
+
/** The scale takes the median no higher than this, in units: a drawing alone stays a drawing. */
|
|
19
|
+
ceiling: 100,
|
|
20
|
+
/** The least width and height of a crop, px: Claude may err on images under 200 pixels. */
|
|
21
|
+
side: 200,
|
|
22
|
+
/**
|
|
23
|
+
* The longest side, px, and the most visual tokens, ⌈w / 28⌉ × ⌈h / 28⌉: what every model of
|
|
24
|
+
* Claude takes without scaling the image down, its standard tier.
|
|
25
|
+
*/
|
|
26
|
+
edge: 1568,
|
|
27
|
+
tokens: 1568,
|
|
28
|
+
patch: 28,
|
|
29
|
+
/**
|
|
30
|
+
* The height of a digit a crop is drawn at unless the module's manifest says another, px: 24
|
|
31
|
+
* since the module format 0.2, with or without a manifest.
|
|
32
|
+
*/
|
|
33
|
+
digit: 24,
|
|
34
|
+
/**
|
|
35
|
+
* The heights of digits a sample's crops are drawn at, px, until the eval protocol names its own
|
|
36
|
+
* (R09; R08 tutor).
|
|
37
|
+
*/
|
|
38
|
+
heights: Object.freeze([16, 24, 32, 48]),
|
|
39
|
+
/** The tallest digit a crop takes, px: its ink then still stays within the margin. */
|
|
40
|
+
tallest: 256,
|
|
41
|
+
});
|
|
42
|
+
/** A stroke as the crop sees it: the box of its points in tenths. */
|
|
43
|
+
/**
|
|
44
|
+
* The bundles of the strokes on a sheet (R25): strokes no lower than `CROP.flat`, joined by a chain
|
|
45
|
+
* of boxes that touch or overlap, whenever each was written. Each bundle by its IDs ascending, the
|
|
46
|
+
* bundles by their smallest ID.
|
|
47
|
+
*/
|
|
48
|
+
export function bundlesOf(strokes) {
|
|
49
|
+
return bundled(extentsOf(strokes))
|
|
50
|
+
.map((bundle) => bundle.map(({ id }) => id).sort(byNumber))
|
|
51
|
+
.sort((a, b) => (a[0] ?? 0) - (b[0] ?? 0));
|
|
52
|
+
}
|
|
53
|
+
/** h_med (R25): the lower median of the heights of the bundles, in units; `CROP.flat` if none. */
|
|
54
|
+
export function medianOf(strokes) {
|
|
55
|
+
return medianTenths(extentsOf(strokes)) / 10;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The crop of the strokes on a sheet with its digits `digit` px high, and the frames of their
|
|
59
|
+
* groups (R25): the strokes the sheet shows, and their groups by the sheet's rule (R24). A sheet
|
|
60
|
+
* without strokes has no crop: it goes to no check (H3).
|
|
61
|
+
*/
|
|
62
|
+
export function cropOf(strokes, groups, digit = CROP.digit) {
|
|
63
|
+
if (!Number.isInteger(digit) || digit < 1 || digit > CROP.tallest) {
|
|
64
|
+
throw new RangeError(`a digit ${digit} px high: a crop takes whole px from 1 to ${CROP.tallest}`);
|
|
65
|
+
}
|
|
66
|
+
const extents = extentsOf(strokes);
|
|
67
|
+
const members = membersOf(groups, extents);
|
|
68
|
+
if (extents.length === 0)
|
|
69
|
+
return null;
|
|
70
|
+
const box = around(extents);
|
|
71
|
+
const median = medianTenths(extents);
|
|
72
|
+
const unit = Math.min(median, CROP.ceiling * 10);
|
|
73
|
+
let size = sizeOf(box, unit, digit);
|
|
74
|
+
// At a digit of 1 px every sheet fits: its sides are 200 px, for (14000 / 150 + 24) px is less.
|
|
75
|
+
while (!fits(size) && size.drawn > 1)
|
|
76
|
+
size = sizeOf(box, unit, size.drawn - 1);
|
|
77
|
+
const { drawn, width, height, top, left } = size;
|
|
78
|
+
// The line's width in twelfths of a px, 12 at least.
|
|
79
|
+
const line = Math.max(drawn, 12);
|
|
80
|
+
// Places on the crop as whole numbers, X × 24 × unit. A frame reaches past the points by half the
|
|
81
|
+
// line and by the pixel at its edge, which the raster still shades: (line / 24 + 1) px.
|
|
82
|
+
const across = (x) => 24 * ((x - box.minX) * drawn + left * unit);
|
|
83
|
+
const down = (y) => 24 * ((y - box.minY) * drawn + top * unit);
|
|
84
|
+
const reach = (line + 24) * unit;
|
|
85
|
+
const frames = groups.map(({ label }, n) => {
|
|
86
|
+
const frame = around(members[n] ?? []);
|
|
87
|
+
return {
|
|
88
|
+
label,
|
|
89
|
+
box: [
|
|
90
|
+
floorDiv(1000 * (down(frame.minY) - reach), 24 * unit * height),
|
|
91
|
+
floorDiv(1000 * (across(frame.minX) - reach), 24 * unit * width),
|
|
92
|
+
ceilDiv(1000 * (down(frame.maxY) + reach), 24 * unit * height),
|
|
93
|
+
ceilDiv(1000 * (across(frame.maxX) + reach), 24 * unit * width),
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
const margins = [top, left];
|
|
98
|
+
const exact = { box, median: unit };
|
|
99
|
+
return {
|
|
100
|
+
digit,
|
|
101
|
+
drawn,
|
|
102
|
+
median: median / 10,
|
|
103
|
+
scale: (10 * drawn) / unit,
|
|
104
|
+
line: line / 12,
|
|
105
|
+
width,
|
|
106
|
+
height,
|
|
107
|
+
margins,
|
|
108
|
+
sheetBox: sheetBoxOf({ drawn, width, height, margins, exact }),
|
|
109
|
+
frames,
|
|
110
|
+
exact,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Where a crop with its margins lies on the sheet, its `sheet_box` (R25): [ymin, xmin, ymax, xmax]
|
|
115
|
+
* in units, each one division of two whole numbers, from the whole numbers of the crop. A
|
|
116
|
+
* submission's `sheet_box` is checked by it when the tutor's marks come back with it (R26).
|
|
117
|
+
*/
|
|
118
|
+
export function sheetBoxOf(crop) {
|
|
119
|
+
const { drawn, width, height, margins: [top, left], exact: { box, median: unit }, } = crop;
|
|
120
|
+
return [
|
|
121
|
+
(box.minY * drawn - top * unit) / (10 * drawn),
|
|
122
|
+
(box.minX * drawn - left * unit) / (10 * drawn),
|
|
123
|
+
(box.minY * drawn - top * unit + height * unit) / (10 * drawn),
|
|
124
|
+
(box.minX * drawn - left * unit + width * unit) / (10 * drawn),
|
|
125
|
+
];
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The strokes as the crop sees them. A stroke without points, or an ID twice, is no sheet's, and a
|
|
129
|
+
* point that is no finite place on the sheet is no point of it.
|
|
130
|
+
*/
|
|
131
|
+
function extentsOf(strokes) {
|
|
132
|
+
const seen = new Set();
|
|
133
|
+
return strokes.map((stroke) => {
|
|
134
|
+
if (seen.has(stroke.id))
|
|
135
|
+
throw new TypeError(`the stroke ${stroke.id} is on the sheet twice`);
|
|
136
|
+
seen.add(stroke.id);
|
|
137
|
+
return extentOf(stroke);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Each group's strokes. The groups are those of the strokes given: each stroke in one of them, and
|
|
142
|
+
* no stroke of a group missing from the sheet.
|
|
143
|
+
*/
|
|
144
|
+
function membersOf(groups, extents) {
|
|
145
|
+
const byId = new Map(extents.map((extent) => [extent.id, extent]));
|
|
146
|
+
const grouped = new Set();
|
|
147
|
+
const members = groups.map(({ label, ids }) => {
|
|
148
|
+
if (ids.length === 0)
|
|
149
|
+
throw new TypeError(`the group ${label} has no strokes`);
|
|
150
|
+
return ids.map((id) => {
|
|
151
|
+
const extent = byId.get(id);
|
|
152
|
+
if (extent === undefined)
|
|
153
|
+
throw new TypeError(`the group ${label} has a stroke ${id} not on the sheet`);
|
|
154
|
+
if (grouped.has(id))
|
|
155
|
+
throw new TypeError(`the stroke ${id} is in two groups`);
|
|
156
|
+
grouped.add(id);
|
|
157
|
+
return extent;
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
for (const { id } of extents) {
|
|
161
|
+
if (!grouped.has(id))
|
|
162
|
+
throw new TypeError(`the stroke ${id} is in no group`);
|
|
163
|
+
}
|
|
164
|
+
return members;
|
|
165
|
+
}
|
|
166
|
+
/** The bundles, each the extents of its strokes: the index finds the boxes that touch. */
|
|
167
|
+
function bundled(extents) {
|
|
168
|
+
const tall = extents.filter(({ minY, maxY }) => maxY - minY >= CROP.flat * 10);
|
|
169
|
+
const index = new RBush().load(tall.map((extent, at) => ({ ...extent, at })));
|
|
170
|
+
const sets = new Sets(tall.length);
|
|
171
|
+
for (const [at, extent] of tall.entries()) {
|
|
172
|
+
for (const other of index.search(extent))
|
|
173
|
+
sets.join(at, other.at);
|
|
174
|
+
}
|
|
175
|
+
const bundles = new Map();
|
|
176
|
+
for (const [at, extent] of tall.entries()) {
|
|
177
|
+
const root = sets.root(at);
|
|
178
|
+
const bundle = bundles.get(root);
|
|
179
|
+
if (bundle === undefined)
|
|
180
|
+
bundles.set(root, [extent]);
|
|
181
|
+
else
|
|
182
|
+
bundle.push(extent);
|
|
183
|
+
}
|
|
184
|
+
return [...bundles.values()];
|
|
185
|
+
}
|
|
186
|
+
/** h_med in tenths: the height of the bundle in the lower middle, ⌊(n − 1) / 2⌋ from the lowest. */
|
|
187
|
+
function medianTenths(extents) {
|
|
188
|
+
const heights = bundled(extents)
|
|
189
|
+
.map((bundle) => {
|
|
190
|
+
const { minY, maxY } = around(bundle);
|
|
191
|
+
return maxY - minY;
|
|
192
|
+
})
|
|
193
|
+
.sort(byNumber);
|
|
194
|
+
return heights[Math.floor((heights.length - 1) / 2)] ?? CROP.flat * 10;
|
|
195
|
+
}
|
|
196
|
+
function sizeOf(box, unit, drawn) {
|
|
197
|
+
const width = ceilDiv((box.maxX - box.minX) * drawn, unit) + 2 * CROP.margin;
|
|
198
|
+
const height = ceilDiv((box.maxY - box.minY) * drawn, unit) + 2 * CROP.margin;
|
|
199
|
+
return {
|
|
200
|
+
drawn,
|
|
201
|
+
width: Math.max(width, CROP.side),
|
|
202
|
+
height: Math.max(height, CROP.side),
|
|
203
|
+
top: CROP.margin + floorDiv(Math.max(CROP.side - height, 0), 2),
|
|
204
|
+
left: CROP.margin + floorDiv(Math.max(CROP.side - width, 0), 2),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/** Whether every model of Claude takes the crop as it is: no side too long, no tokens too many. */
|
|
208
|
+
function fits(size) {
|
|
209
|
+
return Math.max(size.width, size.height) <= CROP.edge && tokensOf(size) <= CROP.tokens;
|
|
210
|
+
}
|
|
211
|
+
/** What Claude counts an image as: ⌈w / 28⌉ × ⌈h / 28⌉ visual tokens, each a patch 28 px square. */
|
|
212
|
+
export function tokensOf({ width, height }) {
|
|
213
|
+
return ceilDiv(width, CROP.patch) * ceilDiv(height, CROP.patch);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* ⌊a / b⌋ of two whole numbers, a ≥ 0 and b > 0: what is left over is taken away first, so the
|
|
217
|
+
* division is exact. The numbers of the rule stay below 2^53, where whole numbers are exact.
|
|
218
|
+
*/
|
|
219
|
+
function floorDiv(a, b) {
|
|
220
|
+
return (a - (a % b)) / b;
|
|
221
|
+
}
|
|
222
|
+
function ceilDiv(a, b) {
|
|
223
|
+
return floorDiv(a + b - 1, b);
|
|
224
|
+
}
|
|
225
|
+
function byNumber(a, b) {
|
|
226
|
+
return a - b;
|
|
227
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Stroke } from "./ink.js";
|
|
2
|
+
import { type PointerSample } from "./recorder.js";
|
|
3
|
+
import type { Point } from "./screen.js";
|
|
4
|
+
/** `button` of a pen's eraser, its other end (Pointer Events Level 3). */
|
|
5
|
+
export declare const ERASER_BUTTON = 5;
|
|
6
|
+
/**
|
|
7
|
+
* The eraser (R22): one press of a pointer takes off the sheet, whole, every stroke its path comes
|
|
8
|
+
* near — a tap is a path of one point. The strokes it comes near are pending while it is down, and
|
|
9
|
+
* when it lifts they go to the handler the eraser is made with, in one action, with the page's
|
|
10
|
+
* time of the lift: one undo brings them all back. `pointercancel` drops the press and erases
|
|
11
|
+
* nothing. One pointer erases at a time, with the primary button or a pen's eraser.
|
|
12
|
+
*/
|
|
13
|
+
export declare class Eraser {
|
|
14
|
+
#private;
|
|
15
|
+
constructor(near: (from: Point, to: Point, reach: number) => readonly Stroke[], finished: (ids: readonly number[], time: number) => void);
|
|
16
|
+
/** The `pointerId` of the pointer erasing, or nothing between presses. */
|
|
17
|
+
get pointer(): number | undefined;
|
|
18
|
+
/** The IDs of the strokes the press under way has come near: they go when it lifts. */
|
|
19
|
+
get pending(): ReadonlySet<number>;
|
|
20
|
+
/**
|
|
21
|
+
* `pointerdown`: a press begins here, `reach` sheet units wide on either side, if none is under
|
|
22
|
+
* way and the primary button or a pen's eraser went down. It takes what lies under it at once.
|
|
23
|
+
*/
|
|
24
|
+
down(sample: PointerSample, reach: number): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* `pointermove`, once for each coalesced event: the eraser goes on to here, and says whether it
|
|
27
|
+
* came near a stroke it had not. A move without its button held ends the press where it was.
|
|
28
|
+
* A pointer out of sight of the sheet — beyond the part on the screen, where a captured pointer
|
|
29
|
+
* goes on — takes nothing, and the way goes on from the last place in sight when it comes back:
|
|
30
|
+
* the eraser takes only what the learner sees it pass.
|
|
31
|
+
*/
|
|
32
|
+
move(sample: PointerSample, inSight?: boolean): boolean;
|
|
33
|
+
/** `pointerup`: the eraser goes on to where the pointer left, if in sight, and the press lands. */
|
|
34
|
+
up(sample: PointerSample, inSight?: boolean): boolean;
|
|
35
|
+
/** `pointercancel`: the device took the touch for an accident, and nothing is erased (R05). */
|
|
36
|
+
cancel(pointerId: number): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* The press ends where the pointer last was, its `pointerup` never to come: the pointer's capture
|
|
39
|
+
* was lost, or the sheet left the page. Any pointer's press, unless one is named.
|
|
40
|
+
*/
|
|
41
|
+
end(pointerId?: number): boolean;
|
|
42
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { PRIMARY, PRIMARY_HELD } from "./recorder.js";
|
|
2
|
+
/** `button` of a pen's eraser, its other end (Pointer Events Level 3). */
|
|
3
|
+
export const ERASER_BUTTON = 5;
|
|
4
|
+
/** The eraser's bit in `buttons`. */
|
|
5
|
+
const ERASER_HELD = 32;
|
|
6
|
+
const NONE = new Set();
|
|
7
|
+
/**
|
|
8
|
+
* The eraser (R22): one press of a pointer takes off the sheet, whole, every stroke its path comes
|
|
9
|
+
* near — a tap is a path of one point. The strokes it comes near are pending while it is down, and
|
|
10
|
+
* when it lifts they go to the handler the eraser is made with, in one action, with the page's
|
|
11
|
+
* time of the lift: one undo brings them all back. `pointercancel` drops the press and erases
|
|
12
|
+
* nothing. One pointer erases at a time, with the primary button or a pen's eraser.
|
|
13
|
+
*/
|
|
14
|
+
export class Eraser {
|
|
15
|
+
#near;
|
|
16
|
+
#finished;
|
|
17
|
+
#press;
|
|
18
|
+
constructor(near, finished) {
|
|
19
|
+
this.#near = near;
|
|
20
|
+
this.#finished = finished;
|
|
21
|
+
}
|
|
22
|
+
/** The `pointerId` of the pointer erasing, or nothing between presses. */
|
|
23
|
+
get pointer() {
|
|
24
|
+
return this.#press?.pointerId;
|
|
25
|
+
}
|
|
26
|
+
/** The IDs of the strokes the press under way has come near: they go when it lifts. */
|
|
27
|
+
get pending() {
|
|
28
|
+
return this.#press?.hits ?? NONE;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* `pointerdown`: a press begins here, `reach` sheet units wide on either side, if none is under
|
|
32
|
+
* way and the primary button or a pen's eraser went down. It takes what lies under it at once.
|
|
33
|
+
*/
|
|
34
|
+
down(sample, reach) {
|
|
35
|
+
if (this.#press !== undefined)
|
|
36
|
+
return false;
|
|
37
|
+
let held;
|
|
38
|
+
if (sample.button === PRIMARY)
|
|
39
|
+
held = PRIMARY_HELD;
|
|
40
|
+
else if (sample.button === ERASER_BUTTON && sample.kind === "pen")
|
|
41
|
+
held = ERASER_HELD;
|
|
42
|
+
else
|
|
43
|
+
return false;
|
|
44
|
+
const point = { x: sample.x, y: sample.y };
|
|
45
|
+
const press = {
|
|
46
|
+
pointerId: sample.pointerId,
|
|
47
|
+
held,
|
|
48
|
+
reach,
|
|
49
|
+
last: point,
|
|
50
|
+
time: sample.time,
|
|
51
|
+
hits: new Set(),
|
|
52
|
+
};
|
|
53
|
+
this.#press = press;
|
|
54
|
+
this.#take(press, point);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* `pointermove`, once for each coalesced event: the eraser goes on to here, and says whether it
|
|
59
|
+
* came near a stroke it had not. A move without its button held ends the press where it was.
|
|
60
|
+
* A pointer out of sight of the sheet — beyond the part on the screen, where a captured pointer
|
|
61
|
+
* goes on — takes nothing, and the way goes on from the last place in sight when it comes back:
|
|
62
|
+
* the eraser takes only what the learner sees it pass.
|
|
63
|
+
*/
|
|
64
|
+
move(sample, inSight = true) {
|
|
65
|
+
const press = this.#press;
|
|
66
|
+
if (press === undefined || sample.pointerId !== press.pointerId)
|
|
67
|
+
return false;
|
|
68
|
+
if ((sample.buttons & press.held) === 0)
|
|
69
|
+
return this.#finish(press.time);
|
|
70
|
+
press.time = sample.time;
|
|
71
|
+
if (!inSight)
|
|
72
|
+
return false;
|
|
73
|
+
const point = { x: sample.x, y: sample.y };
|
|
74
|
+
const took = this.#take(press, point);
|
|
75
|
+
press.last = point;
|
|
76
|
+
return took;
|
|
77
|
+
}
|
|
78
|
+
/** `pointerup`: the eraser goes on to where the pointer left, if in sight, and the press lands. */
|
|
79
|
+
up(sample, inSight = true) {
|
|
80
|
+
const press = this.#press;
|
|
81
|
+
if (press === undefined || sample.pointerId !== press.pointerId)
|
|
82
|
+
return false;
|
|
83
|
+
if (inSight)
|
|
84
|
+
this.#take(press, { x: sample.x, y: sample.y });
|
|
85
|
+
return this.#finish(sample.time);
|
|
86
|
+
}
|
|
87
|
+
/** `pointercancel`: the device took the touch for an accident, and nothing is erased (R05). */
|
|
88
|
+
cancel(pointerId) {
|
|
89
|
+
if (this.#press?.pointerId !== pointerId)
|
|
90
|
+
return false;
|
|
91
|
+
this.#press = undefined;
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The press ends where the pointer last was, its `pointerup` never to come: the pointer's capture
|
|
96
|
+
* was lost, or the sheet left the page. Any pointer's press, unless one is named.
|
|
97
|
+
*/
|
|
98
|
+
end(pointerId) {
|
|
99
|
+
const press = this.#press;
|
|
100
|
+
if (press === undefined)
|
|
101
|
+
return false;
|
|
102
|
+
if (pointerId !== undefined && press.pointerId !== pointerId)
|
|
103
|
+
return false;
|
|
104
|
+
return this.#finish(press.time);
|
|
105
|
+
}
|
|
106
|
+
/** The strokes near the eraser's way from its last place to `to`: whether any was new. */
|
|
107
|
+
#take(press, to) {
|
|
108
|
+
let took = false;
|
|
109
|
+
for (const stroke of this.#near(press.last, to, press.reach)) {
|
|
110
|
+
if (press.hits.has(stroke.id))
|
|
111
|
+
continue;
|
|
112
|
+
press.hits.add(stroke.id);
|
|
113
|
+
took = true;
|
|
114
|
+
}
|
|
115
|
+
return took;
|
|
116
|
+
}
|
|
117
|
+
#finish(time) {
|
|
118
|
+
const press = this.#press;
|
|
119
|
+
if (press === undefined)
|
|
120
|
+
return false;
|
|
121
|
+
// The press is no longer under way by the time the handler has its strokes.
|
|
122
|
+
this.#press = undefined;
|
|
123
|
+
if (press.hits.size > 0)
|
|
124
|
+
this.#finished([...press.hits], time);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { DemoState } from "@altnovatrail/spec/canvas";
|
|
2
|
+
import type * as open from "@altnovatrail/spec/formatv0/open";
|
|
3
|
+
import type { GridKind } from "./grid.js";
|
|
4
|
+
import { type PicturePart, SHEET_KIND, type SheetTemplate, type StatementPart, type TextPart, type Zone } from "./task.js";
|
|
5
|
+
/** A lesson's worked example as a consumer gives it: spec's type (R73 spec). */
|
|
6
|
+
export type WorkedExample = open.WorkedExample;
|
|
7
|
+
/** The sheet of a worked example, as the element checked it: a handwriting sheet and its grid. */
|
|
8
|
+
export interface ExampleCanvas {
|
|
9
|
+
readonly kind: typeof SHEET_KIND;
|
|
10
|
+
/** Squares unless given. */
|
|
11
|
+
readonly grid?: GridKind;
|
|
12
|
+
/** The grid's step in sheet units, a whole number from 20 to 500: 50 unless given. */
|
|
13
|
+
readonly grid_step?: number;
|
|
14
|
+
}
|
|
15
|
+
/** A picture of the demo on the sheet: shown on the layer of the author's demo, in its area. */
|
|
16
|
+
export interface AreaPicture {
|
|
17
|
+
/** Its path from the module's media/, which the consumer's `media` takes to give its file. */
|
|
18
|
+
readonly picture: string;
|
|
19
|
+
/** What the picture shows, in words: "" for a picture that only decorates. */
|
|
20
|
+
readonly alt: string;
|
|
21
|
+
/** The area of the sheet it is shown in, at its size: on the sheet, by the rule lesson.areas. */
|
|
22
|
+
readonly area: Zone;
|
|
23
|
+
}
|
|
24
|
+
/** A part of the author's demo: a paragraph, a picture beside the sheet, or one on the sheet. */
|
|
25
|
+
export type ExamplePart = TextPart | PicturePart | AreaPicture;
|
|
26
|
+
/** A worked example as the element checked it: its statement, its sheet and its demo's parts. */
|
|
27
|
+
export interface CheckedExample {
|
|
28
|
+
/** What the example asks, in the module's explanation language, as a task's statement. */
|
|
29
|
+
readonly statement: readonly StatementPart[];
|
|
30
|
+
readonly canvas: ExampleCanvas;
|
|
31
|
+
/** The parts of how the example is solved, in order: the learner opens them one at a time. */
|
|
32
|
+
readonly demo: readonly ExamplePart[];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* How far the learner has gone through the author's demo (R42): how many of its parts are open,
|
|
36
|
+
* from the first, and whether the whole demo is hidden. app keeps it and may give it back. spec's
|
|
37
|
+
* type (R74 spec).
|
|
38
|
+
*/
|
|
39
|
+
export type { DemoState };
|
|
40
|
+
/** Where every demo starts: no part open, and nothing hidden. One for every element. */
|
|
41
|
+
export declare const DEMO_START: DemoState;
|
|
42
|
+
/**
|
|
43
|
+
* A worked example's open view, checked as the format's schema and its rule lesson.areas check it,
|
|
44
|
+
* and a copy of it with the fields the schema names: the element reads each field once. What they
|
|
45
|
+
* refuse is a TypeError that names the field by its path. A field is left out by `undefined` alone:
|
|
46
|
+
* `null` is a value, and a wrong one.
|
|
47
|
+
*/
|
|
48
|
+
export declare function exampleOf(value: unknown): CheckedExample;
|
|
49
|
+
/**
|
|
50
|
+
* What the sheet makes of a worked example, as of a task (R42): the grid on the sheet, the
|
|
51
|
+
* statement above it, and the pen alone, for the learner does not write on it.
|
|
52
|
+
*/
|
|
53
|
+
export declare function exampleTemplate(example: CheckedExample): SheetTemplate;
|
|
54
|
+
/**
|
|
55
|
+
* The state of a demo of `parts` parts, checked (R42): an object of `opened`, a whole number from 0
|
|
56
|
+
* to `parts`, and `hidden`, true or false, both of them and nothing else. What it refuses is a
|
|
57
|
+
* TypeError that names the field by its path. It gives a frozen copy of its own.
|
|
58
|
+
*/
|
|
59
|
+
export declare function demoStateOf(value: unknown, parts: number): DemoState;
|
|
60
|
+
/** Whether a part of the demo stands on the sheet, in an area of it. */
|
|
61
|
+
export declare function onSheet(part: ExamplePart): part is AreaPicture;
|
|
62
|
+
/**
|
|
63
|
+
* Whether two worked examples show the same (R42): the same statement, the same grid — its kind and
|
|
64
|
+
* step as drawn, the defaults filled in — and the same parts of the demo in the same order.
|
|
65
|
+
*/
|
|
66
|
+
export declare function sameExample(a: CheckedExample, b: CheckedExample): boolean;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// A lesson's worked example (R58 spec; R42): the open view of a segment `worked_example`, of spec's
|
|
2
|
+
// type WorkedExample (R73 spec; R41) — its statement, the handwriting sheet the author's demo is
|
|
3
|
+
// shown on, and the demo's parts in order: paragraphs, pictures beside the sheet and pictures on
|
|
4
|
+
// areas of it. The element checks it as the schema does, every object closed (R32), and by the
|
|
5
|
+
// format's rule lesson.areas too: an area lies on the sheet. What the check gives is the element's
|
|
6
|
+
// own shape of the same example, the kinds of its parts apart, which the rest of the element reads,
|
|
7
|
+
// and the sheet makes a template of it as of a task: its grid, its statement and the pen alone. How
|
|
8
|
+
// far the learner has gone through the demo is a state of its own, which app keeps and gives back.
|
|
9
|
+
import { closed, listOf, sameItems, shown, whole } from "./fields.js";
|
|
10
|
+
import { SHEET } from "./sheet.js";
|
|
11
|
+
import { altOf, gridFrom, gridOf, partOf, pictureOf, SHEET_KIND, samePart, textOrPictureOf, zoneOf, } from "./task.js";
|
|
12
|
+
/** Where every demo starts: no part open, and nothing hidden. One for every element. */
|
|
13
|
+
export const DEMO_START = Object.freeze({ opened: 0, hidden: false });
|
|
14
|
+
/** The path the element names a worked example's fields by: its property's name. */
|
|
15
|
+
const ROOT = "workedExample";
|
|
16
|
+
/** The path the element names the state of a demo's fields by: its property's name. */
|
|
17
|
+
const STATE = "demoState";
|
|
18
|
+
/** A worked example's tools: the pen alone, which writes nothing on its sheet (R42). */
|
|
19
|
+
const PEN_ALONE = Object.freeze(["pen"]);
|
|
20
|
+
/**
|
|
21
|
+
* A worked example's open view, checked as the format's schema and its rule lesson.areas check it,
|
|
22
|
+
* and a copy of it with the fields the schema names: the element reads each field once. What they
|
|
23
|
+
* refuse is a TypeError that names the field by its path. A field is left out by `undefined` alone:
|
|
24
|
+
* `null` is a value, and a wrong one.
|
|
25
|
+
*/
|
|
26
|
+
export function exampleOf(value) {
|
|
27
|
+
const example = closed(value, ROOT, ["statement", "canvas", "demo"], "a worked example");
|
|
28
|
+
const statement = listOf(example.statement, `${ROOT}.statement`, 1, "parts", partOf);
|
|
29
|
+
const canvas = exampleCanvasOf(example.canvas, `${ROOT}.canvas`);
|
|
30
|
+
const demo = listOf(example.demo, `${ROOT}.demo`, 1, "parts of the demo", demoPartOf);
|
|
31
|
+
return { statement, canvas, demo };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* What the sheet makes of a worked example, as of a task (R42): the grid on the sheet, the
|
|
35
|
+
* statement above it, and the pen alone, for the learner does not write on it.
|
|
36
|
+
*/
|
|
37
|
+
export function exampleTemplate(example) {
|
|
38
|
+
return { grid: gridFrom(example.canvas), statement: example.statement, tools: PEN_ALONE };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The state of a demo of `parts` parts, checked (R42): an object of `opened`, a whole number from 0
|
|
42
|
+
* to `parts`, and `hidden`, true or false, both of them and nothing else. What it refuses is a
|
|
43
|
+
* TypeError that names the field by its path. It gives a frozen copy of its own.
|
|
44
|
+
*/
|
|
45
|
+
export function demoStateOf(value, parts) {
|
|
46
|
+
const state = closed(value, STATE, ["opened", "hidden"], "the state of a demo");
|
|
47
|
+
const opened = whole(state.opened, `${STATE}.opened`, 0, parts);
|
|
48
|
+
if (typeof state.hidden !== "boolean") {
|
|
49
|
+
throw new TypeError(`${STATE}.hidden: true or false, not ${shown(state.hidden)}`);
|
|
50
|
+
}
|
|
51
|
+
return Object.freeze({ opened, hidden: state.hidden });
|
|
52
|
+
}
|
|
53
|
+
/** Whether a part of the demo stands on the sheet, in an area of it. */
|
|
54
|
+
export function onSheet(part) {
|
|
55
|
+
return "area" in part;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Whether two worked examples show the same (R42): the same statement, the same grid — its kind and
|
|
59
|
+
* step as drawn, the defaults filled in — and the same parts of the demo in the same order.
|
|
60
|
+
*/
|
|
61
|
+
export function sameExample(a, b) {
|
|
62
|
+
const [one, other] = [gridFrom(a.canvas), gridFrom(b.canvas)];
|
|
63
|
+
return (sameItems(a.statement, b.statement, samePart) &&
|
|
64
|
+
one.kind === other.kind &&
|
|
65
|
+
one.step === other.step &&
|
|
66
|
+
sameItems(a.demo, b.demo, sameDemoPart));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The sheet of a worked example: a handwriting sheet and its grid, and no tools, for the learner
|
|
70
|
+
* does not write on it (README of the format, §21). A workbook is in no version of the format yet.
|
|
71
|
+
*/
|
|
72
|
+
function exampleCanvasOf(value, path) {
|
|
73
|
+
const canvas = closed(value, path, ["kind", "grid", "grid_step"], "a worked example's canvas");
|
|
74
|
+
if (canvas.kind !== SHEET_KIND) {
|
|
75
|
+
throw new TypeError(`${path}.kind: "${SHEET_KIND}", for a worked example is shown on a handwriting sheet, not ${shown(canvas.kind)}`);
|
|
76
|
+
}
|
|
77
|
+
return { kind: SHEET_KIND, ...gridOf(canvas, path) };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* A part of the demo: a paragraph, a picture beside the sheet with its size, or a picture on the
|
|
81
|
+
* sheet with its area, which has no size of its own.
|
|
82
|
+
*/
|
|
83
|
+
function demoPartOf(value, path) {
|
|
84
|
+
const fields = ["text", "picture", "alt", "width", "height", "area"];
|
|
85
|
+
const part = closed(value, path, fields, "a part of the demo");
|
|
86
|
+
if (part.picture === undefined)
|
|
87
|
+
closed(part, path, ["text"], "a paragraph of the demo");
|
|
88
|
+
if (part.area === undefined)
|
|
89
|
+
return textOrPictureOf(part, path, "the demo");
|
|
90
|
+
closed(part, path, ["picture", "alt", "area"], "a picture on the sheet");
|
|
91
|
+
const picture = pictureOf(part.picture, `${path}.picture`);
|
|
92
|
+
const alt = altOf(part.alt, `${path}.alt`);
|
|
93
|
+
return { picture, alt, area: areaOf(part.area, `${path}.area`) };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* An area of the sheet, as a zone is, that lies on the sheet: its right edge at most at the sheet's
|
|
97
|
+
* width and its bottom at most at its height, or the sheet could not show it (lesson.areas).
|
|
98
|
+
*/
|
|
99
|
+
function areaOf(value, path) {
|
|
100
|
+
const area = zoneOf(value, path);
|
|
101
|
+
if (area.left + area.width > SHEET.width || area.top + area.height > SHEET.height) {
|
|
102
|
+
throw new TypeError(`${path}: an area on the sheet, its right edge at most ${SHEET.width} and its bottom at most ${SHEET.height}, not ${shown(value)}`);
|
|
103
|
+
}
|
|
104
|
+
return area;
|
|
105
|
+
}
|
|
106
|
+
/** Whether two parts of the demo show the same: a paragraph, or a picture beside or on the sheet. */
|
|
107
|
+
function sameDemoPart(a, b) {
|
|
108
|
+
if (!onSheet(a) || !onSheet(b))
|
|
109
|
+
return !onSheet(a) && !onSheet(b) && samePart(a, b);
|
|
110
|
+
return (a.picture === b.picture &&
|
|
111
|
+
a.alt === b.alt &&
|
|
112
|
+
a.area.left === b.area.left &&
|
|
113
|
+
a.area.top === b.area.top &&
|
|
114
|
+
a.area.width === b.area.width &&
|
|
115
|
+
a.area.height === b.area.height);
|
|
116
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
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 declare function record(value: unknown, path: string): Record<string, unknown>;
|
|
8
|
+
/**
|
|
9
|
+
* The fields of an object closed as the module format closes its objects (R32), read once into a
|
|
10
|
+
* record of their own: a field it does not name is a TypeError at that field's path. A field left
|
|
11
|
+
* out by `undefined` is no field, and is not in the record.
|
|
12
|
+
*/
|
|
13
|
+
export declare function closed(value: unknown, path: string, names: readonly string[], what: string): Record<string, unknown>;
|
|
14
|
+
/**
|
|
15
|
+
* A list of at least `least` items, each read by `each` at its own path. Array.from visits every
|
|
16
|
+
* index, a hole too, which map would pass over unchecked.
|
|
17
|
+
*/
|
|
18
|
+
export declare function listOf<T>(value: unknown, path: string, least: number, what: string, each: (item: unknown, path: string) => T): T[];
|
|
19
|
+
/** A text for people to read: a string, not empty. */
|
|
20
|
+
export declare function text(value: unknown, path: string, what: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* A whole number from `least` to `most`, or from `least` up, short of 2^53, where a number stops
|
|
23
|
+
* being exact.
|
|
24
|
+
*/
|
|
25
|
+
export declare function whole(value: unknown, path: string, least: number, most?: number): number;
|
|
26
|
+
/** A value as an error message shows it. */
|
|
27
|
+
export declare function shown(value: unknown): string;
|
|
28
|
+
/** Whether two lists are the same, item by item, by `same`: by identity unless given. */
|
|
29
|
+
export declare function sameItems<T>(a: readonly T[], b: readonly T[], same?: (x: T, y: T) => boolean): boolean;
|