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 = { [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 lineAt?: (i: number) => string onSeek: (top: number) => void }) { const box = useRef(null) const content = useRef(null) const overlay = useRef(null) const gutter = useRef(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() 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 (