hoi4-agent-tools 4.3.0 → 4.4.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 (110) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/dist/hoi4_agent_tools/core/bounded-source.js +31 -9
  3. package/dist/hoi4_agent_tools/core/configuration.js +11 -0
  4. package/dist/hoi4_agent_tools/core/operation-context.js +17 -1
  5. package/dist/hoi4_agent_tools/core/temporary-directories.js +7 -2
  6. package/dist/hoi4_agent_tools/core/transaction-limits.js +18 -4
  7. package/dist/hoi4_agent_tools/core/transactions.js +23 -4
  8. package/dist/hoi4_agent_tools/core/workspace-json.js +54 -0
  9. package/dist/hoi4_agent_tools/core/workspace.js +42 -6
  10. package/dist/hoi4_agent_tools/map/appearance-validation.js +1110 -0
  11. package/dist/hoi4_agent_tools/map/bmp.js +9 -1
  12. package/dist/hoi4_agent_tools/map/border-quality-input.js +209 -0
  13. package/dist/hoi4_agent_tools/map/border-quality.js +1180 -0
  14. package/dist/hoi4_agent_tools/map/colormap.js +128 -0
  15. package/dist/hoi4_agent_tools/map/geography/geometry.js +284 -0
  16. package/dist/hoi4_agent_tools/map/geography/index.js +6 -0
  17. package/dist/hoi4_agent_tools/map/geography/model.js +196 -0
  18. package/dist/hoi4_agent_tools/map/geography/preparation.js +120 -0
  19. package/dist/hoi4_agent_tools/map/geography/preview.js +647 -0
  20. package/dist/hoi4_agent_tools/map/geography/raster.js +253 -0
  21. package/dist/hoi4_agent_tools/map/geography/resolve.js +1467 -0
  22. package/dist/hoi4_agent_tools/map/model.js +15 -2
  23. package/dist/hoi4_agent_tools/map/operations.js +111 -8
  24. package/dist/hoi4_agent_tools/map/partition-fields.js +398 -0
  25. package/dist/hoi4_agent_tools/map/partition-geography.js +113 -0
  26. package/dist/hoi4_agent_tools/map/partition-graph.js +165 -0
  27. package/dist/hoi4_agent_tools/map/partition-groups.js +164 -0
  28. package/dist/hoi4_agent_tools/map/partition-provinces.js +727 -0
  29. package/dist/hoi4_agent_tools/map/partition-tuning.js +101 -0
  30. package/dist/hoi4_agent_tools/map/partition-watershed.js +252 -0
  31. package/dist/hoi4_agent_tools/map/partition.js +163 -0
  32. package/dist/hoi4_agent_tools/map/reference-import.js +333 -0
  33. package/dist/hoi4_agent_tools/map/reference-preparation.js +138 -5
  34. package/dist/hoi4_agent_tools/map/reference-sheets.js +231 -0
  35. package/dist/hoi4_agent_tools/map/reference-world.js +104 -13
  36. package/dist/hoi4_agent_tools/map/relief/checks.js +281 -0
  37. package/dist/hoi4_agent_tools/map/relief/fields.js +15 -0
  38. package/dist/hoi4_agent_tools/map/relief/full-resolution.js +133 -0
  39. package/dist/hoi4_agent_tools/map/relief/grid.js +190 -0
  40. package/dist/hoi4_agent_tools/map/relief/hydrology.js +1315 -0
  41. package/dist/hoi4_agent_tools/map/relief/index.js +27 -0
  42. package/dist/hoi4_agent_tools/map/relief/land.js +110 -0
  43. package/dist/hoi4_agent_tools/map/relief/macro-relief.js +535 -0
  44. package/dist/hoi4_agent_tools/map/relief/math.js +93 -0
  45. package/dist/hoi4_agent_tools/map/relief/river-raster.js +680 -0
  46. package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +304 -0
  47. package/dist/hoi4_agent_tools/map/render.js +36 -0
  48. package/dist/hoi4_agent_tools/map/service.js +191 -16
  49. package/dist/hoi4_agent_tools/map/tool-operations.js +208 -2
  50. package/dist/hoi4_agent_tools/map/validation.js +9 -0
  51. package/dist/hoi4_agent_tools/map/water.js +227 -0
  52. package/dist/hoi4_agent_tools/map/world-context.js +24 -1
  53. package/dist/hoi4_agent_tools/map/world-edge.js +335 -0
  54. package/dist/hoi4_agent_tools/map/world-noise.js +34 -0
  55. package/dist/hoi4_agent_tools/map/world-partition.js +119 -0
  56. package/dist/hoi4_agent_tools/map/world-provinces.js +131 -56
  57. package/dist/hoi4_agent_tools/map/world.js +462 -124
  58. package/dist/hoi4_agent_tools/mcp/tools/map.js +69 -1
  59. package/dist/hoi4_agent_tools/reference/operations.js +3 -2
  60. package/dist/hoi4_agent_tools/schemas/geography.js +325 -0
  61. package/dist/hoi4_agent_tools/schemas/map-requests.js +20 -2
  62. package/dist/hoi4_agent_tools/schemas/map.js +209 -2
  63. package/dist/hoi4_agent_tools/schemas/reference-sheets.js +92 -0
  64. package/dist/hoi4_agent_tools/version.js +1 -1
  65. package/docs/configuration.md +17 -14
  66. package/docs/map.md +258 -15
  67. package/docs/tools.md +4 -4
  68. package/docs/troubleshooting.md +11 -11
  69. package/npm-shrinkwrap.json +109 -91
  70. package/package.json +3 -3
  71. package/schemas/configuration.schema.json +65 -58
  72. package/schemas/continuous-focus-palette.schema.json +1 -1
  73. package/schemas/custom-weighted-pool.schema.json +1 -1
  74. package/schemas/decision-inspect-input.schema.json +1 -1
  75. package/schemas/error-log-input.schema.json +1 -1
  76. package/schemas/event-feature-manifest.schema.json +1 -1
  77. package/schemas/focus-plan.schema.json +1 -1
  78. package/schemas/focus-planning-sidecar.schema.json +1 -1
  79. package/schemas/geography-model.schema.json +1148 -0
  80. package/schemas/gui-animation-source.schema.json +1 -1
  81. package/schemas/gui-helper.schema.json +5 -11
  82. package/schemas/gui-scenario.schema.json +1 -1
  83. package/schemas/helper-expansion-request.schema.json +1 -1
  84. package/schemas/helper-expansion-summary.schema.json +1 -1
  85. package/schemas/impact-inspect-input.schema.json +1 -1
  86. package/schemas/map-operation.schema.json +2156 -259
  87. package/schemas/mechanic-test-input.schema.json +1 -1
  88. package/schemas/mod-index-input.schema.json +1 -1
  89. package/schemas/operation-result.schema.json +1 -1
  90. package/schemas/package-check-input.schema.json +1 -1
  91. package/schemas/package-manifest.schema.json +1 -1
  92. package/schemas/probability-analysis-result.schema.json +25 -7
  93. package/schemas/probability-compare-input.schema.json +1 -1
  94. package/schemas/probability-evaluate-input.schema.json +1 -1
  95. package/schemas/probability-inspect-input.schema.json +1 -1
  96. package/schemas/probability-render-input.schema.json +1 -1
  97. package/schemas/probability-scenario-set.schema.json +1 -1
  98. package/schemas/probability-sequence-input.schema.json +1 -1
  99. package/schemas/probability-simulate-input.schema.json +1 -1
  100. package/schemas/probability-sweep-input.schema.json +1 -1
  101. package/schemas/reference-context-input.schema.json +1 -1
  102. package/schemas/reference-read-input.schema.json +1 -1
  103. package/schemas/reference-search-input.schema.json +1 -1
  104. package/schemas/reference-sheets.schema.json +267 -0
  105. package/schemas/scenario-suite.schema.json +1 -1
  106. package/schemas/scenario-test-input.schema.json +1 -1
  107. package/schemas/script-validate-input.schema.json +1 -1
  108. package/schemas/shared-scenario.schema.json +1 -1
  109. package/schemas/source-lookup-input.schema.json +1 -1
  110. package/server.json +2 -2
