This commit is contained in:
2026-03-16 00:12:12 +01:00
parent b507a0e367
commit eccd2f968d
18 changed files with 796 additions and 234 deletions
+80 -17
View File
@@ -1,33 +1,97 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { ref, onMounted, computed, watch } from "vue";
import LucideIcon from "./components/LucideIcon.vue";
import CustomButton from "./components/CustomButton.vue";
import { DEFAULT_CONFIG, VEGETABLES } from "./contants";
import { type Config } from "./interfaces";
import { randomElement } from "./lib/random";
import { TableGenerator } from "./lib/table-gen";
import { formatTime, timeToMinute } from "./lib/time";
import ConfigTable from "./components/ConfigTable.vue";
import OutputTable from "./components/OutputTable.vue";
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),
);
const vegetable2 = computed<string>(() =>
randomElement(Object.keys(VEGETABLES), config.value.seed + 1),
);
const startTimeMinute = computed<number>(() =>
timeToMinute(config.value.startTime),
);
const endTimeMinute = computed<number>(() => {
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,
),
);
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));
}
const generator = new TableGenerator(
candidates.value,
mixThreshold,
slots,
config.value.seed,
);
const newTable = generator.generate();
if (config.value.endWithAll && newTable.length > 0) {
newTable.splice(-1, 1, [slots.slice(-1)[0] ?? "?", "🥗 SALAD 🥗"]);
}
table.value.push(...newTable);
}
onMounted(() => {
setTimeout(() => {
visible.value = true;
});
document.title = `${vegetable.value} Légume`;
});
watch(vegetable, () => {
document.title = `${vegetable.value} Légume`;
});
watch(config, generateData, { deep: true });
</script>
<template>
<main :style="{ display: visible ? 'inherit' : 'none' }">
<!-- TODO: 1. rename app -->
<h1>
<LucideIcon name="package" />
Vue-Boilerplate
</h1>
<h1>{{ vegetable }} Legume</h1>
<br />
<p>
Fill this page with <i>whatever</i> you're going to develop.
<br />
<b>Then enjoy!</b>
</p>
<CustomButton>
<LucideIcon name="square-arrow-right" /> This is a sample button yay
</CustomButton>
<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">
@@ -35,10 +99,9 @@ onMounted(() => {
&nbsp;
<a href="https://github.com/klemek" target="_blank">klemek</a>
-
<!-- TODO: 1. rename app -->
<LucideIcon name="github" />
&nbsp;
<a href="https://github.com/klemek/vue-boilerplate" target="_blank">
<a href="https://github.com/klemek/legume" target="_blank">
Repository
</a>
- 2025
+191
View File
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { DEFAULT_CONFIG, VEGETABLES } from "@/contants";
import { type Config } from "@/interfaces";
import { getDataCookie, setCookie } from "@/lib/cookies";
import { randomSeed, shuffleSeeded } from "@/lib/random";
import { formatTime, timeToMinute } from "@/lib/time";
import { computed, onMounted, watch } from "vue";
import LucideIcon from "./LucideIcon.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,
),
);
const startTimeMinute = computed<number>(() =>
timeToMinute(config.value.startTime),
);
const endTimeMinute = computed<number>(() => {
const result = timeToMinute(config.value.endTime);
return result < startTimeMinute.value ? result + 1440 : result;
});
const totalDuration = computed<number>(
() => endTimeMinute.value - startTimeMinute.value,
);
const slotTooBig = computed<boolean>(() => {
const slotCount = Math.ceil(
totalDuration.value / parseInt(config.value.duration, 10),
);
if (config.value.endWithAll) {
return slotCount - 1 < candidates.value.length;
}
return slotCount < candidates.value.length;
});
function newSeed() {
config.value.seed = randomSeed();
}
function saveConfig() {
setCookie("legume-config", JSON.stringify(config.value));
}
function loadConfig() {
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");
}
onMounted(() => {
newVegetables();
loadConfig();
});
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>
</template>
<style scoped>
table.config td {
padding: 0.25em 0.5em;
vertical-align: top;
}
table.config input,
table.config select,
button.full {
width: 100%;
}
table.config td:first-child {
text-align: right;
}
</style>
-36
View File
@@ -1,36 +0,0 @@
<script setup lang="ts">
interface Props {
href?: string;
color?: string;
}
defineProps<Props>();
</script>
<template>
<component
:is="href ? 'a' : 'div'"
:class="`button ${color ? 'b-' + color + ' ' + color : ''}`"
>
<slot></slot>
</component>
</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;
}
.button:hover {
background-color: var(--background-secondary);
}
</style>
+64
View File
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { ref } from "vue";
import LucideIcon from "./LucideIcon.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);
}
</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>
</template>
<style scoped>
table.output,
table.output th,
table.output td {
border: 1px solid var(--text-secondary);
}
table.output td {
text-align: center;
font-size: 1.1em;
padding: 0.25em 0.5em;
}
table.output td:first-child {
text-align: right;
}
</style>
+31
View File
@@ -0,0 +1,31 @@
import { type Config } from "./interfaces";
import { randomSeed } from "./lib/random";
export const VEGETABLES: Record<string, string> = {
"🥦": "Broccoli",
"🥕": "Carrot",
"🧅": "Onion",
"🌶️": "Pepper",
"🍆": "Eggplant",
"🥔": "Potato",
"🍄": "Mushroom",
"🧄": "Garlic",
"🥬": "Lettuce",
"🥒": "Cucumber",
"🥑": "Avocado",
"🌽": "Corn",
"🫘": "Beans",
"🫚": "Ginger",
"🫛": "Pea",
"🫜": "Radish",
};
export const DEFAULT_CONFIG: Config = {
startTime: "21:00",
endTime: "03:00",
duration: "30",
seed: randomSeed(),
candidates: "",
endWithAll: true,
mix: "25",
};
+9
View File
@@ -0,0 +1,9 @@
export interface Config {
startTime: string;
endTime: string;
duration: string;
seed: number;
candidates: string;
endWithAll: boolean;
mix: string;
}
+34
View File
@@ -0,0 +1,34 @@
export function setCookie(name: string, value: string, days = 30) {
const date = new Date();
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
const expires = `expires=${date.toUTCString()}`;
document.cookie = `${name}=${value}; path=/; ${expires}`;
}
export function getCookie(name: string, defaultValue: string): string {
const prefix = `${name}=`;
const decodedCookie = decodeURIComponent(document.cookie);
const cookies = decodedCookie.split(";");
for (const cookie of cookies) {
if (cookie.trim().startsWith(prefix)) {
return cookie.trim().substring(prefix.length, cookie.length);
}
}
return defaultValue;
}
export function getDataCookie<T extends object>(
name: string,
defaultValue: T,
): T {
const rawCookie = getCookie(name, "");
if (rawCookie.length) {
try {
const parsedConfig = JSON.parse(rawCookie) as T;
return { ...parsedConfig, ...defaultValue };
} catch {
/* Empty */
}
}
return defaultValue;
}
+12
View File
@@ -0,0 +1,12 @@
export function gcd(numA: number, numB: number): number {
let tmpA = numA;
let tmpB = numB;
while (tmpA !== tmpB) {
if (tmpA > tmpB) {
tmpA -= tmpB;
} else {
tmpB -= tmpA;
}
}
return tmpA;
}
+40
View File
@@ -0,0 +1,40 @@
export function randomSeed(): number {
return (Math.random() * 2 ** 32) >>> 0;
}
export function splitmix32(seed: number): () => number {
let localSeed = seed;
return function () {
localSeed |= 0;
localSeed = (localSeed + 0x9e3779b9) | 0;
let tmp = localSeed ^ (localSeed >>> 16);
tmp = Math.imul(tmp, 0x21f0aaad);
tmp ^= tmp >>> 15;
tmp = Math.imul(tmp, 0x735a2d97);
return ((tmp ^ (tmp >>> 15)) >>> 0) / 4294967296;
};
}
export function randomInt(max: number, seed: number) {
const prng = splitmix32(seed);
return Math.floor(prng() * max);
}
export function randomElement<T>(array: T[], seed: number): T {
return array[randomInt(array.length, seed)] as T;
}
export function shuffleSeeded<T>(array: T[], seed: number): T[] {
const output = array.slice();
const prng = splitmix32(seed);
for (let iteration = 0; iteration < array.length * 4; iteration += 1) {
const i1 = Math.floor(prng() * array.length);
const i2 = Math.floor(prng() * array.length);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const tmp = output[i2]!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
output[i2] = output[i1]!;
output[i1] = tmp;
}
return output;
}
+178
View File
@@ -0,0 +1,178 @@
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[];
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;
}
}
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 = new Array(mixTableLength)
.fill(0)
.map((unused, index) => index < mixSlots / g);
return shuffleSeeded(tmpMixTable, seed + 1);
}
mixKey(index1: number, index2: number): number {
return Math.min(index1, index2) * 1000 + Math.max(index1, index2);
}
getRandomIndex(): number {
return Math.floor(this.prng() * this.size);
}
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];
}
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));
}
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),
]);
}
}
+9
View File
@@ -0,0 +1,9 @@
export function formatTime(minutes: number): string {
return `${Math.floor((minutes / 60) % 24)
.toFixed(0)
.padStart(2, "0")}:${(minutes % 60).toFixed(0).padStart(2, "0")}`;
}
export function timeToMinute(time: string): number {
return Math.floor(Date.parse(`1970-01-01T${time}:00Z`) / 60000);
}