-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
d6f8dcf
commit e478a4d
Showing
10 changed files
with
308 additions
and
19 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
</> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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: '' | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
} | ||
} |