@jay-framework/list-compare 0.5.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/dist/index.d.ts +53 -0
- package/dist/index.js +233 -0
- package/package.json +31 -0
- package/readme.md +103 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
declare const EoF: unique symbol;
|
|
2
|
+
declare const BoF: unique symbol;
|
|
3
|
+
interface LinkedListItem<T, S> {
|
|
4
|
+
id: string;
|
|
5
|
+
value: T;
|
|
6
|
+
attach?: S;
|
|
7
|
+
next: LinkedListItem<T, S> | typeof EoF;
|
|
8
|
+
prev: LinkedListItem<T, S> | typeof BoF;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* hack for writing tests without typing, to reduce the need to cast items between LinkedListItem and BoF or EoF
|
|
12
|
+
*/
|
|
13
|
+
interface UntypedRandomAccessLinkedList {
|
|
14
|
+
first(): any;
|
|
15
|
+
last(): any;
|
|
16
|
+
get(id: any): any;
|
|
17
|
+
has(id: any): any;
|
|
18
|
+
add(obj: any, beforeItem: any, attach?: any): any;
|
|
19
|
+
remove(item: any): any;
|
|
20
|
+
move(itemToMove: any, toBefore: any): any;
|
|
21
|
+
forEach(handler: (item: any, attach: any, index: any) => void): any;
|
|
22
|
+
}
|
|
23
|
+
declare class RandomAccessLinkedList<T, S> implements UntypedRandomAccessLinkedList {
|
|
24
|
+
private readonly _matchBy;
|
|
25
|
+
private readonly _map;
|
|
26
|
+
private _last;
|
|
27
|
+
private _first;
|
|
28
|
+
constructor(arr: Array<T>, matchBy: string);
|
|
29
|
+
first(): LinkedListItem<T, S> | typeof EoF;
|
|
30
|
+
last(): LinkedListItem<T, S> | typeof BoF;
|
|
31
|
+
has(id: string): boolean;
|
|
32
|
+
get(id: string): LinkedListItem<T, S>;
|
|
33
|
+
move(itemToMove: LinkedListItem<T, S>, toBefore: LinkedListItem<T, S>): void;
|
|
34
|
+
remove(item: LinkedListItem<T, S>): void;
|
|
35
|
+
forEach(handler: (value: T, attach: S, index: number) => void): void;
|
|
36
|
+
add(obj: T, beforeItem?: LinkedListItem<T, S> | typeof EoF, attach?: S): void;
|
|
37
|
+
distance(from: LinkedListItem<T, S> | typeof EoF, to: LinkedListItem<T, S>): number;
|
|
38
|
+
get matchBy(): string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
declare const ITEM_ADDED = "IA";
|
|
42
|
+
declare const ITEM_REMOVED = "IR";
|
|
43
|
+
declare const ITEM_MOVED = "IM";
|
|
44
|
+
interface MatchResult<T, S> {
|
|
45
|
+
action: typeof ITEM_ADDED | typeof ITEM_MOVED | typeof ITEM_REMOVED;
|
|
46
|
+
item?: T;
|
|
47
|
+
pos: number;
|
|
48
|
+
fromPos?: number;
|
|
49
|
+
elem?: S;
|
|
50
|
+
}
|
|
51
|
+
declare function listCompare<T, S>(oldArray: RandomAccessLinkedList<T, S>, newArray: RandomAccessLinkedList<T, S>, mkElement: (T: any, id: string) => S): Array<MatchResult<T, S>>;
|
|
52
|
+
|
|
53
|
+
export { BoF, EoF, ITEM_ADDED, ITEM_MOVED, ITEM_REMOVED, type LinkedListItem, type MatchResult, RandomAccessLinkedList, type UntypedRandomAccessLinkedList, listCompare };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
3
|
+
var __publicField = (obj, key, value) => {
|
|
4
|
+
__defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
5
|
+
return value;
|
|
6
|
+
};
|
|
7
|
+
const EoF = Symbol("EoF");
|
|
8
|
+
const BoF = Symbol("BoF");
|
|
9
|
+
class RandomAccessLinkedList {
|
|
10
|
+
constructor(arr, matchBy) {
|
|
11
|
+
__publicField(this, "_matchBy");
|
|
12
|
+
__publicField(this, "_map");
|
|
13
|
+
__publicField(this, "_last");
|
|
14
|
+
__publicField(this, "_first");
|
|
15
|
+
this._matchBy = matchBy;
|
|
16
|
+
this._map = {};
|
|
17
|
+
this._last = BoF;
|
|
18
|
+
this._first = arr.reduceRight(
|
|
19
|
+
(nextItem, obj) => {
|
|
20
|
+
let item = {
|
|
21
|
+
id: obj[matchBy],
|
|
22
|
+
value: obj,
|
|
23
|
+
next: nextItem,
|
|
24
|
+
prev: BoF
|
|
25
|
+
};
|
|
26
|
+
if (nextItem !== EoF)
|
|
27
|
+
nextItem.prev = item;
|
|
28
|
+
if (this._last === BoF)
|
|
29
|
+
this._last = item;
|
|
30
|
+
this._map[item.id] = item;
|
|
31
|
+
return item;
|
|
32
|
+
},
|
|
33
|
+
EoF
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
first() {
|
|
37
|
+
return this._first;
|
|
38
|
+
}
|
|
39
|
+
last() {
|
|
40
|
+
return this._last;
|
|
41
|
+
}
|
|
42
|
+
has(id) {
|
|
43
|
+
return !!this._map[id];
|
|
44
|
+
}
|
|
45
|
+
get(id) {
|
|
46
|
+
return this._map[id];
|
|
47
|
+
}
|
|
48
|
+
move(itemToMove, toBefore) {
|
|
49
|
+
this.remove(itemToMove);
|
|
50
|
+
this.add(itemToMove.value, toBefore, itemToMove.attach);
|
|
51
|
+
}
|
|
52
|
+
remove(item) {
|
|
53
|
+
delete this._map[this._matchBy];
|
|
54
|
+
if (item.prev === BoF) {
|
|
55
|
+
if (item.next !== EoF) {
|
|
56
|
+
item.next.prev = BoF;
|
|
57
|
+
this._first = item.next;
|
|
58
|
+
} else {
|
|
59
|
+
this._first = EoF;
|
|
60
|
+
this._last = BoF;
|
|
61
|
+
}
|
|
62
|
+
} else if (item.next === EoF) {
|
|
63
|
+
this._last = item.prev;
|
|
64
|
+
item.prev.next = EoF;
|
|
65
|
+
} else {
|
|
66
|
+
item.prev.next = item.next;
|
|
67
|
+
item.next.prev = item.prev;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
forEach(handler) {
|
|
71
|
+
let listItem = this.first();
|
|
72
|
+
let index = 0;
|
|
73
|
+
while (listItem !== EoF) {
|
|
74
|
+
handler(listItem.value, listItem.attach, index++);
|
|
75
|
+
listItem = listItem.next;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
add(obj, beforeItem = EoF, attach = void 0) {
|
|
79
|
+
let newItem;
|
|
80
|
+
if (this._first === EoF && this._last === BoF) {
|
|
81
|
+
newItem = { id: obj[this._matchBy], value: obj, prev: BoF, next: EoF, attach };
|
|
82
|
+
this._first = newItem;
|
|
83
|
+
this._last = newItem;
|
|
84
|
+
} else if (beforeItem === EoF) {
|
|
85
|
+
newItem = { id: obj[this._matchBy], value: obj, prev: this._last, next: EoF, attach };
|
|
86
|
+
this._last.next = newItem;
|
|
87
|
+
this._last = newItem;
|
|
88
|
+
} else if (beforeItem === this._first) {
|
|
89
|
+
newItem = { id: obj[this._matchBy], value: obj, prev: BoF, next: beforeItem, attach };
|
|
90
|
+
this._first = newItem;
|
|
91
|
+
beforeItem.prev = newItem;
|
|
92
|
+
} else {
|
|
93
|
+
let itemBefore = beforeItem.prev;
|
|
94
|
+
newItem = {
|
|
95
|
+
id: obj[this._matchBy],
|
|
96
|
+
value: obj,
|
|
97
|
+
prev: itemBefore,
|
|
98
|
+
next: beforeItem,
|
|
99
|
+
attach
|
|
100
|
+
};
|
|
101
|
+
itemBefore.next = newItem;
|
|
102
|
+
beforeItem.prev = newItem;
|
|
103
|
+
}
|
|
104
|
+
this._map[newItem.id] = newItem;
|
|
105
|
+
}
|
|
106
|
+
distance(from, to) {
|
|
107
|
+
let count = 0;
|
|
108
|
+
while (from !== to && from !== EoF) {
|
|
109
|
+
count++;
|
|
110
|
+
from = from.next;
|
|
111
|
+
}
|
|
112
|
+
return from !== EoF ? count : -1;
|
|
113
|
+
}
|
|
114
|
+
get matchBy() {
|
|
115
|
+
return this._matchBy;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const ITEM_ADDED = "IA";
|
|
119
|
+
const ITEM_REMOVED = "IR";
|
|
120
|
+
const ITEM_MOVED = "IM";
|
|
121
|
+
const MOVED_FORWARD_NONE = 0;
|
|
122
|
+
const MOVED_FORWARD_IN_SEQUENCE = 1;
|
|
123
|
+
function listCompare(oldArray, newArray, mkElement) {
|
|
124
|
+
let oldList = oldArray;
|
|
125
|
+
let newList = newArray;
|
|
126
|
+
let oldListItem = oldList.first();
|
|
127
|
+
let newListItem_ = newList.first();
|
|
128
|
+
let oldIndex = 0;
|
|
129
|
+
let index = 0;
|
|
130
|
+
let instructions = [];
|
|
131
|
+
function addNewItem(newListItem) {
|
|
132
|
+
let newElement = mkElement(newListItem.value, newListItem.id);
|
|
133
|
+
oldList.add(newListItem.value, oldListItem, newElement);
|
|
134
|
+
newListItem.attach = newElement;
|
|
135
|
+
instructions.push({
|
|
136
|
+
action: ITEM_ADDED,
|
|
137
|
+
item: newListItem.value,
|
|
138
|
+
pos: index,
|
|
139
|
+
elem: newElement
|
|
140
|
+
});
|
|
141
|
+
newListItem_ = newListItem.next;
|
|
142
|
+
index += 1;
|
|
143
|
+
}
|
|
144
|
+
while (newListItem_ !== EoF) {
|
|
145
|
+
let newListItem = newListItem_;
|
|
146
|
+
if (oldListItem === EoF) {
|
|
147
|
+
addNewItem(newListItem);
|
|
148
|
+
} else if (oldListItem.id !== newListItem.id) {
|
|
149
|
+
if (!newList.has(oldListItem.id)) {
|
|
150
|
+
instructions.push({
|
|
151
|
+
action: ITEM_REMOVED,
|
|
152
|
+
item: oldListItem.value,
|
|
153
|
+
pos: index,
|
|
154
|
+
elem: oldListItem.attach
|
|
155
|
+
});
|
|
156
|
+
oldList.remove(oldListItem);
|
|
157
|
+
oldListItem = oldListItem.next;
|
|
158
|
+
} else if (oldList.has(newListItem.id)) {
|
|
159
|
+
let oldListItemToMove = oldList.get(newListItem.id);
|
|
160
|
+
newListItem.attach = oldListItemToMove.attach;
|
|
161
|
+
let distance = oldList.distance(oldListItem, oldListItemToMove);
|
|
162
|
+
instructions.push({
|
|
163
|
+
action: ITEM_MOVED,
|
|
164
|
+
item: oldListItemToMove.value,
|
|
165
|
+
pos: index,
|
|
166
|
+
fromPos: oldIndex + distance
|
|
167
|
+
});
|
|
168
|
+
oldList.move(oldListItemToMove, oldListItem);
|
|
169
|
+
newListItem_ = newListItem.next;
|
|
170
|
+
index += 1;
|
|
171
|
+
oldIndex += 1;
|
|
172
|
+
} else {
|
|
173
|
+
addNewItem(newListItem);
|
|
174
|
+
oldIndex += 1;
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
newListItem.attach = oldListItem.attach;
|
|
178
|
+
oldListItem = oldListItem.next;
|
|
179
|
+
newListItem_ = newListItem.next;
|
|
180
|
+
index += 1;
|
|
181
|
+
oldIndex += 1;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
while (oldListItem !== EoF) {
|
|
185
|
+
instructions.push({
|
|
186
|
+
action: ITEM_REMOVED,
|
|
187
|
+
item: oldListItem.value,
|
|
188
|
+
pos: oldIndex,
|
|
189
|
+
elem: oldListItem.attach
|
|
190
|
+
});
|
|
191
|
+
oldList.remove(oldListItem);
|
|
192
|
+
oldListItem = oldListItem.next;
|
|
193
|
+
}
|
|
194
|
+
return optimize(instructions);
|
|
195
|
+
}
|
|
196
|
+
function optimize(instructions) {
|
|
197
|
+
function optimizeSequence(sequenceStart, sequenceEnd) {
|
|
198
|
+
let newMove = {
|
|
199
|
+
action: ITEM_MOVED,
|
|
200
|
+
pos: instructions[sequenceEnd].fromPos,
|
|
201
|
+
fromPos: instructions[sequenceStart].pos
|
|
202
|
+
};
|
|
203
|
+
instructions.splice(sequenceStart, sequenceEnd - sequenceStart + 1, newMove);
|
|
204
|
+
}
|
|
205
|
+
let movedForwardSequenceEnd;
|
|
206
|
+
let movedForwardState = MOVED_FORWARD_NONE;
|
|
207
|
+
for (let i = instructions.length - 1; i >= 0; i--) {
|
|
208
|
+
let isCandidateForOptimization = instructions[i].action === ITEM_MOVED && instructions[i].pos + 1 === instructions[i].fromPos;
|
|
209
|
+
if (movedForwardState === MOVED_FORWARD_IN_SEQUENCE && (!isCandidateForOptimization || instructions[i].pos + 1 !== instructions[i + 1].pos)) {
|
|
210
|
+
if (i + 1 !== movedForwardSequenceEnd) {
|
|
211
|
+
optimizeSequence(i + 1, movedForwardSequenceEnd);
|
|
212
|
+
}
|
|
213
|
+
movedForwardState = MOVED_FORWARD_NONE;
|
|
214
|
+
}
|
|
215
|
+
if (movedForwardState === MOVED_FORWARD_NONE && isCandidateForOptimization) {
|
|
216
|
+
movedForwardState = MOVED_FORWARD_IN_SEQUENCE;
|
|
217
|
+
movedForwardSequenceEnd = i;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (movedForwardState === MOVED_FORWARD_IN_SEQUENCE && movedForwardSequenceEnd > 1) {
|
|
221
|
+
optimizeSequence(0, movedForwardSequenceEnd);
|
|
222
|
+
}
|
|
223
|
+
return instructions;
|
|
224
|
+
}
|
|
225
|
+
export {
|
|
226
|
+
BoF,
|
|
227
|
+
EoF,
|
|
228
|
+
ITEM_ADDED,
|
|
229
|
+
ITEM_MOVED,
|
|
230
|
+
ITEM_REMOVED,
|
|
231
|
+
RandomAccessLinkedList,
|
|
232
|
+
listCompare
|
|
233
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jay-framework/list-compare",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"readme.md"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "npm run build:js && npm run build:types",
|
|
13
|
+
"build:watch": "npm run build:js -- --watch & npm run build:types -- --watch",
|
|
14
|
+
"build:js": "vite build",
|
|
15
|
+
"build:types": "tsup lib/index.ts --dts-only --format esm",
|
|
16
|
+
"build:check-types": "tsc",
|
|
17
|
+
"clean": "rimraf dist",
|
|
18
|
+
"confirm": "npm run clean && npm run build && npm run build:check-types && npm run test",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"test:watch": "vitest"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@jay-framework/dev-environment": "workspace:^",
|
|
24
|
+
"@types/node": "^20.11.5",
|
|
25
|
+
"rimraf": "^5.0.5",
|
|
26
|
+
"tsup": "^8.0.1",
|
|
27
|
+
"typescript": "^5.3.3",
|
|
28
|
+
"vite": "^5.0.11",
|
|
29
|
+
"vitest": "^1.2.1"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# list-compare `listCompare<T, S>()`
|
|
2
|
+
|
|
3
|
+
The list compare library is an algorithm to compute the mutations needed to update list `A` into list `B`.
|
|
4
|
+
|
|
5
|
+
## list compare signature
|
|
6
|
+
|
|
7
|
+
The algorithm signature is
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
export interface MatchResult<T, S> {
|
|
11
|
+
action: typeof ITEM_ADDED | typeof ITEM_MOVED | typeof ITEM_REMOVED;
|
|
12
|
+
item?: T;
|
|
13
|
+
pos: number;
|
|
14
|
+
fromPos?: number;
|
|
15
|
+
elem?: S;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
declare function listCompare<T, S>(
|
|
19
|
+
oldArray: RandomAccessLinkedList<T, S>,
|
|
20
|
+
newArray: RandomAccessLinkedList<T, S>,
|
|
21
|
+
mkElement: (T, id: string) => S,
|
|
22
|
+
): Array<MatchResult<T, S>>;
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The algorithm compares two instances of a RandomAccessLinkedList data structure and generates instructions for
|
|
26
|
+
transforming one list into the other.
|
|
27
|
+
|
|
28
|
+
### Inputs:
|
|
29
|
+
|
|
30
|
+
- `oldArray`: The first RandomAccessLinkedList instance (assumed to be the original list).
|
|
31
|
+
- `newArray`: The second RandomAccessLinkedList instance (assumed to be the desired state).
|
|
32
|
+
- `mkElement`: A function that takes an item value and an ID and creates an element of type S.
|
|
33
|
+
This function is application specific to the application using the algorithm.
|
|
34
|
+
|
|
35
|
+
### Outputs:
|
|
36
|
+
|
|
37
|
+
An array of `MatchResult` objects, which describe the changes needed to transform the old list into the new list.
|
|
38
|
+
Each `MatchResult` object has the following properties:
|
|
39
|
+
|
|
40
|
+
- `action`: A string representing the type of change needed. Possible values are:
|
|
41
|
+
- `ITEM_ADDED`: A new item needs to be inserted.
|
|
42
|
+
- `ITEM_REMOVED`: An existing item needs to be removed.
|
|
43
|
+
- `ITEM_MOVED`: An existing item needs to be moved to a different position.
|
|
44
|
+
- `item`: The value of the item being added or moved (applicable for ITEM_ADDED and ITEM_MOVED).
|
|
45
|
+
- `pos`: The position in the new list where the item should be (applicable for ITEM_ADDED and ITEM_MOVED).
|
|
46
|
+
- `fromPos`: The original position of the item being moved from (applicable for ITEM_MOVED).
|
|
47
|
+
- `elem`: The attachment object of type `S` created for the item for application specific usage.
|
|
48
|
+
|
|
49
|
+
## algorithm notes
|
|
50
|
+
|
|
51
|
+
- The algorithm **mutates the `oldArray` into `newArray`** as part of the algorithm.
|
|
52
|
+
- The algorithm efficiency is `O(N log(N))`.
|
|
53
|
+
- The algorithm is used by the `@jay-framework/json-patch` to compute the JSON diff with support for item movement,
|
|
54
|
+
- The algorithm is used by the `@jay-framework/runtime` to compute how to update the DOM for repeated items in the most efficient way.
|
|
55
|
+
- The algorithm is using the `RandomAccessLinkedList<T, S>` to optimize performance
|
|
56
|
+
- The algorithm assumes items `T` have an `id` which is used to match the same item between `oldArray` into `newArray`.
|
|
57
|
+
- The algorithm assumes an item `T` may have an attached item `S`.
|
|
58
|
+
- The attachment `S` is moved with the original item `T`.
|
|
59
|
+
- The attachment is used by the `@jay-framework/runtime` package when ordering `ViewState` items, while the attachment is the DOM
|
|
60
|
+
element associated with the `ViewState` item. This allows the algorithm instructions to be used for moving DOM elements.
|
|
61
|
+
- The algorithm, when using attachments, also receives a `mkElement` function that is used for creating the attachment
|
|
62
|
+
for new items (items that appear in `newArray` but not in `oldArray` by `id` matching).
|
|
63
|
+
|
|
64
|
+
## Example
|
|
65
|
+
|
|
66
|
+
Consider the following two arrays:
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
const array_1 = [
|
|
70
|
+
{ id: 'a114' },
|
|
71
|
+
{ id: 'a33' },
|
|
72
|
+
{ id: 'a75' },
|
|
73
|
+
{ id: 'a201' },
|
|
74
|
+
{ id: 'a153' },
|
|
75
|
+
{ id: 'a204' },
|
|
76
|
+
{ id: 'a207' },
|
|
77
|
+
];
|
|
78
|
+
const array_2 = [
|
|
79
|
+
{ id: 'a114' },
|
|
80
|
+
{ id: 'a75' },
|
|
81
|
+
{ id: 'a153' },
|
|
82
|
+
{ id: 'a204' },
|
|
83
|
+
{ id: 'a207' },
|
|
84
|
+
{ id: 'a210' },
|
|
85
|
+
{ id: 'a201' },
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
const result = listCompare(
|
|
89
|
+
new RandomAccessLinkedList(array_1),
|
|
90
|
+
new RandomAccessLinkedList(array_2),
|
|
91
|
+
() => undefined,
|
|
92
|
+
);
|
|
93
|
+
expect(result).toEqual([
|
|
94
|
+
{ action: ITEM_REMOVED, item: { id: 'a33' }, pos: 1 },
|
|
95
|
+
{ action: ITEM_MOVED, pos: 5, fromPos: 2 },
|
|
96
|
+
{ action: ITEM_ADDED, item: { id: 'a210' }, pos: 5 },
|
|
97
|
+
]);
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## `RandomAccessLinkedList<T, S>`
|
|
101
|
+
|
|
102
|
+
A double-sided linked list implementation combined with a map `id -> LinkedListItem<T, S>` random access.
|
|
103
|
+
It is used by the `listCompare<T, S>()` algorithm for performance.
|