Skip to content

Commit

Permalink
Make trainer work
Browse files Browse the repository at this point in the history
  • Loading branch information
simonkellly committed May 24, 2024
1 parent d6f8dcf commit e478a4d
Show file tree
Hide file tree
Showing 10 changed files with 308 additions and 19 deletions.
16 changes: 16 additions & 0 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,19 @@ import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

export function shouldIgnoreEvent(ev: KeyboardEvent) {
if (
ev.target instanceof HTMLInputElement ||
ev.target instanceof HTMLTextAreaElement ||
ev.target instanceof HTMLButtonElement ||
ev.target instanceof HTMLSelectElement
)
return true;

if (ev.target instanceof HTMLElement) {
return ev.target.role !== null;
}

return false;
}
2 changes: 1 addition & 1 deletion src/routes/timer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ function Timer() {
</h1>
</div>
</div>
<ScrollArea className="h-72 mt-2 rounded-lg">
<ScrollArea className="h-72 rounded-lg">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
<LiveCubeCard />
<ResultsCard />
Expand Down
26 changes: 25 additions & 1 deletion src/routes/trainer.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,38 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import LiveCubeCard from "@/timer/liveCubeCard";
import DrawSolutionCard from "@/trainer/DrawSolutionCard";
import SolutionDisplay from "@/trainer/SolutionDisplay";
import TrainerBar from "@/trainer/TrainerBar";
import { TrainerStore } from "@/trainer/trainerStore";
import useAlgTrainer from "@/trainer/useAlgTrainer";
import { createFileRoute } from "@tanstack/react-router";
import { useStore } from "@tanstack/react-store";

export const Route = createFileRoute('/trainer')({
component: Trainer,
});

function Trainer() {
function Trainer() {
useAlgTrainer();
const alg = useStore(TrainerStore, (state) => state.alg);

return (
<div className="flex flex-col justify-between h-dvh w-screen p-2">
<TrainerBar />
<div className="bg-card rounded-lg border w-full relative grow mt-2">
<SolutionDisplay />
<div className="absolute top-0 left-0 w-full h-full flex">
<h1 className="m-auto text-6xl sm:text-7xl md:text-9xl font-extrabold select-none">
{alg ? <>{alg.case.first}{alg.case.second}</> : "--"}
</h1>
</div>
</div>
<ScrollArea className="h-72 rounded-lg">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
<LiveCubeCard />
<DrawSolutionCard />
</div>
</ScrollArea>
</div>
);
}
2 changes: 1 addition & 1 deletion src/timer/drawScrambleCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export default function DrawScrambleCard() {
<fieldset className="bg-card rounded-lg border px-4 col-span-1 h-72">
<legend className="">
<Badge variant="outline" className="bg-background">
Draw scramble
Scramble
</Badge>
</legend>
<DrawScramble className="h-full w-full p-2" scramble={scramble} />
Expand Down
17 changes: 1 addition & 16 deletions src/timer/useCubeTimer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { SOLVED, dnfAnalyser } from '@/lib/dnfAnalyser';
import { CubeStore } from '@/lib/smartCube';
import { extractAlgs } from '@/lib/solutionParser';
import { TimerStore } from './timerStore';
import { shouldIgnoreEvent } from '@/lib/utils';

export enum TimerState {
Inactive = 'INACTIVE',
Expand All @@ -26,22 +27,6 @@ export enum TimerState {

export const HOLD_DOWN_TIME = 300;

function shouldIgnoreEvent(ev: KeyboardEvent) {
if (
ev.target instanceof HTMLInputElement ||
ev.target instanceof HTMLTextAreaElement ||
ev.target instanceof HTMLButtonElement ||
ev.target instanceof HTMLSelectElement
)
return true;

if (ev.target instanceof HTMLElement) {
return ev.target.role !== null;
}

return false;
}

async function updateScrambleFromCubeState(originalScramble: Alg | string) {
const ogScrambleStr = originalScramble.toString();
if (!CubeStore.state.kpattern) {
Expand Down
19 changes: 19 additions & 0 deletions src/trainer/DrawSolutionCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useStore } from '@tanstack/react-store';
import DrawScramble from '@/components/cubing/drawScramble';
import { Badge } from '@/components/ui/badge';
import { TrainerStore } from './trainerStore';

export default function DrawSolutionCard() {
const scramble = useStore(TrainerStore, state => state.moves).map(m => m.move).join(' ');

return (
<fieldset className="bg-card rounded-lg border px-4 col-span-1 h-72">
<legend className="">
<Badge variant="outline" className="bg-background">
Solution
</Badge>
</legend>
<DrawScramble className="h-full w-full p-2" scramble={scramble} />
</fieldset>
);
}
31 changes: 31 additions & 0 deletions src/trainer/SolutionDisplay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useStore } from '@tanstack/react-store';
import { cn } from '@/lib/utils';
import { TrainerStore } from './trainerStore';
import { simplify } from '@/lib/solutionParser';

export default function SolutionDisplay() {
const moves = useStore(TrainerStore, state => state.moves).map(m => m.move);
const analysed = useStore(TrainerStore, state => state.analysedMoves);
const movesTotal = simplify(moves.join(' ')).toString();
const splitMoves = movesTotal.split(' ');

return (
<>
<h2 className="text-1xl sm:text-3xl font-semibold text-center p-4 pb-0 flex-none select-none">
{splitMoves.map((move, i) => {
const className = cn(
'inline-block px-1 mx-0.5 py-0.5 sm:px-2 sm:mx-1 sm:py-1 rounded-lg',
);
return (
<div key={movesTotal + ' ' + i} className={className}>
<pre>{move.padEnd(2, ' ')}</pre>
</div>
);
})}
</h2>
<h3 className="text-xl sm:text-1xl font-semibold text-secondary text-center p-1 select-none">
{analysed}
</h3>
</>
);
}
102 changes: 102 additions & 0 deletions src/trainer/algSheet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Alg as CubingAlg } from "cubing/alg";
import { z } from "zod";

const SHEET_ID = "1NEYh8MeTqHwnwA4s_CAYBGWU76pqdlutxR0SA2hNZKk";
const SHEET_NAME = "UFR Corners";

const letter = z.string().length(1);

const letterPair = z.object({
first: letter,
second: letter,
})

const alg = z.object({
case: letterPair,
alg: z.string(),
})

export type Alg = z.infer<typeof alg>;

const algSet = z.record(letter, alg);

type AlgSet = z.infer<typeof algSet>;

const algCollection = z.record(letter, algSet);

type AlgCollection = z.infer<typeof algCollection>;

export const algSheet = z.object({
letters: z.array(letter),
algs: algCollection,
});

export type AlgSheet = z.infer<typeof algSheet>;

function getAlgFromInverse(first: string, second: string, algArray: AlgCollection): Alg {
const algSet = algArray[second];
if (algSet == undefined) {
throw new Error(`No inverse for ${first}${second}`);
}
const alg = algSet[first];
if (alg == undefined) {
throw new Error(`No inverse for ${first}${second}`);
}
return { case: { first, second }, alg: new CubingAlg(alg.alg).invert().toString() }
}

export async function fetchGoogleSheet(): Promise<AlgSheet> {
const apiURL = `https://docs.google.com/spreadsheets/d/${SHEET_ID}/gviz/tq?sheet=${SHEET_NAME}`;
const sheetReq = await fetch(apiURL);
const sheetData = await sheetReq.text();

const sheetTrimmed = sheetData
.split("\n", 2)[1]
.replace(/google.visualization.Query.setResponse\(|\);/g, "");
const data = JSON.parse(sheetTrimmed);

const rows = data.table.rows.slice(1);

const firstLetters: string[] = data.table.rows[0].c
.slice(1)
.map((cell: { v: string }) => cell?.v?.substring(0, 1));
const secondLetters = rows
.slice(1)
.map((row: { c: { v: string }[] }) => row?.c[0]?.v?.substring(0, 1));

const algArray: AlgCollection = {};

firstLetters.forEach((firstLetter: string, firstIndex: number) => {
const algSet: AlgSet = {};
secondLetters.forEach((secondLetter: string, secondIndex: number) => {
const alg = rows[secondIndex + 1].c[firstIndex + 1]?.v;
if (alg != undefined) {
algSet[secondLetter] = { case: { first: firstLetter, second: secondLetter }, alg };
}
});
algArray[firstLetter] = algSet;
});

const inverses: AlgCollection = {};
for (const first in algArray) {
for (const second in algArray[first]) {
if (
algArray[second] == undefined || algArray[second][first] == undefined
)
inverses[second] ??= {};
inverses[second][first] = getAlgFromInverse(second, first, algArray);
}
}

for (const first in inverses) {
for (const second in inverses[first]) {
algArray[first] ??= {};
algArray[first][second] = inverses[first][second];
}
}

return {
letters: firstLetters,
algs: algArray,
};
}
9 changes: 9 additions & 0 deletions src/trainer/trainerStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Store } from '@tanstack/react-store';
import { Alg } from './algSheet';
import { GanCubeMove } from 'gan-web-bluetooth';

export const TrainerStore = new Store({
alg: undefined as Alg | undefined,
moves: [] as GanCubeMove[],
analysedMoves: ''
});
103 changes: 103 additions & 0 deletions src/trainer/useAlgTrainer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { useCallback, useEffect, useState } from "react";
import { AlgSheet, fetchGoogleSheet } from "./algSheet";
import { useStore } from "@tanstack/react-store";
import { CubeStore } from "@/lib/smartCube";
import { GanCubeEvent, GanCubeMove, cubeTimestampLinearFit } from "gan-web-bluetooth";
import { TrainerStore } from "./trainerStore";
import { cube3x3x3 } from "cubing/puzzles";
import { extractAlgs } from "@/lib/solutionParser";
import { shouldIgnoreEvent } from "@/lib/utils";
import { Key } from "ts-key-enum";

function randomAlg(sheet: AlgSheet) {
const randomLetter = sheet.letters[Math.floor(Math.random() * sheet.letters.length)];
const algSet = sheet.algs[randomLetter];
const algLetters = Object.keys(algSet);
const randomCase = algLetters[Math.floor(Math.random() * algLetters.length)];
return algSet[randomCase];
}

export default function useAlgTrainer() {
const [algs, setAlgs] = useState<AlgSheet | undefined>();
const cube = useStore(CubeStore, state => state.cube);

useEffect(() => {
fetchGoogleSheet().then(sheet => {
setAlgs({ ...sheet });
TrainerStore.setState((state) => ({ ...state, alg: randomAlg(sheet)}));
})
}, [setAlgs]);

const processMove = useCallback(async (move: GanCubeMove) => {
const moves = [...TrainerStore.state.moves, move];

const currentAlg = TrainerStore.state.alg?.alg;
if (!algs || !currentAlg) return;
const puzzle = await cube3x3x3.kpuzzle();
const solutionMoves = moves.map(m => m.move);
const isSolved = puzzle
.algToTransformation(currentAlg)
.invert()
.applyAlg(solutionMoves.join(' '))
.toKPattern()
.experimentalIsSolved({
ignoreCenterOrientation: true,
ignorePuzzleOrientation: true,
});

if (isSolved) {
TrainerStore.setState(state => ({ ...state, moves: [], analysedMoves: '', alg: randomAlg(algs) }));
const fullMoves = CubeStore.state.lastMoves;
const fixedMoves = fullMoves ? moves.length > fullMoves.length ? moves : cubeTimestampLinearFit(fullMoves).slice(-moves.length) : moves;
const time = fixedMoves.at(-1)!.cubeTimestamp - fixedMoves.at(0)!.cubeTimestamp;
console.log(time);

} else {
const analysis = await extractAlgs(solutionMoves);
TrainerStore.setState(state => ({ ...state, moves, analysedMoves: analysis.map(a => a[0]).join(' ') }));
}
}, [algs]);

useEffect(() => {
const subscription = cube?.events$.subscribe((event: GanCubeEvent) => {
if (event.type !== 'MOVE') return;
processMove(event);
});

return () => {
subscription?.unsubscribe();
};
}, [cube, processMove]);

useEffect(() => {
if (!algs) return;
const onKeyDown = (ev: KeyboardEvent) => {
if (shouldIgnoreEvent(ev)) {
return;
}

if (ev.key === ' ') {
ev.preventDefault();
ev.stopImmediatePropagation();

TrainerStore.setState(state => ({ ...state, moves: [], analysedMoves: '' }));
}

if (ev.key === Key.Escape) {
ev.preventDefault();
ev.stopImmediatePropagation();

TrainerStore.setState(state => ({ ...state, moves: [], analysedMoves: '', alg: randomAlg(algs) }));
}
};

document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
};
}, [algs]);

return {
algs,
}
}

0 comments on commit e478a4d

Please sign in to comment.