Більшість веб-проєктів обирають бібліотеку іконок в перший же день і більше про іконки не думають. Ми зробили навпаки: спочатку випустили сайт, дали категоріям викристалізуватися за багато хвиль контенту, і лише потім спроєктували набір іконок. Результат — словник символів, тісно пов'язаний із реальною тематикою: гліф вихору для гідродинаміки не схожий на типову іконку "оновити", і ця специфічність виявляється надзвичайно важливою для розуміння матеріалу учнями.
Система SVG-спрайтів із 91 символу
Спрайт-лист — це один файл .svg, що містить багато елементів <symbol>, кожен зі своїм унікальним id. Будь-яка сторінка сайту може посилатися на них одним рядком:
<!-- вставляється один раз на сторінку, приховано --> <svg style="display:none" aria-hidden="true"> <use href="/shared/icons.svg#wave"/> </svg> <!-- використання будь-де --> <svg class="icon" aria-hidden="true"> <use href="/shared/icons.svg#wave"/> </svg>
Такий підхід означає, що кожна іконка на кожній сторінці подається з одного кешованого файлу. Оскільки кожен <symbol> успадковує currentColor, тонувати іконки можна однією CSS-властивістю — без потреби підтримувати окремі кольорові варіанти.
Вирішити, які саме 91 символ включити, вдалося лише зіставивши кожну симуляцію з головним візуальним поняттям. Ця вправа виявила три родини іконок:
- Фізичні примітиви — хвиля, силова лінія, частинка, вектор, орбіта, маятник, пружина, зіткнення.
- Предметні маркери — спіраль ДНК, клітинна мембрана, вузол схеми, геологічний шар, призма, лінза, нейронна мережа.
- Елементи керування — відтворити, пауза, скинути, прискорити, завантажити, повний екран, налаштування, поділитися.
Кожен символ намальований на полотні 24×24 з товщиною лінії 1.5 px — досить тонкою, щоб читатись у малому розмірі, і досить виразною, щоб впізнаватись з першого погляду. Контури вручну обведені в Inkscape, а потім оброблені Node-скриптом, який прибирає атрибути fill, stroke та style, щоб CSS, який їх споживає, мав повний контроль.
// strip-attrs.mjs — постобробка експортованих SVG-символів import { readFileSync, writeFileSync } from 'node:fs'; const src = readFileSync('icons-raw.svg', 'utf8'); const clean = src .replace(/ fill="[^"]*"/g, '') .replace(/ stroke="[^"]*"/g, '') .replace(/ style="[^"]*"/g, ''); writeFileSync('public/shared/icons.svg', clean);
Одне архітектурне рішення ми обговорювали довго: чи мають символи нести типові fill="none" stroke="currentColor"? Зрештою ми винесли ці значення в CSS-клас .icon, а не прописали їх прямо в SVG — так розмітка спрайту лишається без стилів і, відповідно, composable в будь-якому контексті.
Чому ми відмовились від Lucide (і від усіх інших бібліотек)
Lucide — чудовий набір іконок для продуктових інтерфейсів: форм входу, дашбордів, навігаційних меню. У ньому близько 1500 символів, і його підтримує окрема команда. Ми свідомо не стали його використовувати з трьох конкретних причин.
1. Смислова невідповідність
"Хвиля" в Lucide — це декоративний синусоїдний завиток. "Хвиля" в нашій категорії фізики має передавати довжину хвилі λ, амплітуду A і саме відчуття поширення форми — в ідеалі викликаючи асоціацію з дисперсійним співвідношенням ω² = gk для поверхневих хвиль. Загальні іконки просто не несуть такої щільності інформації; учень, що переглядає сітку категорій, читає наш власний гліф як "фізика хвиль", а іконку Lucide — просто як "прикрасу".
2. Вага пакету проти реальності
Навіть після tree-shaking Lucide вимагає імпорту окремого компонента на кожну іконку, додаючи ~1–2 КБ на символ після gzip. Весь наш спрайт із 91 символу важить 18 КБ у стисненому вигляді й кешується назавжди. Точка, у якій бібліотека стала б легшою, лежить десь понад ~200 використаних іконок по всьому сайту — це набагато більше, ніж нам потрібно.
3. Обмеження SVG-заливки
Кілька символів Lucide мають жорстко прописані атрибути fill на внутрішніх фігурах заради стилістичного нашарування. Коли намагаєшся перефарбувати їх через color, ці заливки не реагують на currentColor, як очікувалось. Аудит і патчинг стороньої бібліотеки додає тягар підтримки, що зростає з кожним оновленням апстріму. Наш власноруч написаний спрайт не має прихованих атрибутів такого штибу — grep -r 'fill=' public/shared/icons.svg повертає нуль збігів.
Конвеєр gen-previews.mjs на Puppeteer
Зображення Open Graph — це перше, що бачить користувач, коли посилання на симуляцію ділять у соцмережах чи месенджерах. Для 350+ симуляцій нам був потрібен відтворюваний, автоматизований спосіб їх генерувати — а не папка вручну експортованих PNG.
Наше рішення — gen-previews.mjs, Node-скрипт, що за допомогою Puppeteer у headless-режимі завантажує кожну сторінку симуляції, чекає, доки WebGL-канвас відмалює перший кадр, і робить скріншот 1200×630:
// gen-previews.mjs (спрощений фрагмент) import puppeteer from 'puppeteer'; import { readFileSync } from 'node:fs'; const sims = JSON.parse(readFileSync('seeds.json', 'utf8')); const browser = await puppeteer.launch({ args: ['--no-sandbox'] }); for (const sim of sims) { const page = await browser.newPage(); await page.setViewport({ width: 1200, height: 630 }); await page.goto(`http://localhost:4321/sims/${sim.slug}/`); // чекаємо, доки канвас матиме ненульові пікселі await page.waitForFunction(() => { const c = document.querySelector('canvas'); if (!c) return false; const ctx = c.getContext('2d'); const d = ctx.getImageData(0, 0, 4, 4).data; return d.some(v => v > 0); }, { timeout: 8000 }); await page.screenshot({ path: `public/preview/${sim.slug}.jpg`, type: 'jpeg', quality: 82 }); await page.close(); } await browser.close();
Перевірка готовності канвасу — вибірка блоку пікселів 4×4 у верхньому лівому куті — і є головною евристикою. Порожній канвас має всі RGBA-значення нульовими; відмальований WebGL-кадр — ні. Це дозволяє уникнути довільних затримок setTimeout, які були б або занадто короткими (порожній скриншот), або занадто довгими (конвеєр підвисає).
Підводний камінь: WebGL-контексти, створені в headless-Puppeteer, за замовчуванням використовують програмний растеризатор, якщо не передати --use-gl=swiftshader в аргументи запуску браузера. Без цього симуляції, які викликають getContext('webgl2'), тихо відкочуються до порожнього канвасу, і перевірка пікселів ніколи не спрацьовує.
Конвеєр виконується приблизно 12 хвилин для 350 симуляцій на CI-раннері з 4 vCPU. Ми кешуємо каталог public/preview/ між запусками за допомогою Actions cache action, ключем слугує хеш seeds.json — тож перескриншотяться лише нові чи змінені симуляції.
seeds.json — структуровані метадані для генерації зображень через AI
Не всі симуляції дають візуально виразний скриншот першого кадру. Деякі починаються зі статичного початкового стану: порожній осцилятор, що чекає на збурення від користувача, або клітинний автомат на нульовій сітці. Для таких випадків ми використовуємо seeds.json, щоб задати замість цього AI-згенероване зображення для OG.
Кожен запис у seeds.json описує симуляцію з достатнім контекстом, щоб модель генерації зображень могла створити репрезентативний візуал:
{
"slug": "reaction-diffusion",
"title": "Reaction-Diffusion Patterns",
"category": "chemistry",
"ogStrategy": "ai-generated",
"prompt": "Turing pattern, Belousov–Zhabotinsky spirals, teal and dark background, macro photography style, scientific, no text",
"negativePrompt": "cartoon, watermark, logo, UI chrome",
"aspectRatio": "1200x630",
"iconSymbol": "chemistry-spiral"
}
Поле iconSymbol прив'язує кожен запис назад до SVG-спрайту, тому один і той самий маніфест метаданих керує і стратегією OG-зображення, і піктограмою картки категорії, що показується на сайті. Наявність єдиного джерела правди для обох цих речей запобігає ситуації, коли симуляція показує на сторінці іконку гідродинаміки, а при шарингу в соцмережах — зображення кристалографії.
Поле ogStrategy може приймати одне з трьох значень: "puppeteer" (живий скриншот), "ai-generated" (за промптом) або "static" (вручну надане зображення). Конвеєр читає це поле й скеровує кожну симуляцію відповідно, що дає змогу легко перевизначити типову поведінку скриншотингу для окремих записів, не чіпаючи сам скрипт.
Спробуйте самі
Система іконок і конвеєр OG були створені, щоб обслуговувати саме такі симуляції — відкрийте їх і подивіться, що захоплює скриншотер:
Підсумкова думка
Урок, винесений із побудови цієї системи, полягає в тому, що візуальну інфраструктуру варто проєктувати від першопринципів предметної області, а не з припущення, що бібліотека загального призначення буде "достатньо хорошою". Для сайту, чия головна мета — зробити абстрактні наукові поняття відчутними, іконки та прев'ю-зображення — не декорація, а частина пояснення. Учень, який бачить точно намальований гліф силової лінії поруч зі словом "Електромагнетизм", уже почав формувати правильну ментальну модель ще до кліку.
Шаблон маніфесту seeds.json — це те, що ми плануємо розвивати й далі: майбутні хвилі використовуватимуть поле iconSymbol, щоб керувати анімованими варіантами мініатюр, де символ спрайту слугує кліп-маскою для циклічного WebGL-канвасу. Тоді сама сітка категорій стане живим прев'ю кожної симуляції, яку вона містить.
У наступному девлозі ми розглянемо архітектуру пошуку, що індексує всі 350+ симуляцій на стороні клієнта без бекенду — на диво непросту задачу, коли ваш словник містить терміни на кшталт "рівняння Нав'є–Стокса", "масштаб Колмогорова" та "розклад за власними векторами".