ФАЙЛИ В /var/log/netpulse. rsyslog читає journald і розкладає по файлах
на службу. Драйвер docker лишається journald, а не syslog: syslog
віддав би рядки назовні й нічого не лишив докеру, тож `docker logs`
замовк би назавжди — і мовчання виглядало б як «служба нічого не пише».
Тепер працюють усі три шляхи: docker logs, journalctl, файли.
Дорогою три власні помилки, кожна виглядала як «rsyslog не працює»:
1. Умова матчила $!CONTAINER_NAME — метадані журналу. journalctl їх
бачить, а до правил rsyslog вони доходять не завжди. Каталог
створювався й лишався порожнім.
2. Тег «netpulse/{{.Name}}» здавався охайнішим, але rsyslog обриває
programname на скісній: для ВСІХ контейнерів вона ставала просто
«netpulse». Тег тепер — саме ім'я контейнера, воно й так має
префікс проєкту.
3. Шаблон `%msg:::sp-if-no-1st-sp,drop-last-lf%` давав ПОРОЖНІЙ
текст: файли були, рядки були, слів не було. Журнал виглядав
робочим і не містив нічого — найгірший різновид поломки.
Конфігурації в deploy/, щоб їхали клієнтам, а не лишались разовим
налаштуванням одного сервера.
МІНІКАРТА. Штрих рядка був 2 px при 3 px на рядок — просвіт в один
піксель. На дробовому масштабі екрана (1.25, 1.5 — тобто на більшості
ноутбуків) він губився при округленні, і рядки злипались у суцільну
пляму: мінікарта показувала не форму конфігу, а сірий прямокутник.
Тепер штрих 1 px, просвіт удвічі товщий за нього й переживає будь-яке
округлення. Малюнок став блідішим — це правильний бік розміну: на
мінікарту дивляться, щоб побачити структуру, а не прочитати текст.
612 lines
31 KiB
TypeScript
612 lines
31 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
|
||
/**
|
||
* Мінікарта великого тексту — вузька смужка збоку з усім файлом у
|
||
* мініатюрі й рамкою поточного вікна.
|
||
*
|
||
* Розрахунок на реальний масштаб: ZTE C320 віддає 32 854 рядки. Звідси
|
||
* всі рішення нижче.
|
||
*
|
||
* ЧОМУ CANVAS, А НЕ DOM. Тридцять три тисячі рядків — це тридцять три
|
||
* тисячі елементів, тобто рівно те, від чого вже тікає віртуалізація в
|
||
* ConfigViewer. Малювати мініатюру елементами означало б повернути в
|
||
* сторінку весь той DOM, який вона обходить.
|
||
*
|
||
* ЧОМУ СМУЖКА ПРОКРУЧУЄТЬСЯ, А НЕ ВМІЩАЄ ВЕСЬ ФАЙЛ. Перша версія
|
||
* стискала весь документ у висоту смужки. На 32 000 рядків це п'ятдесят
|
||
* рядків на піксель — і мініатюра перетворювалась на суцільну заливку,
|
||
* бо в кожному пікселі знаходився хоч один довгий рядок і хоч один без
|
||
* відступу. Ні кольори, ні контраст цього не лікують: інформації там
|
||
* уже немає, лишається силует.
|
||
*
|
||
* Тому як у редакторах коду: рядок отримує щонайменше два пікселі, а
|
||
* коли файл у смужку не влазить — смужка сама їде за прокруткою.
|
||
* Видно рядки, а не хвилю. Короткий файл, як і раніше, показується
|
||
* цілком: там прокручувати нічого.
|
||
*
|
||
* Позначки (збіги пошуку, місця змін) при цьому лишаються по ВСЬОМУ
|
||
* документу — вузькою колонкою ліворуч. Інакше пошук втратив би головне:
|
||
* скільки збігів і де вони, а не лише ті, що поруч.
|
||
*
|
||
* ЧОМУ КІЛЬКА ШАРІВ. Позначки (збіги пошуку, місця змін) живуть на
|
||
* власному полотні й залежать лише від самих позначок. Рамка видимого
|
||
* вікна — звичайний div, який рухається зміною style.
|
||
*
|
||
* У режимі «влазить цілком» прокрутка не коштує жодного піксельного
|
||
* малювання взагалі. У режимі прокрутки вміст перемальовується разом із
|
||
* вікном — але це рівно стільки прямокутників, скільки рядків у вікні
|
||
* (близько трьохсот), а не тридцять тисяч. Ціна свідома: без неї
|
||
* мініатюра лишалась би суцільною плямою, тобто не працювала б зовсім.
|
||
*/
|
||
|
||
/** Позначка на смужці: рядок (або діапазон рядків) певним кольором. */
|
||
export interface MinimapMark {
|
||
/** Індекс рядка у віртуальному документі. */
|
||
row: number
|
||
/** Скільки рядків займає позначка; 1 — один рядок. */
|
||
span?: number
|
||
color: string
|
||
}
|
||
|
||
/**
|
||
* Ширина смужки.
|
||
*
|
||
* Було 68 — і цього замало: на такій ширині слова зливаються в суцільну
|
||
* рису незалежно від того, як їх малювати. Разом із більшою шириною
|
||
* росте й MAX_COLS: на смужку лягає більше символів рядка, кожен —
|
||
* вужчий. Тобто «текст дрібніший, а видно його більше».
|
||
*
|
||
* Число ОДНЕ на полотно й на контейнер. Було два: полотно малювало по
|
||
* 180, а клас лишався `w-[68px]` від попередньої ширини — і браузер
|
||
* стискав намальоване в два з половиною рази. На екрані це виглядало не
|
||
* як помилка, а як «дрібний шрифт»: смужка чесно показувала весь текст,
|
||
* тільки сплюснутий по горизонталі. Тому ширина йде в style, а не
|
||
* класом: клас із довільним числом Tailwind збирає статично, і наступна
|
||
* зміна MAP_W знову розійшлася б із ним мовчки.
|
||
*/
|
||
export const MAP_W = 180
|
||
|
||
/**
|
||
* Скільки пікселів заввишки віддається рядку — і скільки з них займає
|
||
* сам рядок.
|
||
*
|
||
* Три й два: рядок малюється двома пікселями, третій лишається
|
||
* порожнім. Проміжок обов'язковий — без нього рядки зливаються в
|
||
* пляму; але й одного пікселя на рядок замало, він виходить блідим і
|
||
* рваним. Два пікселі дають щільний штрих, який видно чітко.
|
||
*
|
||
* Ціна — менше рядків у вікні (близько двохсот замість трьохсот).
|
||
* Розмін свідомий: мініатюра, на якій нічого не розібрати, не стає
|
||
* кориснішою від того, що вміщає більше.
|
||
*/
|
||
const ROW_PX = 3
|
||
|
||
/**
|
||
* Товщина штриха рядка. Один піксель, а не два — і це не косметика.
|
||
*
|
||
* При двох просвіт між сусідніми рядками лишався один піксель. На екрані
|
||
* зі звичайним масштабом це ще працювало, а на дробовому (1.25, 1.5 —
|
||
* тобто на більшості ноутбуків) єдиний піксель просвіту губився при
|
||
* округленні, і рядки злипались у суцільну пляму. Мінікарта показувала
|
||
* не форму конфігу, а сірий прямокутник — тобто рівно те, заради чого
|
||
* її не варто малювати взагалі.
|
||
*
|
||
* Один піксель штриха при трьох на рядок дає просвіт удвічі товщий за
|
||
* сам штрих. Він переживає будь-яке округлення: навіть якщо штрих і
|
||
* просвіт з'їдуть на півпікселя, між рядками лишиться видима межа.
|
||
*
|
||
* Мінікарта від цього стає блідішою — і це правильний бік розміну.
|
||
* Блідий, але читабельний малюнок кращий за яскраву пляму: на неї
|
||
* дивляться, щоб побачити СТРУКТУРУ (де розділи, де порожні рядки, де
|
||
* знайдене), а не щоб прочитати текст.
|
||
*/
|
||
const BAR_PX = 1
|
||
|
||
/** Ширина колонки позначок по всьому документу (режим прокрутки). */
|
||
const GUTTER_W = 6
|
||
|
||
/** Скільки символів рядка вміщається в ширину смужки. Далі — обрізаємо. */
|
||
const MAX_COLS = 280
|
||
|
||
/**
|
||
* Скільки слів у рядку малюємо окремо.
|
||
*
|
||
* Саме проміжки між словами роблять смужку схожою на текст: суцільна
|
||
* риса від відступу до кінця рядка читається як штрих, а не як рядок.
|
||
*
|
||
* Стеля потрібна, бо в конфігах трапляються рядки на сотні коротких
|
||
* лексем (списки VLAN, префікс-листи): малювати їх усі — це тисячі
|
||
* прямокутників заради ділянки в кілька пікселів завширшки.
|
||
*/
|
||
const MAX_SEGS = 40
|
||
|
||
const SP = ' '
|
||
const TAB = ' '
|
||
|
||
/**
|
||
* Форма тексту: довжина й відступ кожного рядка.
|
||
*
|
||
* Рахується один раз на текст. Int32Array, а не масив об'єктів: на
|
||
* 33 000 рядків різниця між двома типізованими масивами й тридцятьма
|
||
* трьома тисячами об'єктів — це мегабайти купи й робота для збирача
|
||
* сміття під час прокрутки.
|
||
*/
|
||
export interface Shape {
|
||
len: Int32Array
|
||
indent: Int32Array
|
||
/** Вид рядка: KIND_*. Визначає колір смужки. */
|
||
kind: Uint8Array
|
||
}
|
||
|
||
export function buildShape(lines: string[]): Shape {
|
||
const n = lines.length
|
||
const len = new Int32Array(n)
|
||
const indent = new Int32Array(n)
|
||
const kind = new Uint8Array(n)
|
||
for (let i = 0; i < n; i++) {
|
||
const s = lines[i]
|
||
len[i] = s.length
|
||
let k = 0
|
||
while (k < s.length && (s.charCodeAt(k) === 32 || s.charCodeAt(k) === 9)) k++
|
||
// Табуляція шириною чотири: у конфігах відступ несе структуру, і
|
||
// рахувати табуляцію за один символ означало б показати блоки
|
||
// Juniper пласкими.
|
||
let w = 0
|
||
for (let j = 0; j < k; j++) w += s.charCodeAt(j) === 9 ? 4 : 1
|
||
indent[i] = s.length === k ? 0 : w
|
||
|
||
// Вид рядка визначає колір на мініатюрі. Без нього карта — рівна
|
||
// сіра штриховка, на якій не видно, де закінчився один розділ і
|
||
// почався інший; а саме це людина й шукає, коли тягне повзунок.
|
||
if (s.length === k) {
|
||
kind[i] = KIND_BLANK
|
||
} else {
|
||
const c = s.charCodeAt(k)
|
||
// #, !, ; — коментар у всіх поширених CLI; «//» — у JunOS-подібних.
|
||
const comment =
|
||
c === 35 || c === 33 || c === 59 || (c === 47 && s.charCodeAt(k + 1) === 47)
|
||
if (comment) kind[i] = KIND_COMMENT
|
||
else if (w === 0) kind[i] = KIND_TOP
|
||
else kind[i] = KIND_BODY
|
||
}
|
||
}
|
||
return { len, indent, kind }
|
||
}
|
||
|
||
// Види рядків. Числами, а не рядками: масив на тридцять тисяч
|
||
// елементів має бути Uint8Array, інакше сама мініатюра коштує більше
|
||
// за текст, який вона показує.
|
||
export const KIND_BLANK = 0
|
||
export const KIND_TOP = 1
|
||
export const KIND_BODY = 2
|
||
export const KIND_COMMENT = 3
|
||
|
||
// Кольори підібрані так, щоб карта читалась як текст, а не як
|
||
// штрихування: рядок верхнього рівня — найяскравіший, бо саме він
|
||
// розділяє розділи; коментар — приглушений, бо його читають востаннє.
|
||
//
|
||
// Насиченість тут навмисно висока. Мініатюра шириною в кількасот
|
||
// пікселів і так втрачає все, крім силуету; бліда заливка перетворює
|
||
// цей силует на рівний сірий шум, у якому не видно жодної межі.
|
||
/** Колір знайденого рядка. Той самий бурштин, що й у підсвітці тексту. */
|
||
const HIT_COLOR = '#fbbf24'
|
||
|
||
export const KIND_COLOR: Record<number, string> = {
|
||
[KIND_TOP]: '#e2e8f0',
|
||
[KIND_BODY]: '#94a3b8',
|
||
[KIND_COMMENT]: '#64748b',
|
||
}
|
||
|
||
/**
|
||
* Колір штриха рядка. null — рядок не малюється зовсім.
|
||
*
|
||
* Окремою функцією, бо це єдине місце мінікарти, де є що перевірити без
|
||
* полотна, — і саме тут ховалась помилка: рядок із самих пробілів
|
||
* отримує KIND_BLANK, але довжину має ненульову, тож доходив до
|
||
* малювання. Кольору для KIND_BLANK у таблиці немає, спрацьовував
|
||
* запасний колір тіла — і порожній рядок виглядав звичайним. А саме
|
||
* порожні рядки розділяють розділи конфігу: намальовані як текст, вони
|
||
* стирають межі, по яких людина на мінікарту й дивиться.
|
||
*
|
||
* Знайдений рядок перефарбовується цілком, а не заклеюється смугою
|
||
* поверх: смуга на всю ширину ховала форму рядка — те саме, що людина
|
||
* шукає очима.
|
||
*/
|
||
export function rowColor(shape: Shape, i: number, hit: boolean): string | null {
|
||
if (shape.len[i] <= 0 || shape.kind[i] === KIND_BLANK) return null
|
||
if (hit) return HIT_COLOR
|
||
return KIND_COLOR[shape.kind[i]] ?? KIND_COLOR[KIND_BODY]
|
||
}
|
||
|
||
export function Minimap({
|
||
count,
|
||
offsets,
|
||
total,
|
||
viewTop,
|
||
viewH,
|
||
shape,
|
||
marks,
|
||
hits,
|
||
lineAt,
|
||
onSeek,
|
||
}: {
|
||
/** Скільки рядків у віртуальному документі. */
|
||
count: number
|
||
/** Верх кожного рядка в пікселях документа; довжина count+1. */
|
||
offsets: Int32Array
|
||
/** Повна висота документа. */
|
||
total: number
|
||
/** Поточна позиція прокрутки. */
|
||
viewTop: number
|
||
/** Висота видимого вікна. */
|
||
viewH: number
|
||
shape: Shape
|
||
marks?: MinimapMark[]
|
||
/** Куди прокрутити (координата документа, верх вікна). */
|
||
/**
|
||
* Текст рядка. Без нього мініатюра малює суцільні риси; з ним —
|
||
* окремі слова, і саме це відрізняє «схоже на текст» від
|
||
* «схоже на штрихування».
|
||
*
|
||
* Функція, а не масив: читаються лише ті кількасот рядків, що зараз
|
||
* у вікні смужки, і тримати заради них другу копію документа не треба.
|
||
*/
|
||
/**
|
||
* Рядки зі збігами пошуку.
|
||
*
|
||
* Окремо від marks, бо поводяться інакше: збіг — це один рядок, який
|
||
* треба ПЕРЕФАРБУВАТИ, а не ділянка, яку треба позначити смугою.
|
||
* Множина, а не масив позначок: сюди потрапляють усі збіги до
|
||
* єдиного, і на двадцяти тисячах різниця відчутна.
|
||
*/
|
||
hits?: Set<number>
|
||
lineAt?: (i: number) => string
|
||
onSeek: (top: number) => void
|
||
}) {
|
||
const box = useRef<HTMLDivElement | null>(null)
|
||
const content = useRef<HTMLCanvasElement | null>(null)
|
||
const overlay = useRef<HTMLCanvasElement | null>(null)
|
||
const gutter = useRef<HTMLCanvasElement | null>(null)
|
||
const [h, setH] = useState(0)
|
||
|
||
useEffect(() => {
|
||
const el = box.current
|
||
if (!el) return
|
||
const ro = new ResizeObserver(() => setH(el.clientHeight))
|
||
ro.observe(el)
|
||
setH(el.clientHeight)
|
||
return () => ro.disconnect()
|
||
}, [])
|
||
|
||
// Чи влазить документ цілком.
|
||
//
|
||
// Влазить — показуємо весь, як карту; не влазить — смужка їде за
|
||
// прокруткою й показує вікно рядків. Межа не в кількості рядків, а в
|
||
// тому, чи лишається рядку два пікселі: саме з них починається
|
||
// схожість на текст.
|
||
const winRows = Math.max(1, Math.floor(h / ROW_PX))
|
||
const fits = count > 0 && count <= winRows
|
||
|
||
// Скільки пікселів смужки припадає на піксель документа. Потрібне
|
||
// лише в режимі «влазить цілком».
|
||
const k = total > 0 ? h / total : 0
|
||
|
||
// Перший рядок вікна смужки.
|
||
//
|
||
// Частка прокрутки документа стає часткою прокрутки смужки: коли
|
||
// людина внизу файлу — смужка теж унизу. Так само поводяться
|
||
// мінікарти в редакторах, і будь-яка інша прив'язка збиває відчуття
|
||
// «де я».
|
||
const startRow = useMemo(() => {
|
||
if (fits || count <= winRows) return 0
|
||
const room = Math.max(1, total - viewH)
|
||
const frac = Math.max(0, Math.min(1, viewTop / room))
|
||
return Math.round(frac * (count - winRows))
|
||
}, [fits, count, winRows, total, viewH, viewTop])
|
||
|
||
/** Рядок → y на смужці. -1, якщо рядок поза видимим вікном смужки. */
|
||
const yOfRow = useCallback(
|
||
(i: number) => {
|
||
if (fits) return Math.min(h - 1, (offsets[i] * k) | 0)
|
||
const r = i - startRow
|
||
return r < 0 || r >= winRows ? -1 : r * ROW_PX
|
||
},
|
||
[fits, offsets, k, h, startRow, winRows],
|
||
)
|
||
|
||
/** y на смужці → рядок. Зворотне до yOfRow. */
|
||
const rowAtY = useCallback(
|
||
(y: number) => {
|
||
if (!fits) return Math.max(0, Math.min(count - 1, startRow + Math.floor(y / ROW_PX)))
|
||
// Двійковий пошук по offsets: рядки можуть мати різну висоту
|
||
// (у порівнянні є порожні заповнювачі), тож ділення не годиться.
|
||
const target = k > 0 ? y / k : 0
|
||
let lo = 0
|
||
let hi = count - 1
|
||
while (lo < hi) {
|
||
const mid = (lo + hi + 1) >> 1
|
||
if (offsets[mid] <= target) lo = mid
|
||
else hi = mid - 1
|
||
}
|
||
return lo
|
||
},
|
||
[fits, count, startRow, offsets, k],
|
||
)
|
||
|
||
// Найдовший рядок задає горизонтальний масштаб. Обрізаємо стелею:
|
||
// один рядок ACL на тисячу символів інакше стиснув би весь інший
|
||
// файл у ліву третину смужки й позбавив би мініатюру сенсу.
|
||
const cols = useMemo(() => {
|
||
let m = 1
|
||
for (let i = 0; i < shape.len.length; i++) if (shape.len[i] > m) m = shape.len[i]
|
||
return Math.min(m, MAX_COLS)
|
||
}, [shape])
|
||
|
||
// --- шар 1: сам файл ---
|
||
useEffect(() => {
|
||
const cv = content.current
|
||
if (!cv || h <= 0 || count === 0) return
|
||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||
cv.width = Math.round(MAP_W * dpr)
|
||
cv.height = Math.round(h * dpr)
|
||
const ctx = cv.getContext('2d')
|
||
if (!ctx) return
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||
ctx.clearRect(0, 0, MAP_W, h)
|
||
|
||
const left = fits ? 3 : GUTTER_W + 3
|
||
const scale = (MAP_W - left - 3) / cols
|
||
|
||
// У режимі «влазить цілком» рядку дістається стільки пікселів,
|
||
// скільки дає масштаб; беремо не більше, ніж у режимі прокрутки,
|
||
// щоб короткий файл не перетворювався на набір товстих брусків.
|
||
// Середня висота рядка документа в пікселях смужки. Точну взяти
|
||
// нема звідки — у порівнянні є порожні заповнювачі, — але для
|
||
// товщини штриха середньої досить.
|
||
const perRow = count > 0 ? (h * 1.0) / count : BAR_PX
|
||
const barH = fits ? Math.max(1, Math.min(BAR_PX, Math.floor(perRow) - 1 || 1)) : BAR_PX
|
||
|
||
const bar = (a: number, b: number, y: number) => {
|
||
const x0 = left + Math.min(a, cols) * scale
|
||
const x1 = left + Math.min(b, cols) * scale
|
||
// Мінімум один піксель ширини: коротке `!` теж має бути видно.
|
||
ctx.fillRect(x0, y, Math.max(1, x1 - x0), barH)
|
||
}
|
||
|
||
const draw = (i: number) => {
|
||
const color = rowColor(shape, i, hits?.has(i) === true)
|
||
if (color === null) return
|
||
const L = shape.len[i]
|
||
const y = yOfRow(i)
|
||
if (y < 0) return
|
||
ctx.fillStyle = color
|
||
|
||
// Без доступу до самого тексту лишається суцільна риса від
|
||
// відступу до кінця рядка. Працює, але читається гірше.
|
||
const text = lineAt?.(i)
|
||
if (!text) {
|
||
bar(shape.indent[i], L, y)
|
||
return
|
||
}
|
||
|
||
let j = 0
|
||
let segs = 0
|
||
while (j < text.length && segs < MAX_SEGS) {
|
||
while (j < text.length && (text[j] === SP || text[j] === TAB)) j++
|
||
const a = j
|
||
while (j < text.length && text[j] !== SP && text[j] !== TAB) j++
|
||
if (j > a) {
|
||
bar(a, j, y)
|
||
segs++
|
||
}
|
||
}
|
||
}
|
||
|
||
if (fits) {
|
||
for (let i = 0; i < count; i++) draw(i)
|
||
} else {
|
||
const last = Math.min(count, startRow + winRows)
|
||
for (let i = startRow; i < last; i++) draw(i)
|
||
}
|
||
}, [count, shape, cols, h, fits, startRow, winRows, yOfRow, lineAt, hits])
|
||
|
||
// --- шар 1б: позначки по ВСЬОМУ документу (лише в режимі прокрутки) ---
|
||
//
|
||
// Вікно смужки показує кількасот рядків із тридцяти тисяч. Без цієї
|
||
// колонки людина, яка шукає слово, бачила б лише збіги поруч і не
|
||
// знала б ні скільки їх усього, ні куди прокручувати.
|
||
useEffect(() => {
|
||
const cv = gutter.current
|
||
if (!cv || h <= 0) return
|
||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||
cv.width = Math.round(GUTTER_W * dpr)
|
||
cv.height = Math.round(h * dpr)
|
||
const ctx = cv.getContext('2d')
|
||
if (!ctx) return
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||
ctx.clearRect(0, 0, GUTTER_W, h)
|
||
if (fits || count === 0) return
|
||
|
||
// Щільність, а не «є/немає».
|
||
//
|
||
// На 32 000 рядків у 620 пікселів в один піксель падає п'ятдесят
|
||
// рядків. Бінарна заливка перетворює колонку на суцільну смугу вже
|
||
// від сотні розкиданих збігів — і людина бачить «збіги всюди»
|
||
// замість «ось де їх багато».
|
||
const kk = h / count
|
||
const dens = new Float32Array(h)
|
||
let peak = 0
|
||
const bump = (row: number) => {
|
||
if (row < 0 || row >= count) return
|
||
const y = Math.min(h - 1, (row * kk) | 0)
|
||
dens[y] += 1
|
||
if (dens[y] > peak) peak = dens[y]
|
||
}
|
||
let color = 'rgba(251,191,36,0.9)'
|
||
if (hits) for (const row of hits) bump(row)
|
||
else if (marks) {
|
||
for (const m of marks) bump(m.row)
|
||
if (marks.length > 0) color = marks[0].color
|
||
}
|
||
if (peak === 0) return
|
||
|
||
for (let y = 0; y < h; y++) {
|
||
const d = dens[y]
|
||
if (d === 0) continue
|
||
// Корінь, а не пряма пропорція: поодинокий збіг має лишатись
|
||
// помітним поруч зі згустком на півсотні.
|
||
ctx.globalAlpha = 0.35 + 0.65 * Math.sqrt(d / peak)
|
||
ctx.fillStyle = color
|
||
ctx.fillRect(0, y, GUTTER_W, 1)
|
||
}
|
||
ctx.globalAlpha = 1
|
||
}, [marks, hits, count, h, fits])
|
||
|
||
// --- шар 2: позначки ---
|
||
useEffect(() => {
|
||
const cv = overlay.current
|
||
if (!cv || h <= 0) return
|
||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||
cv.width = Math.round(MAP_W * dpr)
|
||
cv.height = Math.round(h * dpr)
|
||
const ctx = cv.getContext('2d')
|
||
if (!ctx) return
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||
ctx.clearRect(0, 0, MAP_W, h)
|
||
if (!marks || marks.length === 0 || count === 0) return
|
||
|
||
// Двадцять тисяч збігів пошуку в шістсот пікселів — це двадцять
|
||
// тисяч викликів fillRect поверх шести сотень видимих ліній.
|
||
// Позначка на піксель уже намальована — другу малювати нема сенсу.
|
||
// Збіги пошуку сюди не потрапляють: їх малює шар вмісту, кольором
|
||
// самого рядка. Тут лишаються смуги порівняння — вони позначають
|
||
// ділянку, а не окремий рядок, і закривати текст їм не страшно.
|
||
const left = fits ? 0 : GUTTER_W
|
||
const painted = new Map<number, string>()
|
||
for (const m of marks) {
|
||
if (m.row < 0 || m.row >= count) continue
|
||
const y0 = yOfRow(m.row)
|
||
if (y0 < 0) continue
|
||
const y1 = fits
|
||
? Math.min(h, (offsets[Math.min(count, m.row + (m.span ?? 1))] * k) | 0)
|
||
: y0 + Math.max(1, (m.span ?? 1) * ROW_PX)
|
||
for (let y = y0; y < Math.max(y0 + 1, Math.min(h, y1)); y++) {
|
||
if (!painted.has(y)) painted.set(y, m.color)
|
||
}
|
||
}
|
||
for (const [y, color] of painted) {
|
||
ctx.fillStyle = color
|
||
ctx.fillRect(left, y, MAP_W - left, 1)
|
||
}
|
||
}, [marks, offsets, count, h, k, fits, yOfRow])
|
||
|
||
// --- перетягування ---
|
||
//
|
||
// Клік і тяга — одна дія, а не дві: людина тисне на потрібне місце й
|
||
// нерідко одразу ж підправляє позицію, не відпускаючи кнопки.
|
||
// setPointerCapture потрібен, щоб курсор, який виїхав за смужку, не
|
||
// обривав тягу — інакше довелося б вести мишу рівно по ширині смужки.
|
||
const seekTo = useCallback(
|
||
(clientY: number) => {
|
||
const el = box.current
|
||
if (!el || k <= 0) return
|
||
const rect = el.getBoundingClientRect()
|
||
const y = Math.max(0, Math.min(rect.height, clientY - rect.top))
|
||
// Клікнуте місце стає ЦЕНТРОМ вікна, а не його верхом: людина
|
||
// цілиться в те, що хоче побачити, а не в межу екрана.
|
||
onSeek(Math.max(0, offsets[rowAtY(y)] - viewH / 2))
|
||
},
|
||
[rowAtY, offsets, viewH, onSeek],
|
||
)
|
||
|
||
const [dragging, setDragging] = useState(false)
|
||
|
||
// Рамка видимого вікна. Звичайний div: прокрутка рухає його зміною
|
||
// style, і жодного піксельного малювання при цьому не відбувається.
|
||
// Рамка видимого вікна.
|
||
//
|
||
// У режимі прокрутки вона рахується в РЯДКАХ, а не в пікселях
|
||
// документа: смужка тепер показує рядки, і рамка, побудована на
|
||
// піксельній частці, з'їжджала б із них на пів екрана.
|
||
const firstRow = fits ? 0 : viewFirstRow(offsets, count, viewTop)
|
||
const rowsInView = fits ? 0 : Math.max(1, Math.round(viewH / Math.max(1, total / count)))
|
||
const frameTop = fits
|
||
? Math.max(0, viewTop * k)
|
||
: Math.max(0, (firstRow - startRow) * ROW_PX)
|
||
const frameH = fits
|
||
? Math.max(4, Math.min(h - frameTop, viewH * k))
|
||
: Math.max(4, Math.min(h - frameTop, rowsInView * ROW_PX))
|
||
|
||
if (count === 0) return null
|
||
|
||
return (
|
||
<div
|
||
ref={box}
|
||
// Смужка ховається на вузькому екрані: на телефоні вона забирає
|
||
// півширини під те, у що там однаково не влучити пальцем.
|
||
className={`relative hidden shrink-0 cursor-pointer select-none
|
||
border-l border-slate-800 bg-slate-950/80 md:block
|
||
${dragging ? 'bg-slate-900/60' : ''}`}
|
||
// Ширина звідти ж, звідки її бере полотно, — інакше намальоване
|
||
// знову стиснеться під чужий клас.
|
||
style={{ width: MAP_W }}
|
||
title="Мінікарта: клік або перетягування прокручує конфіг"
|
||
onPointerDown={(e) => {
|
||
e.currentTarget.setPointerCapture(e.pointerId)
|
||
setDragging(true)
|
||
seekTo(e.clientY)
|
||
}}
|
||
onPointerMove={(e) => {
|
||
if (dragging) seekTo(e.clientY)
|
||
}}
|
||
onPointerUp={(e) => {
|
||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||
setDragging(false)
|
||
}}
|
||
onPointerCancel={() => setDragging(false)}
|
||
>
|
||
<canvas ref={content} className="absolute inset-0 h-full w-full" />
|
||
<canvas ref={overlay} className="absolute inset-0 h-full w-full" />
|
||
{!fits && (
|
||
<canvas
|
||
ref={gutter}
|
||
className="pointer-events-none absolute inset-y-0 left-0"
|
||
style={{ width: GUTTER_W }}
|
||
/>
|
||
)}
|
||
{/*
|
||
Рамка видимого вікна — світла рамка БЕЗ заливки поверх тексту.
|
||
Заливка, хай і на п'ятнадцять відсотків, лягала саме на ту
|
||
частину карти, яку людина зараз читає, і робила її блідішою за
|
||
решту — рівно навпаки до задуму.
|
||
|
||
Замість неї притемнюємо те, що ПОЗА вікном: так видиме місце
|
||
лишається найяскравішим на смужці.
|
||
*/}
|
||
<div
|
||
className="pointer-events-none absolute inset-x-0 top-0 bg-slate-950/55"
|
||
style={{ height: frameTop }}
|
||
/>
|
||
<div
|
||
className="pointer-events-none absolute inset-x-0 bottom-0 bg-slate-950/55"
|
||
style={{ top: frameTop + frameH }}
|
||
/>
|
||
<div
|
||
className="pointer-events-none absolute left-0 w-full rounded-sm border border-sky-400/70 shadow-[0_0_0_1px_rgba(2,6,23,0.6)]"
|
||
style={{ top: frameTop, height: frameH }}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** Перший рядок, видимий у самому конфізі. Потрібен для рамки. */
|
||
function viewFirstRow(offsets: Int32Array | number[], count: number, viewTop: number): number {
|
||
let lo = 0
|
||
let hi = count - 1
|
||
while (lo < hi) {
|
||
const mid = (lo + hi + 1) >> 1
|
||
if (offsets[mid] <= viewTop) lo = mid
|
||
else hi = mid - 1
|
||
}
|
||
return lo
|
||
}
|