@mavware/bug-surveillance 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 +21 -0
- package/README.md +214 -0
- package/package.json +46 -0
- package/src/brightness.js +65 -0
- package/src/camera.js +69 -0
- package/src/captureLogic.js +124 -0
- package/src/detector.js +137 -0
- package/src/index.d.ts +685 -0
- package/src/index.js +72 -0
- package/src/localNight.js +196 -0
- package/src/localNightSink.js +114 -0
- package/src/nightStore.js +164 -0
- package/src/nightStoreMemory.js +97 -0
- package/src/replay.js +175 -0
- package/src/reportControls.js +108 -0
- package/src/sessionAnalytics.js +194 -0
- package/src/tracker.js +150 -0
- package/src/wakeLock.js +34 -0
package/src/replay.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Renders trails and the animated replay over the reference photo. All
|
|
2
|
+
// coordinates in the data are full-frame pixels; the canvas is drawn at the
|
|
3
|
+
// frame's native resolution and scaled by CSS.
|
|
4
|
+
export class Replay {
|
|
5
|
+
constructor({ canvas, data, referenceImage }) {
|
|
6
|
+
this.canvas = canvas;
|
|
7
|
+
this.ctx = canvas.getContext('2d');
|
|
8
|
+
this.data = data;
|
|
9
|
+
this.referenceImage = referenceImage;
|
|
10
|
+
this.duration = Math.max(1, ...data.tracks.map((track) => track.endOffsetMs));
|
|
11
|
+
this.playheadMs = null;
|
|
12
|
+
this.playing = false;
|
|
13
|
+
this.speed = 60;
|
|
14
|
+
this.showTrails = true;
|
|
15
|
+
this.highlightedTrackId = null;
|
|
16
|
+
this.lastFrameTime = null;
|
|
17
|
+
|
|
18
|
+
canvas.width = data.frameWidth || referenceImage?.naturalWidth || 1280;
|
|
19
|
+
canvas.height = data.frameHeight || referenceImage?.naturalHeight || 720;
|
|
20
|
+
|
|
21
|
+
this.draw();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
trackColor(index, alpha = 1) {
|
|
25
|
+
return `hsla(${(index * 67) % 360}, 85%, 60%, ${alpha})`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
draw() {
|
|
29
|
+
const { ctx, canvas } = this;
|
|
30
|
+
|
|
31
|
+
if (this.referenceImage !== null) {
|
|
32
|
+
ctx.drawImage(this.referenceImage, 0, 0, canvas.width, canvas.height);
|
|
33
|
+
ctx.fillStyle = 'rgba(0, 0, 0, 0.35)';
|
|
34
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
35
|
+
} else {
|
|
36
|
+
ctx.fillStyle = '#18181b';
|
|
37
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
this.data.tracks.forEach((track, index) => {
|
|
41
|
+
const upTo = this.playheadMs === null ? Infinity : this.playheadMs;
|
|
42
|
+
this.drawTrack(track, index, upTo);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
this.drawZones();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
drawTrack(track, index, upToMs) {
|
|
49
|
+
const { ctx } = this;
|
|
50
|
+
const visible = track.points.filter(([t]) => t <= upToMs);
|
|
51
|
+
|
|
52
|
+
if (visible.length === 0) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const highlighted = this.highlightedTrackId === track.id;
|
|
57
|
+
const dimmed = this.highlightedTrackId !== null && !highlighted;
|
|
58
|
+
|
|
59
|
+
if (this.showTrails || highlighted || this.playheadMs !== null) {
|
|
60
|
+
ctx.beginPath();
|
|
61
|
+
ctx.moveTo(visible[0][1], visible[0][2]);
|
|
62
|
+
for (const [, x, y] of visible) {
|
|
63
|
+
ctx.lineTo(x, y);
|
|
64
|
+
}
|
|
65
|
+
ctx.strokeStyle = this.trackColor(index, dimmed ? 0.15 : 0.9);
|
|
66
|
+
ctx.lineWidth = highlighted ? 5 : 2.5;
|
|
67
|
+
ctx.stroke();
|
|
68
|
+
|
|
69
|
+
// Entry dot and exit arrow only once the track is fully drawn.
|
|
70
|
+
if (upToMs >= track.endOffsetMs) {
|
|
71
|
+
ctx.beginPath();
|
|
72
|
+
ctx.arc(visible[0][1], visible[0][2], highlighted ? 9 : 6, 0, Math.PI * 2);
|
|
73
|
+
ctx.fillStyle = this.trackColor(index, dimmed ? 0.15 : 1);
|
|
74
|
+
ctx.fill();
|
|
75
|
+
this.drawArrowhead(visible, index, dimmed, highlighted);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Bright head dot while replaying.
|
|
80
|
+
if (this.playheadMs !== null && upToMs < track.endOffsetMs && visible.length > 0) {
|
|
81
|
+
const [, x, y] = visible[visible.length - 1];
|
|
82
|
+
ctx.beginPath();
|
|
83
|
+
ctx.arc(x, y, 8, 0, Math.PI * 2);
|
|
84
|
+
ctx.fillStyle = '#ffffff';
|
|
85
|
+
ctx.fill();
|
|
86
|
+
ctx.beginPath();
|
|
87
|
+
ctx.arc(x, y, 5, 0, Math.PI * 2);
|
|
88
|
+
ctx.fillStyle = this.trackColor(index);
|
|
89
|
+
ctx.fill();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
drawArrowhead(points, index, dimmed, highlighted) {
|
|
94
|
+
if (points.length < 2) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const { ctx } = this;
|
|
99
|
+
const [, x2, y2] = points[points.length - 1];
|
|
100
|
+
const [, x1, y1] = points[points.length - 2];
|
|
101
|
+
const angle = Math.atan2(y2 - y1, x2 - x1);
|
|
102
|
+
const size = highlighted ? 18 : 12;
|
|
103
|
+
|
|
104
|
+
ctx.beginPath();
|
|
105
|
+
ctx.moveTo(x2, y2);
|
|
106
|
+
ctx.lineTo(x2 - size * Math.cos(angle - 0.4), y2 - size * Math.sin(angle - 0.4));
|
|
107
|
+
ctx.lineTo(x2 - size * Math.cos(angle + 0.4), y2 - size * Math.sin(angle + 0.4));
|
|
108
|
+
ctx.closePath();
|
|
109
|
+
ctx.fillStyle = this.trackColor(index, dimmed ? 0.15 : 1);
|
|
110
|
+
ctx.fill();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
drawZones() {
|
|
114
|
+
const zones = [
|
|
115
|
+
...(this.data.analytics?.entry_zones ?? []).map((zone) => ({ ...zone, kind: 'entry' })),
|
|
116
|
+
...(this.data.analytics?.exit_zones ?? []).map((zone) => ({ ...zone, kind: 'exit' })),
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
const { ctx } = this;
|
|
120
|
+
|
|
121
|
+
for (const zone of zones) {
|
|
122
|
+
const horizontal = zone.edge === 'top' || zone.edge === 'bottom';
|
|
123
|
+
const thickness = 10;
|
|
124
|
+
const [x, y, width, height] = horizontal
|
|
125
|
+
? [zone.from, zone.edge === 'top' ? 0 : this.canvas.height - thickness, zone.to - zone.from, thickness]
|
|
126
|
+
: [zone.edge === 'left' ? 0 : this.canvas.width - thickness, zone.from, thickness, zone.to - zone.from];
|
|
127
|
+
|
|
128
|
+
ctx.fillStyle = zone.kind === 'entry' ? 'rgba(74, 222, 128, 0.65)' : 'rgba(248, 113, 113, 0.65)';
|
|
129
|
+
ctx.fillRect(x, y, width, height);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
play() {
|
|
134
|
+
this.playing = true;
|
|
135
|
+
this.playheadMs = this.playheadMs === null || this.playheadMs >= this.duration ? 0 : this.playheadMs;
|
|
136
|
+
this.lastFrameTime = performance.now();
|
|
137
|
+
requestAnimationFrame((time) => this.tick(time));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
pause() {
|
|
141
|
+
this.playing = false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
stopReplay() {
|
|
145
|
+
this.playing = false;
|
|
146
|
+
this.playheadMs = null;
|
|
147
|
+
this.draw();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
seek(fraction) {
|
|
151
|
+
this.playheadMs = Math.round(fraction * this.duration);
|
|
152
|
+
this.draw();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
tick(time) {
|
|
156
|
+
if (!this.playing) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
this.playheadMs += (time - this.lastFrameTime) * this.speed;
|
|
161
|
+
this.lastFrameTime = time;
|
|
162
|
+
|
|
163
|
+
if (this.playheadMs >= this.duration) {
|
|
164
|
+
this.playheadMs = this.duration;
|
|
165
|
+
this.playing = false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
this.draw();
|
|
169
|
+
this.onFrame?.(this.playheadMs / this.duration);
|
|
170
|
+
|
|
171
|
+
if (this.playing) {
|
|
172
|
+
requestAnimationFrame((nextTime) => this.tick(nextTime));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// The replay controls behind both report pages: play, speed, scrub, trails and
|
|
2
|
+
// row highlighting, over a Replay built from whatever loadData hands back. The
|
|
3
|
+
// logged-in report reads its payload from a JSON island the server re-renders;
|
|
4
|
+
// the local report reads it from the night store. Neither knows the other.
|
|
5
|
+
import { formatClock } from './captureLogic.js';
|
|
6
|
+
import { Replay } from './replay.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {HTMLElement} root The element holding the data-report controls.
|
|
10
|
+
* @param {{ loadData: () => Promise<{ data: object, referenceImage: HTMLImageElement | null }> }} options
|
|
11
|
+
* @returns {{ rebuild: () => Promise<void>, ready: Promise<void> }}
|
|
12
|
+
*/
|
|
13
|
+
export function mountReportControls(root, { loadData }) {
|
|
14
|
+
const el = (name) => root.querySelector(`[data-report="${name}"]`);
|
|
15
|
+
|
|
16
|
+
const canvas = el('canvas');
|
|
17
|
+
const playButton = el('play');
|
|
18
|
+
const speedSelect = el('speed');
|
|
19
|
+
const scrub = el('scrub');
|
|
20
|
+
const clock = el('clock');
|
|
21
|
+
const trailsToggle = el('trails');
|
|
22
|
+
|
|
23
|
+
let replay = null;
|
|
24
|
+
|
|
25
|
+
const rebuild = async () => {
|
|
26
|
+
replay?.pause();
|
|
27
|
+
|
|
28
|
+
const { data, referenceImage } = await loadData();
|
|
29
|
+
|
|
30
|
+
replay = new Replay({ canvas, data, referenceImage });
|
|
31
|
+
replay.speed = Number(speedSelect.value ?? 60);
|
|
32
|
+
replay.showTrails = trailsToggle.checked;
|
|
33
|
+
replay.draw();
|
|
34
|
+
|
|
35
|
+
replay.onFrame = (fraction) => {
|
|
36
|
+
scrub.value = Math.round(fraction * 1000);
|
|
37
|
+
clock.textContent = formatClock(replay.playheadMs);
|
|
38
|
+
if (!replay.playing) {
|
|
39
|
+
playButton.textContent = 'Replay';
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
scrub.value = 0;
|
|
44
|
+
clock.textContent = '–';
|
|
45
|
+
playButton.textContent = 'Replay';
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
playButton.addEventListener('click', () => {
|
|
49
|
+
if (replay.playing) {
|
|
50
|
+
replay.pause();
|
|
51
|
+
playButton.textContent = 'Replay';
|
|
52
|
+
} else {
|
|
53
|
+
replay.speed = Number(speedSelect.value ?? 60);
|
|
54
|
+
replay.play();
|
|
55
|
+
playButton.textContent = 'Pause';
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
speedSelect.addEventListener('change', () => {
|
|
60
|
+
replay.speed = Number(speedSelect.value);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
scrub.addEventListener('input', () => {
|
|
64
|
+
replay.pause();
|
|
65
|
+
playButton.textContent = 'Replay';
|
|
66
|
+
replay.seek(Number(scrub.value) / 1000);
|
|
67
|
+
clock.textContent = formatClock(replay.playheadMs);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
trailsToggle.addEventListener('change', () => {
|
|
71
|
+
replay.showTrails = trailsToggle.checked;
|
|
72
|
+
replay.draw();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Delegated so listeners survive the table rows being re-rendered.
|
|
76
|
+
root.addEventListener('click', (event) => {
|
|
77
|
+
const row = event.target.closest('[data-track-id]');
|
|
78
|
+
|
|
79
|
+
if (row === null || event.target.closest('button') !== null || replay === null) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const trackId = trackIdFrom(row.dataset.trackId);
|
|
84
|
+
replay.highlightedTrackId = replay.highlightedTrackId === trackId ? null : trackId;
|
|
85
|
+
replay.draw();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return { rebuild, ready: rebuild() };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Server tracks have numeric ids and local ones have uuids; Replay compares
|
|
93
|
+
* with ===, so the id must come back as the same type the payload carries.
|
|
94
|
+
*/
|
|
95
|
+
export function trackIdFrom(raw) {
|
|
96
|
+
const numeric = Number(raw);
|
|
97
|
+
|
|
98
|
+
return raw !== '' && Number.isInteger(numeric) ? numeric : raw;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function loadImage(url) {
|
|
102
|
+
return new Promise((resolve) => {
|
|
103
|
+
const image = new Image();
|
|
104
|
+
image.onload = () => resolve(image);
|
|
105
|
+
image.onerror = () => resolve(null);
|
|
106
|
+
image.src = url;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// The night's analytics summary, computed in the browser for nights that never
|
|
2
|
+
// reach the server. This is a line-for-line port of
|
|
3
|
+
// App\Actions\Surveillance\ComputeSessionAnalytics: same margins, same bins,
|
|
4
|
+
// same output shape (snake_case keys), so replay.js and the report read both
|
|
5
|
+
// the server's and the browser's summaries without knowing which they got.
|
|
6
|
+
// Keep the two in step — tests/js/surveillance/sessionAnalytics.test.js pins
|
|
7
|
+
// the same cases as the PHP unit test.
|
|
8
|
+
|
|
9
|
+
export const EDGE_BINS = 10;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Classify a full-frame point as belonging to a frame edge or the interior.
|
|
13
|
+
* The margin is 5% of the dimension, never less than 8px. In a corner the
|
|
14
|
+
* nearer edge wins; on a tie the first of left, right, top, bottom wins, which
|
|
15
|
+
* is the order PHP's stable asort leaves them in.
|
|
16
|
+
*/
|
|
17
|
+
export function classifyEdge([x, y], width, height) {
|
|
18
|
+
const marginX = Math.max(8, Math.round(width * 0.05));
|
|
19
|
+
const marginY = Math.max(8, Math.round(height * 0.05));
|
|
20
|
+
|
|
21
|
+
const distances = [
|
|
22
|
+
['left', x <= marginX ? x : null],
|
|
23
|
+
['right', width - x <= marginX ? width - x : null],
|
|
24
|
+
['top', y <= marginY ? y : null],
|
|
25
|
+
['bottom', height - y <= marginY ? height - y : null],
|
|
26
|
+
].filter(([, distance]) => distance !== null);
|
|
27
|
+
|
|
28
|
+
if (distances.length === 0) {
|
|
29
|
+
return 'interior';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let [nearestEdge, nearestDistance] = distances[0];
|
|
33
|
+
|
|
34
|
+
for (const [edge, distance] of distances.slice(1)) {
|
|
35
|
+
if (distance < nearestDistance) {
|
|
36
|
+
nearestEdge = edge;
|
|
37
|
+
nearestDistance = distance;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return nearestEdge;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Cluster edge points into zones by bucketing positions along each edge axis
|
|
46
|
+
* and merging adjacent non-empty buckets.
|
|
47
|
+
*
|
|
48
|
+
* @returns {Array<{edge: string, from: number, to: number, center: [number, number], count: number}>}
|
|
49
|
+
*/
|
|
50
|
+
export function clusterEdgePoints(points, width, height) {
|
|
51
|
+
if (width < 1 || height < 1) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const byEdge = new Map();
|
|
56
|
+
|
|
57
|
+
for (const point of points) {
|
|
58
|
+
const edge = classifyEdge(point, width, height);
|
|
59
|
+
|
|
60
|
+
if (edge === 'interior') {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const axisPosition = edge === 'top' || edge === 'bottom' ? point[0] : point[1];
|
|
65
|
+
|
|
66
|
+
if (!byEdge.has(edge)) {
|
|
67
|
+
byEdge.set(edge, []);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
byEdge.get(edge).push(axisPosition);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const zones = [];
|
|
74
|
+
|
|
75
|
+
for (const [edge, positions] of byEdge) {
|
|
76
|
+
const axisLength = edge === 'top' || edge === 'bottom' ? width : height;
|
|
77
|
+
const binSize = axisLength / EDGE_BINS;
|
|
78
|
+
const bins = new Array(EDGE_BINS).fill(0);
|
|
79
|
+
|
|
80
|
+
for (const position of positions) {
|
|
81
|
+
bins[Math.min(EDGE_BINS - 1, Math.floor(position / binSize))]++;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const [fromBin, toBin, count] of mergeAdjacentBins(bins)) {
|
|
85
|
+
const from = Math.round(fromBin * binSize);
|
|
86
|
+
const to = Math.round((toBin + 1) * binSize);
|
|
87
|
+
const centerAlongAxis = Math.round((from + to) / 2);
|
|
88
|
+
|
|
89
|
+
zones.push({
|
|
90
|
+
edge,
|
|
91
|
+
from,
|
|
92
|
+
to,
|
|
93
|
+
center: zoneCenter(edge, centerAlongAxis, width, height),
|
|
94
|
+
count,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Array.prototype.sort is stable, as PHP 8's usort is, so equal counts keep
|
|
100
|
+
// their edge order.
|
|
101
|
+
return zones.sort((a, b) => b.count - a.count);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function zoneCenter(edge, centerAlongAxis, width, height) {
|
|
105
|
+
switch (edge) {
|
|
106
|
+
case 'top':
|
|
107
|
+
return [centerAlongAxis, 0];
|
|
108
|
+
case 'bottom':
|
|
109
|
+
return [centerAlongAxis, height];
|
|
110
|
+
case 'left':
|
|
111
|
+
return [0, centerAlongAxis];
|
|
112
|
+
default:
|
|
113
|
+
return [width, centerAlongAxis];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Merge runs of adjacent non-empty bins into [fromBin, toBin, totalCount] triples. */
|
|
118
|
+
export function mergeAdjacentBins(bins) {
|
|
119
|
+
const runs = [];
|
|
120
|
+
let current = null;
|
|
121
|
+
|
|
122
|
+
bins.forEach((count, index) => {
|
|
123
|
+
if (count === 0) {
|
|
124
|
+
if (current !== null) {
|
|
125
|
+
runs.push(current);
|
|
126
|
+
current = null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (current === null) {
|
|
133
|
+
current = [index, index, count];
|
|
134
|
+
} else {
|
|
135
|
+
current[1] = index;
|
|
136
|
+
current[2] += count;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
if (current !== null) {
|
|
141
|
+
runs.push(current);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return runs;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Where a track came in and went out, from its first and last point. The
|
|
149
|
+
* server does this at insert time; the local sink does it here.
|
|
150
|
+
*/
|
|
151
|
+
export function trackEdges(points, width, height) {
|
|
152
|
+
if (points.length === 0) {
|
|
153
|
+
return { entryEdge: null, exitEdge: null };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const first = points[0];
|
|
157
|
+
const last = points[points.length - 1];
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
entryEdge: classifyEdge([first[1], first[2]], width, height),
|
|
161
|
+
exitEdge: classifyEdge([last[1], last[2]], width, height),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The summary stored on a night. Dismissed tracks never count — this is the
|
|
167
|
+
* one place that rule lives for local nights, matching the confirmed() scope
|
|
168
|
+
* the server applies.
|
|
169
|
+
*/
|
|
170
|
+
export function computeNightAnalytics({ tracks, startedAt = null, endedAt = null, frameWidth = 0, frameHeight = 0 }) {
|
|
171
|
+
const confirmed = tracks.filter((track) => track.dismissedAt == null);
|
|
172
|
+
const entryPoints = [];
|
|
173
|
+
const exitPoints = [];
|
|
174
|
+
|
|
175
|
+
for (const track of confirmed) {
|
|
176
|
+
const points = track.points;
|
|
177
|
+
|
|
178
|
+
if (points.length === 0) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
entryPoints.push([points[0][1], points[0][2]]);
|
|
183
|
+
const last = points[points.length - 1];
|
|
184
|
+
exitPoints.push([last[1], last[2]]);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
track_count: confirmed.length,
|
|
189
|
+
total_points: confirmed.reduce((sum, track) => sum + (track.pointCount ?? track.points.length), 0),
|
|
190
|
+
duration_ms: startedAt !== null && endedAt !== null ? Math.max(0, endedAt - startedAt) : 0,
|
|
191
|
+
entry_zones: clusterEdgePoints(entryPoints, frameWidth, frameHeight),
|
|
192
|
+
exit_zones: clusterEdgePoints(exitPoints, frameWidth, frameHeight),
|
|
193
|
+
};
|
|
194
|
+
}
|
package/src/tracker.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
export const TRACKER_DEFAULTS = {
|
|
2
|
+
maxMatchDistance: 40, // proc-px per frame
|
|
3
|
+
confirmAfterHits: 3,
|
|
4
|
+
closeAfterMisses: 8,
|
|
5
|
+
minPoints: 5,
|
|
6
|
+
minDisplacement: 10, // proc-px; discard jitter that never went anywhere
|
|
7
|
+
maxPointsPerTrack: 3000,
|
|
8
|
+
maxTrackDurationMs: 5 * 60 * 1000,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// Associates per-frame blobs into tracks via nearest-neighbor matching, and
|
|
12
|
+
// hands closed tracks (scaled to full-frame pixels) to the onTrackClosed
|
|
13
|
+
// callback for upload.
|
|
14
|
+
export class Tracker {
|
|
15
|
+
constructor({ scale, sessionStartTime, captureCrop, onTrackClosed, params = {} }) {
|
|
16
|
+
this.params = { ...TRACKER_DEFAULTS, ...params };
|
|
17
|
+
this.scale = scale;
|
|
18
|
+
this.sessionStartTime = sessionStartTime;
|
|
19
|
+
this.captureCrop = captureCrop;
|
|
20
|
+
this.onTrackClosed = onTrackClosed;
|
|
21
|
+
this.candidates = [];
|
|
22
|
+
this.active = [];
|
|
23
|
+
this.closedCount = 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
update(blobs, now = Date.now()) {
|
|
27
|
+
const offsetMs = Math.max(0, Math.round(now - this.sessionStartTime));
|
|
28
|
+
const unmatched = new Set(blobs.map((_, index) => index));
|
|
29
|
+
|
|
30
|
+
for (const track of [...this.active, ...this.candidates]) {
|
|
31
|
+
const matchIndex = this.nearestBlob(track, blobs, unmatched);
|
|
32
|
+
|
|
33
|
+
if (matchIndex === null) {
|
|
34
|
+
track.misses++;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
unmatched.delete(matchIndex);
|
|
39
|
+
const blob = blobs[matchIndex];
|
|
40
|
+
track.misses = 0;
|
|
41
|
+
track.hits++;
|
|
42
|
+
track.lastX = blob.cx;
|
|
43
|
+
track.lastY = blob.cy;
|
|
44
|
+
track.points.push([offsetMs, Math.round(blob.cx * this.scale), Math.round(blob.cy * this.scale)]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const index of unmatched) {
|
|
48
|
+
const blob = blobs[index];
|
|
49
|
+
this.candidates.push({
|
|
50
|
+
id: crypto.randomUUID(),
|
|
51
|
+
points: [[offsetMs, Math.round(blob.cx * this.scale), Math.round(blob.cy * this.scale)]],
|
|
52
|
+
lastX: blob.cx,
|
|
53
|
+
lastY: blob.cy,
|
|
54
|
+
hits: 1,
|
|
55
|
+
misses: 0,
|
|
56
|
+
startCrop: null,
|
|
57
|
+
endCrop: null,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
this.promoteCandidates();
|
|
62
|
+
this.closeStaleTracks(offsetMs);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
promoteCandidates() {
|
|
66
|
+
const { confirmAfterHits, closeAfterMisses } = this.params;
|
|
67
|
+
|
|
68
|
+
this.candidates = this.candidates.filter((candidate) => {
|
|
69
|
+
if (candidate.hits >= confirmAfterHits) {
|
|
70
|
+
const [, x, y] = candidate.points[candidate.points.length - 1];
|
|
71
|
+
candidate.startCrop = this.captureCrop(x, y);
|
|
72
|
+
this.active.push(candidate);
|
|
73
|
+
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return candidate.misses < closeAfterMisses;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
closeStaleTracks(nowOffsetMs) {
|
|
82
|
+
const { closeAfterMisses, maxPointsPerTrack, maxTrackDurationMs } = this.params;
|
|
83
|
+
|
|
84
|
+
this.active = this.active.filter((track) => {
|
|
85
|
+
const durationMs = nowOffsetMs - track.points[0][0];
|
|
86
|
+
const stale = track.misses >= closeAfterMisses;
|
|
87
|
+
const oversized = track.points.length >= maxPointsPerTrack || durationMs >= maxTrackDurationMs;
|
|
88
|
+
|
|
89
|
+
if (!stale && !oversized) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
this.closeTrack(track);
|
|
94
|
+
|
|
95
|
+
return false;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
closeTrack(track) {
|
|
100
|
+
const { minPoints, minDisplacement } = this.params;
|
|
101
|
+
|
|
102
|
+
if (track.points.length < minPoints || this.displacement(track) < minDisplacement * this.scale) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const [, endX, endY] = track.points[track.points.length - 1];
|
|
107
|
+
track.endCrop = this.captureCrop(endX, endY);
|
|
108
|
+
this.closedCount++;
|
|
109
|
+
|
|
110
|
+
this.onTrackClosed({
|
|
111
|
+
client_track_id: track.id,
|
|
112
|
+
start_offset_ms: track.points[0][0],
|
|
113
|
+
end_offset_ms: track.points[track.points.length - 1][0],
|
|
114
|
+
points: track.points,
|
|
115
|
+
start_crop: track.startCrop,
|
|
116
|
+
end_crop: track.endCrop,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Close everything still open (used when the night ends).
|
|
121
|
+
flush() {
|
|
122
|
+
this.active.forEach((track) => this.closeTrack(track));
|
|
123
|
+
this.active = [];
|
|
124
|
+
this.candidates = [];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
nearestBlob(track, blobs, unmatched) {
|
|
128
|
+
let best = null;
|
|
129
|
+
let bestDistance = this.params.maxMatchDistance;
|
|
130
|
+
|
|
131
|
+
for (const index of unmatched) {
|
|
132
|
+
const blob = blobs[index];
|
|
133
|
+
const distance = Math.hypot(blob.cx - track.lastX, blob.cy - track.lastY);
|
|
134
|
+
|
|
135
|
+
if (distance <= bestDistance) {
|
|
136
|
+
best = index;
|
|
137
|
+
bestDistance = distance;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return best;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
displacement(track) {
|
|
145
|
+
const [, startX, startY] = track.points[0];
|
|
146
|
+
const [, endX, endY] = track.points[track.points.length - 1];
|
|
147
|
+
|
|
148
|
+
return Math.hypot(endX - startX, endY - startY);
|
|
149
|
+
}
|
|
150
|
+
}
|
package/src/wakeLock.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Keeps the screen awake for the whole night; the OS releases the lock when
|
|
2
|
+
// the tab is hidden, so re-acquire it whenever the page becomes visible again.
|
|
3
|
+
export class WakeLock {
|
|
4
|
+
constructor(onUnsupported) {
|
|
5
|
+
this.sentinel = null;
|
|
6
|
+
this.onUnsupported = onUnsupported ?? (() => {});
|
|
7
|
+
this.onVisibilityChange = () => {
|
|
8
|
+
if (document.visibilityState === 'visible' && this.sentinel !== null) {
|
|
9
|
+
this.acquire();
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async acquire() {
|
|
15
|
+
if (!('wakeLock' in navigator)) {
|
|
16
|
+
this.onUnsupported();
|
|
17
|
+
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
this.sentinel = await navigator.wakeLock.request('screen');
|
|
23
|
+
document.addEventListener('visibilitychange', this.onVisibilityChange);
|
|
24
|
+
} catch {
|
|
25
|
+
this.onUnsupported();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async release() {
|
|
30
|
+
document.removeEventListener('visibilitychange', this.onVisibilityChange);
|
|
31
|
+
await this.sentinel?.release();
|
|
32
|
+
this.sentinel = null;
|
|
33
|
+
}
|
|
34
|
+
}
|