diff --git a/src/buffered-data-hooks.tsx b/src/buffered-data-hooks.tsx index 8237e4e7d..982269e37 100644 --- a/src/buffered-data-hooks.tsx +++ b/src/buffered-data-hooks.tsx @@ -83,20 +83,13 @@ export const useHasMoved = (): boolean => { const [connectStatus] = useConnectStatus(); const connection = useConnectActions(); useEffect(() => { - if (connectStatus !== ConnectionStatus.Connected) { - setHasMoved(false); - } let ignore = false; const delta: AccelerometerData = { x: 0, y: 0, z: 0 }; let lastSample: AccelerometerData | undefined; const threshold = 40_000; const minDelta = 100; - const skipSamples = 10; - let skipped = 0; const listener = (e: AccelerometerDataEvent) => { - if (skipped < skipSamples) { - skipped++; - } else if (lastSample) { + if (lastSample) { const deltaX = Math.abs(lastSample.x - e.data.x); if (deltaX > minDelta) { delta.x += deltaX; @@ -115,7 +108,7 @@ export const useHasMoved = (): boolean => { (delta.x > threshold ? 1 : 0) + (delta.y > threshold ? 1 : 0) + (delta.z > threshold ? 1 : 0) > - 1 + 0 ) { connection.removeAccelerometerListener(listener); if (!ignore) { diff --git a/src/components/ActionNameCard.tsx b/src/components/ActionNameCard.tsx index c97152f63..4c5504c06 100644 --- a/src/components/ActionNameCard.tsx +++ b/src/components/ActionNameCard.tsx @@ -91,6 +91,7 @@ const ActionNameCard = ({ [setHint] ); + const setHasMoved = useStore((s) => s.setHasMoved); const onChange: React.ChangeEventHandler = useCallback( async (e) => { const name = e.target.value; @@ -110,10 +111,11 @@ const ActionNameCard = ({ return; } setLocalName(name); + setHasMoved(true); await debouncedSetActionName(id, name); debouncedSetHint(); }, - [debouncedSetActionName, debouncedSetHint, id, intl, toast] + [debouncedSetActionName, debouncedSetHint, id, intl, setHasMoved, toast] ); const handleIconSelected = useCallback( diff --git a/src/components/DataSamplesTable.tsx b/src/components/DataSamplesTable.tsx index 0aa8bc99b..4b8a77923 100644 --- a/src/components/DataSamplesTable.tsx +++ b/src/components/DataSamplesTable.tsx @@ -243,8 +243,9 @@ const DataSamplesTable = ({ selected={selectedAction.id === action.id} onSelectRow={() => setSelectedActionIdx(idx)} onRecord={handleRecord} - // Only show hint for the last row. - hint={idx === actions.length - 1 ? hint : null} + actionIdx={idx} + hint={hint} + isLastRow={actions.length - 1 === idx} onDeleteAction={deleteActionConfirmOnOpen} renameShortcutScopeRef={renameActionShortcutScopeRef} /> diff --git a/src/components/DataSamplesTableHints.tsx b/src/components/DataSamplesTableHints.tsx index f154fecd3..99e6097ee 100644 --- a/src/components/DataSamplesTableHints.tsx +++ b/src/components/DataSamplesTableHints.tsx @@ -8,6 +8,7 @@ import { Box, HStack, Image, + keyframes, Stack, Text, usePrefersReducedMotion, @@ -57,6 +58,35 @@ export const NameActionHint = () => { ); }; +export const NameActionShortHint = () => { + return ( + + + + + + + + + + + + ); +}; + export const NameActionWithSamplesHint = () => { return ( { - + { ); }; -export const RecordFirstActionHint = () => { - const { isConnected } = useConnectionStage(); - return ( - - - {isConnected ? ( - - ) : ( - - - - )} - - ); -}; - export const RecordHint = () => { const { isConnected } = useConnectionStage(); return ( - - - - - - {isConnected ? ( + {isConnected ? ( + <> + - ) : ( - + + ) : ( + <> + + - )} - - + + )} + ); }; @@ -153,7 +185,7 @@ export const RecordMoreHint = ({ @@ -233,6 +265,93 @@ export const AddActionHint = ({ action }: { action: Action }) => { ); }; +// Timeout for move micro:bit hint before assuming user already knows and setting hasMoved to true. +// 28s = 4 wobble cycles × 2s + 4 pause cycles × 5s +export const moveMicrobitHintTimeoutInSec = 28; //s + +const moveMicrobitEmojiKeyframes = keyframes({ + // Wobble for 2s. + "0%": { + transform: "rotate(0deg)", + }, + "1.79%": { + transform: "rotate(22deg)", + }, + "3.57%": { + transform: "rotate(-18deg)", + }, + "5.36%": { + transform: "rotate(14deg)", + }, + "7.14%": { + transform: "rotate(-10deg)", + }, + "8.93%": { + transform: "rotate(0deg)", + }, + // Wait 5 seconds. Wobble again for another 2s. + "26.79%": { + transform: "rotate(0deg)", + }, + "28.57%": { + transform: "rotate(22deg)", + }, + "30.36%": { + transform: "rotate(-18deg)", + }, + "32.14%": { + transform: "rotate(14deg)", + }, + "33.93%": { + transform: "rotate(-10deg)", + }, + "35.71%": { + transform: "rotate(0deg)", + }, + // Wait 5 seconds. Wobble again for another 2s. + "53.57%": { + transform: "rotate(0deg)", + }, + "55.36%": { + transform: "rotate(22deg)", + }, + "57.14%": { + transform: "rotate(-18deg)", + }, + "58.93%": { + transform: "rotate(14deg)", + }, + "60.71%": { + transform: "rotate(-10deg)", + }, + "62.5%": { + transform: "rotate(0deg)", + }, + // Wait 5 seconds. Wobble again for another 2s. + "80.36%": { + transform: "rotate(0deg)", + }, + "82.14%": { + transform: "rotate(22deg)", + }, + "83.93%": { + transform: "rotate(-18deg)", + }, + "85.71%": { + transform: "rotate(14deg)", + }, + "87.5%": { + transform: "rotate(-10deg)", + }, + "89.29%": { + transform: "rotate(0deg)", + }, + // Wait 5 seconds. + "100%": { + transform: "rotate(0deg)", + }, +}); + export const MoveMicrobitHint = () => { const prefersReducedMotion = usePrefersReducedMotion(); return ( @@ -263,7 +382,11 @@ export const MoveMicrobitHint = () => { diff --git a/src/components/DataSamplesTableRow.tsx b/src/components/DataSamplesTableRow.tsx index d9aed26cb..6b6361abe 100644 --- a/src/components/DataSamplesTableRow.tsx +++ b/src/components/DataSamplesTableRow.tsx @@ -12,15 +12,17 @@ import ActionDataSamplesCard from "./ActionDataSamplesCard"; import ActionNameCard, { ActionCardNameViewMode } from "./ActionNameCard"; import { NameActionHint, + NameActionShortHint, NameActionWithSamplesHint, NameFirstActionHint, - RecordFirstActionHint, RecordHint, RecordMoreHint, } from "./DataSamplesTableHints"; import { RecordingOptions } from "./RecordingDialog"; interface DataSamplesTableRowProps { + actionIdx: number; + isLastRow: boolean; preview?: boolean; action: ActionData; selected: boolean; @@ -34,6 +36,8 @@ interface DataSamplesTableRowProps { } const DataSamplesTableRow = ({ + actionIdx, + isLastRow, action, selected, onSelectRow, @@ -72,13 +76,16 @@ const DataSamplesTableRow = ({ } /> - {(hint === "name-first-action" || hint === "name-action") && ( + {hint?.type === "name-action" && hint.actionIdx === actionIdx && ( - {hint === "name-first-action" && } - {hint === "name-action" && } + {hint?.actionIdx === 0 && } + {hint?.actionIdx === 1 && } )} - + + {hint?.type === "name-action-short" && hint.actionIdx === actionIdx && ( + + )} {(action.name.length > 0 || action.recordings.length > 0) && ( )} - {hint === "record-action" && } - {hint === "record-more-action" && ( - + {hint?.type === "record-action" && hint.actionIdx === actionIdx && ( + )} - {hint === "name-action-with-samples" && ( - - - - )} - {hint === "record-first-action" && ( + {hint?.type === "record-more-action" && isLastRow && ( <> - {/* Skip first column to correctly place hint. */} + {/* Empty grid item to fill action column for positioning */} - - {hint === "record-first-action" && } + + )} + {hint?.type === "name-action-with-samples" && isLastRow && ( + + + + )} ); }; diff --git a/src/components/Emoji.tsx b/src/components/Emoji.tsx index 6318a50af..c8dab18d1 100644 --- a/src/components/Emoji.tsx +++ b/src/components/Emoji.tsx @@ -6,20 +6,6 @@ import { } from "@chakra-ui/react"; export const animations = { - wobble: `${keyframes({ - "0%": { - transform: "rotate(15deg)", - }, - "25%": { - transform: "rotate(-15deg)", - }, - "50%": { - transform: "rotate(15deg)", - }, - "75%": { - transform: "rotate(-15deg)", - }, - })} 2s`, tada: `${keyframes({ "0%": { transform: "scale(1) rotate(0deg)", diff --git a/src/components/ProjectPreview.tsx b/src/components/ProjectPreview.tsx index 2f20852f8..3d9ab34b9 100644 --- a/src/components/ProjectPreview.tsx +++ b/src/components/ProjectPreview.tsx @@ -192,13 +192,15 @@ const DataPreview = ({ dataset }: DataPreviewProps) => { - {dataset.map((action) => ( + {dataset.map((action, idx) => ( ))} diff --git a/src/model.ts b/src/model.ts index 707044437..a5875b58d 100644 --- a/src/model.ts +++ b/src/model.ts @@ -242,15 +242,15 @@ export enum DataSamplesView { export type DataSamplesPageHint = | null - | "move-microbit" - | "name-first-action" - | "record-first-action" - | "record-more-action" - | "add-action" - | "name-action" - | "record-action" - | "train" - | "name-action-with-samples"; + | { type: "move-microbit" | "record-more-action" | "add-action" | "train" } + | { + type: + | "name-action" + | "record-action" + | "name-action-with-samples" + | "name-action-short"; + actionIdx: number; + }; export enum PostImportDialogState { None = "none", diff --git a/src/pages/DataSamplesPage.tsx b/src/pages/DataSamplesPage.tsx index eab2bc5ee..bcca9af71 100644 --- a/src/pages/DataSamplesPage.tsx +++ b/src/pages/DataSamplesPage.tsx @@ -21,6 +21,7 @@ import DataSamplesTable from "../components/DataSamplesTable"; import { AddActionHint, MoveMicrobitHint, + moveMicrobitHintTimeoutInSec, TrainHint, } from "../components/DataSamplesTableHints"; import DefaultPageLayout, { @@ -39,7 +40,11 @@ import { PostImportDialogState, } from "../model"; import { projectSessionStorage } from "../session-storage"; -import { useHasSufficientDataForTraining, useStore } from "../store"; +import { + useHasSufficientDataForTraining, + useSettings, + useStore, +} from "../store"; import { tourElClassname } from "../tours"; import { createHomePageUrl, createTestingModelPageUrl } from "../urls"; @@ -106,28 +111,34 @@ const DataSamplesPage = () => { dismissWelcomeDialog, ]); const hasMoved = useHasMoved(); - const tourInProgress = useStore((s) => !!s.tourState); + const tourState = useStore((s) => s.tourState); const isRecordingDialogOpen = useStore((s) => !!s.isRecordingDialogOpen); const isPostImportDialogOpen = useStore( (s) => s.postImportDialogState !== PostImportDialogState.None ); + const tourInProgress = !!tourState; const isDialogOpen = isWelcomeDialogOpen || isConnectionDialogOpen || tourInProgress || isRecordingDialogOpen || isPostImportDialogOpen; + const hint = useStore((s) => s.hint); const setHint = useStore((s) => s.setHint); useEffect(() => { // Initialise hint on first load. setHint(true); }, [setHint]); - const dataSamplesHint: DataSamplesPageHint = isDialogOpen - ? null - : isConnected && !hasMoved - ? "move-microbit" - : hint; + const dataSamplesHint: DataSamplesPageHint = useMemo( + () => + isDialogOpen + ? null + : isConnected && !hasMoved + ? { type: "move-microbit" } + : hint, + [hasMoved, hint, isConnected, isDialogOpen] + ); const pageRef = useRef(null); const region = useLiveRegion(pageRef.current); @@ -145,6 +156,21 @@ const DataSamplesPage = () => { [region] ); + const setHasMoved = useStore((s) => s.setHasMoved); + const [settings] = useSettings(); + useEffect(() => { + if ( + dataSamplesHint?.type === "move-microbit" && + settings.toursCompleted.includes("Connect") + ) { + const id = setTimeout( + () => setHasMoved(true), + moveMicrobitHintTimeoutInSec * 1000 + ); + return () => clearTimeout(id); + } + }, [dataSamplesHint?.type, setHasMoved, settings.toursCompleted]); + useEffect(() => { if (!dataSamplesHint) { return; @@ -157,7 +183,7 @@ const DataSamplesPage = () => { actionWithHint ); debouncedSpeakHint(hintText); - }, [actions, dataSamplesHint, debouncedSpeakHint, intl, isConnected, region]); + }, [actions, dataSamplesHint, debouncedSpeakHint, intl, isConnected]); return ( <> @@ -210,7 +236,7 @@ const DataSamplesPage = () => { - {dataSamplesHint === "add-action" && ( + {dataSamplesHint?.type === "add-action" && ( )} @@ -241,8 +267,8 @@ const DataSamplesPage = () => { )} - {dataSamplesHint === "train" && } - {dataSamplesHint === "move-microbit" && } + {dataSamplesHint?.type === "train" && } + {dataSamplesHint?.type === "move-microbit" && } { ]); const tourStart = useStore((s) => s.tourStart); + const setHasMoved = useStore((s) => s.setHasMoved); const { isConnected } = useConnectionStage(); const wasConnected = usePrevious(isConnected); useEffect(() => { @@ -85,8 +86,9 @@ const TestingModelPage = () => { { name: "TrainModel", delayedUntilConnection: wasConnected === false }, false ); + setHasMoved(true); } - }, [isConnected, tourStart, wasConnected]); + }, [isConnected, setHasMoved, tourStart, wasConnected]); const { openEditor, resetProject, projectEdited } = useProject(); const { getDataCollectionBoardVersion } = useConnectActions(); diff --git a/src/store.ts b/src/store.ts index 54a76cfb9..6c35fb959 100644 --- a/src/store.ts +++ b/src/store.ts @@ -737,6 +737,7 @@ const createMlStore = (logging: Logging) => { hint: getHint(newActions, false), dataWindow: newDataWindow, model: undefined, + hasMoved: true, timestamp, ...updatedProject, }); @@ -923,6 +924,7 @@ const createMlStore = (logging: Logging) => { hint: getHint(updatedActions, false), dataWindow: newDataWindow, model: undefined, + hasMoved: true, timestamp, ...updatedProject, }); @@ -956,6 +958,7 @@ const createMlStore = (logging: Logging) => { hint: getHint(actions, false), dataWindow: currentDataWindow, model: undefined, + hasMoved: true, ...updatedProject, }); await storageWithErrHandling(() => @@ -1973,33 +1976,45 @@ const getHint = ( // We don't let you have zero. If you have > 2 you've seen it all before. if (actions.length === 0 || actions.length > 2) { if (sufficientDataForTraining && !suppressTrainAndAddActionHint) { - return "train"; + return { type: "train" }; } return null; } const lastActionIdx = actions.length - 1; - const action = actions[lastActionIdx]; - const isFirstAction = lastActionIdx === 0; + const lastAction = actions[lastActionIdx]; - if (action.name.length === 0) { - if (action.recordings.length === 0) { - return isFirstAction ? "name-first-action" : "name-action"; - } else { - return "name-action-with-samples"; - } + const firstUnnamedActionIdx = actions.findIndex( + (a) => a.name.length === 0 && a.recordings.length === 0 + ); + if (firstUnnamedActionIdx > -1 && firstUnnamedActionIdx !== lastActionIdx) { + return { type: "name-action-short", actionIdx: firstUnnamedActionIdx }; } - - if (action.recordings.length === 0) { - return isFirstAction ? "record-first-action" : "record-action"; + if (lastAction.name.length === 0) { + return { + type: + lastAction.recordings.length === 0 + ? "name-action" + : "name-action-with-samples", + actionIdx: lastActionIdx, + }; + } + const firstNoRecordingsActionIdx = actions.findIndex( + (a) => a.recordings.length === 0 + ); + if (firstNoRecordingsActionIdx > -1) { + return { + type: "record-action", + actionIdx: firstNoRecordingsActionIdx, + }; } - if (action.recordings.length < 3) { - return "record-more-action"; + if (lastAction.recordings.length < 3) { + return { type: "record-more-action" }; } - if (isFirstAction && !suppressTrainAndAddActionHint) { - return "add-action"; + if (lastActionIdx === 0 && !suppressTrainAndAddActionHint) { + return { type: "add-action" }; } if (sufficientDataForTraining && !suppressTrainAndAddActionHint) { - return "train"; + return { type: "train" }; } return null; };