hapticks 3 3 3 3 3 3

This commit is contained in:
legop3
2026-07-21 18:38:01 -04:00
parent 358aa0b1d6
commit fb9565faf9
19 changed files with 155 additions and 26 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -78,7 +78,7 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-DFlKqFdJ.js"></script>
<script type="module" crossorigin src="/assets/index-CEQiOJg5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DCUrAu3o.css">
</head>
<body>
+28 -1
View File
@@ -17,7 +17,8 @@
"react-joystick-component": "^6.2.1",
"react-router-dom": "^7.9.6",
"three": "^0.184.0",
"uplot": "^1.6.32"
"uplot": "^1.6.32",
"web-haptics": "latest"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
@@ -4189,6 +4190,32 @@
}
}
},
"node_modules/web-haptics": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/web-haptics/-/web-haptics-0.0.6.tgz",
"integrity": "sha512-eCzcf1LDi20+Fr0x9V3OkX92k0gxEQXaHajmhXHitsnk6SxPeshv8TBtBRqxyst8HI1uf2FyFVE7QS3jo1gkrw==",
"license": "MIT",
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18",
"svelte": ">=4",
"vue": ">=3"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"svelte": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+2 -1
View File
@@ -19,7 +19,8 @@
"react-joystick-component": "^6.2.1",
"react-router-dom": "^7.9.6",
"three": "^0.184.0",
"uplot": "^1.6.32"
"uplot": "^1.6.32",
"web-haptics": "latest"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
@@ -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(
+3
View File
@@ -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);
},
+42
View File
@@ -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;
}
+5
View File
@@ -18,6 +18,11 @@ function analyticsTags() {
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), analyticsTags()],
server: {
// Listen on every local interface during development so a phone or tablet
// on the same LAN can load the touch UI using this machine's network IP.
host: true,
},
build: {
outDir: '../server/public',
emptyOutDir: true,