UNPKG

@1771technologies/lytenyte-pro

Version:

Blazingly fast headless React data grid with 100s of features.

520 lines (519 loc) 20.9 kB
import { equal } from "@1771technologies/lytenyte-shared"; import { makeAsyncTree } from "./async-tree/make-async-tree.js"; import { RangeTree } from "./range-tree/range-tree.js"; import { getRequestId } from "./utils/get-request-id.js"; import { getNodePath } from "./utils/get-node-path.js"; import { getNodeDepth } from "./utils/get-node-depth.js"; const noopFetcher = async () => []; export class ServerData { #tree; #top = { asOf: 0, rows: [] }; #bottom = { asOf: 0, rows: [] }; #blocksize; #flat; #dataFetcher = noopFetcher; #pivotMode; #expansions; #pivotExpansions; #onResetLoadBegin; #onResetLoadError; #onResetLoadEnd; #onFlatten; #onInvalidate; #rowViewBounds = [0, 0]; #seenRequests = new Set(); #loadingRows = new Set(); #loadingGroup = new Set(); #rowsWithError = new Map(); #rowsWithGroupError = new Map(); #controllers = new Set(); #defaultExpansion; constructor({ blocksize, pivotMode, pivotExpansions, expansions, onResetLoadBegin, onResetLoadEnd, onResetLoadError, onFlatten, onInvalidate, defaultExpansion, }) { this.#tree = makeAsyncTree(); this.#blocksize = blocksize; this.#pivotMode = pivotMode; this.#expansions = expansions; this.#pivotExpansions = pivotExpansions; this.#defaultExpansion = defaultExpansion; this.#onResetLoadBegin = onResetLoadBegin; this.#onResetLoadEnd = onResetLoadEnd; this.#onResetLoadError = onResetLoadError; this.#onFlatten = onFlatten; this.#onInvalidate = onInvalidate; } // Properties set dataFetcher(d) { if (this.#dataFetcher === d) return; this.#dataFetcher = d; this.reset(); } set pivotMode(b) { if (b === this.#pivotMode) return; this.#pivotMode = b; this.reset(); } set expansions(d) { this.#expansions = d; if (this.#pivotMode) return; this.#flatten(); } set pivotExpansions(d) { this.#pivotExpansions = d; if (!this.#pivotMode) return; this.#flatten(); } set defaultExpansion(d) { this.#defaultExpansion = d; } set rowViewBounds(viewBounds) { if (equal(viewBounds, this.#rowViewBounds)) return; this.#rowViewBounds = viewBounds; this.handleViewBoundsChange(); } // Methods reset = async () => { // Abort all the existing requests in flight. this.#controllers.forEach((c) => c.abort()); this.#tree = makeAsyncTree(); this.#flatten(); this.#rowsWithError.clear(); this.#rowsWithGroupError.clear(); this.#loadingRows.clear(); this.#loadingGroup.clear(); try { this.#onResetLoadBegin(); this.#seenRequests.clear(); const req = { rowStartIndex: 0, rowEndIndex: this.#blocksize, id: getRequestId([], 0, this.#blocksize), path: [], start: 0, end: this.#blocksize, }; this.#seenRequests.add(req.id); const res = await this.#dataFetcher([req], this.#expansions, this.#pivotExpansions); this.handleResponses(res); } catch (e) { this.#onResetLoadError(e); } finally { this.#onResetLoadEnd(); } }; requestForGroup(i) { const ranges = this.#flat.rangeTree.findRangesForRowIndex(i); const path = ranges.slice(1).map((c) => (c.parent.kind === "parent" ? c.parent.path : null)); const row = this.#flat.rowIndexToRow.get(i); if (row?.kind !== "branch") return null; path.push(row.key); const r = (ranges.at(-1)?.parent).byPath.get(row.key); const blocksize = this.#blocksize; const start = 0; const end = Math.min(start + blocksize, r.size); const reqSize = end - start; const req = { path, start: start, end: end, id: getRequestId(path, 0, blocksize), rowStartIndex: i + 1, rowEndIndex: i + 1 + reqSize, }; return req; } handleRequests = async (requests, opts = {}) => { const controller = new AbortController(); this.#controllers.add(controller); const skip = opts.skipState ?? false; // We need to request our new data now. There are a few scenarios to be aware. Once we are requesting rows, // we should mark the rows loading. This means we mark the row indices as loading (maybe by range)? // The load may fail, in which case we should mark the request as an error. try { // Mark these rows as loading requests.forEach((req) => { if (!skip) for (let i = req.rowStartIndex; i < req.rowEndIndex; i++) this.#loadingRows.add(i); }); const responses = await this.#dataFetcher(requests, this.#expansions, this.#pivotExpansions); // The request was aborted, so we can ignore it from this point if (controller.signal.aborted) return; this.handleResponses(responses, () => { if (!skip) requests.forEach((req) => { for (let i = req.rowStartIndex; i < req.rowEndIndex; i++) this.#rowsWithError.delete(i); for (let i = req.rowStartIndex; i < req.rowEndIndex; i++) this.#loadingRows.delete(i); }); }); opts?.onSuccess?.(); } catch (e) { if (controller.signal.aborted) return; opts?.onError?.(e); this.#onInvalidate(); if (!skip) requests.forEach((req) => { for (let i = req.rowStartIndex; i < req.rowEndIndex; i++) this.#rowsWithError.set(i, { error: e, request: req }); for (let i = req.rowStartIndex; i < req.rowEndIndex; i++) this.#loadingRows.delete(i); }); } finally { this.#controllers.delete(controller); } }; handleResponses = (data, beforeOnFlat) => { const pinned = data.filter((c) => c.kind === "top" || c.kind === "bottom"); const center = data.filter((c) => c.kind !== "top" && c.kind !== "bottom"); // handle pinned for (let i = 0; i < pinned.length; i++) { const r = pinned[i]; if (r.kind === "top" && r.asOfTime > this.#top.asOf) { this.#top = { asOf: r.asOfTime, rows: r.data.map((c) => ({ id: c.id, data: c.data, kind: "leaf" })), }; } else if (r.kind === "bottom" && r.asOfTime > this.#bottom.asOf) { this.#bottom = { asOf: r.asOfTime, rows: r.data.map((c) => ({ id: c.id, data: c.data, kind: "leaf" })), }; } } center.sort((l, r) => l.path.length - r.path.length); for (let i = 0; i < center.length; i++) { const r = center[i]; this.#tree.set({ path: r.path, items: r.data.map((c, i) => { if (c.kind === "leaf") { return { kind: "leaf", data: { kind: "leaf", data: c.data, id: c.id, }, relIndex: r.start + i, }; } else { return { kind: "parent", data: { kind: "branch", data: c.data, depth: r.path.length, id: c.id, key: c.key, }, path: c.key, relIndex: r.start + i, size: c.childCount, }; } }), size: r.size, asOf: r.asOfTime, }); } // Re-flatten our tree once everything has been re-updated. this.#flatten(beforeOnFlat); }; requestForNextSlice(req) { let current = this.#tree; for (const c of req.path) { if (current.kind === "leaf") return null; const next = current.byPath.get(c); if (!next) return null; current = next; } if (current.kind === "leaf") return null; const maxSize = current.size; if (req.end >= maxSize) return null; const prevSize = req.end - req.start; const start = req.end; const end = Math.min(req.end + this.#blocksize, maxSize); const size = end - start; return { id: getRequestId(req.path, start, start + this.#blocksize), path: req.path, start, end, rowStartIndex: req.rowStartIndex + prevSize, rowEndIndex: req.rowStartIndex + prevSize + size, }; } requestsForView(start, end) { const bounds = this.#rowViewBounds; start = start ?? bounds[0]; end = end ?? bounds[1]; const seen = new Set(); const requests = []; for (let i = start; i < end; i++) { const ranges = this.#flat.rangeTree.findRangesForRowIndex(i); ranges.forEach((c) => { if (c.parent.kind === "root") { const blockIndex = Math.floor(i / this.#blocksize); const start = blockIndex * this.#blocksize; const end = Math.min(start + this.#blocksize, c.parent.size); const path = []; const reqId = getRequestId(path, start, start + this.#blocksize); if (seen.has(reqId)) return; seen.add(reqId); const size = start + this.#blocksize > c.parent.size ? c.parent.size - start : this.#blocksize; requests.push({ id: reqId, path, start, end, rowStartIndex: i, rowEndIndex: i + size }); } else { const blockIndex = Math.floor((i - c.rowStart) / this.#blocksize); const start = blockIndex * this.#blocksize; const end = Math.min(start + this.#blocksize, c.parent.size); const path = getNodePath(c.parent); const reqId = getRequestId(path, start, start + this.#blocksize); if (seen.has(reqId)) return; seen.add(reqId); const size = start + this.#blocksize > c.parent.size ? c.parent.size - start : this.#blocksize; requests.push({ id: reqId, path, start, end, rowStartIndex: i, rowEndIndex: i + size }); } }); } return requests; } async handleViewBoundsChange() { const requests = this.requestsForView(); const newRequests = requests.filter((c) => !this.#seenRequests.has(c.id)); // We don't have any new requests to make in our view, so we can return if (!newRequests.length) return; for (const n of newRequests) this.#seenRequests.add(n.id); await this.handleRequests(newRequests); } retry() { const inViewSet = new Set(this.requestsForView().map((c) => c.id)); const erroredRequests = [...this.#rowsWithError.values()] .map((c) => c.request) .filter(Boolean); const erroredGroups = [...this.#rowsWithGroupError.entries()]; const errors = erroredRequests.filter((x) => inViewSet.has(x.id)); const [start, end] = this.#rowViewBounds; const groupErrors = erroredGroups .filter(([index]) => { return index >= start && index < end; }) .map(([index, c]) => [index, c.request]); const seenRequests = this.#seenRequests; erroredRequests.map((x) => seenRequests.delete(x.id)); erroredGroups.map((x) => seenRequests.delete(x[1].request.id)); this.#rowsWithError.clear(); this.#rowsWithGroupError.clear(); const requests = []; const seen = new Set(); groupErrors.forEach((x) => { if (seen.has(x[1].id)) return; requests.push(x[1]); seen.add(x[1].id); }); errors.forEach((x) => { if (seen.has(x.id)) return; seen.add(x.id); requests.push(x); }); for (const x of groupErrors) { this.#loadingGroup.add(x[0]); } requests.forEach((x) => seenRequests.add(x.id)); const invalidate = this.#onInvalidate; const withGroupError = this.#rowsWithGroupError; const loadingGroup = this.#loadingGroup; this.handleRequests(requests, { onError: (e) => { invalidate(); groupErrors.forEach((c) => { withGroupError.set(c[0], { error: e, request: c[1] }); loadingGroup.delete(c[0]); }); }, onSuccess: () => { groupErrors.forEach((c) => { loadingGroup.delete(c[0]); }); }, }); } updateRow(id, data) { const centerRow = this.#flat.rowIdToTreeNode.get(id); if (centerRow) { centerRow.data = { ...centerRow.data, data }; return; } // Maybe its a pinned row? const topIndex = this.#top.rows.findIndex((c) => c.id === id); if (topIndex != -1) { this.#top.rows[topIndex] = { ...this.#top.rows[topIndex], data }; } const botIndex = this.#bottom.rows.findIndex((c) => c.id === id); if (botIndex != -1) { this.#top.rows[botIndex] = { ...this.#top.rows[botIndex], data }; } } flatten = () => { this.#flatten(); }; #flatten = (beforeOnFlat) => { // The mode we are in determines the expansions we will use for the server data. const mode = this.#pivotMode; const expansions = mode ? this.#pivotExpansions : this.#expansions; const t = this.#tree; // We use these maps to keep track of the current view. These are helpful for // quick lookup. They are also used to implement many of the data source APIs. const rowIdToRow = new Map(); const rowIndexToRow = new Map(); const rowIdToRowIndex = new Map(); const rowIdToTreeNode = new Map(); // When flattening the tree we need to keep track of the ranges. The tree itself // will only have some rows loaded, but the ranges will be fully defined. const ranges = []; const blocksize = this.#blocksize; const seen = this.#seenRequests; // Tracks the error and loading state of the rows. const withGroupError = this.#rowsWithGroupError; const withLoadingGroup = this.#loadingGroup; const handleRequests = this.handleRequests; const defaultExpansion = this.#defaultExpansion; const postFlatRequests = []; function processParent(node, start) { const rows = [...node.byIndex.values()].sort((l, r) => l.relIndex - r.relIndex); let offset = 0; for (let i = 0; i < rows.length; i++) { const row = rows[i]; const rowIndex = row.relIndex + start + offset; rowIndexToRow.set(rowIndex, row.data); rowIdToRowIndex.set(row.data.id, rowIndex); rowIdToRow.set(row.data.id, row.data); rowIdToTreeNode.set(row.data.id, row); // If this rows is a parent row, we need to check if it is expanded. There are a couple of // situations this to consider. // - the row is not expanded, in which case we only add the row itself to the flat view // - the row is expanded but it has no data loaded. We should then request data, but not add the rows // - the row is expanded and there is add. This is the easy case, we simply add the child rows as we flatten if (row.kind === "parent") { const expanded = expansions[row.data.id] ?? (typeof defaultExpansion === "number" ? getNodeDepth(row) <= defaultExpansion : defaultExpansion); // Expanded but no data. Fetch the child data. if (expanded && !row.byIndex.size) { const path = getNodePath(row); const start = 0; const end = Math.min(start + blocksize, row.size); const reqSize = end - start; const req = { path, start: start, end: end, id: getRequestId(path, 0, blocksize), rowStartIndex: rowIndex + 1, rowEndIndex: rowIndex + 1 + reqSize, }; // If we haven't already requested the children data for this node, let's request it. if (!seen.has(req.id)) { postFlatRequests.push([rowIndex, req]); } } else if (expanded) { offset += processParent(row, rowIndex + 1); } } } ranges.push({ rowStart: start, rowEnd: offset + node.size + start, parent: node, }); return offset + node.size; } const topCount = this.#top.rows.length; const bottomCount = this.#bottom.rows.length; for (let i = 0; i < topCount; i++) { const row = this.#top.rows[i]; rowIndexToRow.set(i, row); rowIdToRow.set(row.id, row); rowIdToRowIndex.set(row.id, i); } const size = processParent(t, topCount); for (let i = 0; i < bottomCount; i++) { const row = this.#bottom.rows[i]; const rowIndex = i + size; rowIndexToRow.set(rowIndex, row); rowIdToRow.set(row.id, row); rowIdToRowIndex.set(row.id, rowIndex); } const rangeTree = new RangeTree(ranges); if (postFlatRequests.length > 0) { postFlatRequests.forEach((c) => { withLoadingGroup.add(c[0]); seen.add(c[1].id); }); const invalidate = this.#onInvalidate; const reqs = postFlatRequests.map((c) => c[1]); handleRequests(reqs, { skipState: true, onError: (e) => { invalidate(); postFlatRequests.forEach((c) => { const rowIndex = c[0]; const req = c[1]; withLoadingGroup.delete(rowIndex); withGroupError.set(rowIndex, { error: e, request: req }); }); }, onSuccess: () => { postFlatRequests.forEach((c) => { withLoadingGroup.delete(c[0]); }); }, }); } this.#flat = { tree: t, top: topCount, center: size - topCount, bottom: bottomCount, rangeTree, rowIndexToRow, rowIdToRow, rowIdToRowIndex, rowIdToTreeNode, errored: this.#rowsWithError, erroredGroup: this.#rowsWithGroupError, loading: this.#loadingRows, loadingGroup: this.#loadingGroup, seenRequests: seen, }; beforeOnFlat?.(); this.#onFlatten(this.#flat); }; }