@enhanced-dom/virtualizer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023-present CD9B4A105290E17E0948E021DF4105107C88693C59C0B891CCC08366C51AEA990902A6A156AC87D88A2FC41422A5E1C3C4071F251F19441C4516000EC25F87DF
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1 @@
1
+ Helpers for item list virtualization in 1d or 2d
package/index.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ export type IVirtualizerEntry = {
2
+ level?: number;
3
+ id: string | number;
4
+ maxSize?: number;
5
+ weight?: number;
6
+ } & ({
7
+ minSize: number;
8
+ fixedSize?: number;
9
+ } | {
10
+ minSize?: number;
11
+ fixedSize: number;
12
+ });
13
+ export interface IVirtualizerConfig {
14
+ viewportSize: number;
15
+ offset?: number;
16
+ overscan?: number;
17
+ }
18
+ export interface IVirtualizerResult extends Pick<IVirtualizerEntry, 'id'> {
19
+ size: number;
20
+ offset?: number;
21
+ relativeOffset?: number;
22
+ minOffset?: number;
23
+ maxOffset?: number;
24
+ level?: number;
25
+ }
26
+ export declare const virtualize: (config: IVirtualizerConfig, entries: IVirtualizerEntry[]) => IVirtualizerResult[];
package/index.js ADDED
@@ -0,0 +1,333 @@
1
+ var TreePartType;
2
+ (function (TreePartType) {
3
+ TreePartType["NODE"] = "node";
4
+ TreePartType["FOREST"] = "forest";
5
+ })(TreePartType || (TreePartType = {}));
6
+ class StickyGroupNode {
7
+ static isInstance(treePart) {
8
+ return (treePart === null || treePart === void 0 ? void 0 : treePart.type) === TreePartType.NODE;
9
+ }
10
+ constructor(id, level) {
11
+ this.parent = null;
12
+ this.children = [];
13
+ this.type = TreePartType.NODE;
14
+ this._size = null;
15
+ this._depth = null;
16
+ this.id = id;
17
+ this.level = level;
18
+ }
19
+ addChild(child) {
20
+ child.parent = this;
21
+ this.children.push(child);
22
+ return child;
23
+ }
24
+ getAncestor(level) {
25
+ var _a;
26
+ if (this.level > level) {
27
+ return this;
28
+ }
29
+ return (_a = this.parent) === null || _a === void 0 ? void 0 : _a.getAncestor(level);
30
+ }
31
+ findNode(id) {
32
+ if (this.id == id) {
33
+ return this;
34
+ }
35
+ return this.children.map((c) => c.findNode(id)).find((n) => !!n);
36
+ }
37
+ findNextSibling() {
38
+ var _a;
39
+ const currentNodeIndex = this.parent.children.indexOf(this);
40
+ return (_a = this.parent.children[currentNodeIndex + 1]) !== null && _a !== void 0 ? _a : this.parent.findNextSibling();
41
+ }
42
+ findPreviousSibling() {
43
+ var _a;
44
+ const currentNodeIndex = this.parent.children.indexOf(this);
45
+ return (_a = this.parent.children[currentNodeIndex - 1]) !== null && _a !== void 0 ? _a : (StickyGroupNode.isInstance(this.parent) ? this.parent : null);
46
+ }
47
+ hasChildren() {
48
+ return !!this.children.length;
49
+ }
50
+ get size() {
51
+ if (this._size == null) {
52
+ this._size = this.hasChildren() ? Math.max(...this.children.map((c) => c.size)) + 1 : 0;
53
+ }
54
+ return this._size;
55
+ }
56
+ get depth() {
57
+ if (this._depth == null) {
58
+ this._depth = this.parent.depth + 1;
59
+ }
60
+ return this._depth;
61
+ }
62
+ }
63
+ class StickyGroupForest {
64
+ constructor() {
65
+ this.children = [];
66
+ this.type = TreePartType.FOREST;
67
+ this._size = null;
68
+ }
69
+ addChild(child) {
70
+ child.parent = this;
71
+ this.children.push(child);
72
+ }
73
+ hasChildren() {
74
+ return !!this.children.length;
75
+ }
76
+ getAncestor() {
77
+ return null;
78
+ }
79
+ size(id) {
80
+ var _a;
81
+ if (id != null) {
82
+ return (_a = this.findNode(id)) === null || _a === void 0 ? void 0 : _a.size;
83
+ }
84
+ if (this._size == null) {
85
+ this._size = this.hasChildren() ? Math.max(...this.children.map((c) => c.size)) + 1 : 0;
86
+ }
87
+ return this._size;
88
+ }
89
+ get depth() {
90
+ return 0;
91
+ }
92
+ findNode(id) {
93
+ return this.children.map((c) => c.findNode(id)).find((n) => !!n);
94
+ }
95
+ findAncestors(id) {
96
+ let node = this.findNode(id);
97
+ if (!node) {
98
+ return [];
99
+ }
100
+ const ancestors = [];
101
+ while (node.parent !== this) {
102
+ node = node.parent;
103
+ ancestors.unshift(node);
104
+ }
105
+ return ancestors;
106
+ }
107
+ findNextSibling() {
108
+ return null;
109
+ }
110
+ findPreviousSibling() {
111
+ return null;
112
+ }
113
+ }
114
+ export const virtualize = (config, entries) => {
115
+ var _a, _b, _c;
116
+ const { offset, overscan, viewportSize } = {
117
+ offset: 0,
118
+ overscan: 5,
119
+ ...config,
120
+ };
121
+ // first, let us figure out if the size is smaller than the viewport
122
+ // while we iterate over the entries, we should also find out some 'statisticts' about weights
123
+ let lowerBoundSize = 0;
124
+ const entriesWithDynamicSpace = [];
125
+ const unboundedEntries = [];
126
+ const entriesOffsets = [];
127
+ const stickyGroups = new StickyGroupForest();
128
+ const entryById = {};
129
+ let currentStickyGroupNode = null;
130
+ let minimumExplicitWeight = Infinity;
131
+ let minimumDerivedWeight = Infinity;
132
+ for (const e of entries) {
133
+ const entryMinSize = (_a = e.fixedSize) !== null && _a !== void 0 ? _a : e.minSize;
134
+ entriesOffsets.push({ id: e.id, offset: lowerBoundSize, size: entryMinSize });
135
+ lowerBoundSize += entryMinSize;
136
+ entryById[e.id] = e;
137
+ if (e.level != null) {
138
+ currentStickyGroupNode =
139
+ ((_b = currentStickyGroupNode === null || currentStickyGroupNode === void 0 ? void 0 : currentStickyGroupNode.level) !== null && _b !== void 0 ? _b : 0) > e.level ? currentStickyGroupNode : currentStickyGroupNode === null || currentStickyGroupNode === void 0 ? void 0 : currentStickyGroupNode.getAncestor(e.level);
140
+ if (!currentStickyGroupNode) {
141
+ currentStickyGroupNode = new StickyGroupNode(e.id, e.level);
142
+ stickyGroups.addChild(currentStickyGroupNode);
143
+ }
144
+ else {
145
+ currentStickyGroupNode = currentStickyGroupNode.addChild(new StickyGroupNode(e.id, e.level));
146
+ }
147
+ }
148
+ if (e.fixedSize == null && (e.weight || e.maxSize == null || e.maxSize > e.minSize)) {
149
+ entriesWithDynamicSpace.push(e);
150
+ if (e.maxSize == null) {
151
+ unboundedEntries.push(e.id);
152
+ }
153
+ if (e.weight && e.weight < minimumExplicitWeight) {
154
+ minimumExplicitWeight = e.weight;
155
+ }
156
+ // TODO: we assume that the input is 'sane', and maxSize > minSize if given
157
+ if (e.maxSize && e.maxSize - e.minSize < minimumDerivedWeight) {
158
+ minimumDerivedWeight = e.maxSize - e.minSize;
159
+ }
160
+ }
161
+ }
162
+ if (!isFinite(minimumExplicitWeight)) {
163
+ minimumExplicitWeight = 1;
164
+ }
165
+ if (!isFinite(minimumDerivedWeight)) {
166
+ minimumDerivedWeight = 1;
167
+ }
168
+ if (lowerBoundSize <= viewportSize) {
169
+ // we will show ALL entries
170
+ const totalSpaceToDistribute = viewportSize - lowerBoundSize;
171
+ if (entriesWithDynamicSpace.some((e) => e.weight)) {
172
+ // we will attempt distribute the rest of the available space among entries that have not maxSize based on their weights
173
+ const totalExplicitWeights = entriesWithDynamicSpace.reduce((acc, e) => {
174
+ var _a;
175
+ return acc + ((_a = e.weight) !== null && _a !== void 0 ? _a : minimumExplicitWeight);
176
+ }, 0);
177
+ const unitOfSpace = totalSpaceToDistribute / totalExplicitWeights;
178
+ let remainingSpaceToDistribute = totalSpaceToDistribute;
179
+ const transformedEntries = entries.map((e) => {
180
+ var _a;
181
+ const transformedEntry = { id: e.id, floating: false, size: e.fixedSize };
182
+ if (transformedEntry.size == null) {
183
+ let computedSize = e.minSize;
184
+ if (remainingSpaceToDistribute > 0) {
185
+ const entryWeight = (_a = e.weight) !== null && _a !== void 0 ? _a : minimumExplicitWeight;
186
+ let entryExtraSpace = Math.min(remainingSpaceToDistribute, Math.max(Math.round(entryWeight * unitOfSpace), 1));
187
+ computedSize = e.minSize + entryExtraSpace;
188
+ if (e.maxSize && computedSize > e.maxSize) {
189
+ computedSize = e.maxSize;
190
+ entryExtraSpace = e.maxSize - e.minSize;
191
+ }
192
+ remainingSpaceToDistribute -= entryExtraSpace;
193
+ }
194
+ transformedEntry.size = computedSize;
195
+ }
196
+ return transformedEntry;
197
+ });
198
+ if (remainingSpaceToDistribute > 0 && unboundedEntries.length) {
199
+ const outstandingUnitOfSpace = remainingSpaceToDistribute / unboundedEntries.length;
200
+ transformedEntries.forEach((e) => {
201
+ if (unboundedEntries.includes(e.id) && remainingSpaceToDistribute > 0) {
202
+ const extraEntrySpace = e.id === unboundedEntries[unboundedEntries.length - 1] ? remainingSpaceToDistribute : Math.round(outstandingUnitOfSpace);
203
+ e.size = e.size + extraEntrySpace;
204
+ remainingSpaceToDistribute -= extraEntrySpace;
205
+ }
206
+ });
207
+ }
208
+ return transformedEntries;
209
+ }
210
+ // we attempt to distribute the remaining space based on the maxSize - minSize
211
+ const totalDerivedWeights = entriesWithDynamicSpace.reduce((acc, e) => {
212
+ return acc + (e.maxSize ? e.maxSize - e.minSize : minimumDerivedWeight);
213
+ }, 0);
214
+ if (minimumDerivedWeight) {
215
+ const unitOfSpace = totalSpaceToDistribute / totalDerivedWeights;
216
+ let remainingSpaceToDistribute = totalSpaceToDistribute;
217
+ const transformedEntries = entries.map((e) => {
218
+ const transformedEntry = { id: e.id, size: e.fixedSize };
219
+ if (transformedEntry.size == null) {
220
+ let computedSize = e.minSize;
221
+ if (remainingSpaceToDistribute > 0) {
222
+ const entryWeight = e.maxSize ? e.maxSize - e.minSize : minimumDerivedWeight;
223
+ let entryExtraSpace = Math.min(remainingSpaceToDistribute, Math.max(Math.round(entryWeight * unitOfSpace), 1));
224
+ computedSize = e.minSize + entryExtraSpace;
225
+ if (e.maxSize && computedSize > e.maxSize) {
226
+ computedSize = e.maxSize;
227
+ entryExtraSpace = e.maxSize - e.minSize;
228
+ }
229
+ remainingSpaceToDistribute -= entryExtraSpace;
230
+ }
231
+ transformedEntry.size = computedSize;
232
+ }
233
+ return transformedEntry;
234
+ });
235
+ if (remainingSpaceToDistribute > 0 && unboundedEntries.length) {
236
+ const outstandingUnitOfSpace = remainingSpaceToDistribute / unboundedEntries.length;
237
+ transformedEntries.forEach((e) => {
238
+ if (unboundedEntries.includes(e.id) && remainingSpaceToDistribute > 0) {
239
+ const extraEntrySpace = e.id === unboundedEntries[unboundedEntries.length - 1] ? remainingSpaceToDistribute : Math.round(outstandingUnitOfSpace);
240
+ e.size = e.size + extraEntrySpace;
241
+ remainingSpaceToDistribute -= extraEntrySpace;
242
+ }
243
+ });
244
+ }
245
+ return transformedEntries;
246
+ }
247
+ // no luck distributing the weights - we will return the entries as they are...
248
+ return entries.map((e) => { var _a; return ({ id: e.id, floating: false, size: (_a = e.fixedSize) !== null && _a !== void 0 ? _a : e.minSize }); });
249
+ }
250
+ // we will show only 'some' entries. Let's figure out which ones are in the window
251
+ const firstOffsetInWindow = entriesOffsets.find((o) => o.offset <= offset && o.offset + o.size >= offset);
252
+ if (!firstOffsetInWindow) {
253
+ return [];
254
+ }
255
+ const firstEntryIdxInWindow = entriesOffsets.indexOf(firstOffsetInWindow);
256
+ const firstEntryIdx = Math.max(0, firstEntryIdxInWindow - overscan);
257
+ const firstOffsetOutOfWindow = entriesOffsets.find((o) => o.offset >= offset + viewportSize);
258
+ const lastEntryIdxInWindow = firstOffsetOutOfWindow ? entriesOffsets.indexOf(firstOffsetOutOfWindow) - 1 : entries.length - 1;
259
+ const lastEntryIdx = Math.min(entries.length - 1, lastEntryIdxInWindow + overscan);
260
+ // take the entries corresponding to the viewport and include the overscan
261
+ const selectedEntries = entries.slice(firstEntryIdx, lastEntryIdx + 1);
262
+ const entryOffsetById = entriesOffsets.reduce((acc, eo) => {
263
+ acc[eo.id] = { start: eo.offset, end: eo.offset + eo.size };
264
+ return acc;
265
+ }, {});
266
+ let transformedEntries = selectedEntries.map((e) => {
267
+ var _a;
268
+ return ({
269
+ id: e.id,
270
+ size: (_a = e.fixedSize) !== null && _a !== void 0 ? _a : e.minSize,
271
+ offset: entryOffsetById[e.id].start,
272
+ level: e.level,
273
+ });
274
+ });
275
+ // adjust selected entries off the window which have a stickyGroup
276
+ if (stickyGroups.hasChildren()) {
277
+ // first, we will adjust the maxOffset for the sticky entries within the view:
278
+ transformedEntries.forEach((e) => {
279
+ if (e.level) {
280
+ const entryGroupNode = stickyGroups.findNode(e.id);
281
+ const nextSiblingGroupNode = entryGroupNode.findNextSibling();
282
+ e.maxOffset = nextSiblingGroupNode ? entryOffsetById[nextSiblingGroupNode.id].start - e.size : null;
283
+ e.minOffset = e.offset;
284
+ }
285
+ });
286
+ const firstStickyEntryOutsideTheWindow = entries
287
+ .slice(0, firstEntryIdx)
288
+ .reverse()
289
+ .find((e) => !!e.level);
290
+ if (firstStickyEntryOutsideTheWindow) {
291
+ const stickyAncestors = stickyGroups.findAncestors(firstStickyEntryOutsideTheWindow.id);
292
+ // then, find and transform preceding sticky entries
293
+ const stickyTransformedEntries = stickyAncestors.map((stickyGroupNode) => {
294
+ var _a;
295
+ const entry = entryById[stickyGroupNode.id];
296
+ const size = (_a = entry.fixedSize) !== null && _a !== void 0 ? _a : entry.minSize;
297
+ return {
298
+ id: entry.id,
299
+ size,
300
+ offset: entryOffsetById[entry.id].start,
301
+ level: entry.level,
302
+ };
303
+ });
304
+ transformedEntries = [
305
+ ...stickyTransformedEntries,
306
+ {
307
+ id: firstStickyEntryOutsideTheWindow.id,
308
+ size: (_c = firstStickyEntryOutsideTheWindow.fixedSize) !== null && _c !== void 0 ? _c : firstStickyEntryOutsideTheWindow.minSize,
309
+ offset: entryOffsetById[firstStickyEntryOutsideTheWindow.id].start,
310
+ level: firstStickyEntryOutsideTheWindow.level,
311
+ },
312
+ ...transformedEntries,
313
+ ];
314
+ }
315
+ // then figure out the level of the sticky entries based on the depth of the tree of group nodes
316
+ const relativeOffsets = {};
317
+ transformedEntries
318
+ .filter((te) => !!te.level)
319
+ .sort((e1, e2) => e2.level - e1.level)
320
+ .forEach((te) => {
321
+ var _a;
322
+ const stickyGroupNode = stickyGroups.findNode(te.id);
323
+ const nextSiblingGroupNode = stickyGroupNode.findNextSibling();
324
+ te.maxOffset = nextSiblingGroupNode ? entryOffsetById[nextSiblingGroupNode.id].start - te.size : lowerBoundSize - te.size;
325
+ te.minOffset = te.offset;
326
+ const parentGroupNode = StickyGroupNode.isInstance(stickyGroupNode.parent) ? stickyGroupNode.parent : null;
327
+ const levelOffset = (_a = relativeOffsets[parentGroupNode === null || parentGroupNode === void 0 ? void 0 : parentGroupNode.id]) !== null && _a !== void 0 ? _a : 0;
328
+ te.relativeOffset = levelOffset;
329
+ relativeOffsets[te.id] = levelOffset + te.size;
330
+ });
331
+ }
332
+ return transformedEntries;
333
+ };
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@enhanced-dom/virtualizer",
3
+ "version": "0.1.0",
4
+ "description": "Helpers for item list virtualization in 1d or 2d",
5
+ "repository": "https://github.com/enhanced-dom/virtualizer.git",
6
+ "main": "index.js",
7
+ "keywords": [],
8
+ "author": "CD9B4A105290E17E0948E021DF4105107C88693C59C0B891CCC08366C51AEA990902A6A156AC87D88A2FC41422A5E1C3C4071F251F19441C4516000EC25F87DF",
9
+ "license": "MIT",
10
+ "dependencies": {},
11
+ "devDependencies": {
12
+ "@enhanced-dom/build": "^0.1.0",
13
+ "@enhanced-dom/jest": "^0.1.0",
14
+ "@enhanced-dom/lint": "^0.1.4",
15
+ "@enhanced-dom/react": "^0.1.0",
16
+ "@enhanced-dom/webpack": "^0.1.0",
17
+ "@types/jest": "^29.5.12",
18
+ "@types/react": "^17.0.75",
19
+ "@types/react-dom": "^17.0.25",
20
+ "typescript": "^5.3.3",
21
+ "lodash.debounce": "^4.0.8",
22
+ "use-lodash-debounce": "^1.3.0"
23
+ },
24
+ "engines": {
25
+ "node": ">=18",
26
+ "npm": ">=10"
27
+ }
28
+ }