@@ -0,0 +1,113 @@
1
+ import { ServiceError } from '../core/result.js';
2
+ import { forEachPolylinePixel, rowStrips, scanAreaStrip, } from './geography/index.js';
3
+ import { PARTITION_KIND } from './partition-fields.js';
4
+ import { BARRIER_CREST, BARRIER_FORT_LINE, BARRIER_WALL, } from './relief/fields.js';
5
+ /**
6
+ * Geography inputs of the partition (ADR 0058): the named regions and seas of the model become the
7
+ * `regionId` plane and the region table (with the density each asks for), and every range that
8
+ * declares itself a wall or an impassable barrier becomes a barrier line no province may straddle.
9
+ * Relief, rivers, fertility and terrain classes stay as the caller computed them.
10
+ */
11
+ /** Pixels written by one range path per side: three pixels wide, so no 4-connected path crosses. */
12
+ const WALL_HALF_WIDTH = 1;
13
+ /**
14
+ * Return `fields` with `regionId`, `regions` and `barrier` taken from the resolved geography.
15
+ * Land regions are written on land pixels and named seas on sea pixels, so a lake is never part
16
+ * of a region by itself. Overlapping areas resolve in id order, later ids winning; ids are the
17
+ * 1-based position in the sorted list of regions that have an area, followed by the seas.
18
+ */
19
+ export function partitionFieldsFromGeography(fields, geography, options = {}) {
20
+ const { width, height, kind } = fields;
21
+ const grid = { width, height };
22
+ const regionId = new Uint16Array(fields.regionId);
23
+ const barrier = new Uint8Array(fields.barrier);
24
+ const table = [];
25
+ const areas = [];
26
+ const seaAreas = [];
27
+ let next = 1;
28
+ for (const region of geography.regions) {
29
+ if (region.area === undefined)
30
+ continue;
31
+ const entry = { id: next, kind: 'land' };
32
+ if (region.name !== undefined)
33
+ entry.name = region.name;
34
+ if (region.provinceAreaPx !== undefined)
35
+ entry.provinceAreaPx = region.provinceAreaPx;
36
+ if (region.provincesPerState !== undefined)
37
+ entry.provincesPerState = region.provincesPerState;
38
+ table.push(entry);
39
+ areas.push({ id: next, area: region.area });
40
+ next += 1;
41
+ }
42
+ for (const sea of geography.seas) {
43
+ if (next > 65_535)
44
+ break;
45
+ const entry = { id: next, kind: 'sea' };
46
+ if (sea.name !== undefined)
47
+ entry.name = sea.name;
48
+ table.push(entry);
49
+ seaAreas.push({ id: next, area: sea.area });
50
+ next += 1;
51
+ }
52
+ for (const strip of rowStrips(grid, options.stripRows ?? 256)) {
53
+ for (const { id, area } of areas)
54
+ scanAreaStrip(area, grid, strip, (y, x0, x1) => {
55
+ const row = y * width;
56
+ for (let x = x0; x < x1; x++)
57
+ if (kind[row + x] === PARTITION_KIND.land)
58
+ regionId[row + x] = id;
59
+ });
60
+ for (const { id, area } of seaAreas)
61
+ scanAreaStrip(area, grid, strip, (y, x0, x1) => {
62
+ const row = y * width;
63
+ for (let x = x0; x < x1; x++)
64
+ if (kind[row + x] === PARTITION_KIND.sea)
65
+ regionId[row + x] = id;
66
+ });
67
+ }
68
+ const whole = { y0: 0, y1: height };
69
+ for (const range of geography.ranges) {
70
+ if (range.profile !== 'wall' && range.barrier !== 'impassable')
71
+ continue;
72
+ forEachPolylinePixel(range.path, grid, whole, (x, y) => {
73
+ for (let dy = -WALL_HALF_WIDTH; dy <= WALL_HALF_WIDTH; dy++)
74
+ for (let dx = -WALL_HALF_WIDTH; dx <= WALL_HALF_WIDTH; dx++) {
75
+ if (Math.abs(dx) + Math.abs(dy) > WALL_HALF_WIDTH)
76
+ continue;
77
+ const yy = y + dy;
78
+ if (yy < 0 || yy >= height)
79
+ continue;
80
+ barrier[yy * width + ((x + dx + width) % width)] = 255;
81
+ }
82
+ });
83
+ }
84
+ return { ...fields, regionId, barrier, regions: table };
85
+ }
86
+ /** Strength each model-drawn line of the terrain synthesis gives the partition's barrier plane. */
87
+ const TERRAIN_LINE_STRENGTH = [
88
+ [BARRIER_WALL, 255],
89
+ [BARRIER_FORT_LINE, 180],
90
+ [BARRIER_CREST, 150],
91
+ ];
92
+ /**
93
+ * Return `fields` with the lines the terrain synthesis drew from the model (ADR 0056) raised in
94
+ * its `barrier` plane: a wall outside its pass gaps at 255 (a hard line no province straddles), a
95
+ * fort line at 180 and a range crest at 150. Rivers and steep ground are left to the
96
+ * `riverDistance`, `slope` and `ridge` planes, which already carry them, so they do not count
97
+ * twice. A stronger value already in the plane is kept.
98
+ */
99
+ export function partitionFieldsWithTerrainLines(fields, lines) {
100
+ const { width, height } = fields;
101
+ if (lines.width !== width || lines.height !== height)
102
+ throw new ServiceError('MAP_PARTITION_FIELDS_INVALID', 'The terrain barrier field does not match the partition dimensions', { width, height, barrier: { width: lines.width, height: lines.height } });
103
+ const barrier = new Uint8Array(fields.barrier);
104
+ for (let p = 0; p < barrier.length; p++) {
105
+ const sources = lines.sources[p];
106
+ if (sources === 0)
107
+ continue;
108
+ for (const [flag, strength] of TERRAIN_LINE_STRENGTH)
109
+ if ((sources & flag) !== 0 && strength > barrier[p])
110
+ barrier[p] = strength;
111
+ }
112
+ return { ...fields, barrier };
113
+ }
@@ -0,0 +1,165 @@
1
+ import { ServiceError } from '../core/result.js';
2
+ /** Build the weighted graph in one pass over the province raster. */
3
+ export function buildProvinceGraph(input) {
4
+ const { width, height, pixel, barrier } = input;
5
+ const count = input.kind.length;
6
+ if (pixel.length !== width * height)
7
+ throw new ServiceError('MAP_PARTITION_GRAPH_INVALID', 'The province raster does not match the map dimensions');
8
+ if (barrier !== undefined && barrier.length !== pixel.length)
9
+ throw new ServiceError('MAP_PARTITION_GRAPH_INVALID', 'The barrier plane does not match the map dimensions');
10
+ const index = new Map();
11
+ let capacity = 1 << 14;
12
+ let lengthList = new Uint32Array(capacity);
13
+ let sumList = new Float64Array(capacity);
14
+ let fromList = new Int32Array(capacity);
15
+ let toList = new Int32Array(capacity);
16
+ let edges = 0;
17
+ const add = (a, b, strength) => {
18
+ const low = a < b ? a : b;
19
+ const high = a < b ? b : a;
20
+ const key = low * 65_536 + high;
21
+ let slot = index.get(key);
22
+ if (slot === undefined) {
23
+ slot = edges++;
24
+ if (slot === capacity) {
25
+ capacity *= 2;
26
+ const l = new Uint32Array(capacity);
27
+ l.set(lengthList);
28
+ lengthList = l;
29
+ const s = new Float64Array(capacity);
30
+ s.set(sumList);
31
+ sumList = s;
32
+ const f = new Int32Array(capacity);
33
+ f.set(fromList);
34
+ fromList = f;
35
+ const t = new Int32Array(capacity);
36
+ t.set(toList);
37
+ toList = t;
38
+ }
39
+ fromList[slot] = low;
40
+ toList[slot] = high;
41
+ index.set(key, slot);
42
+ }
43
+ lengthList[slot]++;
44
+ sumList[slot] += strength;
45
+ };
46
+ if (count > 65_535)
47
+ throw new ServiceError('MAP_PARTITION_GRAPH_INVALID', 'The graph supports 65,535 provinces');
48
+ for (let y = 0; y < height; y++) {
49
+ const row = y * width;
50
+ for (let x = 0; x < width; x++) {
51
+ const p = row + x;
52
+ const id = pixel[p];
53
+ const east = x + 1 < width ? p + 1 : row;
54
+ const other = pixel[east];
55
+ if (other !== id)
56
+ add(id, other, barrier === undefined ? 0 : Math.max(barrier[p], barrier[east]));
57
+ if (y + 1 < height) {
58
+ const south = p + width;
59
+ const below = pixel[south];
60
+ if (below !== id)
61
+ add(id, below, barrier === undefined ? 0 : Math.max(barrier[p], barrier[south]));
62
+ }
63
+ }
64
+ }
65
+ const degree = new Int32Array(count + 1);
66
+ for (let e = 0; e < edges; e++) {
67
+ degree[fromList[e] + 1]++;
68
+ degree[toList[e] + 1]++;
69
+ }
70
+ for (let i = 0; i < count; i++)
71
+ degree[i + 1] += degree[i];
72
+ const offsets = degree;
73
+ const cursor = Int32Array.from(offsets.subarray(0, count));
74
+ const to = new Int32Array(offsets[count]);
75
+ const length = new Uint32Array(offsets[count]);
76
+ const mean = new Float32Array(offsets[count]);
77
+ // Edges sorted by (low, high) keep every row ascending without a second sort.
78
+ const order = Array.from({ length: edges }, (_, i) => i).sort((a, b) => fromList[a] - fromList[b] || toList[a] - toList[b]);
79
+ for (const e of order) {
80
+ const a = fromList[e];
81
+ const b = toList[e];
82
+ const strength = sumList[e] / lengthList[e] / 255;
83
+ to[cursor[a]] = b;
84
+ length[cursor[a]] = lengthList[e];
85
+ mean[cursor[a]++] = strength;
86
+ }
87
+ for (const e of order) {
88
+ const a = fromList[e];
89
+ const b = toList[e];
90
+ const strength = sumList[e] / lengthList[e] / 255;
91
+ to[cursor[b]] = a;
92
+ length[cursor[b]] = lengthList[e];
93
+ mean[cursor[b]++] = strength;
94
+ }
95
+ // Rows now hold the high-numbered neighbours of each province, then the low-numbered ones.
96
+ sortRows(offsets, to, length, mean);
97
+ const kind = Uint8Array.from({ length: count }, (_, i) => input.kind[i]);
98
+ const size = Uint32Array.from({ length: count }, (_, i) => input.size[i]);
99
+ const centerX = new Float32Array(count);
100
+ const centerY = new Float32Array(count);
101
+ for (let i = 0; i < count; i++) {
102
+ const n = Math.max(1, size[i]);
103
+ centerX[i] = input.sumX[i] / n;
104
+ centerY[i] = input.sumY[i] / n;
105
+ }
106
+ return {
107
+ count,
108
+ kind,
109
+ offsets,
110
+ to,
111
+ length,
112
+ barrier: mean,
113
+ centerX,
114
+ centerY,
115
+ width,
116
+ height,
117
+ size,
118
+ };
119
+ }
120
+ function sortRows(offsets, to, length, barrier) {
121
+ for (let row = 0; row + 1 < offsets.length; row++) {
122
+ const start = offsets[row];
123
+ const end = offsets[row + 1];
124
+ // Insertion sort: rows hold a handful of neighbours.
125
+ for (let i = start + 1; i < end; i++) {
126
+ const t = to[i];
127
+ const l = length[i];
128
+ const b = barrier[i];
129
+ let j = i - 1;
130
+ while (j >= start && to[j] > t) {
131
+ to[j + 1] = to[j];
132
+ length[j + 1] = length[j];
133
+ barrier[j + 1] = barrier[j];
134
+ j--;
135
+ }
136
+ to[j + 1] = t;
137
+ length[j + 1] = l;
138
+ barrier[j + 1] = b;
139
+ }
140
+ }
141
+ }
142
+ /**
143
+ * Directed edge costs for growing territory over the graph: centroid distance (across the seam
144
+ * when shorter) times `1 + barrier * strength`, aligned with `graph.to`. Territory assignment
145
+ * runs its Dijkstra on these and may add its own terms.
146
+ */
147
+ export function provinceEdgeCosts(graph, options = {}) {
148
+ const beta = options.barrier ?? 6;
149
+ const water = options.water ?? 3;
150
+ const cost = new Float32Array(graph.to.length);
151
+ for (let a = 0; a < graph.count; a++)
152
+ for (let e = graph.offsets[a]; e < graph.offsets[a + 1]; e++) {
153
+ const b = graph.to[e];
154
+ let dx = graph.centerX[a] - graph.centerX[b];
155
+ if (dx > graph.width / 2)
156
+ dx -= graph.width;
157
+ else if (dx < -graph.width / 2)
158
+ dx += graph.width;
159
+ const dy = graph.centerY[a] - graph.centerY[b];
160
+ const distance = Math.sqrt(dx * dx + dy * dy);
161
+ cost[e] =
162
+ distance * (1 + beta * graph.barrier[e]) * (graph.kind[a] === graph.kind[b] ? 1 : water);
163
+ }
164
+ return cost;
165
+ }
@@ -0,0 +1,164 @@
1
+ /** A binary max-heap keyed by score, then by the lower ids. */
2
+ class CandidateHeap {
3
+ #items = [];
4
+ get size() {
5
+ return this.#items.length;
6
+ }
7
+ static #before(x, y) {
8
+ if (x.score !== y.score)
9
+ return x.score > y.score;
10
+ if (x.a !== y.a)
11
+ return x.a < y.a;
12
+ return x.b < y.b;
13
+ }
14
+ push(item) {
15
+ const items = this.#items;
16
+ let i = items.length;
17
+ items.push(item);
18
+ while (i > 0) {
19
+ const parent = (i - 1) >> 1;
20
+ if (!CandidateHeap.#before(items[i], items[parent]))
21
+ break;
22
+ [items[i], items[parent]] = [items[parent], items[i]];
23
+ i = parent;
24
+ }
25
+ }
26
+ pop() {
27
+ const items = this.#items;
28
+ if (items.length === 0)
29
+ return undefined;
30
+ const top = items[0];
31
+ const last = items.pop();
32
+ if (items.length > 0) {
33
+ items[0] = last;
34
+ let i = 0;
35
+ for (;;) {
36
+ const l = i * 2 + 1;
37
+ const r = l + 1;
38
+ let best = i;
39
+ if (l < items.length && CandidateHeap.#before(items[l], items[best]))
40
+ best = l;
41
+ if (r < items.length && CandidateHeap.#before(items[r], items[best]))
42
+ best = r;
43
+ if (best === i)
44
+ break;
45
+ [items[i], items[best]] = [items[best], items[i]];
46
+ i = best;
47
+ }
48
+ }
49
+ return top;
50
+ }
51
+ }
52
+ /**
53
+ * Cluster `members` (province ids) over the graph. Returns the cluster of every member, numbered
54
+ * from 0 by the smallest member id of each cluster.
55
+ */
56
+ export function clusterProvinces(members, graph, options, hooks = {}) {
57
+ const lambda = options.lambda ?? 12;
58
+ const hard = options.hardBarrier ?? 0.6;
59
+ const unlike = options.unlikeGround ?? 0.55;
60
+ const sorted = [...members].sort((a, b) => a - b);
61
+ const local = new Map(sorted.map((id, index) => [id, index]));
62
+ const n = sorted.length;
63
+ const sizeOf = new Int32Array(n).fill(1);
64
+ const perimeter = new Float64Array(n);
65
+ const version = new Int32Array(n);
66
+ const alive = new Uint8Array(n).fill(1);
67
+ const affinity = Array.from({ length: n }, () => new Map());
68
+ const shared = Array.from({ length: n }, () => new Map());
69
+ for (let i = 0; i < n; i++) {
70
+ const id = sorted[i];
71
+ for (let e = graph.offsets[id]; e < graph.offsets[id + 1]; e++) {
72
+ perimeter[i] += graph.length[e];
73
+ const other = local.get(graph.to[e]);
74
+ if (other === undefined || other === i)
75
+ continue;
76
+ if (hooks.group !== undefined && hooks.group(id) !== hooks.group(graph.to[e]))
77
+ continue;
78
+ const strength = graph.barrier[e];
79
+ const damp = 1 / ((1 + lambda * strength) * (1 + lambda * strength));
80
+ let weight = graph.length[e] * damp;
81
+ if (strength > hard)
82
+ weight *= 0.05;
83
+ if (hooks.label !== undefined && hooks.label(id) !== hooks.label(graph.to[e]))
84
+ weight *= unlike;
85
+ affinity[i].set(other, weight);
86
+ shared[i].set(other, graph.length[e]);
87
+ }
88
+ }
89
+ const heap = new CandidateHeap();
90
+ const scoreOf = (a, b) => {
91
+ const total = sizeOf[a] + sizeOf[b];
92
+ if (total > options.max)
93
+ return 0;
94
+ let factor = total <= options.target ? 1 : (options.max - total) / (options.max - options.target + 1);
95
+ const small = Math.min(sizeOf[a], sizeOf[b]) < options.min;
96
+ if (small)
97
+ factor = Math.max(factor, 0.05) * 2;
98
+ if (factor <= 0)
99
+ return 0;
100
+ const base = affinity[a].get(b);
101
+ return (base / Math.sqrt(Math.max(1, perimeter[a]) * Math.max(1, perimeter[b]))) * factor;
102
+ };
103
+ const offer = (a, b) => {
104
+ const low = Math.min(a, b);
105
+ const high = Math.max(a, b);
106
+ const score = scoreOf(low, high);
107
+ if (score > 0)
108
+ heap.push({ score, a: low, b: high, versionA: version[low], versionB: version[high] });
109
+ };
110
+ for (let i = 0; i < n; i++)
111
+ for (const other of affinity[i].keys())
112
+ if (other > i)
113
+ offer(i, other);
114
+ const parent = Int32Array.from({ length: n }, (_, i) => i);
115
+ for (let top = heap.pop(); top !== undefined; top = heap.pop()) {
116
+ const { a, b } = top;
117
+ if (!alive[a] || !alive[b] || version[a] !== top.versionA || version[b] !== top.versionB)
118
+ continue;
119
+ // Merge b into a. Only pairs that involve a change, so only a's version moves on.
120
+ const between = shared[a].get(b) ?? 0;
121
+ perimeter[a] = perimeter[a] + perimeter[b] - 2 * between;
122
+ sizeOf[a] += sizeOf[b];
123
+ alive[b] = 0;
124
+ parent[b] = a;
125
+ affinity[a].delete(b);
126
+ shared[a].delete(b);
127
+ for (const [c, weight] of affinity[b]) {
128
+ if (c === a)
129
+ continue;
130
+ const combined = (affinity[a].get(c) ?? 0) + weight;
131
+ const length = (shared[a].get(c) ?? 0) + (shared[b].get(c) ?? 0);
132
+ affinity[a].set(c, combined);
133
+ shared[a].set(c, length);
134
+ affinity[c].delete(b);
135
+ shared[c].delete(b);
136
+ affinity[c].set(a, combined);
137
+ shared[c].set(a, length);
138
+ }
139
+ affinity[b].clear();
140
+ shared[b].clear();
141
+ version[a]++;
142
+ for (const c of affinity[a].keys())
143
+ offer(a, c);
144
+ }
145
+ // Number clusters by their smallest member (members are sorted, so the first one seen).
146
+ const root = (i) => {
147
+ let r = i;
148
+ while (parent[r] !== r)
149
+ r = parent[r];
150
+ return r;
151
+ };
152
+ const dense = new Map();
153
+ const result = new Map();
154
+ for (let i = 0; i < n; i++) {
155
+ const r = root(i);
156
+ let id = dense.get(r);
157
+ if (id === undefined) {
158
+ id = dense.size;
159
+ dense.set(r, id);
160
+ }
161
+ result.set(sorted[i], id);
162
+ }
163
+ return result;
164
+ }