@jian-ai/remote-control 0.8.1 → 0.8.3
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/README.md +22 -4
- package/bin/computer-linux.mjs +537 -0
- package/bin/computer-macos.mjs +205 -62
- package/bin/computer-shared.mjs +61 -0
- package/bin/computer-windows.mjs +516 -0
- package/bin/computer.mjs +63 -0
- package/bin/jian-remote.mjs +819 -105
- package/linux/computer-helper.py +695 -0
- package/macos/computer-helper +0 -0
- package/macos/computer-helper.swift +416 -141
- package/macos/jian-pointer.png +0 -0
- package/package.json +3 -1
- package/windows/computer-helper.ps1 +501 -0
package/bin/computer-macos.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* macOS computer-use: primary-display screenshots, coord mapping, Jian overlay helper.
|
|
3
|
-
* English-only messages. Imported by
|
|
3
|
+
* English-only messages. Imported by computer.mjs on darwin.
|
|
4
4
|
*/
|
|
5
5
|
import { spawn, spawnSync } from 'node:child_process';
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
@@ -9,40 +9,41 @@ import { chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
|
9
9
|
import { homedir } from 'node:os';
|
|
10
10
|
import { dirname, join } from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { COMPUTER_CAPABILITIES, imagePixelToPoint, scaleFromGeometry } from './computer-shared.mjs';
|
|
12
13
|
|
|
13
|
-
export
|
|
14
|
+
export { imagePixelToPoint, scaleFromGeometry } from './computer-shared.mjs';
|
|
15
|
+
|
|
16
|
+
export const COMPUTER_CAPABILITY = COMPUTER_CAPABILITIES.darwin;
|
|
14
17
|
const IDLE_MS = 30_000;
|
|
15
18
|
const HELPER_NAME = 'computer-helper';
|
|
19
|
+
/** How long we cache lock / display-asleep probes between HTTPS polls. */
|
|
20
|
+
const UI_STATE_CACHE_MS = 5_000;
|
|
21
|
+
export const MAC_UI_STATES = Object.freeze(['unlocked', 'locked', 'display_asleep', 'unknown']);
|
|
16
22
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const { originX, originY, pointWidth, pointHeight, widthPx, heightPx } = geometry;
|
|
20
|
-
if (!(widthPx > 0 && heightPx > 0 && pointWidth > 0 && pointHeight > 0)) {
|
|
21
|
-
throw new Error('Invalid screenshot geometry for coordinate mapping');
|
|
22
|
-
}
|
|
23
|
-
return {
|
|
24
|
-
pointX: originX + (x + 0.5) * (pointWidth / widthPx),
|
|
25
|
-
pointY: originY + (y + 0.5) * (pointHeight / heightPx),
|
|
26
|
-
};
|
|
27
|
-
}
|
|
23
|
+
const LOCKED_SCREENSHOT_NOTE =
|
|
24
|
+
'Note: Mac session reports locked — image may be the lock screen (or black if the display stayed asleep). Desktop apps behind loginwindow are often not visible.';
|
|
28
25
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
imageToPointX: geometry.pointWidth / geometry.widthPx,
|
|
32
|
-
imageToPointY: geometry.pointHeight / geometry.heightPx,
|
|
33
|
-
};
|
|
34
|
-
}
|
|
26
|
+
const LOCKED_ACTION_HINT =
|
|
27
|
+
' Mac session reports locked; if this failed, unlock or keep the display awake (post-login lock ≠ FileVault loginwindow).';
|
|
35
28
|
|
|
36
|
-
|
|
29
|
+
let cachedUiState = { at: 0, state: 'unknown' };
|
|
30
|
+
let workerIdleCaffeinate = null;
|
|
31
|
+
let sessionDisplayCaffeinate = null;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Capture primary display and compress for the worker channel.
|
|
35
|
+
* Single-pass ladder starts at 1600/42 (skips a full-res 1920@50 encode that often
|
|
36
|
+
* exceeded the budget and forced a second sips pass — common waste on Retina).
|
|
37
|
+
*/
|
|
37
38
|
export function capturePrimaryScreenshotBase64() {
|
|
38
39
|
const script = [
|
|
39
40
|
'tmp=$(mktemp /tmp/jian-shot.XXXXXX.jpg);',
|
|
40
41
|
'screencapture -x -t jpg -D 1 "$tmp" || { rm -f "$tmp"; exit 1; };',
|
|
41
|
-
'sips -Z
|
|
42
|
+
'sips -Z 1600 -s format jpeg -s formatOptions 42 "$tmp" >/dev/null 2>&1 || true;',
|
|
42
43
|
'size=$(wc -c < "$tmp" | tr -d " ");',
|
|
43
|
-
'if [ "$size" -gt 280000 ]; then sips -Z 1440 -s format jpeg -s formatOptions
|
|
44
|
+
'if [ "$size" -gt 280000 ]; then sips -Z 1440 -s format jpeg -s formatOptions 38 "$tmp" >/dev/null 2>&1 || true; fi;',
|
|
44
45
|
'size=$(wc -c < "$tmp" | tr -d " ");',
|
|
45
|
-
'if [ "$size" -gt 280000 ]; then sips -Z 1280 -s format jpeg -s formatOptions
|
|
46
|
+
'if [ "$size" -gt 280000 ]; then sips -Z 1280 -s format jpeg -s formatOptions 34 "$tmp" >/dev/null 2>&1 || true; fi;',
|
|
46
47
|
'w=$(sips -g pixelWidth "$tmp" 2>/dev/null | awk "/pixelWidth/{print \\$2}");',
|
|
47
48
|
'h=$(sips -g pixelHeight "$tmp" 2>/dev/null | awk "/pixelHeight/{print \\$2}");',
|
|
48
49
|
'echo "$w $h";',
|
|
@@ -137,7 +138,6 @@ async function ensureHelperBinary() {
|
|
|
137
138
|
clearQuarantine(paths.binary);
|
|
138
139
|
await writeFile(paths.stamp, `${hash}\n`, { mode: 0o644 });
|
|
139
140
|
}
|
|
140
|
-
// Keep source nearby for debugging / swiftc fallback after a partial install.
|
|
141
141
|
try {
|
|
142
142
|
const swift = packageSwiftPath();
|
|
143
143
|
if (existsSync(swift)) await copyFile(swift, paths.source);
|
|
@@ -227,8 +227,11 @@ export class MacComputerSession {
|
|
|
227
227
|
|
|
228
228
|
async ensure() {
|
|
229
229
|
if (this.child && !this.child.killed) {
|
|
230
|
-
|
|
231
|
-
|
|
230
|
+
if (!this.active) {
|
|
231
|
+
await this.send({ cmd: 'ensureSession' });
|
|
232
|
+
this.active = true;
|
|
233
|
+
}
|
|
234
|
+
startSessionDisplayAssertion(this.child.pid || process.pid);
|
|
232
235
|
this.bumpIdle();
|
|
233
236
|
return;
|
|
234
237
|
}
|
|
@@ -247,6 +250,7 @@ export class MacComputerSession {
|
|
|
247
250
|
this.failAll('Computer-use helper exited unexpectedly.');
|
|
248
251
|
this.child = null;
|
|
249
252
|
this.active = false;
|
|
253
|
+
stopSessionDisplayAssertion();
|
|
250
254
|
});
|
|
251
255
|
const ping = await this.send({ cmd: 'ping' });
|
|
252
256
|
if (!ping.ok) throw new Error(ping.error || 'Computer-use helper did not respond.');
|
|
@@ -263,6 +267,7 @@ export class MacComputerSession {
|
|
|
263
267
|
};
|
|
264
268
|
await this.send({ cmd: 'ensureSession' });
|
|
265
269
|
this.active = true;
|
|
270
|
+
startSessionDisplayAssertion(this.child.pid || process.pid);
|
|
266
271
|
this.bumpIdle();
|
|
267
272
|
}
|
|
268
273
|
|
|
@@ -343,6 +348,7 @@ export class MacComputerSession {
|
|
|
343
348
|
async end() {
|
|
344
349
|
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
345
350
|
this.idleTimer = null;
|
|
351
|
+
stopSessionDisplayAssertion();
|
|
346
352
|
if (this.child) {
|
|
347
353
|
try {
|
|
348
354
|
await this.send({ cmd: 'endSession' });
|
|
@@ -371,7 +377,7 @@ export class MacComputerSession {
|
|
|
371
377
|
}
|
|
372
378
|
|
|
373
379
|
if (action === 'wait') {
|
|
374
|
-
const ms = Math.round((computer.duration ?? 0.
|
|
380
|
+
const ms = Math.round((computer.duration ?? 0.35) * 1000);
|
|
375
381
|
await new Promise((r) => setTimeout(r, ms));
|
|
376
382
|
if (this.active) this.bumpIdle();
|
|
377
383
|
return {
|
|
@@ -383,49 +389,66 @@ export class MacComputerSession {
|
|
|
383
389
|
};
|
|
384
390
|
}
|
|
385
391
|
|
|
392
|
+
// Wake display when locked/asleep, then always attempt — never soft-block on uiState.
|
|
393
|
+
const uiPrep = prepareComputerUi();
|
|
394
|
+
|
|
386
395
|
if (action === 'screenshot') {
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
heightPx: shot.heightPx,
|
|
394
|
-
};
|
|
395
|
-
this.bumpIdle();
|
|
396
|
-
return {
|
|
397
|
-
exitCode: 0,
|
|
398
|
-
stdout: shot.base64,
|
|
399
|
-
stderr: '',
|
|
400
|
-
timedOut: false,
|
|
401
|
-
computer: {
|
|
402
|
-
ok: true,
|
|
396
|
+
try {
|
|
397
|
+
await this.ensure();
|
|
398
|
+
const shot = capturePrimaryScreenshotBase64();
|
|
399
|
+
if (!this.geometry) throw new Error('Display geometry unavailable.');
|
|
400
|
+
this.shotGeometry = {
|
|
401
|
+
...this.geometry,
|
|
403
402
|
widthPx: shot.widthPx,
|
|
404
403
|
heightPx: shot.heightPx,
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
404
|
+
};
|
|
405
|
+
this.bumpIdle();
|
|
406
|
+
const stderr = uiPrep.sessionLocked ? LOCKED_SCREENSHOT_NOTE : '';
|
|
407
|
+
return {
|
|
408
|
+
exitCode: 0,
|
|
409
|
+
stdout: shot.base64,
|
|
410
|
+
stderr,
|
|
411
|
+
timedOut: false,
|
|
412
|
+
computer: {
|
|
413
|
+
ok: true,
|
|
414
|
+
widthPx: shot.widthPx,
|
|
415
|
+
heightPx: shot.heightPx,
|
|
416
|
+
scale: scaleFromGeometry(this.shotGeometry),
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
} catch (error) {
|
|
420
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
421
|
+
const hint = uiPrep.sessionLocked ? LOCKED_ACTION_HINT : '';
|
|
422
|
+
const tcc = /accessib|not permitted|not authorized|Screen Recording/i.test(message)
|
|
423
|
+
? ' Jian Remote needs Screen Recording (and Accessibility for input) in System Settings.'
|
|
424
|
+
: '';
|
|
425
|
+
const full = `${message}${tcc}${hint}`.trim();
|
|
426
|
+
return {
|
|
427
|
+
exitCode: 1,
|
|
428
|
+
stdout: '',
|
|
429
|
+
stderr: full,
|
|
430
|
+
timedOut: false,
|
|
431
|
+
computer: { ok: false, error: full },
|
|
432
|
+
};
|
|
433
|
+
}
|
|
408
434
|
}
|
|
409
435
|
|
|
410
436
|
await this.ensure();
|
|
411
437
|
|
|
412
438
|
try {
|
|
413
|
-
if (
|
|
414
|
-
action === 'mouse_move' ||
|
|
415
|
-
action === 'left_click' ||
|
|
416
|
-
action === 'right_click' ||
|
|
417
|
-
action === 'double_click'
|
|
418
|
-
) {
|
|
439
|
+
if (action === 'mouse_move') {
|
|
419
440
|
if (!computer.coordinate) throw new Error('coordinate is required for this action.');
|
|
420
441
|
const { pointX, pointY } = this.mapCoordinate(computer.coordinate);
|
|
421
442
|
const moved = await this.send({ cmd: 'move', pointX, pointY });
|
|
422
443
|
if (!moved.ok) throw new Error(moved.error || 'Move failed.');
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
444
|
+
} else if (action === 'left_click' || action === 'right_click' || action === 'double_click') {
|
|
445
|
+
if (!computer.coordinate) throw new Error('coordinate is required for this action.');
|
|
446
|
+
const { pointX, pointY } = this.mapCoordinate(computer.coordinate);
|
|
447
|
+
const button = action === 'right_click' ? 'right' : 'left';
|
|
448
|
+
const count = action === 'double_click' ? 2 : 1;
|
|
449
|
+
// One RPC: animate overlay, click, restore user cursor (helper-side).
|
|
450
|
+
const clicked = await this.send({ cmd: 'clickAt', pointX, pointY, button, count });
|
|
451
|
+
if (!clicked.ok) throw new Error(clicked.error || 'Click failed.');
|
|
429
452
|
} else if (action === 'left_click_drag') {
|
|
430
453
|
if (!computer.startCoordinate || !computer.coordinate) {
|
|
431
454
|
throw new Error('startCoordinate and coordinate are required for drag.');
|
|
@@ -468,12 +491,14 @@ export class MacComputerSession {
|
|
|
468
491
|
const tcc = /accessib|not permitted|not authorized/i.test(message)
|
|
469
492
|
? ' Jian Remote needs Accessibility (and Screen Recording for screenshots) in System Settings.'
|
|
470
493
|
: '';
|
|
494
|
+
const hint = uiPrep.sessionLocked ? LOCKED_ACTION_HINT : '';
|
|
495
|
+
const full = `${message}${tcc}${hint}`.trim();
|
|
471
496
|
return {
|
|
472
497
|
exitCode: 1,
|
|
473
498
|
stdout: '',
|
|
474
|
-
stderr:
|
|
499
|
+
stderr: full,
|
|
475
500
|
timedOut: false,
|
|
476
|
-
computer: { ok: false, error:
|
|
501
|
+
computer: { ok: false, error: full },
|
|
477
502
|
};
|
|
478
503
|
}
|
|
479
504
|
|
|
@@ -535,11 +560,129 @@ export async function probeComputerCapability() {
|
|
|
535
560
|
await ensureHelperBinary();
|
|
536
561
|
return true;
|
|
537
562
|
} catch {
|
|
538
|
-
// Still advertise: screenshot-only path can work; input fails with TCC guidance.
|
|
539
|
-
// Prefer advertising when swiftc exists so gateway can send jobs after first grant.
|
|
540
563
|
return (
|
|
541
564
|
Boolean(spawnSync('which', ['swiftc'], { encoding: 'utf8' }).stdout?.trim()) ||
|
|
542
565
|
existsSync(helperPaths().binary)
|
|
543
566
|
);
|
|
544
567
|
}
|
|
545
568
|
}
|
|
569
|
+
|
|
570
|
+
function normalizeUiState(value) {
|
|
571
|
+
return MAC_UI_STATES.includes(value) ? value : 'unknown';
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Lock screen vs display sleep vs unlocked. Prefer the helper binary (CGSession +
|
|
576
|
+
* CGDisplayIsAsleep); fall back to unknown so we never invent "offline".
|
|
577
|
+
*/
|
|
578
|
+
export function probeMacUiState({ force = false } = {}) {
|
|
579
|
+
if (process.platform !== 'darwin') return 'unknown';
|
|
580
|
+
const now = Date.now();
|
|
581
|
+
if (!force && now - cachedUiState.at < UI_STATE_CACHE_MS) return cachedUiState.state;
|
|
582
|
+
|
|
583
|
+
let state = 'unknown';
|
|
584
|
+
const binary = helperPaths().binary;
|
|
585
|
+
if (existsSync(binary)) {
|
|
586
|
+
const probed = spawnSync(binary, ['--session-status'], {
|
|
587
|
+
encoding: 'utf8',
|
|
588
|
+
timeout: 4_000,
|
|
589
|
+
maxBuffer: 16_000,
|
|
590
|
+
});
|
|
591
|
+
if (probed.status === 0) {
|
|
592
|
+
try {
|
|
593
|
+
const line = (probed.stdout || '').trim().split('\n').pop();
|
|
594
|
+
const parsed = JSON.parse(line || '{}');
|
|
595
|
+
if (parsed?.ok) state = normalizeUiState(parsed.state);
|
|
596
|
+
} catch {
|
|
597
|
+
state = 'unknown';
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
cachedUiState = { at: now, state };
|
|
603
|
+
return state;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Prevent idle system sleep for the worker process so HTTPS polls keep firing when the
|
|
608
|
+
* screen is locked. Does not claim lid-closed / clamshell immunity (see docs/remote.md).
|
|
609
|
+
*/
|
|
610
|
+
export function startWorkerIdleAssertion(pid = process.pid) {
|
|
611
|
+
if (process.platform !== 'darwin') return null;
|
|
612
|
+
stopWorkerIdleAssertion();
|
|
613
|
+
try {
|
|
614
|
+
workerIdleCaffeinate = spawn('/usr/bin/caffeinate', ['-i', '-w', String(pid)], {
|
|
615
|
+
stdio: 'ignore',
|
|
616
|
+
detached: false,
|
|
617
|
+
});
|
|
618
|
+
workerIdleCaffeinate.on('error', () => {
|
|
619
|
+
workerIdleCaffeinate = null;
|
|
620
|
+
});
|
|
621
|
+
workerIdleCaffeinate.unref?.();
|
|
622
|
+
return workerIdleCaffeinate;
|
|
623
|
+
} catch {
|
|
624
|
+
workerIdleCaffeinate = null;
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export function stopWorkerIdleAssertion() {
|
|
630
|
+
if (!workerIdleCaffeinate) return;
|
|
631
|
+
try {
|
|
632
|
+
workerIdleCaffeinate.kill('SIGTERM');
|
|
633
|
+
} catch {
|
|
634
|
+
// already gone
|
|
635
|
+
}
|
|
636
|
+
workerIdleCaffeinate = null;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** While a computer-use session is open, also keep the display from idling off. */
|
|
640
|
+
function startSessionDisplayAssertion(pid = process.pid) {
|
|
641
|
+
if (process.platform !== 'darwin') return;
|
|
642
|
+
stopSessionDisplayAssertion();
|
|
643
|
+
try {
|
|
644
|
+
sessionDisplayCaffeinate = spawn('/usr/bin/caffeinate', ['-dimsu', '-w', String(pid)], {
|
|
645
|
+
stdio: 'ignore',
|
|
646
|
+
detached: false,
|
|
647
|
+
});
|
|
648
|
+
sessionDisplayCaffeinate.on('error', () => {
|
|
649
|
+
sessionDisplayCaffeinate = null;
|
|
650
|
+
});
|
|
651
|
+
sessionDisplayCaffeinate.unref?.();
|
|
652
|
+
} catch {
|
|
653
|
+
sessionDisplayCaffeinate = null;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function stopSessionDisplayAssertion() {
|
|
658
|
+
if (!sessionDisplayCaffeinate) return;
|
|
659
|
+
try {
|
|
660
|
+
sessionDisplayCaffeinate.kill('SIGTERM');
|
|
661
|
+
} catch {
|
|
662
|
+
// already gone
|
|
663
|
+
}
|
|
664
|
+
sessionDisplayCaffeinate = null;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** User-activity assertion: nudge a sleeping (unlocked) display awake. */
|
|
668
|
+
export function wakeMacDisplay(seconds = 3) {
|
|
669
|
+
if (process.platform !== 'darwin') return;
|
|
670
|
+
const secs = Math.max(1, Math.min(30, Math.round(seconds)));
|
|
671
|
+
spawnSync('/usr/bin/caffeinate', ['-u', '-t', String(secs)], {
|
|
672
|
+
encoding: 'utf8',
|
|
673
|
+
timeout: (secs + 2) * 1000,
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Wake the display when locked or asleep (lock often coincides with display sleep).
|
|
679
|
+
* Never soft-blocks: callers always attempt the OS API next.
|
|
680
|
+
*/
|
|
681
|
+
export function prepareComputerUi() {
|
|
682
|
+
let state = probeMacUiState({ force: true });
|
|
683
|
+
if (state === 'display_asleep' || state === 'locked' || state === 'unknown') {
|
|
684
|
+
wakeMacDisplay(3);
|
|
685
|
+
state = probeMacUiState({ force: true });
|
|
686
|
+
}
|
|
687
|
+
return { sessionLocked: state === 'locked', state };
|
|
688
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared computer-use helpers: coords, capabilities, motion easing.
|
|
3
|
+
* Platform backends live in computer-{macos,linux,windows}.mjs.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const COMPUTER_CAPABILITIES = Object.freeze({
|
|
7
|
+
darwin: 'computer-macos-v1',
|
|
8
|
+
linux: 'computer-linux-v1',
|
|
9
|
+
win32: 'computer-windows-v1',
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const CAPABILITY_SET = new Set(Object.values(COMPUTER_CAPABILITIES));
|
|
13
|
+
|
|
14
|
+
/** True when a worker capability string means remote_computer is supported. */
|
|
15
|
+
export function isComputerCapability(value) {
|
|
16
|
+
return CAPABILITY_SET.has(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function capabilityForPlatform(platform = process.platform) {
|
|
20
|
+
return COMPUTER_CAPABILITIES[platform] ?? null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Map screenshot image pixels → display points (primary). Half-pixel centers the click. */
|
|
24
|
+
export function imagePixelToPoint(x, y, geometry) {
|
|
25
|
+
const { originX, originY, pointWidth, pointHeight, widthPx, heightPx } = geometry;
|
|
26
|
+
if (!(widthPx > 0 && heightPx > 0 && pointWidth > 0 && pointHeight > 0)) {
|
|
27
|
+
throw new Error('Invalid screenshot geometry for coordinate mapping');
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
pointX: originX + (x + 0.5) * (pointWidth / widthPx),
|
|
31
|
+
pointY: originY + (y + 0.5) * (pointHeight / heightPx),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function scaleFromGeometry(geometry) {
|
|
36
|
+
return {
|
|
37
|
+
imageToPointX: geometry.pointWidth / geometry.widthPx,
|
|
38
|
+
imageToPointY: geometry.pointHeight / geometry.heightPx,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Ease-out cubic — snappy agent loops, still readable as motion. */
|
|
43
|
+
export function easeOutCubic(t) {
|
|
44
|
+
const x = Math.min(1, Math.max(0, t));
|
|
45
|
+
return 1 - (1 - x) ** 3;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Duration (ms) for overlay travel. Caps keep agent loops fast on long jumps.
|
|
50
|
+
* ~0.25 ms/px with floor 28 ms / ceiling 110 ms.
|
|
51
|
+
*/
|
|
52
|
+
export function motionDurationMs(distancePx) {
|
|
53
|
+
const d = Math.max(0, distancePx);
|
|
54
|
+
return Math.round(Math.min(110, Math.max(28, d * 0.25)));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function pointDistance(a, b) {
|
|
58
|
+
const dx = (b.pointX ?? b.x) - (a.pointX ?? a.x);
|
|
59
|
+
const dy = (b.pointY ?? b.y) - (a.pointY ?? a.y);
|
|
60
|
+
return Math.hypot(dx, dy);
|
|
61
|
+
}
|