Проблема
З 350 сторінками симуляцій старий підхід — фільтрація жорстко заданого масиву JavaScript — ставав некерованим. Користувачі, які шукають «заломлення», мають знаходити закон Снелля, повне внутрішнє відбиття та формування веселки, а не лише сторінки з точним словом «заломлення» в заголовку. Нам був потрібен повнотекстовий пошук з ранжуванням релевантності, що працює повністю в браузері без бекенду.
Структура даних: інвертований індекс
Пошуковий індекс — це звичайний об'єкт JavaScript, побудований під час деплою на основі заголовка, опису, категорії та тегів кожної симуляції. Кожен токен зіставляється зі списком входжень: масивом ідентифікаторів документів з їхніми показниками частоти терміна.
// Спрощена структура індексу const index = { "refraction": [ {
id: "snells-law", tf: 0.82 }, { id: "total-internal-refl", tf:
0.71 }, { id: "rainbow", tf: 0.44 }, ], "lorenz": [ { id:
"lorenz", tf: 1.00 }, { id: "bifurcation", tf: 0.18 }, ], // ...
~4 200 токенів, 28 кБ мінімізовано + gzip → 8.4 кБ };
Оцінка релевантності — це TF-IDF з невеликим підвищенням для заголовка: токени в заголовку зважуються втричі більше, ніж в описі. Багатослівні запити використовують семантику AND для перетину списків входжень, після чого підсумовуються бали.
Автозавершення за префіксом: Trie
Випадаючий список автозавершення — «набери 'заломл' і побач 'заломлення', 'відбиття', 'показник заломлення'» — реалізований на основі trie (префіксного дерева) над тим самим набором токенів. Пошук виконується за O(m), де m — довжина префікса, незалежно від розміру словника.
class Trie { constructor() { this.root = {}; } insert(word) { let
node = this.root; for (const ch of word) { node[ch] ??= {}; node =
node[ch]; } node.$end = true; } suggest(prefix, limit = 5) { let
node = this.root; for (const ch of prefix) { if (!node[ch]) return
[]; node = node[ch]; } // DFS від вузла префікса, зібрати до
`limit` слів return this._collect(node, prefix, [], limit); }
}
Токенізація та нормалізація
Перед індексацією необроблений текст проходить чотири етапи:
- Нижній регістр + нормалізація Unicode (NFC) — «Lorenz» і «lorenz» потрапляють в один і той самий токен.
- Видалення стоп-слів — вилучено 63 англійських стоп-слова для зменшення розміру індексу.
- Легкий стеммер Портера — «refracting», «refracted», «refraction» → «refract». Тримає індекс компактним без втрати повноти пошуку.
- Карта синонімів — вручну складена карта з 40 записів: «fourier» → також індексує «fft», «spectrum»; «lorenz» → «chaos», «attractor».
Продуктивність
Індекс завантажується один раз при першій взаємодії з пошуком через
динамічний import() — нульова вартість на сторінках,
де пошук ніколи не використовується. На холодному кеші він
завантажується приблизно за 40 мс на з'єднанні 3G; наступні запити
— це чисті пошуки в пам'яті.
Стан URL і глибокі посилання
Пошукові запити зберігаються в URL-адресі у вигляді
?q=lorenz+attractor
— і для можливості ділитися, і щоб кнопка «назад» у браузері
працювала очікувано. History API оновлюється через
replaceState при кожному натисканні клавіші (з
затримкою 150 мс) і через pushState лише коли
користувач переходить до результату.
Історія пошуку через localStorage
Останні запити зберігаються в localStorage під ключем
sim_search_history — до 10 записів у вигляді масиву
JSON. Історія показується у вигляді чипів під полем вводу при фокусі.
Жодні персональні дані ніколи не зберігаються — лише сам текст
запиту. Користувачі можуть очистити історію однією кнопкою.
Чому не використати хостований сервіс пошуку? Вартість, приватність і підтримка офлайн-режиму. Algolia/Typesense додають залежність від інфраструктури вартістю 50–200 $/міс і повністю ламають офлайн-режим. Наш індекс на 8.4 кБ завантажується один раз і живе в кеші сервіс-воркера — пошук працює в літаку без Wi-Fi, і немає жодного API-ключа пошуку, який треба ротувати чи який може витекти.