refactor: refresh from template
Deploy / Build (push) Has been cancelled
Deploy / Deploy to Stapler (push) Has been cancelled
Lint / ESLint (push) Has been cancelled
Lint / TypeScript (push) Has been cancelled

This commit is contained in:
2026-05-02 21:44:00 +02:00
parent d8636bd3e4
commit f75325ae1e
15 changed files with 719 additions and 553 deletions
+80 -79
View File
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, computed, watch } from "vue";
import LucideIcon from "./components/LucideIcon.vue";
import { ref, onMounted, computed, watch, onUpdated } from "vue";
import { DEFAULT_CONFIG, VEGETABLES } from "./contants";
import { type Config } from "./interfaces";
import { randomElement } from "./lib/random";
@@ -8,126 +7,128 @@ import { TableGenerator } from "./lib/table-gen";
import { formatTime, timeToMinute } from "./lib/time";
import ConfigTable from "./components/ConfigTable.vue";
import OutputTable from "./components/OutputTable.vue";
import { updateIcons } from "./lib/icons";
const visible = ref<boolean>(false);
const config = ref<Config>(DEFAULT_CONFIG);
const table = ref<[string, string][]>([]);
const vegetable = computed<string>(() =>
randomElement(Object.keys(VEGETABLES), config.value.seed),
randomElement(Object.keys(VEGETABLES), config.value.seed),
);
const vegetable2 = computed<string>(() =>
randomElement(Object.keys(VEGETABLES), config.value.seed + 1),
randomElement(Object.keys(VEGETABLES), config.value.seed + 1),
);
const startTimeMinute = computed<number>(() =>
timeToMinute(config.value.startTime),
timeToMinute(config.value.startTime),
);
const endTimeMinute = computed<number>(() => {
const result = timeToMinute(config.value.endTime);
return result < startTimeMinute.value ? result + 1440 : result;
const result = timeToMinute(config.value.endTime);
return result < startTimeMinute.value ? result + 1440 : result;
});
const candidates = computed<string[]>(() =>
config.value.candidates
.split("\n")
.map((line) => line.trim())
.filter(
(value, index, array) =>
value.length && array.indexOf(value) === index,
),
config.value.candidates
.split("\n")
.map((line) => line.trim())
.filter(
(value, index, array) => value.length && array.indexOf(value) === index,
),
);
function generateData() {
table.value.splice(0, table.value.length);
if (candidates.value.length <= 2) {
return;
}
const duration = parseInt(config.value.duration, 10);
const mixThreshold = parseInt(config.value.mix, 10) / 100;
const slots = [];
for (
let currentTimeMinute = startTimeMinute.value;
currentTimeMinute < endTimeMinute.value;
currentTimeMinute += duration
) {
slots.push(formatTime(currentTimeMinute));
}
table.value.splice(0, table.value.length);
if (candidates.value.length <= 2) {
return;
}
const duration = parseInt(config.value.duration, 10);
const mixThreshold = parseInt(config.value.mix, 10) / 100;
const slots = [];
for (
let currentTimeMinute = startTimeMinute.value;
currentTimeMinute < endTimeMinute.value;
currentTimeMinute += duration
) {
slots.push(formatTime(currentTimeMinute));
}
const generator = new TableGenerator(
candidates.value,
mixThreshold,
slots,
config.value.seed,
);
const generator = new TableGenerator(
candidates.value,
mixThreshold,
slots,
config.value.seed,
);
const newTable = generator.generate();
const newTable = generator.generate();
if (config.value.endWithAll && newTable.length > 0) {
newTable.splice(-1, 1, [slots.slice(-1)[0] ?? "?", "🥗 SALAD 🥗"]);
}
if (config.value.endWithAll && newTable.length > 0) {
newTable.splice(-1, 1, [slots.slice(-1)[0] ?? "?", "🥗 SALAD 🥗"]);
}
table.value.push(...newTable);
table.value.push(...newTable);
}
onMounted(() => {
setTimeout(() => {
visible.value = true;
});
document.title = `${vegetable.value} Légume`;
setTimeout(() => {
visible.value = true;
});
document.title = `${vegetable.value} Légume`;
});
watch(vegetable, () => {
document.title = `${vegetable.value} Légume`;
document.title = `${vegetable.value} Légume`;
});
onUpdated(updateIcons);
watch(config, generateData, { deep: true });
</script>
<template>
<main :style="{ display: visible ? 'inherit' : 'none' }">
<h1>{{ vegetable }} Legume</h1>
<br />
<ConfigTable v-model="config" />
<template v-if="table.length">
<h2>{{ vegetable2 }} Output (Vege)Table</h2>
<OutputTable v-model="table" />
</template>
<br />
<hr />
<small class="footer">
<LucideIcon name="at-sign" />
&nbsp;
<a href="https://github.com/klemek" target="_blank">klemek</a>
-
<LucideIcon name="github" />
&nbsp;
<a href="https://github.com/klemek/legume" target="_blank">
Repository
</a>
- 2025
</small>
</main>
<main :style="{ display: visible ? 'inherit' : 'none' }">
<h1>{{ vegetable }} Legume</h1>
<br />
<ConfigTable v-model="config" />
<template v-if="table.length">
<h2>{{ vegetable2 }} Output (Vege)Table</h2>
<OutputTable v-model="table" />
</template>
<br />
<hr />
<small class="footer">
<i icon="at-sign"></i>
&nbsp;
<a href="https://git.klemek.fr/klemek" target="_blank">klemek</a>
-
<i icon="git-branch"></i>
&nbsp;
<a href="https://git.klemek.fr/klemek/legume" target="_blank">
Repository
</a>
- 2025
</small>
</main>
</template>
<style scoped>
.button {
display: block;
width: 100%;
text-decoration: none;
padding: 1em;
margin-bottom: 0.75em;
border: 1px solid var(--color-primary);
border-radius: 0.5em;
background-color: var(--background);
cursor: pointer;
font-size: 1.333em;
display: block;
width: 100%;
text-decoration: none;
padding: 1em;
margin-bottom: 0.75em;
border: 1px solid var(--color-primary);
border-radius: 0.5em;
background-color: var(--background);
cursor: pointer;
font-size: 1.333em;
}
.button:hover {
background-color: var(--background-secondary);
background-color: var(--background-secondary);
}
.footer {
opacity: 50%;
opacity: 50%;
}
</style>
+125 -139
View File
@@ -2,194 +2,180 @@
import { DEFAULT_CONFIG, VEGETABLES } from "@/contants";
import { type Config } from "@/interfaces";
import { getDataCookie, setCookie } from "@/lib/cookies";
import { updateIcons } from "@/lib/icons";
import { randomSeed, shuffleSeeded } from "@/lib/random";
import { formatTime, timeToMinute } from "@/lib/time";
import { computed, onMounted, watch } from "vue";
import LucideIcon from "./LucideIcon.vue";
import { computed, onMounted, onUpdated, watch } from "vue";
const config = defineModel<Config>({ required: true });
const candidates = computed<string[]>(() =>
config.value.candidates
.split("\n")
.map((line) => line.trim())
.filter(
(value, index, array) =>
value.length && array.indexOf(value) === index,
),
config.value.candidates
.split("\n")
.map((line) => line.trim())
.filter(
(value, index, array) => value.length && array.indexOf(value) === index,
),
);
const startTimeMinute = computed<number>(() =>
timeToMinute(config.value.startTime),
timeToMinute(config.value.startTime),
);
const endTimeMinute = computed<number>(() => {
const result = timeToMinute(config.value.endTime);
return result < startTimeMinute.value ? result + 1440 : result;
const result = timeToMinute(config.value.endTime);
return result < startTimeMinute.value ? result + 1440 : result;
});
const totalDuration = computed<number>(
() => endTimeMinute.value - startTimeMinute.value,
() => endTimeMinute.value - startTimeMinute.value,
);
const slotTooBig = computed<boolean>(() => {
const slotCount = Math.ceil(
totalDuration.value / parseInt(config.value.duration, 10),
);
const slotCount = Math.ceil(
totalDuration.value / parseInt(config.value.duration, 10),
);
if (config.value.endWithAll) {
return slotCount - 1 < candidates.value.length;
}
if (config.value.endWithAll) {
return slotCount - 1 < candidates.value.length;
}
return slotCount < candidates.value.length;
return slotCount < candidates.value.length;
});
function newSeed() {
config.value.seed = randomSeed();
config.value.seed = randomSeed();
}
function saveConfig() {
setCookie("legume-config", JSON.stringify(config.value));
setCookie("legume-config", JSON.stringify(config.value));
}
function loadConfig() {
config.value = getDataCookie("legume-config", DEFAULT_CONFIG);
config.value = getDataCookie("legume-config", DEFAULT_CONFIG);
}
function newVegetables() {
config.value.candidates = shuffleSeeded(
Object.keys(VEGETABLES),
randomSeed(),
)
.map((key) => `${key} ${VEGETABLES[key] ?? "?"}`)
.slice(0, 6)
.join("\n");
config.value.candidates = shuffleSeeded(Object.keys(VEGETABLES), randomSeed())
.map((key) => `${key} ${VEGETABLES[key] ?? "?"}`)
.slice(0, 6)
.join("\n");
}
onMounted(() => {
newVegetables();
loadConfig();
newVegetables();
loadConfig();
});
onUpdated(updateIcons);
watch(config, saveConfig, { deep: true });
</script>
<template>
<table class="config">
<colgroup>
<col style="width: 25%" />
<col />
<col style="width: 25%" />
</colgroup>
<tr>
<td><label for="start-time">Start time:</label></td>
<td>
<input id="start-time" v-model="config.startTime" type="time" />
</td>
</tr>
<tr>
<td><label for="end-time">End time:</label></td>
<td>
<input id="end-time" v-model="config.endTime" type="time" />
</td>
<td>Total: {{ formatTime(totalDuration) }}</td>
</tr>
<tr>
<td><label for="duration">Slot duration:</label></td>
<td>
<input
id="duration"
v-model="config.duration"
type="range"
min="5"
:max="totalDuration"
step="5"
/>
</td>
<td>
<span v-if="slotTooBig" title="slot duration might be too big">
<LucideIcon name="triangle-alert" />
{{ config.duration }} minutes
</span>
<span v-else> {{ config.duration }} minutes </span>
</td>
</tr>
<tr>
<td><label for="seed">Seed:</label></td>
<td><input v-model="config.seed" type="number" /></td>
<td>
<button @click="newSeed"><LucideIcon name="dices" /></button>
</td>
</tr>
<tr>
<td><label for="mix">Mix policy:</label></td>
<td>
<input
id="mix"
v-model="config.mix"
type="range"
min="0"
max="100"
/>
</td>
<td>
<span v-if="parseInt(config.mix, 10) <= 0">None</span>
<span v-else>~{{ config.mix }}%</span>
</td>
</tr>
<tr>
<td><label for="candidates">Candidates:</label></td>
<td>
<textarea
id="candidates"
v-model="config.candidates"
rows="8"
></textarea>
</td>
<td>
<button @click="newVegetables">
<LucideIcon name="dices" />
</button>
<br />
<span
v-if="candidates.length <= 2"
title="not enough candidates"
>
<LucideIcon name="triangle-alert" />
<LucideIcon name="users-round" />
{{ candidates.length }}
</span>
<span v-else>
<LucideIcon name="users-round" /> {{ candidates.length }}
</span>
</td>
</tr>
<tr>
<td></td>
<td>
<button
class="full"
title="Ends event with all candidates (aka salad)"
@click="config.endWithAll = !config.endWithAll"
>
🥗 With salad: {{ config.endWithAll ? "✅" : "❌" }}
</button>
</td>
</tr>
</table>
<table class="config">
<colgroup>
<col style="width: 25%" />
<col />
<col style="width: 25%" />
</colgroup>
<tr>
<td><label for="start-time">Start time:</label></td>
<td>
<input id="start-time" v-model="config.startTime" type="time" />
</td>
</tr>
<tr>
<td><label for="end-time">End time:</label></td>
<td>
<input id="end-time" v-model="config.endTime" type="time" />
</td>
<td>Total: {{ formatTime(totalDuration) }}</td>
</tr>
<tr>
<td><label for="duration">Slot duration:</label></td>
<td>
<input
id="duration"
v-model="config.duration"
type="range"
min="5"
:max="totalDuration"
step="5"
/>
</td>
<td>
<span v-if="slotTooBig" title="slot duration might be too big">
<i icon="triangle-alert"></i>
{{ config.duration }} minutes
</span>
<span v-else> {{ config.duration }} minutes </span>
</td>
</tr>
<tr>
<td><label for="seed">Seed:</label></td>
<td><input v-model="config.seed" type="number" /></td>
<td>
<button @click="newSeed"><i icon="dices"></i></button>
</td>
</tr>
<tr>
<td><label for="mix">Mix policy:</label></td>
<td>
<input id="mix" v-model="config.mix" type="range" min="0" max="100" />
</td>
<td>
<span v-if="parseInt(config.mix, 10) <= 0">None</span>
<span v-else>~{{ config.mix }}%</span>
</td>
</tr>
<tr>
<td><label for="candidates">Candidates:</label></td>
<td>
<textarea
id="candidates"
v-model="config.candidates"
rows="8"
></textarea>
</td>
<td>
<button @click="newVegetables">
<i icon="dices"></i>
</button>
<br />
<span v-if="candidates.length <= 2" title="not enough candidates">
<i icon="triangle-alert"></i>
<i icon="users-round"></i>
{{ candidates.length }}
</span>
<span v-else> <i icon="users-round"></i> {{ candidates.length }} </span>
</td>
</tr>
<tr>
<td></td>
<td>
<button
class="full"
title="Ends event with all candidates (aka salad)"
@click="config.endWithAll = !config.endWithAll"
>
🥗 With salad: {{ config.endWithAll ? "✅" : "❌" }}
</button>
</td>
</tr>
</table>
</template>
<style scoped>
table.config td {
padding: 0.25em 0.5em;
vertical-align: top;
padding: 0.25em 0.5em;
vertical-align: top;
}
table.config input,
table.config select,
button.full {
width: 100%;
width: 100%;
}
table.config td:first-child {
text-align: right;
text-align: right;
}
</style>
-39
View File
@@ -1,39 +0,0 @@
<script setup lang="ts">
import { computed } from "vue";
import * as icons from "lucide-vue-next";
interface Props {
name: string;
color?: string;
strokeWidth?: string;
defaultClass?: string;
}
const props = withDefaults(defineProps<Props>(), {
color: "currentColor",
strokeWidth: "2",
defaultClass: "lucide",
});
function kebab2camel(kebab: string): string {
return kebab
.split("-")
.map(
(item) =>
item.charAt(0).toUpperCase() + item.slice(1).toLowerCase(),
)
.join("");
}
// @ts-expect-error: cannot infer type of all exported data
const icon = computed(() => icons[kebab2camel(props.name)]);
</script>
<template>
<component
:is="icon"
:color="color"
:stroke-width="strokeWidth"
:default-class="defaultClass"
/>
</template>
+38 -39
View File
@@ -1,64 +1,63 @@
<script setup lang="ts">
import { ref } from "vue";
import LucideIcon from "./LucideIcon.vue";
import { updateIcons } from "@/lib/icons";
import { ref, onUpdated } from "vue";
const table = defineModel<[string, string][]>({ required: true });
const copyTableOverride = ref<string | null>(null);
async function copyTable() {
const csvTable = table.value.map((row) => row.join("\t")).join("\n");
try {
await navigator.clipboard.writeText(csvTable);
copyTableOverride.value = "Table Copied";
} catch {
copyTableOverride.value = "Error";
}
setTimeout(() => {
copyTableOverride.value = null;
}, 1000);
const csvTable = table.value.map((row) => row.join("\t")).join("\n");
try {
await navigator.clipboard.writeText(csvTable);
copyTableOverride.value = "Table Copied";
} catch {
copyTableOverride.value = "Error";
}
setTimeout(() => {
copyTableOverride.value = null;
}, 1000);
}
onUpdated(updateIcons);
</script>
<template>
<table id="output" class="output">
<colgroup>
<col style="width: 25%" />
<col />
</colgroup>
<tr v-for="(row, index) in table" :key="`out-row-${index}`">
<td
v-for="(item, index2) in row"
:key="`out-cell-${index}-${index2}`"
>
{{ item }}
</td>
</tr>
</table>
<br />
<p>
<button @click="copyTable">
<LucideIcon name="table" />&nbsp;
<span v-if="copyTableOverride">{{ copyTableOverride }}</span>
<span v-else>Copy table</span>
</button>
</p>
<table id="output" class="output">
<colgroup>
<col style="width: 25%" />
<col />
</colgroup>
<tr v-for="(row, index) in table" :key="`out-row-${index}`">
<td v-for="(item, index2) in row" :key="`out-cell-${index}-${index2}`">
{{ item }}
</td>
</tr>
</table>
<br />
<p>
<button @click="copyTable">
<i icon="table"></i>&nbsp;
<span v-if="copyTableOverride">{{ copyTableOverride }}</span>
<span v-else>Copy table</span>
</button>
</p>
</template>
<style scoped>
table.output,
table.output th,
table.output td {
border: 1px solid var(--text-secondary);
border: 1px solid var(--text-secondary);
}
table.output td {
text-align: center;
font-size: 1.1em;
padding: 0.25em 0.5em;
text-align: center;
font-size: 1.1em;
padding: 0.25em 0.5em;
}
table.output td:first-child {
text-align: right;
text-align: right;
}
</style>
+14
View File
@@ -0,0 +1,14 @@
import { nextTick } from "vue";
import { createIcons, icons } from "lucide";
export async function updateIcons() {
await nextTick();
createIcons({
icons,
nameAttr: "icon",
attrs: {
width: "1.1em",
height: "1.1em",
},
});
}
+150 -156
View File
@@ -2,177 +2,171 @@ import { shuffleSeeded, splitmix32 } from "./random";
import { gcd } from "./math";
export class TableGenerator {
candidates: string[];
size: number;
mixThreshold: number;
slots: string[];
prng: () => number;
indexScores: Record<number, number>;
minIndexScore: number;
maxIndexScore: number;
mixScores: Record<number, number>;
minMixScore: number;
maxMixScore: number;
lastIndexes: number[];
mixTable: boolean[];
candidates: string[];
size: number;
mixThreshold: number;
slots: string[];
prng: () => number;
indexScores: Record<number, number>;
minIndexScore: number;
maxIndexScore: number;
mixScores: Record<number, number>;
minMixScore: number;
maxMixScore: number;
lastIndexes: number[];
mixTable: boolean[];
constructor(
candidates: string[],
mixThreshold: number,
slots: string[],
seed: number,
) {
this.candidates = candidates;
this.size = this.candidates.length;
this.mixThreshold = mixThreshold;
this.slots = slots;
this.prng = splitmix32(seed);
this.indexScores = this.initIndexScores();
this.minIndexScore = 0;
this.maxIndexScore = 0;
this.mixScores = this.initMixScores();
this.minMixScore = 0;
this.maxMixScore = 0;
this.lastIndexes = [];
this.mixTable = this.initMixTable(seed);
constructor(
candidates: string[],
mixThreshold: number,
slots: string[],
seed: number,
) {
this.candidates = candidates;
this.size = this.candidates.length;
this.mixThreshold = mixThreshold;
this.slots = slots;
this.prng = splitmix32(seed);
this.indexScores = this.initIndexScores();
this.minIndexScore = 0;
this.maxIndexScore = 0;
this.mixScores = this.initMixScores();
this.minMixScore = 0;
this.maxMixScore = 0;
this.lastIndexes = [];
this.mixTable = this.initMixTable(seed);
}
initIndexScores(): Record<number, number> {
return Object.fromEntries(this.candidates.map((line, index) => [index, 0]));
}
initMixScores(): Record<number, number> {
const scores: Record<number, number> = {};
for (let index1 = 0; index1 < this.candidates.length - 1; index1 += 1) {
for (
let index2 = index1 + 1;
index2 < this.candidates.length;
index2 += 1
) {
scores[this.mixKey(index1, index2)] = 0;
}
}
initIndexScores(): Record<number, number> {
return Object.fromEntries(
this.candidates.map((line, index) => [index, 0]),
);
return scores;
}
initMixTable(seed: number): boolean[] {
const mixSlots = Math.round(this.mixThreshold * this.slots.length);
if (mixSlots <= 0) {
return [false];
}
const g = gcd(mixSlots, this.slots.length);
const mixTableLength = this.slots.length / g;
const tmpMixTable = Array.from({ length: mixTableLength })
.fill(0)
.map((unused, index) => index < mixSlots / g);
return shuffleSeeded(tmpMixTable, seed + 1);
}
initMixScores(): Record<number, number> {
const scores: Record<number, number> = {};
mixKey(index1: number, index2: number): number {
return Math.min(index1, index2) * 1000 + Math.max(index1, index2);
}
for (let index1 = 0; index1 < this.candidates.length - 1; index1 += 1) {
for (
let index2 = index1 + 1;
index2 < this.candidates.length;
index2 += 1
) {
scores[this.mixKey(index1, index2)] = 0;
}
}
getRandomIndex(): number {
return Math.floor(this.prng() * this.size);
}
return scores;
getRandomMix(): [number, number] {
const index1 = this.getRandomIndex();
let index2;
do {
index2 = this.getRandomIndex();
} while (index1 === index2);
return [index1, index2];
}
updateIndexScores(index1: number, index2: number): void {
for (let index = 0; index < this.size; index += 1) {
if (index !== index1 && index !== index2) {
this.indexScores[index] = (this.indexScores[index] ?? 0) - 1;
} else {
this.indexScores[index] = 0;
}
}
this.minIndexScore = Math.min(...Object.values(this.indexScores));
this.maxIndexScore = Math.max(...Object.values(this.indexScores));
this.lastIndexes = [index1, index2];
}
initMixTable(seed: number): boolean[] {
const mixSlots = Math.round(this.mixThreshold * this.slots.length);
if (mixSlots <= 0) {
return [false];
}
const g = gcd(mixSlots, this.slots.length);
const mixTableLength = this.slots.length / g;
const tmpMixTable = new Array(mixTableLength)
.fill(0)
.map((unused, index) => index < mixSlots / g);
return shuffleSeeded(tmpMixTable, seed + 1);
}
updateMixScores(index1: number, index2: number): void {
this.updateIndexScores(index1, index2);
this.mixScores[this.mixKey(index1, index2)] =
(this.mixScores[this.mixKey(index1, index2)] ?? 0) + 1;
this.minMixScore = Math.min(...Object.values(this.mixScores));
this.maxMixScore = Math.max(...Object.values(this.mixScores));
}
mixKey(index1: number, index2: number): number {
return Math.min(index1, index2) * 1000 + Math.max(index1, index2);
}
indexScoreThreshold(value: number): number {
return (
this.minIndexScore + (this.maxIndexScore - this.minIndexScore) * value
);
}
getRandomIndex(): number {
return Math.floor(this.prng() * this.size);
}
mixScoreThreshold(value: number): number {
return this.minMixScore + (this.maxMixScore - this.minMixScore) * value;
}
getRandomMix(): [number, number] {
const index1 = this.getRandomIndex();
let index2;
do {
index2 = this.getRandomIndex();
} while (index1 === index2);
return [index1, index2];
getMixValue(): string | null {
const indexScoreThreshold = this.indexScoreThreshold(this.mixThreshold);
const mixScoreThreshold = this.mixScoreThreshold(0.1);
let retries = 500;
let index1, index2;
do {
[index1, index2] = this.getRandomMix();
} while (
(this.lastIndexes.includes(index1) ||
this.lastIndexes.includes(index2) ||
(this.indexScores[index1] ?? 0) > indexScoreThreshold ||
(this.indexScores[index2] ?? 0) > indexScoreThreshold ||
(this.mixScores[this.mixKey(index1, index2)] ?? 0) >
mixScoreThreshold) &&
(retries -= 1) > 0
);
if (retries === 0) {
return null;
}
this.updateMixScores(index1, index2);
return `${this.candidates[index1] ?? "?"} & ${this.candidates[index2] ?? "?"}`;
}
updateIndexScores(index1: number, index2: number): void {
for (let index = 0; index < this.size; index += 1) {
if (index !== index1 && index !== index2) {
this.indexScores[index] = (this.indexScores[index] ?? 0) - 1;
} else {
this.indexScores[index] = 0;
}
}
this.minIndexScore = Math.min(...Object.values(this.indexScores));
this.maxIndexScore = Math.max(...Object.values(this.indexScores));
this.lastIndexes = [index1, index2];
}
getCandidateValue(): string {
const indexScoreThreshold = this.indexScoreThreshold(0.1);
let retries = 500;
let index;
do {
index = this.getRandomIndex();
} while (
(this.lastIndexes.includes(index) ||
(this.indexScores[index] ?? 0) > indexScoreThreshold) &&
(retries -= 1) > 0
);
this.updateIndexScores(index, -1);
return this.candidates[index] ?? "?";
}
updateMixScores(index1: number, index2: number): void {
this.updateIndexScores(index1, index2);
this.mixScores[this.mixKey(index1, index2)] =
(this.mixScores[this.mixKey(index1, index2)] ?? 0) + 1;
this.minMixScore = Math.min(...Object.values(this.mixScores));
this.maxMixScore = Math.max(...Object.values(this.mixScores));
getSlotValue(index: number): string {
if (this.mixTable[index % this.mixTable.length]) {
const value = this.getMixValue();
if (value !== null) {
return value;
}
}
return this.getCandidateValue();
}
indexScoreThreshold(value: number): number {
return (
this.minIndexScore +
(this.maxIndexScore - this.minIndexScore) * value
);
}
mixScoreThreshold(value: number): number {
return this.minMixScore + (this.maxMixScore - this.minMixScore) * value;
}
getMixValue(): string | null {
const indexScoreThreshold = this.indexScoreThreshold(this.mixThreshold);
const mixScoreThreshold = this.mixScoreThreshold(0.1);
let retries = 500;
let index1, index2;
do {
[index1, index2] = this.getRandomMix();
} while (
(this.lastIndexes.includes(index1) ||
this.lastIndexes.includes(index2) ||
(this.indexScores[index1] ?? 0) > indexScoreThreshold ||
(this.indexScores[index2] ?? 0) > indexScoreThreshold ||
(this.mixScores[this.mixKey(index1, index2)] ?? 0) >
mixScoreThreshold) &&
(retries -= 1) > 0
);
if (retries === 0) {
return null;
}
this.updateMixScores(index1, index2);
return `${this.candidates[index1] ?? "?"} & ${this.candidates[index2] ?? "?"}`;
}
getCandidateValue(): string {
const indexScoreThreshold = this.indexScoreThreshold(0.1);
let retries = 500;
let index;
do {
index = this.getRandomIndex();
} while (
(this.lastIndexes.includes(index) ||
(this.indexScores[index] ?? 0) > indexScoreThreshold) &&
(retries -= 1) > 0
);
this.updateIndexScores(index, -1);
return this.candidates[index] ?? "?";
}
getSlotValue(index: number): string {
if (this.mixTable[index % this.mixTable.length]) {
const value = this.getMixValue();
if (value !== null) {
return value;
}
}
return this.getCandidateValue();
}
generate(): [string, string][] {
return this.slots.map((slot, index) => [
slot,
this.getSlotValue(index),
]);
}
generate(): [string, string][] {
return this.slots.map((slot, index) => [slot, this.getSlotValue(index)]);
}
}
File diff suppressed because it is too large Load Diff
+204
View File
@@ -0,0 +1,204 @@
@use "./material-colors.css";
/*
=================================================
https://www.joshwcomeau.com/css/custom-css-reset/
=================================================
*/
/*
1. Use a more-intuitive box-sizing model.
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
/*
2. Remove default margin
*/
* {
margin: 0;
}
/*
3. Allow percentage-based heights in the application
*/
html,
body,
div#app {
height: 100%;
}
/*
Typographic tweaks!
4. Add accessible line-height
5. Improve text rendering
*/
body {
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/*
6. Improve media defaults
*/
img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
}
/*
7. Remove built-in form typography styles
*/
input,
button,
textarea,
select {
font: inherit;
}
/*
8. Avoid text overflows
*/
p,
h1,
h2,
h3,
h4,
h5,
h6 {
overflow-wrap: break-word;
}
/*
9. Create a root stacking context
*/
#root,
#__next {
isolation: isolate;
}
/*
=================================================
CUSTOM STYLE
=================================================
*/
@import url("https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap");
:root {
/* https://materialui.co/colors/ */
--hue-primary: 103.2;
--sat-primary: 55.56%;
--background: hsl(var(--hue-primary), var(--sat-primary), 96.08%);
--background-primary: hsl(var(--hue-primary), var(--sat-primary), 93.33%);
--background-secondary: hsl(var(--hue-primary), var(--sat-primary), 90%);
--color-primary: hsl(var(--hue-primary), var(--sat-primary), 50%);
--text-primary: hsl(var(--hue-primary), var(--sat-primary), 25%);
--text-secondary: hsl(var(--hue-primary), var(--sat-primary), 30%);
}
/*
=================================================
https://blog.koley.in/2019/339-bytes-of-responsive-css
https://www.swyx.io/css-100-bytes
https://gist.github.com/JoeyBurzynski/617fb6201335779f8424ad9528b72c41
=================================================
*/
html,
body {
padding: 0;
max-width: 100%;
color: var(--text-primary);
font-family: "Roboto", Verdana, serif;
}
body {
background-color: var(--background);
}
main {
padding: 1.5rem;
margin: auto;
background-color: var(--background-primary);
min-height: 100%;
}
table {
border-collapse: collapse;
width: 100%;
font-size: 0.9em;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 1em 0 0.5em;
}
p,
ul,
ol {
margin-bottom: 2em;
color: var(--text-secondary);
}
hr {
opacity: 25%;
border-bottom: 0;
margin-bottom: 0.5em;
}
textarea,
input,
select,
.mono {
font-family: "Roboto Mono", monospace;
}
textarea {
width: 100%;
min-width: 100%;
max-width: 100%;
}
a {
color: inherit;
}
@media only screen and (min-width: 768px) {
main {
max-width: 42rem;
}
table {
font-size: inherit;
}
}
/*
* LUCIDE ICONS
*/
.lucide {
width: 1.1em;
height: 1.1em;
}
svg.lucide {
display: inline-block;
vertical-align: text-top;
}
b .lucide,
h1 .lucide,
h2 .lucide,
h3 .lucide,
h4 .lucide,
h5 .lucide,
h6 .lucide {
stroke-width: 3;
}