UNPKG

@dilapidated-penguin/cubetimer

Version:

fast and lightweight CLI timer for speedcubing. Track your solves, get random scrambles, and analyze your times

1,034 lines (884 loc) 40.6 kB
#! /usr/bin/env node import chalk, { chalkStderr } from "chalk"; import { Command } from "commander"; import {event_choices,events_list} from './events.json' import {activeWindowSync, Result} from 'get-windows'; import { createTable } from 'nice-table'; import { select,number, input} from '@inquirer/prompts';import { spawn } from 'child_process'; import {settings, sessionLog, session_statistics, SolveInstance} from "./util/interfaces" import * as storage from "./util/storage" import * as settingsUtil from "./util/settings" import * as colour_palette from "./util/colourPalette" import {playAudioFile} from './util/sound' import {startLoader, endLoader} from './util/loading' import { plot, Plot,Layout } from 'nodeplotlib'; import path from 'path' const readline = require('readline'); var Scrambow = require('scrambow').Scrambow; const cfonts = require('cfonts'); import * as title from './cli-title.json' const program = new Command(); var saved_data = storage.loadData() //main_window_id const main_window_id:number = activeWindowSync().id //timer variables********************************** let timer_running:boolean = false let startTime:[number,number] | null = null let space_been_pressed:boolean = false let new_scramble:boolean = false let solve_labelled:boolean = false import {GlobalKeyboardListener} from "@futpib/node-global-key-listener"; import { clearInterval } from "timers"; import { PlotData } from "plotly.js"; import fs from 'fs' const listener = new GlobalKeyboardListener(); //************************************************* //************************************************* if(title.previous_window!==main_window_id){ console.log(title.string) let res = JSON.parse(JSON.stringify(title)) res.previous_window = main_window_id const title_path = path.join(__dirname, './cli-title.json'); fs.writeFileSync(title_path,JSON.stringify(res)) } program .version("1.0.40") .description("fast and lightweight CLI timer for speedcubing. Cstimer in the command line (in progress)") program .command('graph') .argument('<property>','desired statistic to graph') //.option('-c, --console','Displays the graph in the console') .description(`generate a graph of one of the below stats: \n session_mean \n standard_deviation \n variance \n fastest_solve \n slowest_solve`) .action((property:string,options:any)=>{ const property_keys = ['fastest_solve','session_mean','standard_deviation','variance','slowest_solve','all'] as const; type propertyKey = typeof property_keys[number] function normalizeArg(arg:string):propertyKey | null{ const aliases = { fastest_solve: ['f','b','best','fast','fastest','fastest_time'], slowest_solve: ['w','s','worst','slow','slowest','slowest_timer'], session_mean: ['m','mean','avg','average','session_mean'], standard_deviation:['dev','standard_deviation','std.dev','deviation','d'], variance:['var','v','variance','var.'], all:['a'] } for(const [key,val] of Object.entries(aliases)){ if((key === arg) || (val.includes(arg))){ return key as keyof session_statistics } } return null } const normalized_property:propertyKey = normalizeArg(property) if(normalized_property !== null){ const session_data:Map<string,session_statistics> = storage.loadStats().session_data if(session_data.size >=0){ const x_dates:Date[] = Array.from(session_data.keys()) .map((ISO_date)=>{ return new Date(ISO_date) }) const retrieve_data = (property:propertyKey,console_option:boolean = true)=>{ const y_data:number[] = x_dates.map((date:Date)=>{ return session_data.get(date.toISOString())[property] }) return console_option ? { x:x_dates, y:y_data, } : { x:x_dates, y:y_data, type:'scatter', name:property } } function consoleGraph(prop:string){ const allGraph = ()=>{ function randomColor() { return [Math.random() * 255,Math.random()*255, Math.random()*255] } const global_line = contrib.line( { xLabelPadding: 3, xPadding: 5, label: 'Graph', showLegend:true, width:'100%', height:'100%' }) screen.append(global_line) let global_data = [] const new_line = (prop:string)=>{ const {x,y} = retrieve_data(prop as keyof session_statistics) const random_colour = randomColor() const style = { line: random_colour, text:random_colour } global_data.push({ x:x, y:y, title:prop, style:style }) } property_keys.map((property:propertyKey)=>{ new_line(property) }) global_line.setData(global_data) } const defaultGraph = ()=>{ const line = contrib.line( { style: { line: "yellow" , text: "green" , baseline: "black"} , xLabelPadding: 3 , xPadding: 5 , label: `${normalized_property}(s)`}) let prop_data = retrieve_data(normalized_property as keyof session_statistics) screen.append(line) //must append before setting data line.setData([prop_data]) } let resGraph = (graphFunc)=>{ graphFunc() screen.key(['escape', 'q', 'C-c'], function(ch, key) { return process.exit(0); }); screen.render() } return resGraph((prop === 'all')?allGraph:defaultGraph) } if(options.console){ var blessed = require('blessed') , contrib = require('blessed-contrib') , screen = blessed.screen() consoleGraph(normalized_property) }else{ switch(normalized_property){ case 'all': const prop_keys = ['fastest_solve','session_mean','standard_deviation','variance','slowest_solve'] const data:Plot[] = prop_keys.map((property:propertyKey)=>{ const res =retrieve_data(property,false) return res as PlotData }) const layout = { title: 'Graph of all properties', xaxis: { title: 'sessions' }, yaxis: { title: 'times (s)' } }; plot(data,layout) break; default: const single_layout = { title: `Graph of ${normalized_property}`, xaxis: { title: 'sessions' }, yaxis: { title: 'times (s)' } }; plot([retrieve_data(normalized_property as keyof session_statistics,false) as PlotData],single_layout) break; } } }else{ console.error(`error: ` +chalk.red(`There was no available session data`)) } }else{ console.error(chalk.red(`${property}`) + ` is not a valid property. Below are the valid values`) console.log("session_mean \n" + "standard_deviation \n" + "variance \n" + "fastest_solve \n" + "slowest_solve") } }) program .command('scramble') .argument('<format>',`Format of the scramble(s) you'd like to generate`) .argument('[number]','number of scrambles to generate','1') .argument('[length]',`Length of the scramble`) .description('Generate a scramble') .action((event:string,count:string,length:string)=>{ const normalized_event:string = event .toLowerCase() .trim() if(!validEvent(normalized_event)){ console.log(chalk.redBright(`invalid event`)) return } count = count .toLowerCase() .trim() const current_settings:settings = settingsUtil.loadSettings() const scramble_length:number = Number(length) ?? current_settings.scramble_length if((scramble_length<=0) || (scramble_length>40)){ console.log(chalk.red(`invalid length`)) return } var scramble_generator = new Scrambow() const get_scramble_string = async (count:string) => { return scramble_generator .setType(normalized_event) .setLength(scramble_length) .get(Number(count)) .map((scramble_object,index)=>{ return `${index+1}) ${stylizeScramble(scramble_object.scramble_string)}` }) .join(`\n`) } startLoader() get_scramble_string(count).then((scramble_string:string)=>{ console.log(scramble_string) }).catch((err)=>{ console.log(err) }).finally(()=>{ endLoader() }) }) program .command('start') .argument('[event]', 'the event you wish to practice','333') .option('-f, --focusMode','Displays only the most important stats') .option('-w, --window','Opens a second command prompt window to display the informationa and stats related to the solve') .option('-i,--inspect','add inspection time') .description('Begin a session of practicing a certain event') .action((event:string,options:any)=>{ if(event !== undefined){ const normalized_event = event .toLowerCase() .trim() if(validEvent(normalized_event)){ startSession(normalized_event,options) }else{ console.error(chalk.red( `${event} is not a valid/supported event` )); } }else{ select({ message:'Select an event', choices:event_choices }) .then((event_choice:string)=>{ startSession(event_choice,options) }).catch((error)=>{ console.error(chalk.bgRed(`An error occurred`)) }) } }) program .command('metronome') .argument('[bpm]','the bpm of the metronome',settingsUtil.loadSettings().default_bpm) .description('start a metronome') .action((bpm:string)=>{ function metronome(bpm:number){ const interval:number = 60000/bpm const file_path = path.join(__dirname,`/sounds/${settingsUtil.loadSettings().default_metronome}`) setInterval(()=>{ playAudioFile(file_path) },interval) } const bpm_number:number = Number(bpm) if(isNaN(bpm_number)){ console.error(chalk.red(bpm) + ` is not a number`) return } if((bpm_number<3) || (bpm_number>180)){ console.error(chalk.red('the bpm must be between 3 and 180 beats per minute')) return } console.log(`bpm: ` + chalk.bold(bpm)) console.log(`Use ` + chalk.bold(`Ctrl + C`) + ` to exit the metronome`) metronome(bpm_number) }) program .command("settings") .argument("[property]",'configure the cli to your liking') .description('configure the cli to your liking') .action((setting_to_change:string | undefined)=>{ let current_settings:settings = settingsUtil.loadSettings() const settings_list:string[] = Object.keys(current_settings) if(setting_to_change === undefined){ console.log(chalk.green(`Configure any of the below to some new and preferred value`)) console.table(current_settings) select({ message: "Select the setting you'd like to alter", choices: settings_list }).then((answer:string)=>{ updateSetting(current_settings,answer) }) }else{ if(settings_list.indexOf(setting_to_change) !== -1){ updateSetting(current_settings,setting_to_change) }else{ console.error(chalk.red('Invalid argument:' + chalk.white('The argument is not a setting to change'))) } } }) program .command('show-session') .description(`Shows a list of session date markers`) .action(()=>{ const menu_length:number = 5 function newChoices(menu_page:number){ const session_array:sessionLog[] = Array.from(storage.loadData().data.values()) let menu_choices:any = session_array .map((session:sessionLog)=>{ return { name: session.date_formatted, value: session.date } }).filter((v,index)=>{ return (index>=menu_page*(menu_length)) && (index<((menu_page+1)*menu_length)) }) if(menu_page !== 0){ menu_choices.unshift({ name:chalk.blue(`Back`), value: 'back' }) } if(session_array[(menu_page+1)*menu_length] !== undefined){ menu_choices.push({ name:chalk.blue(`next`), value: 'next' }) } select({ message:`Select the session you'd like to observe`, choices:menu_choices }).then((value:string)=>{ switch(value){ case 'back': newChoices(menu_page-1) break; case 'next': newChoices(menu_page+1) break; default: const current_session_data:sessionLog = storage.loadData().data.get(value) let info_table = current_session_data.entries.map((instance,index)=>{ const label = (instance.label === "DNF") ? chalk.red(instance.label) : instance.label return { n: index+1, time: instance.time.toFixed(settingsUtil.loadSettings().sig_fig), label: label ?? chalk.green('OK'), } }) console.log(`\n`) console.log(createTable(info_table,['n','time','label'])) const current_sesssion_stats:session_statistics = storage.loadStats().session_data.get(value) if(current_sesssion_stats !== undefined){ console.log(`${chalk.green(current_session_data.date_formatted)}\n Event:${current_session_data.event} \n`) console.log(Object.keys(current_sesssion_stats).map((stat_name:string)=>{ return `${chalk.yellowBright(stat_name)}: ${current_sesssion_stats[stat_name]}${chalk.green('s')} \n` }).join('')) }else{ console.log(`Statistics unavailable`) } break; } }).catch((err)=>{ console.error(chalk.red(`An error has occurred:${err}`)) }) } newChoices(0) }) program.parse(process.argv) function updateSetting(current_settings:settings,property:string):void{ switch(property){ case 'default_metronome': const fs = require('node:fs'); const sounds_path = path.join(__dirname,`./sounds`) let sound_names:string[] = fs.readdirSync(sounds_path) const current_sound_index = sound_names.findIndex(u => u === current_settings.default_metronome) sound_names[current_sound_index] = chalk.bold(sound_names[current_sound_index]) select({ message:`Select the sound of the metronome`, choices:sound_names }).then((sound_name:string)=>{ current_settings.default_metronome = sound_name settingsUtil.saveSettings(current_settings) console.log(chalk.green(`Metronome sound setting updated`)) }).catch((err)=>{ console.error(err) }) break; default: let prompt switch(typeof current_settings[property]){ case 'number': prompt = number case 'string': prompt = input } prompt({ message: `Enter new value for ${property}`, default: current_settings[property] as never }).then((new_value:string|number)=>{ const num_value:number = Number(new_value) if(!isNaN(num_value)){ current_settings[property] = num_value }else{ current_settings[property] = `${new_value}` } settingsUtil.saveSettings(current_settings) console.log(chalk.green('settings updated!')) console.table(current_settings) }) break; } } function validEvent(event_to_check:string):boolean{ return (events_list.indexOf(event_to_check) !== -1) } function startSession(event: string,options:any):void{ console.clear() const session = Date.now() const session_date = new Date(session) const session_date_ISO = session_date.toISOString() cfonts.say(`session: ${session}`, { font: 'tiny', // define the font face align: 'center', // define text alignment colors: ['magenta'], background: 'transparent', // define the background color, you can also use `backgroundColor` here as key letterSpacing: 1, // define letter spacing }); const current_settings:settings = settingsUtil.loadSettings() saved_data.data.set(session_date_ISO,storage.newSessionLog(session_date,event)) saved_data.last_accessed_log = session_date_ISO storage.saveData(saved_data) new_scramble = true listener.kill() if (options.window) { const scriptPath = path.join(__dirname, 'window.js'); const cmd = spawn('cmd.exe', ['/K', `start cmd /K node ${scriptPath} ${session_date.toISOString()}`], { detached: true, stdio: 'ignore', windowsHide: false }); cmd.unref(); // Allow the parent process to exit without waiting for this new process cmd.on('error', (err) => console.error(`Process error: ${err.message}`)); } newSolve(current_settings,event,session_date,options) } function newSolve(current_settings:settings,event: string,session_date:Date,option:any):void{ const session_date_ISO:string = session_date.toISOString() var scramble_generator = new Scrambow() let lines_after_counter:number = 0 let scramble: string = scramble_generator .setType(event) .setLength(current_settings.scramble_length) .get(1)[0] .scramble_string process.stdout.write(`\x1b[2K\r`) console.log(chalk.bold.red(`Scramble:`)) process.stdout.write("\x1b[2K") console.log(stylizeScramble(scramble) + '\n') const pressedState = ()=>{ space_been_pressed = true belowCounter(chalk.bgRed('...')) } const belowCounter = (text:string)=>{ readline.clearLine(process.stdout, 0); readline.cursorTo(process.stdout, 0) process.stdout.write(`${text}\n`) readline.cursorTo(process.stdout, 0) lines_after_counter++ return lines_after_counter } function newSolvePrompt(){ console.log(`\n`) console.log(chalk.dim(`To label/delete the last solve simply use (`) + chalk.italic.yellow(`e`) + chalk.dim(`/`) + chalk.italic.yellow(`d`) + chalk.dim(`) respectively`)) console.log(chalk.dim(`Exit session mode using`), chalk.green(`Ctrl+C`) + `\n`) console.log(chalk.bold.magentaBright(`Whenever ready use the spacebar to start a new solve using`) + chalk.italic.yellow(` n`)+ `\n \n`) } function inspection_time(inspection_time:number = 15){ let count:number = -1 space_been_pressed = false let timer_started:boolean = false let intervalid belowCounter(`tap the ${chalk.underline(`Spacebar`)} to start the inspection timer`) listener.addListener(function (e, down) { if((e.name === "SPACE")){ if(e.state === "DOWN"){ if(timer_started){ if(!space_been_pressed){ pressedState() } } }else{ if(!timer_started){ timer_started = true startInspectionTimer() belowCounter(chalk.underline(`inspection started`)) } if(space_been_pressed && timer_started){ clearInterval(intervalid) listener.kill() new_scramble = true space_been_pressed = true solve_labelled = false startListener(current_settings,event,session_date,option) } } } }) function startInspectionTimer(){ intervalid = global.setInterval(()=>{ count++ const colour_gradient:number = 1-((inspection_time-count)/inspection_time) const red = (gradient)=>{ return Math.round(255*gradient) } const green = (gradient)=>{ return Math.round(-255*(gradient) + 255) } let colour = chalk.rgb(red(colour_gradient),green(colour_gradient),0) //udpate the timer const updateTimer = (time:number, lines_after_counter:number)=>{ readline.cursorTo(process.stdout, 0) readline.moveCursor(process.stdout, 0, -lines_after_counter- 1); readline.clearLine(process.stdout, 0); process.stdout.write(`${colour(`${time-count}`)}`); readline.moveCursor(process.stdout, 0, lines_after_counter+ 1); readline.cursorTo(process.stdout, 0) } updateTimer(inspection_time,lines_after_counter) if(count >= inspection_time){ if(count = inspection_time){ listener.kill() clearInterval(intervalid) newSolvePrompt() listener.kill() new_scramble = true solve_labelled = false space_been_pressed = false newSolve(current_settings,event,session_date,option) } } },1000) } } if(option.inspect){ inspection_time(current_settings.inspection_sec) }else{ startListener(current_settings,event,session_date,option) } function startListener(current_settings:settings,event: string,session_date:Date,option:any){ const releasedState = ()=>{ space_been_pressed = false readline.moveCursor(process.stdout, 0,-(option.inspect ? 1:2)); readline.cursorTo(process.stdout, 0) readline.clearLine(process.stdout, 0) process.stdout.write(chalk.bgGreenBright('SOLVE') + '\n \n') readline.cursorTo(process.stdout, 0) startTimer() } if(option.inspect){ if(space_been_pressed){ releasedState() } } listener.addListener(function (e, down) { const edit_selected = (date_ISO:string)=>{ number({ message:`Enter the index of the solve you'd like to change`, default:1 }).then((index_to_alter:number)=>{ const current_session:SolveInstance[] = saved_data.data.get(date_ISO).entries if((index_to_alter < 0) || (index_to_alter>current_session.length)){ console.log(chalk.red('invalid index')) return } const selected_entry:SolveInstance = current_session.at(index_to_alter) console.log(Object.keys(selected_entry).map((key)=>{ return `${key}: ${chalk.green(selected_entry[key])}` }) .join('')) select({ message:`Label or delete`, choices:['label','delete'] }).then((answer:string)=>{ switch(answer){ case 'label': editEntry(date_ISO,index_to_alter) break; case 'delete': deleteEntry(date_ISO,index_to_alter) break; } }).catch((err)=>{ console.log(err) }) }).catch((err)=>{ console.log(err) }) } const deleteEntry = (date_ISO:string,index_to_delete:number = null)=>{ let current_session:sessionLog = saved_data.data.get(date_ISO) if(current_session.entries.length>=1){ if(index_to_delete === null){ current_session.entries.pop() console.log(chalk.blue(`Last solve deleted`)) }else{ current_session.entries = current_session.entries.filter((d,index)=> index !==index_to_delete) console.log(`Session ${index_to_delete} deleted`) } saved_data.data.set(date_ISO,current_session) storage.saveData(saved_data) }else{ console.error(chalk.red(`There exist no entries in the current session to delete`)) } } const editEntry = (date_ISO:string,index_to_edit:number = null)=>{ const current_session:sessionLog = saved_data.data.get(date_ISO) if(current_session.entries.length>=1){ const editing_last_solve:boolean = index_to_edit === null const entry_message:string = editing_last_solve ? `Select the label for the previous solve` : `Select the label for solve #${index_to_edit}` select({ message:entry_message, choices:[ '+3', 'DNF', 'OK' ] }).then((answer:string)=>{ const index:number = editing_last_solve ? -1 : index_to_edit+1 current_session.entries.at(index).label = answer saved_data.data.set(date_ISO,current_session) storage.saveData(saved_data) console.log(chalk.green(`Last solve labelled ${answer}`)) console.log(chalk.bold.magentaBright(`Whenever ready use the spacebar to start a new solve`)) }).catch((err)=>{ console.log(chalk.red(`An error has occurred:${err}`)) }) }else{ console.log(chalk.redBright(`There exist no entries in the current session to label`)) } } if(activeWindowSync()?.id !== main_window_id){ return } if((e.name === "D") && (e.state === "UP") && (!new_scramble)){ deleteEntry(session_date_ISO) return } if((e.name === "N") && (e.state === "UP")){ if(!new_scramble){ process.stdout.write('\x1b[2K'); listener.kill() new_scramble = true solve_labelled = false space_been_pressed = false newSolve(current_settings,event,session_date,option) } return } if((e.name === "E") && (e.state === "UP") && (!new_scramble)){ if(!solve_labelled){ solve_labelled = true console.log(`\n \n`) editEntry(session_date_ISO) }else{ console.log(chalk.redBright(`The solve has already been labelled.`)) } return } if((e.name === "SPACE") && (new_scramble)){ if(!timer_running){ if(e.state === "DOWN"){ if(!space_been_pressed){ pressedState() process.stdout.write("\n") }else{ process.stdout.write("\b \b") } }else{ if(space_been_pressed){ releasedState() } } }else{ if(e.state === "DOWN"){ const elapsedTime:number = stopTimer() new_scramble = false const current_session:sessionLog = saved_data.data.get(session_date_ISO) current_session.entries.push({ scramble: scramble, time: elapsedTime, label: null }) const session_average = current_session .entries .reduce((acc,curr)=>{ return acc += curr.time },0)/current_session.entries.length const best_time:number = current_session .entries .reduce((acc,curr)=>{ if(acc<curr.time){ return acc }else{ return curr.time } },Infinity) const worst_time:number = current_session .entries .reduce((acc,curr)=>{ if(acc>curr.time){ return acc }else{ return curr.time } },-Infinity) const variance:number = current_session .entries .reduce((acc,curr)=>{ return acc += (session_average - curr.time)**2 },0)/current_session.entries.length const stats_data = storage.loadStats() const current_stats:session_statistics = { session_mean: session_average, standard_deviation: Math.sqrt(variance), variance: variance, fastest_solve: best_time, slowest_solve: worst_time } const current_Ao5:number = storage.Ao5(current_session) const current_Ao12:number = storage.Ao12(current_session) stats_data.session_data.set(session_date_ISO,current_stats) stats_data.pb_Ao5 = (current_Ao5<stats_data.pb_Ao5) ? current_Ao5 : stats_data.pb_Ao5 stats_data.pb_Ao12 = (current_Ao12<stats_data.pb_Ao12) ? current_Ao12 : stats_data.pb_Ao12 storage.saveStats(stats_data) saved_data.data.set(session_date_ISO,current_session) storage.saveData(saved_data) process.stdout.write("\b \b") cfonts.say(`${elapsedTime.toFixed(2)}s`, { font: 'block', // define the font face align: 'center', // define text alignment colors: ['white'], background: 'transparent', // define the background color, you can also use `backgroundColor` here as key letterSpacing: 1, // define letter spacing }); process.stdout.write("\b \b") const sig_fig:number = settingsUtil.loadSettings().sig_fig console.log( chalk.bold(`Time: `) + elapsedTime.toFixed(sig_fig) + chalk.green('s') + `\n`); const round_average = (value:number) =>{ if(value === null){ return '--' }else{ return value.toFixed(sig_fig) } } console.log(chalk.bold(`Ao5: `)+ chalk.magenta(round_average(current_Ao5)) + chalk.green(`s`)) console.log(chalk.bold(`Ao12: `)+ chalk.magenta(round_average(current_Ao12)) + chalk.green(`s`) + `\n \n`) if(!(option.focusMode) && !(option.window)){ //solves console.table(createTable(current_session.entries.map((instance)=>{ return { time: instance.time.toFixed(sig_fig), label: instance.label ?? 'OK' } }),['time','label'])) //stats const generateStatString = (current_stats:session_statistics)=>{ const titles:string[] = ['average','std. dev.','variance','fastest','slowest'] return Object.keys(current_stats) .map((stat_name:string,index:number)=>{ return `${titles[index]}: ${chalk.bold(current_stats[stat_name].toFixed(sig_fig))}` }) .join(chalk.blue(` | `)) } console.log(generateStatString(current_stats) + `\n`) } newSolvePrompt() //reset timer_running = false startTime = null space_been_pressed = false } } return } process.stdout.write('\x1b[2K\r'); }); } } function stopTimer():number{ if (!startTime) return; timer_running = false const endTime = process.hrtime(startTime) return endTime[0] + endTime[1] / 1e9; } function startTimer():void{ startTime = process.hrtime() timer_running = true } function stylizeScramble(scramble: string,r:number = 133,g:number = 18,b:number = 0): string { function rgb_to_hsl(r: number, g: number, b: number): [number, number, number] { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b), min = Math.min(r, g, b); let h = 0, s = 0, l = (max + min) / 2; if (max !== min) { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; case b: h = (r - g) / d + 4; break; } h *= 60; } return [Math.round(h), +(s * 100).toFixed(1), +(l * 100).toFixed(1)]; } function hsl_to_rgb(h: number, s: number, l: number): [number, number, number] { s /= 100; l /= 100; const c = (1 - Math.abs(2 * l - 1)) * s; const x = c * (1 - Math.abs((h / 60) % 2 - 1)); const m = l - c / 2; let r = 0, g = 0, b = 0; if (0 <= h && h < 60) { r = c; g = x; b = 0; } else if (60 <= h && h < 120) { r = x; g = c; b = 0; } else if (120 <= h && h < 180) { r = 0; g = c; b = x; } else if (180 <= h && h < 240) { r = 0; g = x; b = c; } else if (240 <= h && h < 300) { r = x; g = 0; b = c; } else if (300 <= h && h < 360) { r = c; g = 0; b = x; } return [ Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255) ]; } const [h,s,l] = rgb_to_hsl(r,g,b) const {complementary,fourth_hue,fifth_hue} = colour_palette.tetratic(h,s,l) const colorMap: Record<string, (s: string) => string> = { 'F': chalk.rgb(r,g,b).underline, 'R': chalk.rgb(...hsl_to_rgb(...complementary)), 'L': chalk.rgb(...hsl_to_rgb(...complementary)), 'U': chalk.rgb(...hsl_to_rgb(...fourth_hue)), 'D': chalk.rgb(...hsl_to_rgb(...fourth_hue)), "'": chalk.whiteBright, " ":chalk.whiteBright, '2': chalk.rgb(...hsl_to_rgb(...fifth_hue)), }; const res = scramble .trim() .split('') .map(char => { const stylize = colorMap[char] || chalk.rgb(r,g,b); return stylize(char) }) return res .join('') }