Andrey Epifancev eef358c56e feat(svod2): полосы загрузки в строках-заглушках (Skeleton, как у MRT при «Обновить»)
Ячейки строк с data-svod2-placeholder рисуют MUI Skeleton wave высотой 20 с детерминированной шириной.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:18:13 +04:00

186 lines
7.4 KiB
JavaScript
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.

// Чистая логика серверной постраничной загрузки свода 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;
}
}