@lotics/xlsx 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.
Files changed (90) hide show
  1. package/package.json +30 -0
  2. package/src/auto_sum.test.ts +71 -0
  3. package/src/auto_sum.ts +83 -0
  4. package/src/canvas_cf.ts +135 -0
  5. package/src/canvas_chart.ts +687 -0
  6. package/src/canvas_fill.ts +156 -0
  7. package/src/canvas_images.ts +99 -0
  8. package/src/canvas_layout.test.ts +159 -0
  9. package/src/canvas_layout.ts +239 -0
  10. package/src/canvas_renderer.ts +1010 -0
  11. package/src/canvas_shape.ts +242 -0
  12. package/src/canvas_sparkline.ts +163 -0
  13. package/src/canvas_text.ts +454 -0
  14. package/src/cell_editor.ts +558 -0
  15. package/src/clipboard.test.ts +145 -0
  16. package/src/clipboard.ts +341 -0
  17. package/src/command_history.ts +102 -0
  18. package/src/csv_parser.test.ts +130 -0
  19. package/src/csv_parser.ts +172 -0
  20. package/src/data_validation.test.ts +95 -0
  21. package/src/data_validation.ts +114 -0
  22. package/src/editing_layer.test.ts +309 -0
  23. package/src/excel_cf_evaluate.test.ts +293 -0
  24. package/src/excel_cf_evaluate.ts +719 -0
  25. package/src/excel_cf_icons.ts +108 -0
  26. package/src/excel_cf_types.ts +67 -0
  27. package/src/excel_numfmt.test.ts +607 -0
  28. package/src/excel_numfmt.ts +1033 -0
  29. package/src/excel_parser.test.ts +1061 -0
  30. package/src/excel_parser.ts +393 -0
  31. package/src/excel_pattern_fills.ts +64 -0
  32. package/src/excel_table_styles.ts +160 -0
  33. package/src/excel_utils.test.ts +108 -0
  34. package/src/excel_utils.ts +162 -0
  35. package/src/fast-formula-parser.d.ts +53 -0
  36. package/src/fill_logic.ts +257 -0
  37. package/src/fill_patterns.test.ts +90 -0
  38. package/src/fill_patterns.ts +71 -0
  39. package/src/flash_fill.test.ts +75 -0
  40. package/src/flash_fill.ts +221 -0
  41. package/src/formula_deps.ts +189 -0
  42. package/src/formula_engine.test.ts +348 -0
  43. package/src/formula_engine.ts +401 -0
  44. package/src/formula_highlight.test.ts +81 -0
  45. package/src/formula_highlight.ts +139 -0
  46. package/src/formula_suggest.ts +100 -0
  47. package/src/hidden_sheets.test.ts +49 -0
  48. package/src/index.ts +149 -0
  49. package/src/keyboard_nav.test.ts +394 -0
  50. package/src/keyboard_nav.ts +891 -0
  51. package/src/move_logic.ts +63 -0
  52. package/src/numfmt_type.test.ts +158 -0
  53. package/src/numfmt_type.ts +231 -0
  54. package/src/ooxml_cell_format.ts +70 -0
  55. package/src/ooxml_chart.test.ts +85 -0
  56. package/src/ooxml_chart.ts +347 -0
  57. package/src/ooxml_chart_types.ts +207 -0
  58. package/src/ooxml_color.ts +339 -0
  59. package/src/ooxml_drawing.test.ts +87 -0
  60. package/src/ooxml_drawing.ts +287 -0
  61. package/src/ooxml_pivot.test.ts +195 -0
  62. package/src/ooxml_pivot.ts +468 -0
  63. package/src/ooxml_pivot_types.ts +165 -0
  64. package/src/ooxml_shape.ts +355 -0
  65. package/src/ooxml_sheet.ts +1271 -0
  66. package/src/ooxml_styles.ts +556 -0
  67. package/src/ooxml_table.test.ts +70 -0
  68. package/src/ooxml_table.ts +131 -0
  69. package/src/ooxml_workbook.test.ts +40 -0
  70. package/src/ooxml_workbook.ts +259 -0
  71. package/src/ooxml_zip.ts +33 -0
  72. package/src/pivot_model.ts +237 -0
  73. package/src/pivot_recompute.test.ts +210 -0
  74. package/src/pivot_recompute.ts +413 -0
  75. package/src/selection_model.ts +364 -0
  76. package/src/spreadsheet_commands.test.ts +772 -0
  77. package/src/spreadsheet_commands.ts +1805 -0
  78. package/src/structured_refs.test.ts +128 -0
  79. package/src/structured_refs.ts +160 -0
  80. package/src/style_helpers.test.ts +33 -0
  81. package/src/style_helpers.ts +30 -0
  82. package/src/template_builder.test.ts +139 -0
  83. package/src/template_builder.ts +36 -0
  84. package/src/types.ts +239 -0
  85. package/src/workbook_model.test.ts +536 -0
  86. package/src/workbook_model.ts +911 -0
  87. package/src/xlsx_round_trip.test.ts +279 -0
  88. package/src/xlsx_writer.test.ts +488 -0
  89. package/src/xlsx_writer.ts +1549 -0
  90. package/src/xml_entities.ts +23 -0
