mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
hapticks 3 3 3 3 3 3
This commit is contained in:
@@ -9,6 +9,7 @@ import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import { deriveDriveDockStateFromTelemetry } from './driveDockState.js';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
|
||||
function StatusRow({ value, tone = 'neutral' }) {
|
||||
const toneClasses =
|
||||
@@ -126,6 +127,7 @@ export default function DriveDockAction({
|
||||
|
||||
const handleReturnToDrive = async () => {
|
||||
if (!roverId || pending) return;
|
||||
if (isMobile) triggerTouchHaptic('button');
|
||||
setPending('drive');
|
||||
trackAnalyticsEvent('drive_return', { roverId, source: 'drive_dock_action' });
|
||||
try {
|
||||
@@ -143,6 +145,7 @@ export default function DriveDockAction({
|
||||
|
||||
const handleStartDrive = async () => {
|
||||
if (!roverId || pending) return;
|
||||
if (isMobile) triggerTouchHaptic('button');
|
||||
setConfirmOpen(false);
|
||||
setShowModal(false);
|
||||
setPending('drive');
|
||||
@@ -162,6 +165,7 @@ export default function DriveDockAction({
|
||||
|
||||
const handleConfirmDock = async () => {
|
||||
if (!roverId || pending) return;
|
||||
if (isMobile) triggerTouchHaptic('button');
|
||||
setPending('dock');
|
||||
trackAnalyticsEvent('dock_assist_start', { roverId });
|
||||
try {
|
||||
@@ -179,6 +183,7 @@ export default function DriveDockAction({
|
||||
|
||||
const handleOpenDock = () => {
|
||||
if (dockDisabled) return;
|
||||
if (isMobile) triggerTouchHaptic('button');
|
||||
if (manualAssistActive) {
|
||||
dockAssist.exitAssist();
|
||||
trackAnalyticsEvent('dock_assist_exit', { roverId });
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Purpose: Renders a direct press target for rover GPIO-backed toggles such as the headlight and laser.
|
||||
// Scope: Owns optimistic button state and touch/click de-duplication while callers provide device labels and actions.
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
|
||||
function isBoolean(value) {
|
||||
return typeof value === 'boolean';
|
||||
@@ -53,6 +54,9 @@ export default function GPIOToggleControl({
|
||||
if (disabled) return;
|
||||
const next = hasState ? !displayOn : true;
|
||||
setOptimistic(next);
|
||||
// The optimistic state transition is the control's accepted action, so its
|
||||
// feedback belongs here rather than on an unrelated global pointer event.
|
||||
triggerTouchHaptic('button');
|
||||
onToggle?.(next);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { HORN_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
import { HORN_MAX_FREQUENCY } from '../../controls/constants.js';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
|
||||
function clampFreq(value) {
|
||||
const num = Number(value);
|
||||
@@ -120,6 +121,9 @@ export default function HornControl({
|
||||
event.preventDefault();
|
||||
activePointerIdRef.current = event.pointerId;
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
// Starting the held horn is the meaningful action; release only stops the
|
||||
// same action and therefore should not generate a second confirmation.
|
||||
triggerTouchHaptic('button');
|
||||
onStart?.();
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '../../controls/inputs/driveIntent.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { INPUT_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
import FloatingJoystick from './FloatingJoystick.jsx';
|
||||
import {
|
||||
DRIVE_PAD_REPEAT_MS,
|
||||
@@ -134,8 +135,12 @@ export default function ControlPadPanel({ compact = false, disabled = false }) {
|
||||
|
||||
const handleSpeedModeChange = useCallback(
|
||||
(nextMode) => {
|
||||
if (nextMode === speedModeRef.current) return;
|
||||
setSpeedMode(nextMode);
|
||||
speedModeRef.current = nextMode;
|
||||
// Confirm the accepted mode transition here so tapping an already-active
|
||||
// mode cannot produce feedback for a state change that did not happen.
|
||||
triggerTouchHaptic('button');
|
||||
setCameraPrecisionMode(nextMode === 'precision');
|
||||
if (activeCellRef.current) {
|
||||
sendDriveCell(activeCellRef.current, 'speed', nextMode);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Scope: Owns pointer tracking, fixed overlay positioning, and active 3x3 drive-zone feedback.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
|
||||
const PAD_MARGIN = 12;
|
||||
const CELL_COUNT = 3;
|
||||
@@ -146,6 +147,10 @@ export default function FloatingJoystick({
|
||||
const cell = cellFromPointer(currentPad, event.clientX, event.clientY);
|
||||
if (activeCellIdRef.current !== cell.id) {
|
||||
activeCellIdRef.current = cell.id;
|
||||
// Pulse only when the thumb crosses a real 3x3 cell boundary so
|
||||
// continuous pointermove traffic cannot buzz constantly. The shared
|
||||
// pointerup listener provides a Safari-compatible gesture-end pulse.
|
||||
triggerTouchHaptic('drive');
|
||||
onCellChange?.(cell);
|
||||
}
|
||||
setActivePad({
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Mobile Aux Button
|
||||
// Purpose: Defines the Mobile Aux Button module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
|
||||
export default function MobileAuxButton({ id, label, values, color, disabled, onPress, onRelease }) {
|
||||
return (
|
||||
<button
|
||||
@@ -10,7 +12,12 @@ export default function MobileAuxButton({ id, label, values, color, disabled, on
|
||||
event.preventDefault();
|
||||
onPress(id, values);
|
||||
}}
|
||||
onPointerUp={() => onRelease(id)}
|
||||
onPointerUp={() => {
|
||||
onRelease(id);
|
||||
// A completed held action is the semantic confirmation point. Keeping
|
||||
// feedback beside release avoids buzzing for cancelled aux gestures.
|
||||
triggerTouchHaptic('button');
|
||||
}}
|
||||
onPointerLeave={() => onRelease(id)}
|
||||
onPointerCancel={() => onRelease(id)}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
// Purpose: Provides the mobile-only camera tilt slider that supports simultaneous two-finger mobile driving.
|
||||
// Scope: Owns pointer tracking and visual slider state for the compact vertical mobile camera control.
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
|
||||
const HAPTIC_MIN_ANGLE_DELTA_DEGREES = 2;
|
||||
|
||||
function clampCameraAngle(value, min, max) {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
@@ -23,6 +26,7 @@ export default function VerticalCameraTilt({
|
||||
}) {
|
||||
const trackRef = useRef(null);
|
||||
const pointerIdRef = useRef(null);
|
||||
const lastHapticValueRef = useRef(null);
|
||||
|
||||
const valuePercent = useMemo(() => {
|
||||
if (max === min) return 50;
|
||||
@@ -47,6 +51,17 @@ export default function VerticalCameraTilt({
|
||||
const sendPointerValue = useCallback(
|
||||
(event) => {
|
||||
const next = valueFromPointer(event);
|
||||
|
||||
/*
|
||||
Camera commands must retain the configured 0.5 or 0.1 degree precision,
|
||||
but physical feedback does not need to mirror every servo step. Require
|
||||
two degrees of accumulated travel so the ticks describe slider position
|
||||
without allowing pointer event frequency to control their cadence.
|
||||
*/
|
||||
if (Math.abs(next - lastHapticValueRef.current) >= HAPTIC_MIN_ANGLE_DELTA_DEGREES) {
|
||||
triggerTouchHaptic('camera');
|
||||
lastHapticValueRef.current = next;
|
||||
}
|
||||
onChange?.(next);
|
||||
},
|
||||
[onChange, valueFromPointer],
|
||||
@@ -65,10 +80,13 @@ export default function VerticalCameraTilt({
|
||||
*/
|
||||
event.preventDefault();
|
||||
pointerIdRef.current = event.pointerId;
|
||||
// Seed the slider value so its first position update is not mistaken for
|
||||
// two degrees of travel before the finger has actually moved that far.
|
||||
lastHapticValueRef.current = value;
|
||||
trackRef.current?.setPointerCapture?.(event.pointerId);
|
||||
sendPointerValue(event);
|
||||
},
|
||||
[disabled, sendPointerValue],
|
||||
[disabled, sendPointerValue, value],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
|
||||
@@ -25,6 +25,7 @@ import { isFeatureEnabled } from '../../lib/features.js';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { DEFAULT_PAGE_THEME_KEY, getPageThemeClass } from '../../themes/index.js';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
|
||||
const PTZ_DEFAULT_COLOR = '#38bdf8';
|
||||
|
||||
@@ -157,6 +158,7 @@ function PtzLightingControls({ ptz, disabled = false }) {
|
||||
|
||||
const cycleIr = async () => {
|
||||
if (disabled) return;
|
||||
triggerTouchHaptic('button');
|
||||
setBusy('ir');
|
||||
try {
|
||||
await ptzIr({ state: nextIrMode(irMode) });
|
||||
@@ -208,6 +210,7 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
||||
*/
|
||||
event.preventDefault();
|
||||
if (disabled) return;
|
||||
triggerTouchHaptic('button');
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
setCameraAxisIntent(direction);
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
|
||||
const PTZ_CAMERA_ID = 'ptz-camera';
|
||||
|
||||
@@ -186,6 +187,7 @@ function PtzLightingControls({ ptz, disabled = false }) {
|
||||
|
||||
const cycleIr = async () => {
|
||||
if (disabled) return;
|
||||
triggerTouchHaptic('button');
|
||||
setBusy('ir');
|
||||
try {
|
||||
await ptzIr({ state: nextIrMode(irMode) });
|
||||
@@ -232,6 +234,7 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
||||
*/
|
||||
event.preventDefault();
|
||||
if (disabled) return;
|
||||
triggerTouchHaptic('button');
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
setCameraAxisIntent(direction);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Touch Haptics
|
||||
// Purpose: Adds lightweight physical confirmation to the WebUI's mobile control surfaces.
|
||||
// Scope: Maps semantic control actions to the shared web-haptics compatibility layer.
|
||||
import { WebHaptics } from 'web-haptics';
|
||||
|
||||
/*
|
||||
Use explicit patterns instead of the library's very short selection preset.
|
||||
Apart from being easier to feel on Android, the descending durations give
|
||||
Safari's switch-based fallback room to express a button as several ticks, a
|
||||
drive boundary as fewer ticks, and a camera step as one light tick.
|
||||
*/
|
||||
export const TOUCH_HAPTIC_PATTERNS = Object.freeze({
|
||||
button: [{ duration: 50, intensity: 1 }],
|
||||
drive: [{ duration: 30, intensity: 1 }],
|
||||
camera: [{ duration: 15, intensity: 1 }],
|
||||
});
|
||||
|
||||
/*
|
||||
Keep one vanilla instance for the application lifetime. The controls do not
|
||||
need framework-specific hooks, and a singleton lets the library cancel or
|
||||
replace an in-progress pattern consistently when the driver moves quickly.
|
||||
Browser-specific vibration and Safari fallback behavior deliberately belongs
|
||||
to web-haptics so future dependency upgrades improve every control together.
|
||||
*/
|
||||
const touchHaptics = new WebHaptics();
|
||||
|
||||
export function triggerTouchHaptic(kind = 'button') {
|
||||
/*
|
||||
Controls request feedback by meaning rather than forwarding DOM events.
|
||||
This keeps disabled/no-op decisions beside the command that owns them and
|
||||
leaves browser capability handling entirely inside web-haptics.
|
||||
*/
|
||||
const pattern = TOUCH_HAPTIC_PATTERNS[kind] || TOUCH_HAPTIC_PATTERNS.button;
|
||||
|
||||
/*
|
||||
The promise describes completion of any scheduled pattern; control input
|
||||
must never wait for physical feedback, so intentionally leave it detached
|
||||
from the rover command path.
|
||||
*/
|
||||
void touchHaptics.trigger(pattern);
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user