Ячейки строк с data-svod2-placeholder рисуют MUI Skeleton wave высотой 20 с детерминированной шириной. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
186 lines
7.4 KiB
JavaScript
186 lines
7.4 KiB
JavaScript
// Чистая логика серверной постраничной загрузки свода 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;
|
||
}
|
||
}
|