refactor: refresh from template
This commit is contained in:
+80
-79
@@ -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" />
|
||||
|
||||
<a href="https://github.com/klemek" target="_blank">klemek</a>
|
||||
-
|
||||
<LucideIcon name="github" />
|
||||
|
||||
<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>
|
||||
|
||||
<a href="https://git.klemek.fr/klemek" target="_blank">klemek</a>
|
||||
-
|
||||
<i icon="git-branch"></i>
|
||||
|
||||
<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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user