1import {
2 ACTION_BY_CATEGORY,
3 CAPTURE_COOLDOWN_MS,
4 GESTURE_SCORE_THRESHOLD,
5 HOLD_DURATION_MS,
6 type GestureAction,
7} from "./thresholds"
8
9/* ARCHITECTURE §6: `holdDetector` adalah fungsi murni. Waktu disuntik pemanggil
10 lewat `push(sample, time)`, jadi seluruh alur bisa diuji tanpa kamera dan
11 tanpa timer (PRD GS-02, GS-04). */
12
13/** Satu keluaran `gestureSource`: kategori teratas dengan skornya, atau null. */
14export interface GestureSample {
15 category: string
16 score: number
17}
18
19export type HoldEvent =
20 | { type: "hold-start"; action: GestureAction }
21 | { type: "hold-progress"; action: GestureAction; progress: number }
22 | { type: "hold-complete"; action: GestureAction }
23 | { type: "hold-cancel" }
24
25export interface HoldDetector {
26 push(sample: GestureSample | null, time: number): HoldEvent[]
27 /** GS-04: jeda setelah satu foto sebelum gesture dibaca lagi. */
28 notifyCapture(time: number): void
29 reset(): void
30}
31
32export function createHoldDetector(): HoldDetector {
33 let active: { action: GestureAction; startedAt: number } | null = null
34 /* Aksi yang sudah selesai ditahan. Satu pose tidak menghasilkan dua pemicu:
35 aksi yang sama diabaikan sampai tangan diturunkan (sampel non-aksi itu). */
36 let satisfied: GestureAction | null = null
37 let cooldownUntil = Number.NEGATIVE_INFINITY
38
39 return {
40 push(sample, time) {
41 if (time < cooldownUntil) {
42 active = null
43 return []
44 }
45
46 const action =
47 sample !== null && sample.score >= GESTURE_SCORE_THRESHOLD
48 ? (ACTION_BY_CATEGORY[sample.category] ?? null)
49 : null
50
51 if (action === null) {
52 satisfied = null
53 if (active === null) {
54 return []
55 }
56 active = null
57 return [{ type: "hold-cancel" }]
58 }
59
60 if (satisfied === action) {
61 return []
62 }
63 satisfied = null
64
65 if (active !== null && active.action !== action) {
66 active = null
67 return [{ type: "hold-cancel" }]
68 }
69
70 if (active === null) {
71 active = { action, startedAt: time }
72 return [{ type: "hold-start", action }]
73 }
74
75 const progress = (time - active.startedAt) / HOLD_DURATION_MS
76 if (progress >= 1) {
77 active = null
78 satisfied = action
79 return [{ type: "hold-complete", action }]
80 }
81 return [{ type: "hold-progress", action, progress }]
82 },
83
84 notifyCapture(time) {
85 active = null
86 satisfied = null
87 cooldownUntil = time + CAPTURE_COOLDOWN_MS
88 },
89
90 reset() {
91 /* Cooldown GS-04 sengaja tidak dihapus: reset dipakai saat loop berhenti
92 dan tidak boleh membatalkan jeda setelah foto. */
93 active = null
94 satisfied = null
95 },
96 }
97}
98