|
| 1 | +import { Vector } from "@graphif/data-structures"; |
| 2 | + |
| 3 | +/** |
| 4 | + * 拖拽摇晃检测器 |
| 5 | + * 用于检测用户在拖拽节点时是否进行快速来回摇晃动作(扁平形状,非圆形转动) |
| 6 | + * 如果检测到摇晃,可以触发特定操作(如将节点从连线结构中脱离) |
| 7 | + */ |
| 8 | +export class ShakeDetector { |
| 9 | + // 配置参数 - 使用窗口坐标(屏幕像素),与世界坐标缩放无关 |
| 10 | + private readonly sampleInterval = 50; // 采样间隔(ms) |
| 11 | + private readonly maxSamples = 10; // 最大采样点数 |
| 12 | + private readonly directionChangeThreshold = 3; // 方向改变次数阈值 |
| 13 | + private readonly minShakeDistance = 100; // 最小摇晃距离(窗口坐标,像素) |
| 14 | + private readonly timeWindow = 500; // 时间窗口(ms) |
| 15 | + private readonly minMoveThreshold = 8; // 忽略微小移动的阈值(窗口坐标,像素) |
| 16 | + private readonly minSpeedThreshold = 15; // 最小速度阈值,过滤掉过慢的移动(像素/采样间隔) |
| 17 | + private readonly minAspectRatio = 2.0; // 最小长宽比,确保摇晃是扁平的(来回)而非圆形 |
| 18 | + |
| 19 | + private samples: { location: Vector; time: number }[] = []; |
| 20 | + private lastSampleTime = 0; |
| 21 | + private triggered = false; |
| 22 | + |
| 23 | + /** |
| 24 | + * 重置检测器状态 |
| 25 | + */ |
| 26 | + reset(): void { |
| 27 | + this.samples = []; |
| 28 | + this.lastSampleTime = 0; |
| 29 | + this.triggered = false; |
| 30 | + } |
| 31 | + |
| 32 | + /** |
| 33 | + * 添加一个新的位置样本 |
| 34 | + * @param location 当前位置(窗口坐标/屏幕像素) |
| 35 | + * @param currentTime 当前时间戳 |
| 36 | + * @returns 是否检测到摇晃 |
| 37 | + */ |
| 38 | + addSample(location: Vector, currentTime: number): boolean { |
| 39 | + if (this.triggered) { |
| 40 | + return false; // 已经触发过,不再检测 |
| 41 | + } |
| 42 | + |
| 43 | + // 控制采样频率 |
| 44 | + if (currentTime - this.lastSampleTime < this.sampleInterval) { |
| 45 | + return false; |
| 46 | + } |
| 47 | + this.lastSampleTime = currentTime; |
| 48 | + |
| 49 | + // 添加新样本 |
| 50 | + this.samples.push({ location: location.clone(), time: currentTime }); |
| 51 | + |
| 52 | + // 清理过期样本 |
| 53 | + const cutoffTime = currentTime - this.timeWindow; |
| 54 | + while (this.samples.length > 0 && this.samples[0].time < cutoffTime) { |
| 55 | + this.samples.shift(); |
| 56 | + } |
| 57 | + |
| 58 | + // 限制样本数量 |
| 59 | + if (this.samples.length > this.maxSamples) { |
| 60 | + this.samples.shift(); |
| 61 | + } |
| 62 | + |
| 63 | + // 检测摇晃 |
| 64 | + return this.checkShake(); |
| 65 | + } |
| 66 | + |
| 67 | + /** |
| 68 | + * 检测是否发生摇晃 |
| 69 | + * 算法:检测在一定时间窗口内,快速来回移动(扁平形状) |
| 70 | + */ |
| 71 | + private checkShake(): boolean { |
| 72 | + if (this.samples.length < 4) { |
| 73 | + return false; |
| 74 | + } |
| 75 | + |
| 76 | + // 计算包围盒和总距离 |
| 77 | + let minX = Infinity, |
| 78 | + maxX = -Infinity; |
| 79 | + let minY = Infinity, |
| 80 | + maxY = -Infinity; |
| 81 | + let totalDistance = 0; |
| 82 | + let directionChanges = 0; |
| 83 | + let lastDirectionX = 0; |
| 84 | + let lastDirectionY = 0; |
| 85 | + let fastMoveCount = 0; |
| 86 | + |
| 87 | + for (let i = 1; i < this.samples.length; i++) { |
| 88 | + const prev = this.samples[i - 1]; |
| 89 | + const curr = this.samples[i]; |
| 90 | + const diff = curr.location.subtract(prev.location); |
| 91 | + const distance = diff.magnitude(); |
| 92 | + |
| 93 | + // 更新包围盒 |
| 94 | + minX = Math.min(minX, prev.location.x, curr.location.x); |
| 95 | + maxX = Math.max(maxX, prev.location.x, curr.location.x); |
| 96 | + minY = Math.min(minY, prev.location.y, curr.location.y); |
| 97 | + maxY = Math.max(maxY, prev.location.y, curr.location.y); |
| 98 | + |
| 99 | + // 忽略微小移动 |
| 100 | + if (distance < this.minMoveThreshold) { |
| 101 | + continue; |
| 102 | + } |
| 103 | + |
| 104 | + totalDistance += distance; |
| 105 | + |
| 106 | + // 统计快速移动次数(用于判断是否为快速摇晃) |
| 107 | + if (distance >= this.minSpeedThreshold) { |
| 108 | + fastMoveCount++; |
| 109 | + } |
| 110 | + |
| 111 | + // 计算当前方向(简化为一维方向) |
| 112 | + const currentDirectionX = diff.x > 0 ? 1 : diff.x < 0 ? -1 : 0; |
| 113 | + const currentDirectionY = diff.y > 0 ? 1 : diff.y < 0 ? -1 : 0; |
| 114 | + |
| 115 | + // 检测X方向改变 |
| 116 | + if (lastDirectionX !== 0 && currentDirectionX !== 0 && lastDirectionX !== currentDirectionX) { |
| 117 | + directionChanges++; |
| 118 | + } |
| 119 | + |
| 120 | + // 检测Y方向改变 |
| 121 | + if (lastDirectionY !== 0 && currentDirectionY !== 0 && lastDirectionY !== currentDirectionY) { |
| 122 | + directionChanges++; |
| 123 | + } |
| 124 | + |
| 125 | + if (currentDirectionX !== 0) { |
| 126 | + lastDirectionX = currentDirectionX; |
| 127 | + } |
| 128 | + if (currentDirectionY !== 0) { |
| 129 | + lastDirectionY = currentDirectionY; |
| 130 | + } |
| 131 | + } |
| 132 | + |
| 133 | + // 需要足够多的快速移动(确保是快速摇晃而非慢速拖动) |
| 134 | + if (fastMoveCount < 3) { |
| 135 | + return false; |
| 136 | + } |
| 137 | + |
| 138 | + // 计算长宽比,确保是扁平形状(来回摇晃)而非圆形 |
| 139 | + const width = maxX - minX; |
| 140 | + const height = maxY - minY; |
| 141 | + |
| 142 | + // 如果移动范围太小,不认为是有效摇晃 |
| 143 | + if (width < 30 && height < 30) { |
| 144 | + return false; |
| 145 | + } |
| 146 | + |
| 147 | + const aspectRatioX = height > 0 ? width / height : Infinity; |
| 148 | + const aspectRatioY = width > 0 ? height / width : Infinity; |
| 149 | + const isFlatShape = aspectRatioX >= this.minAspectRatio || aspectRatioY >= this.minAspectRatio; |
| 150 | + |
| 151 | + // 判断是否为有效摇晃: |
| 152 | + // 1. 方向改变次数足够(来回次数) |
| 153 | + // 2. 总距离足够 |
| 154 | + // 3. 是扁平形状(来回摇晃而非转圈) |
| 155 | + if (directionChanges >= this.directionChangeThreshold && totalDistance >= this.minShakeDistance && isFlatShape) { |
| 156 | + this.triggered = true; |
| 157 | + return true; |
| 158 | + } |
| 159 | + |
| 160 | + return false; |
| 161 | + } |
| 162 | + |
| 163 | + /** |
| 164 | + * 标记为已触发,防止重复触发 |
| 165 | + */ |
| 166 | + markTriggered(): void { |
| 167 | + this.triggered = true; |
| 168 | + } |
| 169 | + |
| 170 | + /** |
| 171 | + * 检查是否已经触发过 |
| 172 | + */ |
| 173 | + hasTriggered(): boolean { |
| 174 | + return this.triggered; |
| 175 | + } |
| 176 | +} |
0 commit comments