Девлог №98: Створення візуальної системи — SVG-спрайти, OG-зображення та архітектура іконок

Коли у вас 350+ симуляцій, розкиданих по десятку наукових категорій, узгодженість іконок перестає бути косметичною дрібницею і стає проблемою навігації. Ця стаття пояснює, як ми її розв'язали: вручну намальований SVG-спрайт із 91 символу, конвеєр на Puppeteer, що скріншотить кожну симуляцію для генерації Open Graph-зображень, маніфест seeds.json для арту, створеного за допомогою AI, і — що найважливіше — чому ми не просто взяли готовий Lucide.

Більшість веб-проєктів обирають бібліотеку іконок в перший же день і більше про іконки не думають. Ми зробили навпаки: спочатку випустили сайт, дали категоріям викристалізуватися за багато хвиль контенту, і лише потім спроєктували набір іконок. Результат — словник символів, тісно пов'язаний із реальною тематикою: гліф вихору для гідродинаміки не схожий на типову іконку "оновити", і ця специфічність виявляється надзвичайно важливою для розуміння матеріалу учнями.

91
SVG-символ у спрайті
350+
симуляцій з OG-зображеннями
1
HTTP-запит на всі іконки

Система 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" (вручну надане зображення). Конвеєр читає це поле й скеровує кожну симуляцію відповідно, що дає змогу легко перевизначити типову поведінку скриншотингу для окремих записів, не чіпаючи сам скрипт.

Підсумкова думка

Урок, винесений із побудови цієї системи, полягає в тому, що візуальну інфраструктуру варто проєктувати від першопринципів предметної області, а не з припущення, що бібліотека загального призначення буде "достатньо хорошою". Для сайту, чия головна мета — зробити абстрактні наукові поняття відчутними, іконки та прев'ю-зображення — не декорація, а частина пояснення. Учень, який бачить точно намальований гліф силової лінії поруч зі словом "Електромагнетизм", уже почав формувати правильну ментальну модель ще до кліку.

Шаблон маніфесту seeds.json — це те, що ми плануємо розвивати й далі: майбутні хвилі використовуватимуть поле iconSymbol, щоб керувати анімованими варіантами мініатюр, де символ спрайту слугує кліп-маскою для циклічного WebGL-канвасу. Тоді сама сітка категорій стане живим прев'ю кожної симуляції, яку вона містить.

У наступному девлозі ми розглянемо архітектуру пошуку, що індексує всі 350+ симуляцій на стороні клієнта без бекенду — на диво непросту задачу, коли ваш словник містить терміни на кшталт "рівняння Нав'є–Стокса", "масштаб Колмогорова" та "розклад за власними векторами".