@lp-labs/table-model 0.0.0-stage → 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 +21 -0
- package/README.md +66 -2
- package/dist/cjs/index.d.ts +24 -0
- package/dist/cjs/index.js +201 -0
- package/dist/cjs/package.json +1 -0
- package/dist/esm/index.d.ts +24 -0
- package/dist/esm/index.js +198 -0
- package/package.json +61 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Libin Prasanth
|
|
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
CHANGED
|
@@ -1,3 +1,67 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @lp-labs/table-model
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A small validated table occupancy model. Cells are rectangular spans; any overlap or out-of-range
|
|
4
|
+
operation is rejected before the existing table state changes.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install @lp-labs/table-model
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
import { createTableModel } from '@lp-labs/table-model';
|
|
14
|
+
|
|
15
|
+
const table = createTableModel(3, 4, [
|
|
16
|
+
{
|
|
17
|
+
id: 'title',
|
|
18
|
+
row: 0,
|
|
19
|
+
column: 0,
|
|
20
|
+
rowSpan: 1,
|
|
21
|
+
columnSpan: 3,
|
|
22
|
+
value: 'Quarterly report',
|
|
23
|
+
},
|
|
24
|
+
{ id: 'amount', row: 1, column: 0, value: '42' },
|
|
25
|
+
]);
|
|
26
|
+
console.log(table.cellAt(0, 2)); // the spanning title cell
|
|
27
|
+
table.moveCell('amount', 2, 0);
|
|
28
|
+
table.resizeCell('title', 1, 4);
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## API
|
|
32
|
+
|
|
33
|
+
- `createTableModel(rowCount, columnCount, cells?, options?)` creates a table with positive integer
|
|
34
|
+
dimensions. A cell has a unique `id`, zero-based `row` and `column`, optional positive
|
|
35
|
+
`rowSpan`/`columnSpan` (default 1), and optional text `value`.
|
|
36
|
+
- `TableModel.rowCount` and `TableModel.columnCount` expose the immutable table dimensions.
|
|
37
|
+
- `TableModel.cells()` returns copied cell records in insertion order.
|
|
38
|
+
- `TableModel.cellAt(row, column)` returns a copied occupying cell or `undefined` for an empty or
|
|
39
|
+
out-of-table position.
|
|
40
|
+
- `TableModel.addCell(cell)`, `moveCell(id, row, column)`, and `resizeCell(id, rowSpan, columnSpan)`
|
|
41
|
+
validate the complete candidate layout before committing. A failure leaves every cell unchanged.
|
|
42
|
+
- `TableModel.removeCell(id)` returns `true` when removed and `false` when absent.
|
|
43
|
+
- `TableCell`, `TableModel`, and `TableModelOptions` are exported types.
|
|
44
|
+
|
|
45
|
+
Options `maxRows`, `maxColumns`, and `maxCells` default to 10,000, 10,000, and 100,000 and may only
|
|
46
|
+
lower those limits. Coordinates are zero-based. The model stores plain text values, not formulas,
|
|
47
|
+
styles, row/column metadata, or rendered HTML.
|
|
48
|
+
|
|
49
|
+
Invalid IDs, dimensions, spans, values, duplicates, overlaps, and capacity violations throw
|
|
50
|
+
`TypeError` or `RangeError`; unknown IDs for move/resize throw `TypeError`. Mutating methods are
|
|
51
|
+
atomic with respect to validation failures.
|
|
52
|
+
|
|
53
|
+
## Module formats and customization
|
|
54
|
+
|
|
55
|
+
ESM, CommonJS, and TypeScript declarations are included. For CommonJS, use
|
|
56
|
+
`const { createTableModel } = require('@lp-labs/table-model')`. Adapt application-specific values
|
|
57
|
+
to text in your own layer, or add a separately validated payload map keyed by cell ID.
|
|
58
|
+
|
|
59
|
+
There are no runtime or peer dependencies. TypeScript and Prettier are development-only.
|
|
60
|
+
|
|
61
|
+
## Support
|
|
62
|
+
|
|
63
|
+
If this library is useful to you, consider supporting its maintenance with a [donation via PayPal](https://www.paypal.me/LibinPrasanth).
|
|
64
|
+
|
|
65
|
+
## Author
|
|
66
|
+
|
|
67
|
+
Created by [Libin Prasanth](https://www.linkedin.com/in/libinprasanth/). Connect on LinkedIn for updates and discussion.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface TableCell {
|
|
2
|
+
id: string;
|
|
3
|
+
row: number;
|
|
4
|
+
column: number;
|
|
5
|
+
rowSpan?: number;
|
|
6
|
+
columnSpan?: number;
|
|
7
|
+
value?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface TableModelOptions {
|
|
10
|
+
maxRows?: number;
|
|
11
|
+
maxColumns?: number;
|
|
12
|
+
maxCells?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface TableModel {
|
|
15
|
+
readonly rowCount: number;
|
|
16
|
+
readonly columnCount: number;
|
|
17
|
+
cells(): TableCell[];
|
|
18
|
+
cellAt(row: number, column: number): TableCell | undefined;
|
|
19
|
+
addCell(cell: TableCell): void;
|
|
20
|
+
moveCell(id: string, row: number, column: number): void;
|
|
21
|
+
resizeCell(id: string, rowSpan: number, columnSpan: number): void;
|
|
22
|
+
removeCell(id: string): boolean;
|
|
23
|
+
}
|
|
24
|
+
export declare function createTableModel(rowCount: number, columnCount: number, initialCells?: Iterable<TableCell>, options?: TableModelOptions): TableModel;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createTableModel = createTableModel;
|
|
4
|
+
const MAX_ROWS = 10_000;
|
|
5
|
+
const MAX_COLUMNS = 10_000;
|
|
6
|
+
const MAX_CELLS = 100_000;
|
|
7
|
+
function limit(value, maximum, name) {
|
|
8
|
+
const result = value ?? maximum;
|
|
9
|
+
if (!Number.isSafeInteger(result) || result < 1 || result > maximum) {
|
|
10
|
+
throw new RangeError(`${name} must be an integer from 1 to ${maximum}.`);
|
|
11
|
+
}
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
function integer(value, name) {
|
|
15
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
16
|
+
throw new RangeError(`${name} must be a nonnegative integer.`);
|
|
17
|
+
}
|
|
18
|
+
function copyCell(cell) {
|
|
19
|
+
if (!cell || typeof cell !== 'object')
|
|
20
|
+
throw new TypeError('A cell is required.');
|
|
21
|
+
if (typeof cell.id !== 'string' || !cell.id.trim() || cell.id.length > 128) {
|
|
22
|
+
throw new TypeError('Cell ID must be a nonempty string up to 128 characters.');
|
|
23
|
+
}
|
|
24
|
+
integer(cell.row, 'row');
|
|
25
|
+
integer(cell.column, 'column');
|
|
26
|
+
const rowSpan = cell.rowSpan ?? 1;
|
|
27
|
+
const columnSpan = cell.columnSpan ?? 1;
|
|
28
|
+
if (!Number.isSafeInteger(rowSpan) || rowSpan < 1)
|
|
29
|
+
throw new RangeError('rowSpan must be positive.');
|
|
30
|
+
if (!Number.isSafeInteger(columnSpan) || columnSpan < 1)
|
|
31
|
+
throw new RangeError('columnSpan must be positive.');
|
|
32
|
+
if (cell.value !== undefined && typeof cell.value !== 'string') {
|
|
33
|
+
throw new TypeError('Cell value must be text.');
|
|
34
|
+
}
|
|
35
|
+
return Object.freeze({
|
|
36
|
+
id: cell.id,
|
|
37
|
+
row: cell.row,
|
|
38
|
+
column: cell.column,
|
|
39
|
+
rowSpan,
|
|
40
|
+
columnSpan,
|
|
41
|
+
...(cell.value === undefined ? {} : { value: cell.value }),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function createTableModel(rowCount, columnCount, initialCells = [], options = {}) {
|
|
45
|
+
const maxRows = limit(options.maxRows, MAX_ROWS, 'maxRows');
|
|
46
|
+
const maxColumns = limit(options.maxColumns, MAX_COLUMNS, 'maxColumns');
|
|
47
|
+
const maxCells = limit(options.maxCells, MAX_CELLS, 'maxCells');
|
|
48
|
+
if (!Number.isSafeInteger(rowCount) || rowCount < 1 || rowCount > maxRows) {
|
|
49
|
+
throw new RangeError(`rowCount must be an integer from 1 to ${maxRows}.`);
|
|
50
|
+
}
|
|
51
|
+
if (!Number.isSafeInteger(columnCount) || columnCount < 1 || columnCount > maxColumns) {
|
|
52
|
+
throw new RangeError(`columnCount must be an integer from 1 to ${maxColumns}.`);
|
|
53
|
+
}
|
|
54
|
+
if (initialCells == null || typeof initialCells[Symbol.iterator] !== 'function') {
|
|
55
|
+
throw new TypeError('Initial cells must be iterable.');
|
|
56
|
+
}
|
|
57
|
+
let cells = [];
|
|
58
|
+
const validate = (candidate) => {
|
|
59
|
+
if (candidate.length > maxCells)
|
|
60
|
+
throw new RangeError('Cell capacity exceeded.');
|
|
61
|
+
const ids = new Set();
|
|
62
|
+
const columnEdges = new Set();
|
|
63
|
+
for (const cell of candidate) {
|
|
64
|
+
if (cell.row + cell.rowSpan > rowCount || cell.column + cell.columnSpan > columnCount) {
|
|
65
|
+
throw new RangeError(`Cell ${cell.id} exceeds table dimensions.`);
|
|
66
|
+
}
|
|
67
|
+
if (ids.has(cell.id))
|
|
68
|
+
throw new TypeError(`Duplicate cell ID: ${cell.id}`);
|
|
69
|
+
ids.add(cell.id);
|
|
70
|
+
columnEdges.add(cell.column);
|
|
71
|
+
columnEdges.add(cell.column + cell.columnSpan);
|
|
72
|
+
}
|
|
73
|
+
if (candidate.length < 2)
|
|
74
|
+
return;
|
|
75
|
+
const edges = [...columnEdges].sort((a, b) => a - b);
|
|
76
|
+
const edgeIndex = new Map(edges.map((edge, index) => [edge, index]));
|
|
77
|
+
const events = candidate.flatMap((cell) => [
|
|
78
|
+
{ row: cell.row, delta: 1, cell },
|
|
79
|
+
{ row: cell.row + cell.rowSpan, delta: -1, cell },
|
|
80
|
+
]);
|
|
81
|
+
events.sort((first, second) => first.row - second.row || first.delta - second.delta);
|
|
82
|
+
const segmentCount = edges.length - 1;
|
|
83
|
+
const maximum = new Array(segmentCount * 4).fill(0);
|
|
84
|
+
const lazy = new Array(segmentCount * 4).fill(0);
|
|
85
|
+
const apply = (node, delta) => {
|
|
86
|
+
maximum[node] = maximum[node] + delta;
|
|
87
|
+
lazy[node] = lazy[node] + delta;
|
|
88
|
+
};
|
|
89
|
+
const push = (node) => {
|
|
90
|
+
if (lazy[node] === 0)
|
|
91
|
+
return;
|
|
92
|
+
apply(node * 2, lazy[node]);
|
|
93
|
+
apply(node * 2 + 1, lazy[node]);
|
|
94
|
+
lazy[node] = 0;
|
|
95
|
+
};
|
|
96
|
+
const update = (node, left, right, queryLeft, queryRight, delta) => {
|
|
97
|
+
if (queryLeft <= left && right <= queryRight) {
|
|
98
|
+
apply(node, delta);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
push(node);
|
|
102
|
+
const middle = Math.floor((left + right) / 2);
|
|
103
|
+
if (queryLeft <= middle) {
|
|
104
|
+
update(node * 2, left, middle, queryLeft, queryRight, delta);
|
|
105
|
+
}
|
|
106
|
+
if (queryRight > middle) {
|
|
107
|
+
update(node * 2 + 1, middle + 1, right, queryLeft, queryRight, delta);
|
|
108
|
+
}
|
|
109
|
+
maximum[node] = Math.max(maximum[node * 2], maximum[node * 2 + 1]);
|
|
110
|
+
};
|
|
111
|
+
const queryMaximum = (node, left, right, queryLeft, queryRight) => {
|
|
112
|
+
if (queryLeft <= left && right <= queryRight)
|
|
113
|
+
return maximum[node];
|
|
114
|
+
push(node);
|
|
115
|
+
const middle = Math.floor((left + right) / 2);
|
|
116
|
+
let result = 0;
|
|
117
|
+
if (queryLeft <= middle) {
|
|
118
|
+
result = queryMaximum(node * 2, left, middle, queryLeft, queryRight);
|
|
119
|
+
}
|
|
120
|
+
if (queryRight > middle) {
|
|
121
|
+
result = Math.max(result, queryMaximum(node * 2 + 1, middle + 1, right, queryLeft, queryRight));
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
};
|
|
125
|
+
for (const event of events) {
|
|
126
|
+
const left = edgeIndex.get(event.cell.column);
|
|
127
|
+
const right = edgeIndex.get(event.cell.column + event.cell.columnSpan) - 1;
|
|
128
|
+
if (event.delta < 0) {
|
|
129
|
+
update(1, 0, segmentCount - 1, left, right, -1);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
if (queryMaximum(1, 0, segmentCount - 1, left, right) > 0) {
|
|
133
|
+
throw new RangeError(`Cell ${event.cell.id} overlaps another cell.`);
|
|
134
|
+
}
|
|
135
|
+
update(1, 0, segmentCount - 1, left, right, 1);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
for (const inputCell of initialCells) {
|
|
140
|
+
if (cells.length >= maxCells)
|
|
141
|
+
throw new RangeError('Cell capacity exceeded.');
|
|
142
|
+
const cell = copyCell(inputCell);
|
|
143
|
+
cells.push(cell);
|
|
144
|
+
}
|
|
145
|
+
validate(cells);
|
|
146
|
+
const requireCell = (id) => {
|
|
147
|
+
const cell = cells.find((item) => item.id === id);
|
|
148
|
+
if (!cell)
|
|
149
|
+
throw new TypeError(`Unknown cell ID: ${id}`);
|
|
150
|
+
return cell;
|
|
151
|
+
};
|
|
152
|
+
return {
|
|
153
|
+
rowCount,
|
|
154
|
+
columnCount,
|
|
155
|
+
cells() {
|
|
156
|
+
return cells.map((cell) => ({ ...cell }));
|
|
157
|
+
},
|
|
158
|
+
cellAt(row, column) {
|
|
159
|
+
integer(row, 'row');
|
|
160
|
+
integer(column, 'column');
|
|
161
|
+
if (row >= rowCount || column >= columnCount)
|
|
162
|
+
return undefined;
|
|
163
|
+
const cell = cells.find((item) => row >= item.row &&
|
|
164
|
+
row < item.row + item.rowSpan &&
|
|
165
|
+
column >= item.column &&
|
|
166
|
+
column < item.column + item.columnSpan);
|
|
167
|
+
return cell ? { ...cell } : undefined;
|
|
168
|
+
},
|
|
169
|
+
addCell(inputCell) {
|
|
170
|
+
const cell = copyCell(inputCell);
|
|
171
|
+
const candidate = [...cells, cell];
|
|
172
|
+
validate(candidate);
|
|
173
|
+
cells = candidate;
|
|
174
|
+
},
|
|
175
|
+
moveCell(id, row, column) {
|
|
176
|
+
integer(row, 'row');
|
|
177
|
+
integer(column, 'column');
|
|
178
|
+
const previous = requireCell(id);
|
|
179
|
+
const candidate = cells.map((cell) => cell.id === id ? copyCell({ ...cell, row, column }) : cell);
|
|
180
|
+
validate(candidate);
|
|
181
|
+
if (!previous)
|
|
182
|
+
throw new TypeError(`Unknown cell ID: ${id}`);
|
|
183
|
+
cells = candidate;
|
|
184
|
+
},
|
|
185
|
+
resizeCell(id, rowSpan, columnSpan) {
|
|
186
|
+
const previous = requireCell(id);
|
|
187
|
+
const candidate = cells.map((cell) => cell.id === id ? copyCell({ ...cell, rowSpan, columnSpan }) : cell);
|
|
188
|
+
validate(candidate);
|
|
189
|
+
if (!previous)
|
|
190
|
+
throw new TypeError(`Unknown cell ID: ${id}`);
|
|
191
|
+
cells = candidate;
|
|
192
|
+
},
|
|
193
|
+
removeCell(id) {
|
|
194
|
+
const index = cells.findIndex((cell) => cell.id === id);
|
|
195
|
+
if (index < 0)
|
|
196
|
+
return false;
|
|
197
|
+
cells = cells.filter((cell) => cell.id !== id);
|
|
198
|
+
return true;
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"commonjs"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface TableCell {
|
|
2
|
+
id: string;
|
|
3
|
+
row: number;
|
|
4
|
+
column: number;
|
|
5
|
+
rowSpan?: number;
|
|
6
|
+
columnSpan?: number;
|
|
7
|
+
value?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface TableModelOptions {
|
|
10
|
+
maxRows?: number;
|
|
11
|
+
maxColumns?: number;
|
|
12
|
+
maxCells?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface TableModel {
|
|
15
|
+
readonly rowCount: number;
|
|
16
|
+
readonly columnCount: number;
|
|
17
|
+
cells(): TableCell[];
|
|
18
|
+
cellAt(row: number, column: number): TableCell | undefined;
|
|
19
|
+
addCell(cell: TableCell): void;
|
|
20
|
+
moveCell(id: string, row: number, column: number): void;
|
|
21
|
+
resizeCell(id: string, rowSpan: number, columnSpan: number): void;
|
|
22
|
+
removeCell(id: string): boolean;
|
|
23
|
+
}
|
|
24
|
+
export declare function createTableModel(rowCount: number, columnCount: number, initialCells?: Iterable<TableCell>, options?: TableModelOptions): TableModel;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
const MAX_ROWS = 10_000;
|
|
2
|
+
const MAX_COLUMNS = 10_000;
|
|
3
|
+
const MAX_CELLS = 100_000;
|
|
4
|
+
function limit(value, maximum, name) {
|
|
5
|
+
const result = value ?? maximum;
|
|
6
|
+
if (!Number.isSafeInteger(result) || result < 1 || result > maximum) {
|
|
7
|
+
throw new RangeError(`${name} must be an integer from 1 to ${maximum}.`);
|
|
8
|
+
}
|
|
9
|
+
return result;
|
|
10
|
+
}
|
|
11
|
+
function integer(value, name) {
|
|
12
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
13
|
+
throw new RangeError(`${name} must be a nonnegative integer.`);
|
|
14
|
+
}
|
|
15
|
+
function copyCell(cell) {
|
|
16
|
+
if (!cell || typeof cell !== 'object')
|
|
17
|
+
throw new TypeError('A cell is required.');
|
|
18
|
+
if (typeof cell.id !== 'string' || !cell.id.trim() || cell.id.length > 128) {
|
|
19
|
+
throw new TypeError('Cell ID must be a nonempty string up to 128 characters.');
|
|
20
|
+
}
|
|
21
|
+
integer(cell.row, 'row');
|
|
22
|
+
integer(cell.column, 'column');
|
|
23
|
+
const rowSpan = cell.rowSpan ?? 1;
|
|
24
|
+
const columnSpan = cell.columnSpan ?? 1;
|
|
25
|
+
if (!Number.isSafeInteger(rowSpan) || rowSpan < 1)
|
|
26
|
+
throw new RangeError('rowSpan must be positive.');
|
|
27
|
+
if (!Number.isSafeInteger(columnSpan) || columnSpan < 1)
|
|
28
|
+
throw new RangeError('columnSpan must be positive.');
|
|
29
|
+
if (cell.value !== undefined && typeof cell.value !== 'string') {
|
|
30
|
+
throw new TypeError('Cell value must be text.');
|
|
31
|
+
}
|
|
32
|
+
return Object.freeze({
|
|
33
|
+
id: cell.id,
|
|
34
|
+
row: cell.row,
|
|
35
|
+
column: cell.column,
|
|
36
|
+
rowSpan,
|
|
37
|
+
columnSpan,
|
|
38
|
+
...(cell.value === undefined ? {} : { value: cell.value }),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export function createTableModel(rowCount, columnCount, initialCells = [], options = {}) {
|
|
42
|
+
const maxRows = limit(options.maxRows, MAX_ROWS, 'maxRows');
|
|
43
|
+
const maxColumns = limit(options.maxColumns, MAX_COLUMNS, 'maxColumns');
|
|
44
|
+
const maxCells = limit(options.maxCells, MAX_CELLS, 'maxCells');
|
|
45
|
+
if (!Number.isSafeInteger(rowCount) || rowCount < 1 || rowCount > maxRows) {
|
|
46
|
+
throw new RangeError(`rowCount must be an integer from 1 to ${maxRows}.`);
|
|
47
|
+
}
|
|
48
|
+
if (!Number.isSafeInteger(columnCount) || columnCount < 1 || columnCount > maxColumns) {
|
|
49
|
+
throw new RangeError(`columnCount must be an integer from 1 to ${maxColumns}.`);
|
|
50
|
+
}
|
|
51
|
+
if (initialCells == null || typeof initialCells[Symbol.iterator] !== 'function') {
|
|
52
|
+
throw new TypeError('Initial cells must be iterable.');
|
|
53
|
+
}
|
|
54
|
+
let cells = [];
|
|
55
|
+
const validate = (candidate) => {
|
|
56
|
+
if (candidate.length > maxCells)
|
|
57
|
+
throw new RangeError('Cell capacity exceeded.');
|
|
58
|
+
const ids = new Set();
|
|
59
|
+
const columnEdges = new Set();
|
|
60
|
+
for (const cell of candidate) {
|
|
61
|
+
if (cell.row + cell.rowSpan > rowCount || cell.column + cell.columnSpan > columnCount) {
|
|
62
|
+
throw new RangeError(`Cell ${cell.id} exceeds table dimensions.`);
|
|
63
|
+
}
|
|
64
|
+
if (ids.has(cell.id))
|
|
65
|
+
throw new TypeError(`Duplicate cell ID: ${cell.id}`);
|
|
66
|
+
ids.add(cell.id);
|
|
67
|
+
columnEdges.add(cell.column);
|
|
68
|
+
columnEdges.add(cell.column + cell.columnSpan);
|
|
69
|
+
}
|
|
70
|
+
if (candidate.length < 2)
|
|
71
|
+
return;
|
|
72
|
+
const edges = [...columnEdges].sort((a, b) => a - b);
|
|
73
|
+
const edgeIndex = new Map(edges.map((edge, index) => [edge, index]));
|
|
74
|
+
const events = candidate.flatMap((cell) => [
|
|
75
|
+
{ row: cell.row, delta: 1, cell },
|
|
76
|
+
{ row: cell.row + cell.rowSpan, delta: -1, cell },
|
|
77
|
+
]);
|
|
78
|
+
events.sort((first, second) => first.row - second.row || first.delta - second.delta);
|
|
79
|
+
const segmentCount = edges.length - 1;
|
|
80
|
+
const maximum = new Array(segmentCount * 4).fill(0);
|
|
81
|
+
const lazy = new Array(segmentCount * 4).fill(0);
|
|
82
|
+
const apply = (node, delta) => {
|
|
83
|
+
maximum[node] = maximum[node] + delta;
|
|
84
|
+
lazy[node] = lazy[node] + delta;
|
|
85
|
+
};
|
|
86
|
+
const push = (node) => {
|
|
87
|
+
if (lazy[node] === 0)
|
|
88
|
+
return;
|
|
89
|
+
apply(node * 2, lazy[node]);
|
|
90
|
+
apply(node * 2 + 1, lazy[node]);
|
|
91
|
+
lazy[node] = 0;
|
|
92
|
+
};
|
|
93
|
+
const update = (node, left, right, queryLeft, queryRight, delta) => {
|
|
94
|
+
if (queryLeft <= left && right <= queryRight) {
|
|
95
|
+
apply(node, delta);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
push(node);
|
|
99
|
+
const middle = Math.floor((left + right) / 2);
|
|
100
|
+
if (queryLeft <= middle) {
|
|
101
|
+
update(node * 2, left, middle, queryLeft, queryRight, delta);
|
|
102
|
+
}
|
|
103
|
+
if (queryRight > middle) {
|
|
104
|
+
update(node * 2 + 1, middle + 1, right, queryLeft, queryRight, delta);
|
|
105
|
+
}
|
|
106
|
+
maximum[node] = Math.max(maximum[node * 2], maximum[node * 2 + 1]);
|
|
107
|
+
};
|
|
108
|
+
const queryMaximum = (node, left, right, queryLeft, queryRight) => {
|
|
109
|
+
if (queryLeft <= left && right <= queryRight)
|
|
110
|
+
return maximum[node];
|
|
111
|
+
push(node);
|
|
112
|
+
const middle = Math.floor((left + right) / 2);
|
|
113
|
+
let result = 0;
|
|
114
|
+
if (queryLeft <= middle) {
|
|
115
|
+
result = queryMaximum(node * 2, left, middle, queryLeft, queryRight);
|
|
116
|
+
}
|
|
117
|
+
if (queryRight > middle) {
|
|
118
|
+
result = Math.max(result, queryMaximum(node * 2 + 1, middle + 1, right, queryLeft, queryRight));
|
|
119
|
+
}
|
|
120
|
+
return result;
|
|
121
|
+
};
|
|
122
|
+
for (const event of events) {
|
|
123
|
+
const left = edgeIndex.get(event.cell.column);
|
|
124
|
+
const right = edgeIndex.get(event.cell.column + event.cell.columnSpan) - 1;
|
|
125
|
+
if (event.delta < 0) {
|
|
126
|
+
update(1, 0, segmentCount - 1, left, right, -1);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
if (queryMaximum(1, 0, segmentCount - 1, left, right) > 0) {
|
|
130
|
+
throw new RangeError(`Cell ${event.cell.id} overlaps another cell.`);
|
|
131
|
+
}
|
|
132
|
+
update(1, 0, segmentCount - 1, left, right, 1);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
for (const inputCell of initialCells) {
|
|
137
|
+
if (cells.length >= maxCells)
|
|
138
|
+
throw new RangeError('Cell capacity exceeded.');
|
|
139
|
+
const cell = copyCell(inputCell);
|
|
140
|
+
cells.push(cell);
|
|
141
|
+
}
|
|
142
|
+
validate(cells);
|
|
143
|
+
const requireCell = (id) => {
|
|
144
|
+
const cell = cells.find((item) => item.id === id);
|
|
145
|
+
if (!cell)
|
|
146
|
+
throw new TypeError(`Unknown cell ID: ${id}`);
|
|
147
|
+
return cell;
|
|
148
|
+
};
|
|
149
|
+
return {
|
|
150
|
+
rowCount,
|
|
151
|
+
columnCount,
|
|
152
|
+
cells() {
|
|
153
|
+
return cells.map((cell) => ({ ...cell }));
|
|
154
|
+
},
|
|
155
|
+
cellAt(row, column) {
|
|
156
|
+
integer(row, 'row');
|
|
157
|
+
integer(column, 'column');
|
|
158
|
+
if (row >= rowCount || column >= columnCount)
|
|
159
|
+
return undefined;
|
|
160
|
+
const cell = cells.find((item) => row >= item.row &&
|
|
161
|
+
row < item.row + item.rowSpan &&
|
|
162
|
+
column >= item.column &&
|
|
163
|
+
column < item.column + item.columnSpan);
|
|
164
|
+
return cell ? { ...cell } : undefined;
|
|
165
|
+
},
|
|
166
|
+
addCell(inputCell) {
|
|
167
|
+
const cell = copyCell(inputCell);
|
|
168
|
+
const candidate = [...cells, cell];
|
|
169
|
+
validate(candidate);
|
|
170
|
+
cells = candidate;
|
|
171
|
+
},
|
|
172
|
+
moveCell(id, row, column) {
|
|
173
|
+
integer(row, 'row');
|
|
174
|
+
integer(column, 'column');
|
|
175
|
+
const previous = requireCell(id);
|
|
176
|
+
const candidate = cells.map((cell) => cell.id === id ? copyCell({ ...cell, row, column }) : cell);
|
|
177
|
+
validate(candidate);
|
|
178
|
+
if (!previous)
|
|
179
|
+
throw new TypeError(`Unknown cell ID: ${id}`);
|
|
180
|
+
cells = candidate;
|
|
181
|
+
},
|
|
182
|
+
resizeCell(id, rowSpan, columnSpan) {
|
|
183
|
+
const previous = requireCell(id);
|
|
184
|
+
const candidate = cells.map((cell) => cell.id === id ? copyCell({ ...cell, rowSpan, columnSpan }) : cell);
|
|
185
|
+
validate(candidate);
|
|
186
|
+
if (!previous)
|
|
187
|
+
throw new TypeError(`Unknown cell ID: ${id}`);
|
|
188
|
+
cells = candidate;
|
|
189
|
+
},
|
|
190
|
+
removeCell(id) {
|
|
191
|
+
const index = cells.findIndex((cell) => cell.id === id);
|
|
192
|
+
if (index < 0)
|
|
193
|
+
return false;
|
|
194
|
+
cells = cells.filter((cell) => cell.id !== id);
|
|
195
|
+
return true;
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lp-labs/table-model",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Validated span-aware table layout and atomic cell operations.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/cjs/index.js",
|
|
8
|
+
"module": "./dist/esm/index.js",
|
|
9
|
+
"types": "./dist/esm/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": {
|
|
13
|
+
"types": "./dist/esm/index.d.ts",
|
|
14
|
+
"default": "./dist/esm/index.js"
|
|
15
|
+
},
|
|
16
|
+
"require": {
|
|
17
|
+
"types": "./dist/cjs/index.d.ts",
|
|
18
|
+
"default": "./dist/cjs/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": "\u003e=18"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "node scripts/build.mjs",
|
|
34
|
+
"typecheck": "npm run build \u0026\u0026 node scripts/check-types.mjs",
|
|
35
|
+
"test": "npm run build \u0026\u0026 node --test tests/*.test.mjs",
|
|
36
|
+
"format": "prettier --write src scripts tests *.json README.md",
|
|
37
|
+
"format:check": "prettier --check src scripts tests *.json README.md",
|
|
38
|
+
"prepack": "npm run build"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"typescript": "^5.9.3",
|
|
42
|
+
"prettier": "^3.6.2"
|
|
43
|
+
},
|
|
44
|
+
"prettier": {
|
|
45
|
+
"singleQuote": true,
|
|
46
|
+
"semi": true,
|
|
47
|
+
"trailingComma": "all",
|
|
48
|
+
"printWidth": 100,
|
|
49
|
+
"endOfLine": "lf"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public",
|
|
53
|
+
"registry": "https://registry.npmjs.org/"
|
|
54
|
+
},
|
|
55
|
+
"author": {
|
|
56
|
+
"name": "Libin Prasanth",
|
|
57
|
+
"url": "https://www.linkedin.com/in/libinprasanth/"
|
|
58
|
+
},
|
|
59
|
+
"funding": {
|
|
60
|
+
"type": "paypal",
|
|
61
|
+
"url": "https://www.paypal.me/LibinPrasanth"
|
|
62
|
+
}
|
|
63
|
+
}
|