@@ -0,0 +1,71 @@
1
+ // =============================================================================
2
+ // Fill Patterns
3
+ // =============================================================================
4
+ //
5
+ // Predefined cycle tables for auto-fill series (months, days, quarters).
6
+ // detectCyclePattern checks if values match a contiguous subsequence of any
7
+ // predefined list, enabling drag-fill to continue the cycle.
8
+ // =============================================================================
9
+
10
+ const CYCLE_LISTS: string[][] = [
11
+ ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
12
+ ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
13
+ ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
14
+ ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
15
+ ["Q1", "Q2", "Q3", "Q4"],
16
+ ];
17
+
18
+ // Lowercase lookup tables built once
19
+ const CYCLE_LOOKUPS = CYCLE_LISTS.map((list) => list.map((v) => v.toLowerCase()));
20
+
21
+ export interface CycleMatch {
22
+ list: string[];
23
+ startIndex: number;
24
+ }
25
+
26
+ /**
27
+ * Check if the given values match a contiguous subsequence of any predefined cycle list.
28
+ * Returns the matched list and start index, or null if no match.
29
+ */
30
+ export function detectCyclePattern(values: string[]): CycleMatch | null {
31
+ if (values.length === 0) return null;
32
+
33
+ const lowerValues = values.map((v) => v.toLowerCase());
34
+
35
+ for (let listIdx = 0; listIdx < CYCLE_LOOKUPS.length; listIdx++) {
36
+ const lookup = CYCLE_LOOKUPS[listIdx];
37
+ const list = CYCLE_LISTS[listIdx];
38
+
39
+ // Find the first value in the cycle
40
+ const firstIndex = lookup.indexOf(lowerValues[0]);
41
+ if (firstIndex === -1) continue;
42
+
43
+ // Check if all values follow contiguously (wrapping around)
44
+ let matches = true;
45
+ for (let i = 1; i < lowerValues.length; i++) {
46
+ const expected = lookup[(firstIndex + i) % lookup.length];
47
+ if (lowerValues[i] !== expected) {
48
+ matches = false;
49
+ break;
50
+ }
51
+ }
52
+
53
+ if (matches) {
54
+ return { list, startIndex: firstIndex };
55
+ }
56
+ }
57
+
58
+ return null;
59
+ }
60
+
61
+ /**
62
+ * Given a matched cycle, return the next N values after the matched sequence.
63
+ */
64
+ export function continueCycle(match: CycleMatch, matchedCount: number, count: number): string[] {
65
+ const result: string[] = [];
66
+ for (let i = 0; i < count; i++) {
67
+ const idx = (match.startIndex + matchedCount + i) % match.list.length;
68
+ result.push(match.list[idx]);
69
+ }
70
+ return result;
71
+ }
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { flashFill } from "./flash_fill";
3
+
4
+ describe("flashFill", () => {
5
+ it("induces UPPER from one example", () => {
6
+ const out = flashFill(
7
+ [{ source: "John", output: "JOHN" }],
8
+ ["Mary", "Sue"],
9
+ );
10
+ expect(out).toEqual(["MARY", "SUE"]);
11
+ });
12
+
13
+ it("induces token pick — first by space", () => {
14
+ const out = flashFill(
15
+ [
16
+ { source: "John Smith", output: "John" },
17
+ { source: "Mary Jane", output: "Mary" },
18
+ ],
19
+ ["Bob Builder"],
20
+ );
21
+ expect(out).toEqual(["Bob"]);
22
+ });
23
+
24
+ it("induces token pick — last by space", () => {
25
+ const out = flashFill(
26
+ [
27
+ { source: "John Quincy Smith", output: "Smith" },
28
+ { source: "Mary Jane", output: "Jane" },
29
+ ],
30
+ ["Bob Builder"],
31
+ );
32
+ expect(out).toEqual(["Builder"]);
33
+ });
34
+
35
+ it("induces fixed slice", () => {
36
+ const out = flashFill(
37
+ [
38
+ { source: "12345", output: "234" },
39
+ { source: "67890", output: "789" },
40
+ ],
41
+ ["00000"],
42
+ );
43
+ expect(out).toEqual(["000"]);
44
+ });
45
+
46
+ it("induces concat template (Mr. + first token)", () => {
47
+ const out = flashFill(
48
+ [
49
+ { source: "John Smith", output: "Mr. John" },
50
+ { source: "Mary Jane", output: "Mr. Mary" },
51
+ ],
52
+ ["Bob Builder", "Sue Storm"],
53
+ );
54
+ expect(out).toEqual(["Mr. Bob", "Mr. Sue"]);
55
+ });
56
+
57
+ it("induces token pick from email (split by @)", () => {
58
+ const out = flashFill(
59
+ [
60
+ { source: "alice@example.com", output: "alice" },
61
+ { source: "bob@example.com", output: "bob" },
62
+ ],
63
+ ["carol@example.com"],
64
+ );
65
+ expect(out).toEqual(["carol"]);
66
+ });
67
+
68
+ it("returns undefined when no pattern matches", () => {
69
+ const out = flashFill(
70
+ [{ source: "hello", output: "totally unrelated" }],
71
+ ["test"],
72
+ );
73
+ expect(out).toBeUndefined();
74
+ });
75
+ });
@@ -0,0 +1,221 @@
1
+ // =============================================================================
2
+ // Flash Fill — Excel's Ctrl+E pattern induction
3
+ // =============================================================================
4
+ //
5
+ // Detect a simple text transformation by example: the user types one or more
6
+ // example outputs in a column adjacent to a source column, then presses
7
+ // Ctrl+E. We compare each example to the source row in the column to its
8
+ // left, induce a transformation pattern, and apply it to the rest of the
9
+ // column.
10
+ //
11
+ // Coverage targets the most common Flash Fill use cases that don't need
12
+ // a real DSL synthesizer:
13
+ // - **Identity**: copy source verbatim
14
+ // - **Substring**: prefix / suffix / range slice (start..end)
15
+ // - **Token pick**: split source by a delimiter, pick the Nth token
16
+ // - **Concat**: prefix + token + suffix patterns
17
+ // - **Case change**: UPPER / lower / Title Case
18
+ //
19
+ // More complex transformations (regex, conditional formatting, multi-source
20
+ // columns) are out of scope; users fall back to formulas.
21
+ // =============================================================================
22
+
23
+ export interface FlashFillExample {
24
+ source: string;
25
+ output: string;
26
+ }
27
+
28
+ /**
29
+ * Induce a pattern from examples and apply it to a list of new sources.
30
+ * Returns one output per `applyTo` source, or `undefined` for that row when
31
+ * no pattern matches. If no consistent pattern is induced, returns
32
+ * `undefined` for the whole array (caller should leave cells unchanged).
33
+ */
34
+ export function flashFill(
35
+ examples: FlashFillExample[],
36
+ applyTo: string[],
37
+ ): (string | undefined)[] | undefined {
38
+ const filtered = examples.filter((e) => e.output !== "");
39
+ if (filtered.length === 0) return undefined;
40
+
41
+ const patterns = [
42
+ inducePassthrough,
43
+ induceUpper,
44
+ induceLower,
45
+ induceTitle,
46
+ induceTokenPick,
47
+ induceSlice,
48
+ induceConcatTemplate,
49
+ ];
50
+
51
+ for (const induce of patterns) {
52
+ const fn = induce(filtered);
53
+ if (!fn) continue;
54
+ return applyTo.map((src) => fn(src));
55
+ }
56
+ return undefined;
57
+ }
58
+
59
+ type Transform = (source: string) => string | undefined;
60
+ type Inducer = (examples: FlashFillExample[]) => Transform | undefined;
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Trivial: copy source.
64
+ // ---------------------------------------------------------------------------
65
+ const inducePassthrough: Inducer = (examples) => {
66
+ if (examples.every((e) => e.source === e.output)) return (s) => s;
67
+ return undefined;
68
+ };
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Case transformations.
72
+ // ---------------------------------------------------------------------------
73
+ const induceUpper: Inducer = (examples) => {
74
+ if (examples.every((e) => e.source.toUpperCase() === e.output)) return (s) => s.toUpperCase();
75
+ return undefined;
76
+ };
77
+
78
+ const induceLower: Inducer = (examples) => {
79
+ if (examples.every((e) => e.source.toLowerCase() === e.output)) return (s) => s.toLowerCase();
80
+ return undefined;
81
+ };
82
+
83
+ const induceTitle: Inducer = (examples) => {
84
+ const titlecase = (s: string) =>
85
+ s.replace(/\w\S*/g, (w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase());
86
+ if (examples.every((e) => titlecase(e.source) === e.output)) return titlecase;
87
+ return undefined;
88
+ };
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Token pick: split source by a single delimiter, return the Nth token.
92
+ // We try every common delimiter and every reasonable index.
93
+ // ---------------------------------------------------------------------------
94
+ const COMMON_DELIMITERS = [" ", ",", "-", "/", ".", "_", "@", ";", "\t"];
95
+
96
+ const induceTokenPick: Inducer = (examples) => {
97
+ for (const delim of COMMON_DELIMITERS) {
98
+ if (!examples.every((e) => e.source.includes(delim))) continue;
99
+ const tokenCounts = examples.map((e) => e.source.split(delim).length);
100
+ const minTokens = Math.min(...tokenCounts);
101
+ for (let i = 0; i < minTokens; i++) {
102
+ const pickFromEnd = false;
103
+ let allMatch = true;
104
+ for (const e of examples) {
105
+ const tokens = e.source.split(delim);
106
+ const idx = pickFromEnd ? tokens.length - 1 - i : i;
107
+ if (tokens[idx] !== e.output) { allMatch = false; break; }
108
+ }
109
+ if (allMatch) {
110
+ return (s) => {
111
+ const tokens = s.split(delim);
112
+ return tokens[i];
113
+ };
114
+ }
115
+ }
116
+ // Pick from the end (negative index)
117
+ for (let i = 0; i < minTokens; i++) {
118
+ let allMatch = true;
119
+ for (const e of examples) {
120
+ const tokens = e.source.split(delim);
121
+ if (tokens[tokens.length - 1 - i] !== e.output) { allMatch = false; break; }
122
+ }
123
+ if (allMatch) {
124
+ return (s) => {
125
+ const tokens = s.split(delim);
126
+ return tokens[tokens.length - 1 - i];
127
+ };
128
+ }
129
+ }
130
+ }
131
+ return undefined;
132
+ };
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Substring slice: output is contiguous slice [start..end] of source.
136
+ // We induce by checking the first example, then verifying on the rest.
137
+ // ---------------------------------------------------------------------------
138
+ const induceSlice: Inducer = (examples) => {
139
+ const e0 = examples[0];
140
+ if (!e0.output) return undefined;
141
+ // Find indexOf(output, source) — first matching contiguous slice.
142
+ const idx = e0.source.indexOf(e0.output);
143
+ if (idx < 0) return undefined;
144
+ const len = e0.output.length;
145
+ const fromEnd = e0.source.length - idx - len;
146
+
147
+ // Try fixed-start fixed-length
148
+ if (examples.every((e) => e.source.substr(idx, len) === e.output)) {
149
+ return (s) => s.substr(idx, len);
150
+ }
151
+ // Try fixed-start, run-to-end
152
+ if (examples.every((e) => e.source.substring(idx) === e.output)) {
153
+ return (s) => s.substring(idx);
154
+ }
155
+ // Try start-to-fixed-length
156
+ if (idx === 0 && examples.every((e) => e.source.substring(0, len) === e.output)) {
157
+ return (s) => s.substring(0, len);
158
+ }
159
+ // Try fixed-from-end
160
+ if (examples.every((e) => e.source.substring(e.source.length - fromEnd - len, e.source.length - fromEnd) === e.output)) {
161
+ return (s) => s.substring(s.length - fromEnd - len, s.length - fromEnd);
162
+ }
163
+ return undefined;
164
+ };
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Concat template: detect a literal prefix + extracted-token + literal suffix.
168
+ // Example: "John Smith" → "Mr. John" comes from "Mr. " + first-token-by-space.
169
+ // We probe for the longest literal prefix and suffix shared across outputs,
170
+ // then check that the remaining middle is a token from the source.
171
+ // ---------------------------------------------------------------------------
172
+ const induceConcatTemplate: Inducer = (examples) => {
173
+ if (examples.length < 1) return undefined;
174
+ const outputs = examples.map((e) => e.output);
175
+
176
+ // Common prefix across outputs
177
+ let prefix = outputs[0];
178
+ for (let i = 1; i < outputs.length; i++) {
179
+ let j = 0;
180
+ while (j < prefix.length && j < outputs[i].length && prefix[j] === outputs[i][j]) j++;
181
+ prefix = prefix.slice(0, j);
182
+ }
183
+
184
+ // Common suffix across outputs
185
+ let suffix = outputs[0];
186
+ for (let i = 1; i < outputs.length; i++) {
187
+ let j = 0;
188
+ while (j < suffix.length && j < outputs[i].length && suffix[suffix.length - 1 - j] === outputs[i][outputs[i].length - 1 - j]) j++;
189
+ suffix = suffix.slice(suffix.length - j);
190
+ }
191
+
192
+ if (prefix.length + suffix.length === 0) return undefined;
193
+ // The middle bit (output without the prefix/suffix) must be a substring of
194
+ // source for every example. Check what slice of source produces it.
195
+ const middles = examples.map((e) =>
196
+ e.output.slice(prefix.length, e.output.length - suffix.length),
197
+ );
198
+
199
+ // Try: middle is the first token by some delimiter
200
+ for (const delim of COMMON_DELIMITERS) {
201
+ if (examples.every((e, i) => e.source.split(delim)[0] === middles[i])) {
202
+ return (s) => `${prefix}${s.split(delim)[0]}${suffix}`;
203
+ }
204
+ if (examples.every((e, i) => {
205
+ const t = e.source.split(delim);
206
+ return t[t.length - 1] === middles[i];
207
+ })) {
208
+ return (s) => {
209
+ const t = s.split(delim);
210
+ return `${prefix}${t[t.length - 1]}${suffix}`;
211
+ };
212
+ }
213
+ }
214
+
215
+ // Try: middle is the entire source
216
+ if (examples.every((e, i) => e.source === middles[i])) {
217
+ return (s) => `${prefix}${s}${suffix}`;
218
+ }
219
+
220
+ return undefined;
221
+ };
@@ -0,0 +1,189 @@
1
+ // =============================================================================
2
+ // Formula Dependency Graph
3
+ // =============================================================================
4
+ //
5
+ // Tracks which cells depend on which other cells. Used for:
6
+ // 1. Incremental recalculation — only recompute affected cells after an edit
7
+ // 2. Topological sort — evaluate formulas in correct dependency order
8
+ // 3. Circular reference detection — detect and flag #CIRC! errors
9
+ //
10
+ // Cell keys are qualified: "Sheet1!A1", "Sheet2!B5", etc.
11
+ // =============================================================================
12
+
13
+ // =============================================================================
14
+ // Types
15
+ // =============================================================================
16
+
17
+ export interface CellRef {
18
+ sheet: string;
19
+ ref: string; // A1-style
20
+ }
21
+
22
+ /** Unique string key for a cell reference. */
23
+ export function cellKey(cell: CellRef): string {
24
+ return `${cell.sheet}!${cell.ref}`;
25
+ }
26
+
27
+ export function parseCellKey(key: string): CellRef {
28
+ const bang = key.indexOf("!");
29
+ return { sheet: key.slice(0, bang), ref: key.slice(bang + 1) };
30
+ }
31
+
32
+ // =============================================================================
33
+ // DependencyGraph
34
+ // =============================================================================
35
+
36
+ export class DependencyGraph {
37
+ // Forward edges: cell → cells it references (precedents)
38
+ private precedents = new Map<string, Set<string>>();
39
+ // Reverse edges: cell → cells that reference it (dependents)
40
+ private dependents = new Map<string, Set<string>>();
41
+
42
+ /** Set all dependencies for a cell (replacing any existing ones). */
43
+ setDependencies(cell: string, deps: string[]): void {
44
+ // Remove old edges
45
+ this.removeCell(cell);
46
+
47
+ if (deps.length === 0) return;
48
+
49
+ // Add new edges
50
+ const precSet = new Set(deps);
51
+ this.precedents.set(cell, precSet);
52
+ for (const dep of precSet) {
53
+ let depSet = this.dependents.get(dep);
54
+ if (!depSet) {
55
+ depSet = new Set();
56
+ this.dependents.set(dep, depSet);
57
+ }
58
+ depSet.add(cell);
59
+ }
60
+ }
61
+
62
+ /** Remove all edges for a cell (when formula is deleted or cell is cleared). */
63
+ removeCell(cell: string): void {
64
+ const oldPrec = this.precedents.get(cell);
65
+ if (oldPrec) {
66
+ for (const dep of oldPrec) {
67
+ this.dependents.get(dep)?.delete(cell);
68
+ }
69
+ this.precedents.delete(cell);
70
+ }
71
+ }
72
+
73
+ /** Get all cells that directly depend on the given cell. */
74
+ getDirectDependents(cell: string): string[] {
75
+ return Array.from(this.dependents.get(cell) ?? []);
76
+ }
77
+
78
+ /** Get all cells that need recalculation when the given cells change, in topological order. */
79
+ getRecalcOrder(changed: string[]): string[] {
80
+ const changedSet = new Set(changed);
81
+
82
+ // BFS to find all transitive dependents (index pointer instead of shift())
83
+ const affected = new Set<string>();
84
+ const queue = [...changed];
85
+ let head = 0;
86
+ while (head < queue.length) {
87
+ const cell = queue[head++];
88
+ const deps = this.dependents.get(cell);
89
+ if (!deps) continue;
90
+ for (const dep of deps) {
91
+ if (!affected.has(dep)) {
92
+ affected.add(dep);
93
+ queue.push(dep);
94
+ }
95
+ }
96
+ }
97
+
98
+ if (affected.size === 0) return [];
99
+
100
+ // Topological sort using Kahn's algorithm (restricted to affected cells).
101
+ // inDegree counts precedents in `affected` only — precedents in
102
+ // `changedSet` (the seed) are evaluated before this `order` runs, so
103
+ // their dependents must start with inDegree 0 within affected. Including
104
+ // changedSet here would make every dependent appear cyclic when the
105
+ // seed is the entire formula set (evaluateAll), degenerating Kahn into
106
+ // arbitrary insertion order and breaking cascading SUM/range formulas.
107
+ const inDegree = new Map<string, number>();
108
+ for (const cell of affected) {
109
+ let count = 0;
110
+ const prec = this.precedents.get(cell);
111
+ if (prec) {
112
+ for (const p of prec) {
113
+ if (affected.has(p)) count++;
114
+ }
115
+ }
116
+ inDegree.set(cell, count);
117
+ }
118
+
119
+ const result: string[] = [];
120
+ const ready: string[] = [];
121
+ let readyHead = 0;
122
+ for (const [cell, deg] of inDegree) {
123
+ if (deg === 0) ready.push(cell);
124
+ }
125
+
126
+ while (readyHead < ready.length) {
127
+ const cell = ready[readyHead++];
128
+ result.push(cell);
129
+ const deps = this.dependents.get(cell);
130
+ if (!deps) continue;
131
+ for (const dep of deps) {
132
+ if (!affected.has(dep)) continue;
133
+ const newDeg = (inDegree.get(dep) ?? 1) - 1;
134
+ inDegree.set(dep, newDeg);
135
+ if (newDeg === 0) ready.push(dep);
136
+ }
137
+ }
138
+
139
+ // Any remaining cells with non-zero in-degree are part of a cycle
140
+ const resultSet = new Set(result);
141
+ for (const [cell, deg] of inDegree) {
142
+ if (deg > 0 && !resultSet.has(cell)) {
143
+ result.push(cell);
144
+ }
145
+ }
146
+
147
+ return result;
148
+ }
149
+
150
+ /** Detect if `cell` appears in its own transitive precedent chain (circular reference). */
151
+ detectCycle(cell: string): string[] | null {
152
+ const visited = new Set<string>();
153
+ const stack = new Array<{ cell: string; path: string[] }>();
154
+
155
+ // Seed with direct precedents
156
+ const prec = this.precedents.get(cell);
157
+ if (!prec) return null;
158
+
159
+ for (const p of prec) {
160
+ stack.push({ cell: p, path: [cell, p] });
161
+ }
162
+
163
+ while (stack.length > 0) {
164
+ const { cell: current, path } = stack.pop()!;
165
+ if (current === cell) return path;
166
+ if (visited.has(current)) continue;
167
+ visited.add(current);
168
+
169
+ const nextPrec = this.precedents.get(current);
170
+ if (!nextPrec) continue;
171
+ for (const p of nextPrec) {
172
+ stack.push({ cell: p, path: [...path, p] });
173
+ }
174
+ }
175
+
176
+ return null;
177
+ }
178
+
179
+ /** Clear the entire graph. */
180
+ clear(): void {
181
+ this.precedents.clear();
182
+ this.dependents.clear();
183
+ }
184
+
185
+ /** Number of cells with dependencies. */
186
+ get size(): number {
187
+ return this.precedents.size;
188
+ }
189
+ }