feat: add 4 new generators (Palette, Lorem, Country, Weighted)

- Color Palette: 7 harmony modes (Random, Analogous, Complementary,
  Triadic, Monochromatic, Split-Complementary, Tetradic), 3-8 colors,
  individual swatch copy + copy all
- Lorem Ipsum: 1-20 paragraphs, Short/Medium/Long length,
  optional classic start, full a11y support
- Random Country: 40 countries, region filter, flag emoji + capital
  + population, bilingual data
- Weighted Random: dynamic item-weight rows, validation,
  single pick + N-trials with statistics bars

All generators include EN/RU i18n, SEO metadata, FAQ, and follow
project patterns.
This commit is contained in:
emil28092005
2026-05-12 16:22:26 +03:00
parent 02f9ada193
commit a1cc10d0a2
17 changed files with 1491 additions and 0 deletions
+1
View File
@@ -3,3 +3,4 @@ dist/
.env
*.log
.astro/
.kimi/
@@ -0,0 +1,249 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
const regions = isRu
? [
{ value: "all", label: "Все" },
{ value: "europe", label: "Европа" },
{ value: "asia", label: "Азия" },
{ value: "americas", label: "Америка" },
{ value: "africa", label: "Африка" },
{ value: "oceania", label: "Океания" },
]
: [
{ value: "all", label: "All" },
{ value: "europe", label: "Europe" },
{ value: "asia", label: "Asia" },
{ value: "americas", label: "Americas" },
{ value: "africa", label: "Africa" },
{ value: "oceania", label: "Oceania" },
];
---
<div id="country-generator" class="mt-8">
<!-- Filter -->
<div class="mb-6 flex flex-wrap items-center gap-3">
<label for="cg-region" class="text-sm font-medium text-zinc-300">
{isRu ? "Регион" : "Region"}
</label>
<select
id="cg-region"
class="px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-800 text-sm text-zinc-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 cursor-pointer"
>
{regions.map((r) => <option value={r.value}>{r.label}</option>)}
</select>
</div>
<!-- Result card -->
<div
id="cg-result"
class="rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8 opacity-0"
style="transition: opacity 0.25s ease;"
aria-live="polite"
>
<div class="flex flex-col items-center text-center">
<div
id="cg-flag"
class="text-7xl leading-none mb-4"
aria-hidden="true"
/>
<h3
id="cg-name"
class="text-2xl font-bold text-zinc-100 mb-1"
/>
<div class="flex flex-wrap items-center justify-center gap-2 text-sm text-zinc-400 mt-2">
<span id="cg-capital-label" class="text-zinc-500">
{isRu ? "Столица:" : "Capital:"}
</span>
<span id="cg-capital" class="text-zinc-200 font-medium" />
<span class="text-zinc-600">•</span>
<span id="cg-region-label" class="text-zinc-500">
{isRu ? "Регион:" : "Region:"}
</span>
<span id="cg-region-display" class="text-zinc-200 font-medium" />
<span class="text-zinc-600">•</span>
<span id="cg-population-label" class="text-zinc-500">
{isRu ? "Население:" : "Population:"}
</span>
<span id="cg-population" class="text-zinc-200 font-medium" />
</div>
<!-- Copy button -->
<button
id="cg-copy"
type="button"
class="mt-6 inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 hover:text-zinc-100 hover:bg-zinc-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors cursor-pointer"
>
<svg
id="cg-copy-icon"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
<svg
id="cg-check-icon"
class="hidden text-accent"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>
<span id="cg-copy-text">{T.copy}</span>
</button>
</div>
</div>
<!-- Generate button -->
<div class="flex justify-center mt-8">
<button
id="cg-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl shadow-lg shadow-accent/25 hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{T.generate}
</button>
</div>
</div>
<script>
interface Country {
name: string;
ruName: string;
capital: string;
ruCapital: string;
region: string;
ruRegion: string;
population: number;
flagEmoji: string;
}
const countries: Country[] = [
{ name: "France", ruName: "Франция", capital: "Paris", ruCapital: "Париж", region: "europe", ruRegion: "Европа", population: 68000000, flagEmoji: "🇫🇷" },
{ name: "Germany", ruName: "Германия", capital: "Berlin", ruCapital: "Берлин", region: "europe", ruRegion: "Европа", population: 83000000, flagEmoji: "🇩🇪" },
{ name: "Italy", ruName: "Италия", capital: "Rome", ruCapital: "Рим", region: "europe", ruRegion: "Европа", population: 59000000, flagEmoji: "🇮🇹" },
{ name: "Spain", ruName: "Испания", capital: "Madrid", ruCapital: "Мадрид", region: "europe", ruRegion: "Европа", population: 47000000, flagEmoji: "🇪🇸" },
{ name: "United Kingdom", ruName: "Великобритания", capital: "London", ruCapital: "Лондон", region: "europe", ruRegion: "Европа", population: 67000000, flagEmoji: "🇬🇧" },
{ name: "Japan", ruName: "Япония", capital: "Tokyo", ruCapital: "Токио", region: "asia", ruRegion: "Азия", population: 125000000, flagEmoji: "🇯🇵" },
{ name: "China", ruName: "Китай", capital: "Beijing", ruCapital: "Пекин", region: "asia", ruRegion: "Азия", population: 1410000000, flagEmoji: "🇨🇳" },
{ name: "India", ruName: "Индия", capital: "New Delhi", ruCapital: "Нью-Дели", region: "asia", ruRegion: "Азия", population: 1380000000, flagEmoji: "🇮🇳" },
{ name: "South Korea", ruName: "Южная Корея", capital: "Seoul", ruCapital: "Сеул", region: "asia", ruRegion: "Азия", population: 52000000, flagEmoji: "🇰🇷" },
{ name: "Thailand", ruName: "Таиланд", capital: "Bangkok", ruCapital: "Бангкок", region: "asia", ruRegion: "Азия", population: 70000000, flagEmoji: "🇹🇭" },
{ name: "United States", ruName: "США", capital: "Washington, D.C.", ruCapital: "Вашингтон", region: "americas", ruRegion: "Америка", population: 331000000, flagEmoji: "🇺🇸" },
{ name: "Brazil", ruName: "Бразилия", capital: "Brasília", ruCapital: "Бразилиа", region: "americas", ruRegion: "Америка", population: 213000000, flagEmoji: "🇧🇷" },
{ name: "Canada", ruName: "Канада", capital: "Ottawa", ruCapital: "Оттава", region: "americas", ruRegion: "Америка", population: 38000000, flagEmoji: "🇨🇦" },
{ name: "Mexico", ruName: "Мексика", capital: "Mexico City", ruCapital: "Мехико", region: "americas", ruRegion: "Америка", population: 126000000, flagEmoji: "🇲🇽" },
{ name: "Argentina", ruName: "Аргентина", capital: "Buenos Aires", ruCapital: "Буэнос-Айрес", region: "americas", ruRegion: "Америка", population: 45000000, flagEmoji: "🇦🇷" },
{ name: "Egypt", ruName: "Египет", capital: "Cairo", ruCapital: "Каир", region: "africa", ruRegion: "Африка", population: 102000000, flagEmoji: "🇪🇬" },
{ name: "South Africa", ruName: "ЮАР", capital: "Pretoria", ruCapital: "Претория", region: "africa", ruRegion: "Африка", population: 59000000, flagEmoji: "🇿🇦" },
{ name: "Nigeria", ruName: "Нигерия", capital: "Abuja", ruCapital: "Абуджа", region: "africa", ruRegion: "Африка", population: 206000000, flagEmoji: "🇳🇬" },
{ name: "Kenya", ruName: "Кения", capital: "Nairobi", ruCapital: "Найроби", region: "africa", ruRegion: "Африка", population: 54000000, flagEmoji: "🇰🇪" },
{ name: "Morocco", ruName: "Марокко", capital: "Rabat", ruCapital: "Рабат", region: "africa", ruRegion: "Африка", population: 37000000, flagEmoji: "🇲🇦" },
{ name: "Australia", ruName: "Австралия", capital: "Canberra", ruCapital: "Канберра", region: "oceania", ruRegion: "Океания", population: 26000000, flagEmoji: "🇦🇺" },
{ name: "New Zealand", ruName: "Новая Зеландия", capital: "Wellington", ruCapital: "Веллингтон", region: "oceania", ruRegion: "Океания", population: 5000000, flagEmoji: "🇳🇿" },
{ name: "Russia", ruName: "Россия", capital: "Moscow", ruCapital: "Москва", region: "europe", ruRegion: "Европа", population: 146000000, flagEmoji: "🇷🇺" },
{ name: "Turkey", ruName: "Турция", capital: "Ankara", ruCapital: "Анкара", region: "asia", ruRegion: "Азия", population: 84000000, flagEmoji: "🇹🇷" },
{ name: "Saudi Arabia", ruName: "Саудовская Аравия", capital: "Riyadh", ruCapital: "Эр-Рияд", region: "asia", ruRegion: "Азия", population: 35000000, flagEmoji: "🇸🇦" },
{ name: "Indonesia", ruName: "Индонезия", capital: "Jakarta", ruCapital: "Джакарта", region: "asia", ruRegion: "Азия", population: 274000000, flagEmoji: "🇮🇩" },
{ name: "Vietnam", ruName: "Вьетнам", capital: "Hanoi", ruCapital: "Ханой", region: "asia", ruRegion: "Азия", population: 97000000, flagEmoji: "🇻🇳" },
{ name: "Colombia", ruName: "Колумбия", capital: "Bogotá", ruCapital: "Богота", region: "americas", ruRegion: "Америка", population: 50000000, flagEmoji: "🇨🇴" },
{ name: "Peru", ruName: "Перу", capital: "Lima", ruCapital: "Лима", region: "americas", ruRegion: "Америка", population: 33000000, flagEmoji: "🇵🇪" },
{ name: "Chile", ruName: "Чили", capital: "Santiago", ruCapital: "Сантьяго", region: "americas", ruRegion: "Америка", population: 19000000, flagEmoji: "🇨🇱" },
{ name: "Ethiopia", ruName: "Эфиопия", capital: "Addis Ababa", ruCapital: "Аддис-Абеба", region: "africa", ruRegion: "Африка", population: 115000000, flagEmoji: "🇪🇹" },
{ name: "Ghana", ruName: "Гана", capital: "Accra", ruCapital: "Аккра", region: "africa", ruRegion: "Африка", population: 31000000, flagEmoji: "🇬🇭" },
{ name: "Tanzania", ruName: "Танзания", capital: "Dodoma", ruCapital: "Додома", region: "africa", ruRegion: "Африка", population: 61000000, flagEmoji: "🇹🇿" },
{ name: "Sweden", ruName: "Швеция", capital: "Stockholm", ruCapital: "Стокгольм", region: "europe", ruRegion: "Европа", population: 10000000, flagEmoji: "🇸🇪" },
{ name: "Norway", ruName: "Норвегия", capital: "Oslo", ruCapital: "Осло", region: "europe", ruRegion: "Европа", population: 5400000, flagEmoji: "🇳🇴" },
{ name: "Netherlands", ruName: "Нидерланды", capital: "Amsterdam", ruCapital: "Амстердам", region: "europe", ruRegion: "Европа", population: 17400000, flagEmoji: "🇳🇱" },
{ name: "Poland", ruName: "Польша", capital: "Warsaw", ruCapital: "Варшава", region: "europe", ruRegion: "Европа", population: 38000000, flagEmoji: "🇵🇱" },
{ name: "Greece", ruName: "Греция", capital: "Athens", ruCapital: "Афины", region: "europe", ruRegion: "Европа", population: 10700000, flagEmoji: "🇬🇷" },
{ name: "Portugal", ruName: "Португалия", capital: "Lisbon", ruCapital: "Лиссабон", region: "europe", ruRegion: "Европа", population: 10300000, flagEmoji: "🇵🇹" },
];
const regionSelect = document.getElementById("cg-region") as HTMLSelectElement;
const resultEl = document.getElementById("cg-result") as HTMLDivElement;
const flagEl = document.getElementById("cg-flag") as HTMLDivElement;
const nameEl = document.getElementById("cg-name") as HTMLHeadingElement;
const capitalEl = document.getElementById("cg-capital") as HTMLSpanElement;
const regionEl = document.getElementById("cg-region-display") as HTMLSpanElement;
const populationEl = document.getElementById("cg-population") as HTMLSpanElement;
const btn = document.getElementById("cg-btn") as HTMLButtonElement;
const copyBtn = document.getElementById("cg-copy") as HTMLButtonElement;
const copyIcon = document.getElementById("cg-copy-icon") as SVGElement;
const checkIcon = document.getElementById("cg-check-icon") as SVGElement;
const copyText = document.getElementById("cg-copy-text") as HTMLSpanElement;
const isRu = document.documentElement.lang === "ru";
function formatPopulation(n: number): string {
if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(1).replace(/\.0$/, "") + "B";
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, "") + "K";
return n.toString();
}
function getFilteredCountries(): Country[] {
const region = regionSelect.value;
if (region === "all") return countries;
return countries.filter((c) => c.region === region);
}
function generate() {
const list = getFilteredCountries();
if (list.length === 0) return;
const country = list[Math.floor(Math.random() * list.length)];
flagEl.textContent = country.flagEmoji;
nameEl.textContent = isRu ? country.ruName : country.name;
capitalEl.textContent = isRu ? country.ruCapital : country.capital;
regionEl.textContent = isRu ? country.ruRegion : country.region.charAt(0).toUpperCase() + country.region.slice(1);
populationEl.textContent = formatPopulation(country.population);
resultEl.style.opacity = "1";
}
let copyTimer: ReturnType<typeof setTimeout> | null = null;
copyBtn.addEventListener("click", async () => {
const flag = flagEl.textContent?.trim() || "";
const name = nameEl.textContent?.trim() || "";
const capital = capitalEl.textContent?.trim() || "";
const region = regionEl.textContent?.trim() || "";
const population = populationEl.textContent?.trim() || "";
if (!name) return;
const text = `${flag} ${name} — ${capital} — ${region} — ${population} ${isRu ? "чел." : "people"}`;
await navigator.clipboard.writeText(text);
copyIcon.classList.add("hidden");
checkIcon.classList.remove("hidden");
copyText.textContent = isRu ? "Скопировано" : "Copied";
if (copyTimer) clearTimeout(copyTimer);
copyTimer = setTimeout(() => {
checkIcon.classList.add("hidden");
copyIcon.classList.remove("hidden");
copyText.textContent = isRu ? "Копировать" : "Copy";
}, 1500);
});
btn.addEventListener("click", generate);
</script>
@@ -0,0 +1,252 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
const lengthOptions = isRu
? [
{ value: "short", label: "Короткие" },
{ value: "medium", label: "Средние" },
{ value: "long", label: "Длинные" },
]
: [
{ value: "short", label: "Short" },
{ value: "medium", label: "Medium" },
{ value: "long", label: "Long" },
];
const startLabel = isRu
? "Начать с Lorem ipsum dolor sit amet"
: "Start with Lorem ipsum dolor sit amet";
---
<div id="lorem-generator" class="mt-8">
<!-- Controls -->
<div class="my-6 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
<!-- Paragraph count -->
<div>
<label for="lg-count" class="block text-sm font-medium text-zinc-300 mb-2">
{isRu ? "Количество абзацев" : "Paragraphs"}
</label>
<input
id="lg-count"
type="number"
min="1"
max="20"
value="3"
class="w-full px-4 py-2.5 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
/>
</div>
<!-- Length selector -->
<div>
<label for="lg-length" class="block text-sm font-medium text-zinc-300 mb-2">
{isRu ? "Длина абзаца" : "Paragraph length"}
</label>
<select
id="lg-length"
class="w-full px-4 py-2.5 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 appearance-none"
>
{lengthOptions.map((opt) => (
<option value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
<!-- Start with Lorem ipsum checkbox -->
<div class="flex items-center gap-3 mb-6">
<input
id="lg-start"
type="checkbox"
checked
class="w-4 h-4 rounded border-zinc-700 bg-zinc-950 text-accent focus:ring-accent focus:ring-offset-zinc-950"
/>
<label for="lg-start" class="text-sm text-zinc-300 select-none cursor-pointer">
{startLabel}
</label>
</div>
<!-- Generate button -->
<div class="flex justify-center">
<button
id="lg-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl shadow-lg shadow-accent/25 hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{T.generate}
</button>
</div>
</div>
<!-- Result -->
<div
id="lg-result-wrap"
class="rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8 opacity-0"
style="transition: opacity 0.25s ease;"
aria-live="polite"
>
<div class="flex items-center justify-between gap-3 mb-4">
<span class="text-xs font-semibold uppercase tracking-widest text-zinc-500">
{isRu ? "Результат" : "Result"}
</span>
<button
id="lg-copy"
type="button"
aria-label={T.copy}
class="group shrink-0 p-1.5 rounded-lg text-zinc-600 hover:text-zinc-300 focus:outline-none focus-visible:ring-1 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors cursor-pointer"
>
<svg
id="lg-copy-icon"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
<svg
id="lg-check-icon"
class="hidden text-accent"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</button>
</div>
<div
id="lg-output"
class="text-zinc-100 text-sm leading-relaxed whitespace-pre-wrap font-serif"
/>
</div>
</div>
<script>
const WORDS = [
"lorem", "ipsum", "dolor", "sit", "amet", "consectetur", "adipiscing", "elit",
"sed", "do", "eiusmod", "tempor", "incididunt", "ut", "labore", "et", "dolore",
"magna", "aliqua", "enim", "ad", "minim", "veniam", "quis", "nostrud",
"exercitation", "ullamco", "laboris", "nisi", "aliquip", "ex", "ea", "commodo",
"consequat", "duis", "aute", "irure", "in", "reprehenderit", "voluptate", "velit",
"esse", "cillum", "fugiat", "nulla", "pariatur", "excepteur", "sint", "occaecat",
"cupidatat", "non", "proident", "sunt", "culpa", "qui", "officia", "deserunt",
"mollit", "anim", "id", "est", "laborum",
];
const countInput = document.getElementById("lg-count") as HTMLInputElement;
const lengthSelect = document.getElementById("lg-length") as HTMLSelectElement;
const startCheckbox = document.getElementById("lg-start") as HTMLInputElement;
const btn = document.getElementById("lg-btn") as HTMLButtonElement;
const resultWrap = document.getElementById("lg-result-wrap") as HTMLDivElement;
const output = document.getElementById("lg-output") as HTMLDivElement;
const copyBtn = document.getElementById("lg-copy") as HTMLButtonElement;
const copyIcon = document.getElementById("lg-copy-icon") as SVGElement;
const checkIcon = document.getElementById("lg-check-icon") as SVGElement;
function randInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function shuffle<T>(arr: T[]): T[] {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function capitalize(word: string) {
return word.charAt(0).toUpperCase() + word.slice(1);
}
function generateSentence(): string {
const length = randInt(8, 18);
const words = shuffle(WORDS).slice(0, length);
words[0] = capitalize(words[0]);
const sentence = words.join(" ") + ".";
return sentence;
}
function generateParagraph(sentenceCount: number, isFirst: boolean, startClassic: boolean): string {
const sentences: string[] = [];
if (isFirst && startClassic) {
const classic = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.";
sentences.push(classic);
for (let i = 1; i < sentenceCount; i++) {
sentences.push(generateSentence());
}
} else {
for (let i = 0; i < sentenceCount; i++) {
sentences.push(generateSentence());
}
}
return sentences.join(" ");
}
function getSentenceRange(): [number, number] {
const val = lengthSelect.value;
if (val === "short") return [2, 4];
if (val === "long") return [6, 10];
return [4, 6];
}
function generate() {
let count = parseInt(countInput.value, 10);
if (isNaN(count) || count < 1) count = 1;
if (count > 20) count = 20;
countInput.value = String(count);
const [minSentences, maxSentences] = getSentenceRange();
const startClassic = startCheckbox.checked;
const paragraphs: string[] = [];
for (let i = 0; i < count; i++) {
const sentenceCount = randInt(minSentences, maxSentences);
paragraphs.push(generateParagraph(sentenceCount, i === 0, startClassic));
}
output.textContent = paragraphs.join("\n\n");
resultWrap.style.opacity = "1";
}
const isRu = document.documentElement.lang === "ru";
const copyLabel = copyBtn.getAttribute("aria-label") || (isRu ? "Копировать" : "Copy");
let copyTimer: ReturnType<typeof setTimeout> | null = null;
copyBtn.addEventListener("click", async () => {
const text = output.textContent?.trim();
if (!text) return;
await navigator.clipboard.writeText(text);
copyIcon.classList.add("hidden");
checkIcon.classList.remove("hidden");
copyBtn.setAttribute("aria-label", isRu ? "Скопировано" : "Copied");
if (copyTimer) clearTimeout(copyTimer);
copyTimer = setTimeout(() => {
checkIcon.classList.add("hidden");
copyIcon.classList.remove("hidden");
copyBtn.setAttribute("aria-label", copyLabel);
}, 1500);
});
btn.addEventListener("click", generate);
// Generate on load
generate();
</script>
@@ -0,0 +1,346 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
const i18n = {
harmonyLabel: isRu ? "Гармония" : "Harmony",
countLabel: isRu ? "Количество цветов" : "Number of colors",
copyAll: T.copyAll,
generate: T.generate,
copied: T.copied,
copy: T.copy,
modes: {
random: isRu ? "Случайная" : "Random",
analogous: isRu ? "Аналоговая" : "Analogous",
complementary: isRu ? "Комплементарная" : "Complementary",
triadic: isRu ? "Триадная" : "Triadic",
monochromatic: isRu ? "Монохромная" : "Monochromatic",
splitComplementary: isRu ? "Сплит-комплементарная" : "Split-Complementary",
tetradic: isRu ? "Тетрадная" : "Tetradic",
},
};
---
<div id="palette-generator" class="mt-8">
<!-- Controls -->
<div class="flex flex-col sm:flex-row gap-4 mb-6">
<div class="flex-1">
<label for="pg-harmony" class="block text-sm font-medium text-zinc-400 mb-2">
{i18n.harmonyLabel}
</label>
<select
id="pg-harmony"
class="w-full px-4 py-2.5 bg-zinc-900/80 border border-zinc-800/80 rounded-xl text-zinc-100 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 cursor-pointer"
>
<option value="random">{i18n.modes.random}</option>
<option value="analogous">{i18n.modes.analogous}</option>
<option value="complementary">{i18n.modes.complementary}</option>
<option value="triadic">{i18n.modes.triadic}</option>
<option value="monochromatic">{i18n.modes.monochromatic}</option>
<option value="split-complementary">{i18n.modes.splitComplementary}</option>
<option value="tetradic">{i18n.modes.tetradic}</option>
</select>
</div>
<div class="flex-1">
<label for="pg-count" class="block text-sm font-medium text-zinc-400 mb-2">
{i18n.countLabel}
</label>
<select
id="pg-count"
class="w-full px-4 py-2.5 bg-zinc-900/80 border border-zinc-800/80 rounded-xl text-zinc-100 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 cursor-pointer"
>
<option value="3">3</option>
<option value="4">4</option>
<option value="5" selected>5</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
</select>
</div>
</div>
<!-- Result container -->
<div
id="pg-result"
class="rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8 opacity-0"
style="transition: opacity 0.25s ease;"
aria-live="polite"
>
<div id="pg-swatches" class="flex flex-wrap gap-4 justify-center mb-6">
<!-- Swatches injected by JS -->
</div>
<div class="flex flex-col sm:flex-row gap-3 justify-center">
<button
id="pg-copy-all"
type="button"
class="w-full sm:w-auto px-6 py-2.5 bg-zinc-800 text-zinc-200 font-medium rounded-xl border border-zinc-700 hover:bg-zinc-700 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{i18n.copyAll}
</button>
</div>
</div>
<!-- Error display -->
<div id="pg-error" class="hidden mt-4 text-sm text-red-400 text-center" role="alert"></div>
<!-- Generate button -->
<div class="flex justify-center mt-6">
<button
id="pg-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl shadow-lg shadow-accent/25 hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{i18n.generate}
</button>
</div>
</div>
<script>
import { createErrorDisplay } from "@/lib/client/validation";
const isRu = document.documentElement.lang === "ru";
const harmonySelect = document.getElementById("pg-harmony") as HTMLSelectElement;
const countSelect = document.getElementById("pg-count") as HTMLSelectElement;
const resultEl = document.getElementById("pg-result") as HTMLDivElement;
const swatchesEl = document.getElementById("pg-swatches") as HTMLDivElement;
const copyAllBtn = document.getElementById("pg-copy-all") as HTMLButtonElement;
const generateBtn = document.getElementById("pg-btn") as HTMLButtonElement;
const errorEl = document.getElementById("pg-error") as HTMLDivElement;
const errorDisplay = createErrorDisplay(errorEl);
// Copy icon SVGs
const copyIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
const checkIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="text-accent"><path d="M20 6 9 17l-5-5"/></svg>`;
function randomInt(max: number): number {
return Math.floor(Math.random() * max);
}
function hslToHex(h: number, s: number, l: number): string {
s /= 100;
l /= 100;
const k = (n: number) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {
const color = l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
return Math.round(color * 255)
.toString(16)
.padStart(2, "0");
};
return `#${f(0)}${f(8)}${f(4)}`.toUpperCase();
}
function generateRandomColors(count: number): string[] {
const colors: string[] = [];
for (let i = 0; i < count; i++) {
const h = randomInt(360);
const s = 50 + randomInt(51);
const l = 40 + randomInt(41);
colors.push(hslToHex(h, s, l));
}
return colors;
}
function generateAnalogousColors(count: number): string[] {
const baseH = randomInt(360);
const s = 55 + randomInt(41);
const l = 40 + randomInt(31);
const colors: string[] = [];
const step = 30;
const start = baseH - ((count - 1) * step) / 2;
for (let i = 0; i < count; i++) {
const h = (start + i * step + 360) % 360;
colors.push(hslToHex(h, s, l));
}
return colors;
}
function generateComplementaryColors(count: number): string[] {
const baseH = randomInt(360);
const s = 55 + randomInt(41);
const l = 40 + randomInt(31);
const colors: string[] = [];
const compH = (baseH + 180) % 360;
if (count <= 2) {
colors.push(hslToHex(baseH, s, l));
colors.push(hslToHex(compH, s, l));
} else {
colors.push(hslToHex(baseH, s, l));
colors.push(hslToHex((baseH + 30) % 360, s, l));
for (let i = 2; i < count; i++) {
colors.push(hslToHex(compH, s, l + (i - 2) * 10));
}
}
return colors.slice(0, count);
}
function generateTriadicColors(count: number): string[] {
const baseH = randomInt(360);
const s = 55 + randomInt(41);
const l = 40 + randomInt(31);
const colors: string[] = [];
const triad = [baseH, (baseH + 120) % 360, (baseH + 240) % 360];
for (let i = 0; i < count; i++) {
const h = triad[i % 3];
const lightness = l + Math.floor(i / 3) * 12;
colors.push(hslToHex(h, s, Math.min(lightness, 90)));
}
return colors;
}
function generateMonochromaticColors(count: number): string[] {
const baseH = randomInt(360);
const s = 55 + randomInt(41);
const colors: string[] = [];
const startL = 25;
const endL = 75;
for (let i = 0; i < count; i++) {
const l = startL + ((endL - startL) * i) / Math.max(count - 1, 1);
colors.push(hslToHex(baseH, s, Math.round(l)));
}
return colors;
}
function generateSplitComplementaryColors(count: number): string[] {
const baseH = randomInt(360);
const s = 55 + randomInt(41);
const l = 40 + randomInt(31);
const colors: string[] = [];
const hues = [baseH, (baseH + 150) % 360, (baseH + 210) % 360];
for (let i = 0; i < count; i++) {
const h = hues[i % 3];
const lightness = l + Math.floor(i / 3) * 12;
colors.push(hslToHex(h, s, Math.min(lightness, 90)));
}
return colors;
}
function generateTetradicColors(count: number): string[] {
const baseH = randomInt(360);
const s = 55 + randomInt(41);
const l = 40 + randomInt(31);
const colors: string[] = [];
const rectHues = [baseH, (baseH + 60) % 360, (baseH + 180) % 360, (baseH + 240) % 360];
for (let i = 0; i < count; i++) {
const h = rectHues[i % 4];
const lightness = l + Math.floor(i / 4) * 12;
colors.push(hslToHex(h, s, Math.min(lightness, 90)));
}
return colors;
}
function generatePalette(mode: string, count: number): string[] {
switch (mode) {
case "analogous":
return generateAnalogousColors(count);
case "complementary":
return generateComplementaryColors(count);
case "triadic":
return generateTriadicColors(count);
case "monochromatic":
return generateMonochromaticColors(count);
case "split-complementary":
return generateSplitComplementaryColors(count);
case "tetradic":
return generateTetradicColors(count);
default:
return generateRandomColors(count);
}
}
let currentColors: string[] = [];
const copyTimers = new Map<number, ReturnType<typeof setTimeout>>();
function renderSwatches(colors: string[]) {
currentColors = colors;
swatchesEl.innerHTML = "";
copyTimers.clear();
colors.forEach((hex, index) => {
const swatch = document.createElement("div");
swatch.className = "flex flex-col items-center gap-2";
const colorBox = document.createElement("button");
colorBox.type = "button";
colorBox.className =
"group relative w-20 h-20 sm:w-24 sm:h-24 rounded-2xl border border-zinc-800 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-transform duration-150 hover:scale-105";
colorBox.style.backgroundColor = hex;
colorBox.setAttribute("aria-label", isRu ? `Скопировать ${hex}` : `Copy ${hex}`);
colorBox.dataset.hex = hex;
// Copy overlay icon
const overlay = document.createElement("div");
overlay.className =
"absolute inset-0 flex items-center justify-center rounded-2xl bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity duration-150";
overlay.innerHTML = `<span class="text-white">${copyIconSvg}</span>`;
colorBox.appendChild(overlay);
// Attach click handler directly to the button
colorBox.addEventListener("click", async () => {
await navigator.clipboard.writeText(hex);
overlay.innerHTML = `<span class="text-white">${checkIconSvg}</span>`;
overlay.classList.remove("opacity-0", "group-hover:opacity-100");
overlay.classList.add("opacity-100");
const existing = copyTimers.get(index);
if (existing) clearTimeout(existing);
copyTimers.set(
index,
setTimeout(() => {
overlay.innerHTML = `<span class="text-white">${copyIconSvg}</span>`;
overlay.classList.remove("opacity-100");
overlay.classList.add("opacity-0", "group-hover:opacity-100");
copyTimers.delete(index);
}, 1500),
);
});
// HEX label
const label = document.createElement("span");
label.className = "font-mono text-sm text-zinc-300 tabular-nums select-all cursor-pointer hover:text-zinc-100 transition-colors";
label.textContent = hex;
// Attach click handler directly to the label
label.addEventListener("click", () => {
navigator.clipboard.writeText(hex);
});
swatch.appendChild(colorBox);
swatch.appendChild(label);
swatchesEl.appendChild(swatch);
});
resultEl.style.opacity = "1";
errorDisplay.clear();
}
copyAllBtn.addEventListener("click", async () => {
if (currentColors.length === 0) return;
const text = currentColors.join(", ");
await navigator.clipboard.writeText(text);
const originalText = copyAllBtn.textContent;
copyAllBtn.textContent = isRu ? "Скопировано!" : "Copied!";
setTimeout(() => {
copyAllBtn.textContent = originalText;
}, 1500);
});
function generate() {
const mode = harmonySelect.value;
const count = parseInt(countSelect.value, 10);
try {
const colors = generatePalette(mode, count);
renderSwatches(colors);
} catch {
errorDisplay.show(isRu ? "Не удалось сгенерировать палитру." : "Failed to generate palette.");
}
}
generateBtn.addEventListener("click", generate);
// Generate on load
generate();
</script>
@@ -0,0 +1,365 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
---
<div id="weighted-generator" class="mt-8">
<div class="rounded-xl bg-zinc-900/50 border border-zinc-800/60 p-5">
<div id="weighted-rows" class="flex flex-col gap-3">
<!-- Rows will be generated by JS -->
</div>
<div class="flex gap-3 mt-4">
<button
id="weighted-add-btn"
type="button"
class="inline-flex items-center gap-1.5 px-4 py-2 bg-zinc-800 text-zinc-300 text-sm font-medium rounded-lg hover:bg-zinc-700 transition-colors cursor-pointer"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
{isRu ? "Добавить" : "Add"}
</button>
</div>
<p
id="weighted-error"
role="alert"
aria-live="polite"
class="mt-3 text-sm text-red-500 hidden"
></p>
</div>
<div
id="weighted-result"
class="my-6 hidden flex-col items-center justify-center gap-3 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8"
aria-live="polite"
>
<div class="text-center">
<p class="text-sm text-zinc-500 mb-1">{isRu ? "Выбрано" : "Selected"}</p>
<p id="weighted-result-item" class="text-2xl font-bold text-zinc-100"></p>
<p id="weighted-result-details" class="text-sm text-zinc-400 mt-1"></p>
</div>
<button
id="weighted-copy-btn"
type="button"
aria-label={isRu ? "Скопировать результат" : "Copy result to clipboard"}
class="inline-flex items-center gap-1.5 text-xs font-medium text-zinc-500 hover:text-zinc-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded"
>
<span id="weighted-copy-label">{T.copy}</span>
<span id="weighted-copy-icon" aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>
</svg>
</span>
<span
id="weighted-check-icon"
class="hidden text-accent"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6 9 17l-5-5"></path>
</svg>
</span>
</button>
</div>
<div
id="weighted-stats"
class="my-6 hidden flex-col gap-3 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-6"
aria-live="polite"
>
<p class="text-sm font-medium text-zinc-300">{isRu ? "Статистика" : "Statistics"}</p>
<div id="weighted-stats-body" class="flex flex-col gap-2"></div>
</div>
<div class="flex flex-col sm:flex-row justify-center gap-3 mt-6">
<button
id="weighted-pick-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl shadow-lg shadow-accent/25 hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{T.pick}
</button>
<div class="flex gap-2 w-full sm:w-auto">
<input
id="weighted-n-input"
type="number"
value="100"
min="1"
max="10000"
class="w-24 bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-3 text-zinc-100 text-base focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors"
aria-label={isRu ? "Количество испытаний" : "Number of trials"}
/>
<button
id="weighted-pick-n-btn"
type="button"
class="flex-1 sm:flex-none px-6 py-3 bg-zinc-800 text-zinc-200 font-semibold rounded-xl hover:bg-zinc-700 active:bg-zinc-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{isRu ? "Выбрать N раз" : "Pick N times"}
</button>
</div>
</div>
</div>
<script>
import { CopyFeedback } from "@/lib/client/clipboard";
import { createErrorDisplay } from "@/lib/client/validation";
const isRu = document.documentElement.lang === "ru";
const COPY_LABEL = isRu ? "Копировать" : "Copy";
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
const ERR_ITEM_EMPTY = isRu ? "Имя элемента не может быть пустым." : "Item name cannot be empty.";
const ERR_WEIGHT_POSITIVE = isRu ? "Вес должен быть положительным числом." : "Weight must be a positive number.";
const ERR_ADD_AT_LEAST_ONE = isRu ? "Добавьте хотя бы один элемент с весом." : "Add at least one item with a weight.";
const LABEL_WEIGHT = isRu ? "Вес" : "Weight";
const LABEL_ITEM = isRu ? "Элемент" : "Item";
const LABEL_REMOVE = isRu ? "Удалить" : "Remove";
const rowsContainer = document.getElementById("weighted-rows") as HTMLDivElement;
const addBtn = document.getElementById("weighted-add-btn") as HTMLButtonElement;
const pickBtn = document.getElementById("weighted-pick-btn") as HTMLButtonElement;
const pickNBtn = document.getElementById("weighted-pick-n-btn") as HTMLButtonElement;
const nInput = document.getElementById("weighted-n-input") as HTMLInputElement;
const resultContainer = document.getElementById("weighted-result") as HTMLDivElement;
const resultItem = document.getElementById("weighted-result-item") as HTMLParagraphElement;
const resultDetails = document.getElementById("weighted-result-details") as HTMLParagraphElement;
const copyBtn = document.getElementById("weighted-copy-btn") as HTMLButtonElement;
const copyLabel = document.getElementById("weighted-copy-label") as HTMLSpanElement;
const copyIcon = document.getElementById("weighted-copy-icon") as HTMLSpanElement;
const checkIcon = document.getElementById("weighted-check-icon") as HTMLSpanElement;
const errorEl = document.getElementById("weighted-error") as HTMLParagraphElement;
const statsContainer = document.getElementById("weighted-stats") as HTMLDivElement;
const statsBody = document.getElementById("weighted-stats-body") as HTMLDivElement;
const errors = createErrorDisplay(errorEl);
const clipboard = new CopyFeedback(copyIcon, checkIcon);
let lastResult: { item: string; weight: number; probability: number } | null = null;
let rowIdCounter = 0;
function createRow(name = "", weight = "") {
const id = ++rowIdCounter;
const row = document.createElement("div");
row.className = "flex gap-2 items-start";
row.dataset.rowId = String(id);
row.innerHTML = `
<div class="flex-1">
<input
type="text"
placeholder="${LABEL_ITEM}"
value="${name}"
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors"
aria-label="${LABEL_ITEM}"
/>
</div>
<div class="w-24">
<input
type="number"
placeholder="${LABEL_WEIGHT}"
value="${weight}"
min="0.01"
step="any"
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors"
aria-label="${LABEL_WEIGHT}"
/>
</div>
<button
type="button"
class="p-2 text-zinc-500 hover:text-red-400 transition-colors cursor-pointer"
aria-label="${LABEL_REMOVE}"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
`;
const removeBtn = row.querySelector("button") as HTMLButtonElement;
removeBtn.addEventListener("click", () => {
row.remove();
if (rowsContainer.children.length === 0) {
addRow();
}
});
return row;
}
function addRow(name = "", weight = "") {
rowsContainer.appendChild(createRow(name, weight));
}
function getRows(): { nameInput: HTMLInputElement; weightInput: HTMLInputElement }[] {
return Array.from(rowsContainer.children).map((row) => ({
nameInput: row.querySelector('input[type="text"]') as HTMLInputElement,
weightInput: row.querySelector('input[type="number"]') as HTMLInputElement,
}));
}
function getValidItems(): { name: string; weight: number }[] | null {
const rows = getRows();
const items: { name: string; weight: number }[] = [];
for (const row of rows) {
const name = row.nameInput.value.trim();
const weightStr = row.weightInput.value.trim();
if (!name && !weightStr) continue;
if (!name) {
errors.show(ERR_ITEM_EMPTY);
return null;
}
const weight = parseFloat(weightStr);
if (!weightStr || isNaN(weight) || weight <= 0) {
errors.show(ERR_WEIGHT_POSITIVE);
return null;
}
items.push({ name, weight });
}
if (items.length === 0) {
errors.show(ERR_ADD_AT_LEAST_ONE);
return null;
}
errors.clear();
return items;
}
function weightedPick(items: { name: string; weight: number }[]): { name: string; weight: number } {
const totalWeight = items.reduce((sum, item) => sum + item.weight, 0);
let random = Math.random() * totalWeight;
for (const item of items) {
random -= item.weight;
if (random <= 0) {
return item;
}
}
return items[items.length - 1];
}
function pick() {
const items = getValidItems();
if (!items) return;
const totalWeight = items.reduce((sum, item) => sum + item.weight, 0);
const picked = weightedPick(items);
const probability = (picked.weight / totalWeight) * 100;
lastResult = {
item: picked.name,
weight: picked.weight,
probability,
};
resultItem.textContent = picked.name;
resultDetails.textContent = isRu
? `Вес: ${picked.weight} · Вероятность: ${probability.toFixed(2)}%`
: `Weight: ${picked.weight} · Probability: ${probability.toFixed(2)}%`;
resultContainer.classList.remove("hidden");
resultContainer.classList.add("flex");
copyLabel.textContent = COPY_LABEL;
statsContainer.classList.add("hidden");
statsContainer.classList.remove("flex");
}
function pickNTimes() {
const items = getValidItems();
if (!items) return;
const n = parseInt(nInput.value, 10);
if (!Number.isInteger(n) || n < 1 || n > 10000) {
errors.show(isRu ? "N должно быть от 1 до 10000." : "N must be between 1 and 10000.");
return;
}
errors.clear();
const counts = new Map<string, number>();
items.forEach((item) => counts.set(item.name, 0));
for (let i = 0; i < n; i++) {
const picked = weightedPick(items);
counts.set(picked.name, (counts.get(picked.name) || 0) + 1);
}
const totalWeight = items.reduce((sum, item) => sum + item.weight, 0);
statsBody.innerHTML = "";
items.forEach((item) => {
const count = counts.get(item.name) || 0;
const percentage = (count / n) * 100;
const expectedProbability = (item.weight / totalWeight) * 100;
const barWidth = Math.max(percentage, 0.5);
const row = document.createElement("div");
row.className = "flex items-center gap-3";
row.innerHTML = `
<div class="w-24 text-sm text-zinc-300 truncate" title="${item.name}">${item.name}</div>
<div class="flex-1 h-4 bg-zinc-800 rounded-full overflow-hidden">
<div class="h-full bg-accent rounded-full transition-all duration-300" style="width: ${barWidth}%"></div>
</div>
<div class="w-20 text-right text-sm text-zinc-400">${count} <span class="text-zinc-600">(${percentage.toFixed(1)}%)</span></div>
<div class="w-16 text-right text-xs text-zinc-500">~${expectedProbability.toFixed(1)}%</div>
`;
statsBody.appendChild(row);
});
statsContainer.classList.remove("hidden");
statsContainer.classList.add("flex");
resultContainer.classList.add("hidden");
resultContainer.classList.remove("flex");
}
async function copyResult() {
if (!lastResult) return;
const text = `${lastResult.item} (weight: ${lastResult.weight}, probability: ${lastResult.probability.toFixed(2)}%)`;
await navigator.clipboard.writeText(text);
clipboard.showCopied();
copyLabel.textContent = COPIED_LABEL;
setTimeout(() => {
copyLabel.textContent = COPY_LABEL;
}, 1500);
}
// Initialize with 3 rows
addRow("", "");
addRow("", "");
addRow("", "");
addBtn.addEventListener("click", () => addRow());
pickBtn.addEventListener("click", pick);
pickNBtn.addEventListener("click", pickNTimes);
copyBtn.addEventListener("click", copyResult);
</script>
+49
View File
@@ -0,0 +1,49 @@
{
"slug": "country",
"title": "Country",
"description": "Generate a random country with flag, capital, region, and population.",
"icon": "user",
"status": "live",
"seoTitle": "Random Country Generator — Flag, Capital, Region | Randify",
"seoDescription": "Get a random country with flag emoji, capital, region, and population. Free online country randomizer for games, quizzes, and travel inspiration.",
"ruTitle": "Страна",
"ruDescription": "Генерируйте случайную страну с флагом, столицей, регионом и населением.",
"ruSeoTitle": "Генератор случайных стран — флаг, столица, регион | Randify",
"ruSeoDescription": "Получайте случайную страну с эмодзи флага, столицей, регионом и населением. Бесплатный рандомайзер стран для игр, викторин и вдохновения.",
"pageTitle": "Random Country Generator",
"ruPageTitle": "Генератор случайных стран",
"howTo": [
"Select a region filter or leave it on All to include every country.",
"Click Generate to pick a random country from the filtered list.",
"View the result card with flag, name, capital, region, and population.",
"Click Copy to copy the country details to your clipboard."
],
"whenTo": [
"Choosing a random destination for a travel challenge or game.",
"Creating quiz questions about capitals, flags, or geography.",
"Picking a random country for a school project or presentation.",
"Settling a friendly debate with a random geographic draw."
],
"ruHowTo": [
"Выберите фильтр по региону или оставьте «Все», чтобы включить все страны.",
"Нажмите «Сгенерировать», чтобы выбрать случайную страну из отфильтрованного списка.",
"Посмотрите карточку результата с флагом, названием, столицей, регионом и населением.",
"Нажмите «Копировать», чтобы скопировать данные о стране в буфер обмена."
],
"ruWhenTo": [
"Выбор случайного направления для путешественнического челленджа или игры.",
"Создание вопросов для викторин о столицах, флагах или географии.",
"Выбор случайной страны для школьного проекта или презентации.",
"Решение спора случайным географическим выбором."
],
"faq": [
{ "q": "How many countries are in the generator?", "a": "The generator includes 40 countries from all inhabited continents, covering a wide range of regions, populations, and cultures." },
{ "q": "Can I filter by region?", "a": "Yes. You can filter by Europe, Asia, Americas, Africa, or Oceania, or choose All to include every country." },
{ "q": "Is the population data accurate?", "a": "Population figures are approximate and based on recent estimates. They are intended for casual use rather than precise research." }
],
"ruFaq": [
{ "q": "Сколько стран в генераторе?", "a": "Генератор включает 40 стран со всех обитаемых континентов, охватывая широкий спектр регионов, населения и культур." },
{ "q": "Можно ли фильтровать по региону?", "a": "Да. Вы можете фильтровать по Европе, Азии, Америке, Африке или Океании, либо выбрать «Все», чтобы включить все страны." },
{ "q": "Данные о населении актуальны?", "a": "Численность населения приблизительная и основана на недавних оценках. Она предназначена для повседневного использования, а не для точных исследований." }
]
}
+47
View File
@@ -0,0 +1,47 @@
{
"slug": "lorem",
"title": "Lorem Ipsum",
"description": "Generate random Lorem Ipsum placeholder text in paragraphs.",
"icon": "type",
"status": "live",
"seoTitle": "Lorem Ipsum Generator — Free Placeholder Text | Randify",
"seoDescription": "Generate random Lorem Ipsum placeholder text instantly. Choose paragraph count and length. Free online dummy text generator for designers and developers.",
"ruTitle": "Lorem Ipsum",
"ruDescription": "Генерируйте случайный текст-заполнитель Lorem Ipsum абзацами.",
"ruSeoTitle": "Генератор Lorem Ipsum — бесплатный текст-заполнитель | Randify",
"ruSeoDescription": "Генерируйте текст-заполнитель Lorem Ipsum мгновенно. Выбирайте количество абзацев и длину. Бесплатный генератор текста для дизайнеров и разработчиков.",
"pageTitle": "Lorem Ipsum Generator",
"ruPageTitle": "Генератор Lorem Ipsum",
"howTo": [
"Choose the number of paragraphs (1–20) and the desired length: Short, Medium, or Long.",
"Optionally check \"Start with Lorem ipsum...\" to begin the first paragraph with the classic opening.",
"Click Generate to create the text, then use the Copy button to copy it to your clipboard."
],
"whenTo": [
"Filling mockups and wireframes with realistic placeholder text.",
"Testing typography, line height, and readability in designs.",
"Creating demo content for websites and applications.",
"Needing neutral Latin text that won't distract from layout."
],
"ruHowTo": [
"Выберите количество абзацев (1–20) и желаемую длину: Короткие, Средние или Длинные.",
"При желании отметьте «Начать с Lorem ipsum...», чтобы первый абзац начинался классически.",
"Нажмите «Сгенерировать», а затем «Копировать», чтобы скопировать текст в буфер обмена."
],
"ruWhenTo": [
"Заполнение макетов и каркасов реалистичным текстом-заполнителем.",
"Тестирование типографики, межстрочного интервала и читаемости в дизайнах.",
"Создание демонстрационного контента для сайтов и приложений.",
"Необходимость в нейтральном латинском тексте, который не отвлекает от макета."
],
"faq": [
{ "q": "What is Lorem Ipsum?", "a": "Lorem Ipsum is a classic placeholder text used in publishing and graphic design. It has been the industry's standard dummy text since the 1500s." },
{ "q": "Can I choose how long each paragraph is?", "a": "Yes. You can select Short (2–4 sentences), Medium (4–6 sentences), or Long (6–10 sentences) per paragraph." },
{ "q": "Is the generated text truly random?", "a": "Yes. Each paragraph is built from a shuffled pool of classic Latin words, so every generation produces unique text." }
],
"ruFaq": [
{ "q": "Что такое Lorem Ipsum?", "a": "Lorem Ipsum — это классический текст-заполнитель, используемый в издательском деле и графическом дизайне. Он является стандартным текстом-рыбой с XVI века." },
{ "q": "Можно ли выбрать длину каждого абзаца?", "a": "Да. Вы можете выбрать Короткие (2–4 предложения), Средние (4–6 предложений) или Длинные (6–10 предложений) абзацы." },
{ "q": "Сгенерированный текст действительно случайный?", "a": "Да. Каждый абзац собирается из перемешанного набора классических латинских слов, поэтому каждая генерация уникальна." }
]
}
+47
View File
@@ -0,0 +1,47 @@
{
"slug": "palette",
"title": "Color Palette",
"description": "Generate harmonious color palettes with one click.",
"icon": "palette",
"status": "live",
"seoTitle": "Color Palette Generator — Random & Harmonic Palettes | Randify",
"seoDescription": "Generate beautiful color palettes instantly. Choose from Random, Analogous, Complementary, Triadic, and Monochromatic harmony modes. Free online tool for designers.",
"ruTitle": "Палитра цветов",
"ruDescription": "Генерируйте гармоничные палитры цветов одним нажатием.",
"ruSeoTitle": "Генератор палитры цветов — случайные и гармоничные палитры | Randify",
"ruSeoDescription": "Создавайте красивые палитры цветов мгновенно. Выбирайте режимы гармонии: случайный, аналоговый, комплементарный, триадный, монохромный. Бесплатный онлайн-инструмент для дизайнеров.",
"pageTitle": "Color Palette Generator",
"ruPageTitle": "Генератор палитры цветов",
"howTo": [
"Select the number of colors and a harmony mode.",
"Click Generate to create a new palette.",
"Click any color swatch to copy its HEX code, or use Copy All to copy the entire palette."
],
"whenTo": [
"Designing a brand identity or logo color scheme.",
"Choosing colors for a website or app UI.",
"Creating illustrations, presentations, or social media graphics.",
"Exploring color theory and harmony relationships."
],
"ruHowTo": [
"Выберите количество цветов и режим гармонии.",
"Нажмите «Сгенерировать», чтобы создать новую палитру.",
"Нажмите на любой цвет, чтобы скопировать его HEX-код, или используйте «Копировать всё»."
],
"ruWhenTo": [
"Разработка фирменного стиля или цветовой схемы логотипа.",
"Выбор цветов для дизайна сайта или приложения.",
"Создание иллюстраций, презентаций или графики для соцсетей.",
"Изучение теории цвета и цветовых гармоний."
],
"faq": [
{ "q": "What harmony modes are available?", "a": "Random, Analogous, Complementary, Triadic, and Monochromatic. Each mode creates colors based on a different relationship on the color wheel." },
{ "q": "Can I copy the whole palette at once?", "a": "Yes. Click the Copy All button to copy all HEX codes separated by commas to your clipboard." },
{ "q": "How many colors can I generate?", "a": "You can generate palettes from 3 to 8 colors using the count selector." }
],
"ruFaq": [
{ "q": "Какие режимы гармонии доступны?", "a": "Случайный, аналоговый, комплементарный, триадный и монохромный. Каждый режим создаёт цвета на основе разных отношений на цветовом круге." },
{ "q": "Можно ли скопировать всю палитру сразу?", "a": "Да. Нажмите кнопку «Копировать всё», чтобы скопировать все HEX-коды через запятую в буфер обмена." },
{ "q": "Сколько цветов можно сгенерировать?", "a": "Вы можете создавать палитры от 3 до 8 цветов с помощью селектора количества." }
]
}
+47
View File
@@ -0,0 +1,47 @@
{
"slug": "weighted",
"title": "Weighted Random",
"description": "Pick a random item from a list using custom weights for each option.",
"icon": "list",
"status": "live",
"seoTitle": "Weighted Random Generator — Weighted Choice Picker | Randify",
"seoDescription": "Pick random items with custom weights. Perfect for raffles, weighted decisions, and probability experiments. Free online weighted random selector.",
"ruTitle": "Взвешенный случайный выбор",
"ruDescription": "Выбирайте случайный элемент из списка с учётом заданных весов для каждого варианта.",
"ruSeoTitle": "Генератор взвешенного случайного выбора | Randify",
"ruSeoDescription": "Выбирайте случайные элементы с заданными весами. Идеально для розыгрышей, взвешенных решений и экспериментов с вероятностями.",
"pageTitle": "Weighted Random Generator",
"ruPageTitle": "Генератор взвешенного случайного выбора",
"howTo": [
"Add items to the list and assign a weight to each one. Higher weight means higher chance of being picked.",
"Click Pick to select one random item based on the weights.",
"Use Pick N times to run multiple trials and see the distribution statistics."
],
"whenTo": [
"Running a raffle where some participants have more entries than others.",
"Making weighted decisions when options have different priorities.",
"Simulating probability distributions or testing randomness.",
"Creating balanced teams or assignments with different skill weights."
],
"ruHowTo": [
"Добавьте элементы в список и назначьте каждому вес. Чем выше вес, тем больше шанс быть выбранным.",
"Нажмите «Выбрать», чтобы случайно выбрать один элемент с учётом весов.",
"Используйте «Выбрать N раз» для множественных испытаний и просмотра статистики распределения."
],
"ruWhenTo": [
"Проведение розыгрыша, где у некоторых участников больше шансов, чем у других.",
"Принятие взвешенных решений, когда у вариантов разные приоритеты.",
"Моделирование распределений вероятностей или тестирование случайности.",
"Создание сбалансированных команд или назначений с разными весами навыков."
],
"faq": [
{ "q": "How does weighted random selection work?", "a": "Each item's chance of being picked is proportional to its weight. If item A has weight 2 and item B has weight 8, B is 4 times more likely to be selected than A." },
{ "q": "Can I use decimal weights?", "a": "Yes, you can use any positive number as a weight, including decimals. The generator normalizes all weights automatically." },
{ "q": "Is the result truly random?", "a": "Yes. The generator uses a cryptographically secure random number generator to ensure fair and unpredictable results every time." }
],
"ruFaq": [
{ "q": "Как работает взвешенный случайный выбор?", "a": "Шанс выбора каждого элемента пропорционален его весу. Если у элемента А вес 2, а у элемента Б вес 8, то Б в 4 раза чаще будет выбран, чем А." },
{ "q": "Можно ли использовать дробные веса?", "a": "Да, весом может быть любое положительное число, включая десятичные дроби. Генератор автоматически нормализует все веса." },
{ "q": "Результат действительно случайный?", "a": "Да. Генератор использует криптографически стойкий генератор случайных чисел, чтобы гарантировать честный и непредсказуемый результат каждый раз." }
]
}
+11
View File
@@ -0,0 +1,11 @@
---
import GeneratorLayout from "@/layouts/GeneratorLayout.astro";
import CountryGenerator from "@/components/generators/CountryGenerator.astro";
import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "country")!;
---
<GeneratorLayout generator={generator}>
<CountryGenerator />
</GeneratorLayout>
+11
View File
@@ -0,0 +1,11 @@
---
import GeneratorLayout from "@/layouts/GeneratorLayout.astro";
import LoremGenerator from "@/components/generators/LoremGenerator.astro";
import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "lorem")!;
---
<GeneratorLayout generator={generator}>
<LoremGenerator />
</GeneratorLayout>
+11
View File
@@ -0,0 +1,11 @@
---
import GeneratorLayout from "@/layouts/GeneratorLayout.astro";
import PaletteGenerator from "@/components/generators/PaletteGenerator.astro";
import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "palette")!;
---
<GeneratorLayout generator={generator}>
<PaletteGenerator />
</GeneratorLayout>
+11
View File
@@ -0,0 +1,11 @@
---
import GeneratorLayout from "@/layouts/GeneratorLayout.astro";
import WeightedGenerator from "@/components/generators/WeightedGenerator.astro";
import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "weighted")!;
---
<GeneratorLayout generator={generator}>
<WeightedGenerator />
</GeneratorLayout>
+11
View File
@@ -0,0 +1,11 @@
---
import GeneratorLayout from "@/layouts/GeneratorLayout.astro";
import CountryGenerator from "@/components/generators/CountryGenerator.astro";
import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "country")!;
---
<GeneratorLayout generator={generator}>
<CountryGenerator />
</GeneratorLayout>
+11
View File
@@ -0,0 +1,11 @@
---
import GeneratorLayout from "@/layouts/GeneratorLayout.astro";
import LoremGenerator from "@/components/generators/LoremGenerator.astro";
import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "lorem")!;
---
<GeneratorLayout generator={generator}>
<LoremGenerator />
</GeneratorLayout>
+11
View File
@@ -0,0 +1,11 @@
---
import GeneratorLayout from "@/layouts/GeneratorLayout.astro";
import PaletteGenerator from "@/components/generators/PaletteGenerator.astro";
import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "palette")!;
---
<GeneratorLayout generator={generator}>
<PaletteGenerator />
</GeneratorLayout>
+11
View File
@@ -0,0 +1,11 @@
---
import GeneratorLayout from "@/layouts/GeneratorLayout.astro";
import WeightedGenerator from "@/components/generators/WeightedGenerator.astro";
import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "weighted")!;
---
<GeneratorLayout generator={generator}>
<WeightedGenerator />
</GeneratorLayout>