// Чистая логика серверной постраничной загрузки свода 2: окно страниц, срез с заглушками, // LRU-кэш страниц, параметры запроса, варианты фильтров. Без React, JSX и axios. export const PAGE_SIZE = 300; export const WINDOW_RADIUS = 1; export const PAGE_CACHE_LIMIT = 5; export const FILTER_COLUMNS = ['smeta_type', 'smeta_direction', 'org_name']; export const EMPTY_FACET_MAP = Object.freeze(new Map()); export function getPageCount(total) { return total > 0 ? Math.ceil(total / PAGE_SIZE) : 0; } export function getVisiblePage({ scrollTop, rowHeight, total }) { const lastPage = Math.max(getPageCount(total) - 1, 0); const firstRow = Math.floor(scrollTop / rowHeight); const page = Math.floor(firstRow / PAGE_SIZE); if (!Number.isFinite(page)) return 0; return Math.min(Math.max(page, 0), lastPage); } export function getWindowPages(visiblePage, total) { const count = getPageCount(total); const pages = []; for (let p = visiblePage - WINDOW_RADIUS; p <= visiblePage + WINDOW_RADIUS; p++) { if (p >= 0 && p < count) pages.push(p); } return pages; } export function getWindowRange(pages, total) { if (pages.length === 0) return { windowStart: 0, windowEnd: 0 }; const first = pages[0]; const last = pages.at(-1); return { windowStart: first * PAGE_SIZE, windowEnd: Math.min(total, (last + 1) * PAGE_SIZE) }; } export function buildWindowRows(pages, pageCache, total) { const result = []; for (const p of pages) { const start = p * PAGE_SIZE; const expected = Math.min(total, (p + 1) * PAGE_SIZE) - start; const cached = pageCache.get(p) ?? []; for (let i = 0; i < expected; i++) { result.push(i < cached.length ? cached[i] : { __placeholder: true, index: start + i }); } } return result; } export function getVirtualizerPadding({ windowStart, windowEnd, total, rowHeight }) { return { paddingStart: windowStart * rowHeight, paddingEnd: Math.max(total - windowEnd, 0) * rowHeight, }; } export function touchPage(cache, pageIndex, rows, keepPages = []) { const next = new Map(cache); next.delete(pageIndex); next.set(pageIndex, rows); const keep = new Set(keepPages); keep.add(pageIndex); for (const key of [...next.keys()]) { if (next.size <= PAGE_CACHE_LIMIT) break; if (!keep.has(key)) next.delete(key); } return next; } export function getMissingPages(pages, cache, inflight) { return pages.filter((p) => !cache.has(p) && !inflight.has(p)); } export function buildSvod2QueryParams({ orgIds = [], search = '', columnFilters = [] } = {}) { const params = {}; if (orgIds.length > 0) params.org_ids = orgIds.join(','); if (search !== '') params.search = search; for (const { id, value } of columnFilters) { if (FILTER_COLUMNS.includes(id) && value !== '' && value !== null && value !== undefined) { params[id] = String(value); } } return params; } export function buildSvod2PageParams({ orgIds, search, columnFilters, offset, limit = PAGE_SIZE }) { return { view: 'compact', offset, limit, ...buildSvod2QueryParams({ orgIds, search, columnFilters }) }; } export function facetsToMaps(result) { const maps = new Map(); for (const column of FILTER_COLUMNS) { const map = new Map(); for (const { value, count } of result?.[column] ?? []) map.set(value, count); maps.set(column, map); } return maps; } export function shouldShowFound({ hasCurrentData, loading, facets, error = '' }) { return Boolean(hasCurrentData) && !loading && !error && !!facets && facets.search_total !== facets.total; } // Нужно ли уточнение (refine) при смене поиска/колоночных фильтров: на загруженных данных // либо пока для текущего queryKey ещё летит первая загрузка (иначе ответы станут «текущими» по старым условиям). // dataRefineKey — условия, на которых построены загруженные (или летящие) данные вкладки; нет их — нет и refine. export function shouldRefine({ hasCurrentData, loading, pendingQueryKey, queryKey, dataRefineKey, refineKey }) { if (dataRefineKey === undefined || dataRefineKey === null || dataRefineKey === refineKey) return false; if (hasCurrentData) return true; return Boolean(loading) && pendingQueryKey === queryKey; } // Ширина полосы загрузки в ячейке строки-заглушки: детерминированная (без Math.random, чтобы полосы не «прыгали» // при перерисовке окна), от трети до полной ширины колонки, как у штатных полос MRT. export function getSkeletonWidth(columnSize, rowIndex, columnIndex) { const hash = ((rowIndex + 1) * 2654435761 + (columnIndex + 1) * 40503) >>> 0; const min = Math.round(columnSize / 3); return (min + ((hash % 1000) / 1000) * (columnSize - min)) | 0; } // При смене поиска/фильтров на глубине первая запрошенная страница (по прокрутке) может оказаться за концом нового, // короткого списка: сервер ответит только total. Тогда страницу 0 и общее число берём из запасного запроса offset=0, // не дожидаясь (и не ожидая) ответа на страницу за пределами списка. export function shouldFetchFirstPageFallback({ candidate, total }) { return candidate > 0 && candidate >= getPageCount(total); } export function isPlaceholderRow(row) { return Boolean(row?.__placeholder); } export function getSvod2RowId(row) { if (isPlaceholderRow(row)) return `ph-${row.index}`; return `${row.data?.header?.source_form}-${row.data?.header?.source_id}-${row.data?.line_id}`; } export function createSheetPagingState() { return { generation: 0, dataGeneration: 0, pendingQueryKey: '', loadedQueryKey: '', total: 0, facets: null, pages: new Map(), loading: false, error: '', }; } // Инвариант для хука: settled надо слать для КАЖДОГО поколения; settled старого поколения игнорируется, // поэтому loading, выставленный begin(load), снимает только settled текущего поколения. export function svod2PagingReducer(state, action) { if (action.generation !== state.generation && action.type !== 'begin') return state; switch (action.type) { case 'begin': return { ...state, generation: action.generation, pendingQueryKey: action.queryKey, error: '', loading: action.mode === 'load' ? true : state.loading, }; case 'page': { const first = state.dataGeneration !== action.generation; return { ...state, pages: first ? new Map([[action.pageIndex, action.rows]]) : touchPage(state.pages, action.pageIndex, action.rows, action.keepPages), dataGeneration: action.generation, loadedQueryKey: first ? state.pendingQueryKey : state.loadedQueryKey, total: action.total, }; } case 'facets': return { ...state, facets: action.facets }; case 'fail': if (action.mode === 'scroll') return state; return { ...state, error: action.message, pages: new Map(), total: 0 }; case 'settled': return { ...state, loading: false }; default: return state; } }