Netpulse_SasS/web/src/components/Minimap.tsx
byrsapty 04e1242c52
All checks were successful
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 1m10s
CI / server (push) Successful in 1m41s
CI / agent (push) Successful in 59s
Журнали файлами, тонший штрих мінікарти
ФАЙЛИ В /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, просвіт удвічі товщий за нього й переживає будь-яке
округлення. Малюнок став блідішим — це правильний бік розміну: на
мінікарту дивляться, щоб побачити структуру, а не прочитати текст.
2026-08-27 22:55:29 +03:00

612 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}