Skip to content

Commit

Permalink
Add a color picker to the frontend (#840)
Browse files Browse the repository at this point in the history
* Add endpoint for drawing scrambles and retrieving color schemes

* Render Puzzle SVG in EventPicker frontend

* Show color picker tooltip for SVGs

* Add 'Reset to default' button

* Show sample images for any color scheme

* Run Prettier linting

* Store extension data as actual WCIF extensions

* Add buttons to allow storing scheme per puzzle group

* Honor frontend color scheme in backend PDF drawing

* Fix tests to work with new data model

* Run Prettier linter

* Fix default propagation behavior in FMC, ColorScheme
  • Loading branch information
gregorbg committed Dec 15, 2023
1 parent ccd54cf commit 767c33f
Show file tree
Hide file tree
Showing 59 changed files with 1,392 additions and 500 deletions.
@@ -1,6 +1,6 @@
package org.worldcubeassociation.tnoodle.server.model

enum class EventData(val key: String, val description: String, val scrambler: PuzzleData, val legalFormats: Set<FormatData>) {
enum class EventData(val id: String, val description: String, val scrambler: PuzzleData, val legalFormats: Set<FormatData>) {
THREE(PuzzleData.THREE, FormatData.BIG_AVERAGE_FORMATS),
TWO(PuzzleData.TWO, FormatData.BIG_AVERAGE_FORMATS),
FOUR(PuzzleData.FOUR, FormatData.BIG_AVERAGE_FORMATS),
Expand All @@ -19,10 +19,10 @@ enum class EventData(val key: String, val description: String, val scrambler: Pu
FIVE_BLD("555bf", "5x5x5 Blindfolded", PuzzleData.FIVE_BLD, FormatData.BLD_SPECIAL_FORMATS),
THREE_MULTI_BLD("333mbf", "3x3x3 Multiple Blindfolded", PuzzleData.THREE_BLD, FormatData.BLD_SPECIAL_FORMATS);

constructor(scrambler: PuzzleData, legalFormats: Set<FormatData>) : this(scrambler.key, scrambler.description, scrambler, legalFormats)
constructor(scrambler: PuzzleData, legalFormats: Set<FormatData>) : this(scrambler.id, scrambler.description, scrambler, legalFormats)

companion object {
val WCA_EVENTS = values().associateBy { it.key }.toSortedMap()
val WCA_EVENTS = values().associateBy { it.id }.toSortedMap()

val ONE_HOUR_EVENTS = setOf(THREE_FM, THREE_MULTI_BLD)
val ATTEMPT_BASED_EVENTS = setOf(THREE_FM, THREE_MULTI_BLD)
Expand Down
Expand Up @@ -3,36 +3,42 @@ package org.worldcubeassociation.tnoodle.server.model
import org.worldcubeassociation.tnoodle.scrambles.PuzzleRegistry
import org.worldcubeassociation.tnoodle.server.model.cache.CoroutineScrambleCacher

enum class PuzzleData(private val registry: PuzzleRegistry) {
enum class PuzzleData(
private val registry: PuzzleRegistry,
private val parentScrambler: PuzzleData? = null,
val groupId: String? = null
) {
// To all fellow programmers who wonder about effectively copying an interface:
// 1-- Be able to intercept the `scrambler` reference (see getter below)
// 2-- Be able to limit the selection of tnoodle-lib `Puzzle`s that are exposed.
TWO(PuzzleRegistry.TWO),
THREE(PuzzleRegistry.THREE),
FOUR(PuzzleRegistry.FOUR),
FIVE(PuzzleRegistry.FIVE),
SIX(PuzzleRegistry.SIX),
SEVEN(PuzzleRegistry.SEVEN),
THREE_BLD(PuzzleRegistry.THREE_NI),
FOUR_BLD(PuzzleRegistry.FOUR_NI),
FIVE_BLD(PuzzleRegistry.FIVE_NI),
THREE_FMC(PuzzleRegistry.THREE_FM),
TWO(PuzzleRegistry.TWO, groupId = "nbyn"),
THREE(PuzzleRegistry.THREE, groupId = "nbyn"),
FOUR(PuzzleRegistry.FOUR, groupId = "nbyn"),
FIVE(PuzzleRegistry.FIVE, groupId = "nbyn"),
SIX(PuzzleRegistry.SIX, groupId = "nbyn"),
SEVEN(PuzzleRegistry.SEVEN, groupId = "nbyn"),
THREE_BLD(PuzzleRegistry.THREE_NI, THREE, groupId = "nbyn"),
FOUR_BLD(PuzzleRegistry.FOUR_NI, FOUR, groupId = "nbyn"),
FIVE_BLD(PuzzleRegistry.FIVE_NI, FIVE, groupId = "nbyn"),
THREE_FMC(PuzzleRegistry.THREE_FM, THREE, groupId = "nbyn"),
PYRA(PuzzleRegistry.PYRA),
SQ1(PuzzleRegistry.SQ1),
MEGA(PuzzleRegistry.MEGA),
CLOCK(PuzzleRegistry.CLOCK),
SKEWB(PuzzleRegistry.SKEWB);

// TODO have tnoodle-lib provide an interface that this stuff can be delegated to
val key get() = registry.key
val id get() = registry.key
val description get() = registry.description
val scrambler get() = registry.scrambler
val scramblerWithCache get() = registry.also { warmUpCache() }.scrambler

val cacheSize get() = SCRAMBLE_CACHERS[this.key]?.available
val cacheSize get() = SCRAMBLE_CACHERS[this.id]?.available

val rootScrambler: PuzzleData get() = this.parentScrambler?.rootScrambler ?: this

fun warmUpCache(cacheSize: Int = CACHE_SIZE) {
SCRAMBLE_CACHERS.getOrPut(this.key) { CoroutineScrambleCacher(this.registry.scrambler, cacheSize) }
SCRAMBLE_CACHERS.getOrPut(this.id) { CoroutineScrambleCacher(this.registry.scrambler, cacheSize) }
}

fun generateEfficientScrambles(num: Int, action: (String) -> Unit = {}): List<String> {
Expand All @@ -43,7 +49,11 @@ enum class PuzzleData(private val registry: PuzzleRegistry) {
.toList()
}

private fun yieldScramble() = SCRAMBLE_CACHERS[this.key]
fun generateScramble(action: (String) -> Unit = {}): String {
return yieldScramble().also(action)
}

private fun yieldScramble() = SCRAMBLE_CACHERS[this.id]
?.takeIf { it.available > 0 }?.getScramble()
?: this.scramblerWithCache.generateScramble()

Expand All @@ -52,6 +62,6 @@ enum class PuzzleData(private val registry: PuzzleRegistry) {

private val SCRAMBLE_CACHERS = mutableMapOf<String, CoroutineScrambleCacher>()

val WCA_PUZZLES = values().associateBy { it.key }.toSortedMap()
val WCA_PUZZLES = values().associateBy { it.id }.toSortedMap()
}
}
5 changes: 4 additions & 1 deletion tnoodle-ui/package.json
Expand Up @@ -15,7 +15,9 @@
"react-icons": "^4.12.0",
"react-redux": "^9.0.4",
"react-scripts": "5.0.1",
"web-vitals": "^3.3.2"
"web-vitals": "^3.3.2",
"react-inlinesvg": "^3.0.2",
"react-color": "^2.19.3"
},
"scripts": {
"start": "react-scripts start",
Expand Down Expand Up @@ -66,6 +68,7 @@
"@types/node": "^20.6.2",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.17",
"@types/react-color": "^3.0.6",
"prettier": "^2.8.8",
"typescript": "^4.9.4"
}
Expand Down
55 changes: 31 additions & 24 deletions tnoodle-ui/src/main/api/tnoodle.api.ts
@@ -1,13 +1,12 @@
import axios from "axios";
import BestMbld from "../model/BestMbld";
import RunningVersion from "../model/RunningVersion";
import Translation from "../model/Translation";
import WcaEvent from "../model/WcaEvent";
import WcaFormat from "../model/WcaFormat";
import Wcif from "../model/Wcif";
import { ScrambleClient } from "./tnoodle.socket";
import WebsocketBlobResult from "../model/WebsocketBlobResult";
import FrontendStatus from "../model/FrontendStatus";
import ScrambleAndImage from "../model/ScrambleAndImage";

let backendUrl = new URL("http://localhost:2014");
export const tNoodleBackend = backendUrl.toString().replace(/\/$/g, "");
Expand All @@ -17,24 +16,15 @@ let versionEndpoint = "/version";
let fmcTranslationsEndpoint = "/frontend/fmc/languages/available";
let suggestedFmcTranslationsEndpoint = "/frontend/fmc/languages/competitors";
let bestMbldAttemptEndpoint = "/frontend/mbld/best";
let puzzleColorSchemeEndpoint = (puzzleId: string) =>
`/frontend/puzzle/${puzzleId}/colors`;
let puzzleRandomScrambleEndpoint = (puzzleId: string) =>
`/frontend/puzzle/${puzzleId}/scramble`;
let solvedPuzzleSvgEndpoint = (puzzleId: string) =>
`/frontend/puzzle/${puzzleId}/svg`;
let wcaEventsEndpoint = "/frontend/data/events";
let formatsEndpoint = "/frontend/data/formats";

/**
* Builds the object expected for FMC translations
* @param {array} translations e.g. ["de", "da", "pt-BR"]
*/
const fmcTranslationsHelper = (translations?: Translation[]) => {
if (!translations) {
return null;
}
return {
languageTags: translations
.filter((translation) => translation.status)
.map((translation) => translation.id),
};
};

class TnoodleApi {
fetchWcaEvents = () =>
axios.get<WcaEvent[]>(tNoodleBackend + wcaEventsEndpoint);
Expand All @@ -51,6 +41,29 @@ class TnoodleApi {
fetchBestMbldAttempt = (wcif: Wcif) =>
axios.post<BestMbld>(tNoodleBackend + bestMbldAttemptEndpoint, wcif);

fetchPuzzleColorScheme = (puzzleId: string) =>
axios.get<Record<string, string>>(
tNoodleBackend + puzzleColorSchemeEndpoint(puzzleId)
);

fetchPuzzleRandomScramble = (
puzzleId: string,
colorScheme: Record<string, string> = {}
) =>
axios.post<ScrambleAndImage>(
tNoodleBackend + puzzleRandomScrambleEndpoint(puzzleId),
colorScheme
);

fetchPuzzleSolvedSvg = (
puzzleId: string,
colorScheme: Record<string, string> = {}
) =>
axios.post<string>(
tNoodleBackend + solvedPuzzleSvgEndpoint(puzzleId),
colorScheme
);

fetchRunningVersion = () =>
axios.get<RunningVersion>(tNoodleBackend + versionEndpoint);

Expand All @@ -62,17 +75,11 @@ class TnoodleApi {
fetchZip = (
scrambleClient: ScrambleClient,
wcif: Wcif,
mbld: string,
password: string,
status: FrontendStatus,
translations?: Translation[]
password: string
) => {
let payload = {
wcif,
multiCubes: { requestedScrambles: mbld },
fmcLanguages: fmcTranslationsHelper(translations),
zipPassword: !password ? null : password,
frontendStatus: status,
};

return scrambleClient.loadScrambles(zipEndpoint, payload, wcif.id);
Expand Down
4 changes: 4 additions & 0 deletions tnoodle-ui/src/main/components/EventPicker.css
Expand Up @@ -13,3 +13,7 @@
.cubing-icon {
font-size: 6em;
}

.fit-content .tooltip-inner {
max-width: fit-content;
}

0 comments on commit 767c33f

Please sign in to comment.