hoi4-agent-tools 4.3.0 → 4.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/CHANGELOG.md +33 -1
- package/dist/hoi4_agent_tools/core/bounded-source.js +31 -9
- package/dist/hoi4_agent_tools/core/configuration.js +11 -0
- package/dist/hoi4_agent_tools/core/managed-outputs.js +26 -6
- package/dist/hoi4_agent_tools/core/operation-context.js +17 -1
- package/dist/hoi4_agent_tools/core/source/encoding.js +56 -2
- package/dist/hoi4_agent_tools/core/source/parser.js +16 -1
- package/dist/hoi4_agent_tools/core/temporary-directories.js +7 -2
- package/dist/hoi4_agent_tools/core/transaction-limits.js +18 -4
- package/dist/hoi4_agent_tools/core/transactions.js +23 -4
- package/dist/hoi4_agent_tools/core/workspace-json.js +54 -0
- package/dist/hoi4_agent_tools/core/workspace.js +42 -6
- package/dist/hoi4_agent_tools/focus/drift.js +34 -0
- package/dist/hoi4_agent_tools/focus/service.js +9 -3
- package/dist/hoi4_agent_tools/focus/tool-operations.js +2 -0
- package/dist/hoi4_agent_tools/map/appearance-validation.js +1110 -0
- package/dist/hoi4_agent_tools/map/bmp.js +9 -1
- package/dist/hoi4_agent_tools/map/border-quality-input.js +209 -0
- package/dist/hoi4_agent_tools/map/border-quality.js +1180 -0
- package/dist/hoi4_agent_tools/map/colormap.js +128 -0
- package/dist/hoi4_agent_tools/map/geography/geometry.js +284 -0
- package/dist/hoi4_agent_tools/map/geography/index.js +6 -0
- package/dist/hoi4_agent_tools/map/geography/model.js +196 -0
- package/dist/hoi4_agent_tools/map/geography/preparation.js +120 -0
- package/dist/hoi4_agent_tools/map/geography/preview.js +647 -0
- package/dist/hoi4_agent_tools/map/geography/raster.js +253 -0
- package/dist/hoi4_agent_tools/map/geography/resolve.js +1467 -0
- package/dist/hoi4_agent_tools/map/model.js +17 -3
- package/dist/hoi4_agent_tools/map/operations.js +284 -14
- package/dist/hoi4_agent_tools/map/partition-fields.js +398 -0
- package/dist/hoi4_agent_tools/map/partition-geography.js +113 -0
- package/dist/hoi4_agent_tools/map/partition-graph.js +165 -0
- package/dist/hoi4_agent_tools/map/partition-groups.js +164 -0
- package/dist/hoi4_agent_tools/map/partition-provinces.js +727 -0
- package/dist/hoi4_agent_tools/map/partition-tuning.js +101 -0
- package/dist/hoi4_agent_tools/map/partition-watershed.js +252 -0
- package/dist/hoi4_agent_tools/map/partition.js +163 -0
- package/dist/hoi4_agent_tools/map/reference-import.js +333 -0
- package/dist/hoi4_agent_tools/map/reference-preparation.js +142 -6
- package/dist/hoi4_agent_tools/map/reference-sheets.js +231 -0
- package/dist/hoi4_agent_tools/map/reference-world.js +104 -13
- package/dist/hoi4_agent_tools/map/relief/checks.js +281 -0
- package/dist/hoi4_agent_tools/map/relief/fields.js +15 -0
- package/dist/hoi4_agent_tools/map/relief/full-resolution.js +133 -0
- package/dist/hoi4_agent_tools/map/relief/grid.js +190 -0
- package/dist/hoi4_agent_tools/map/relief/hydrology.js +1315 -0
- package/dist/hoi4_agent_tools/map/relief/index.js +27 -0
- package/dist/hoi4_agent_tools/map/relief/land.js +110 -0
- package/dist/hoi4_agent_tools/map/relief/macro-relief.js +535 -0
- package/dist/hoi4_agent_tools/map/relief/math.js +93 -0
- package/dist/hoi4_agent_tools/map/relief/river-raster.js +680 -0
- package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +304 -0
- package/dist/hoi4_agent_tools/map/render.js +36 -0
- package/dist/hoi4_agent_tools/map/service.js +206 -16
- package/dist/hoi4_agent_tools/map/tool-operations.js +249 -3
- package/dist/hoi4_agent_tools/map/validation.js +9 -0
- package/dist/hoi4_agent_tools/map/water.js +227 -0
- package/dist/hoi4_agent_tools/map/world-context.js +47 -1
- package/dist/hoi4_agent_tools/map/world-edge.js +335 -0
- package/dist/hoi4_agent_tools/map/world-noise.js +34 -0
- package/dist/hoi4_agent_tools/map/world-outputs.js +22 -10
- package/dist/hoi4_agent_tools/map/world-partition.js +119 -0
- package/dist/hoi4_agent_tools/map/world-provinces.js +131 -56
- package/dist/hoi4_agent_tools/map/world-realms.js +152 -0
- package/dist/hoi4_agent_tools/map/world.js +562 -163
- package/dist/hoi4_agent_tools/mcp/tools/map.js +69 -1
- package/dist/hoi4_agent_tools/reference/operations.js +3 -2
- package/dist/hoi4_agent_tools/schemas/geography.js +325 -0
- package/dist/hoi4_agent_tools/schemas/map-requests.js +20 -2
- package/dist/hoi4_agent_tools/schemas/map.js +280 -15
- package/dist/hoi4_agent_tools/schemas/reference-sheets.js +92 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/configuration.md +17 -14
- package/docs/focus.md +6 -0
- package/docs/map.md +292 -15
- package/docs/tools.md +4 -4
- package/docs/troubleshooting.md +11 -11
- package/npm-shrinkwrap.json +109 -91
- package/package.json +3 -3
- package/schemas/configuration.schema.json +65 -58
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +1 -1
- package/schemas/error-log-input.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/geography-model.schema.json +1148 -0
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +5 -11
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +1 -1
- package/schemas/map-operation.schema.json +2325 -292
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/mod-index-input.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/package-check-input.schema.json +1 -1
- package/schemas/package-manifest.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +25 -7
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/schemas/reference-context-input.schema.json +1 -1
- package/schemas/reference-read-input.schema.json +1 -1
- package/schemas/reference-search-input.schema.json +1 -1
- package/schemas/reference-sheets.schema.json +267 -0
- package/schemas/scenario-suite.schema.json +1 -1
- package/schemas/scenario-test-input.schema.json +1 -1
- package/schemas/script-validate-input.schema.json +1 -1
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +1 -1
- package/server.json +2 -2
|
@@ -0,0 +1,1180 @@
|
|
|
1
|
+
import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
|
|
2
|
+
import { assertRenderDimensions } from '../core/render-budget.js';
|
|
3
|
+
import { ServiceError } from '../core/result.js';
|
|
4
|
+
/**
|
|
5
|
+
* Border-naturalness metric (ADR 0059).
|
|
6
|
+
*
|
|
7
|
+
* A border is natural where it runs along something a person would draw a border along: a river,
|
|
8
|
+
* a steep height gradient or ridge, a lake shore, or a change between obstacle terrain classes
|
|
9
|
+
* (mountain, forest, jungle, marsh). The metric is computed on the land-to-land pixel edges
|
|
10
|
+
* between two provinces, states, strategic regions or countries, in one streamed pass over the
|
|
11
|
+
* province raster, and is deterministic: no random numbers, no clock in any result.
|
|
12
|
+
*
|
|
13
|
+
* Two entry points exist for other code. `measureBorderNaturalness` reports every requested layer
|
|
14
|
+
* (alignment, enrichment over chance, the plain natural share, straightness, long straight runs
|
|
15
|
+
* and the worst artificial runs). `buildBorderEdgeTable` plus `scoreBorderAssignment` let a
|
|
16
|
+
* territory assignment try many candidate groupings of provinces without reading the raster again.
|
|
17
|
+
*/
|
|
18
|
+
/** Feature bits stored per border edge; an edge carries the union of its two pixels' bits. */
|
|
19
|
+
export const BORDER_FEATURE_BITS = {
|
|
20
|
+
river: 1,
|
|
21
|
+
slope: 2,
|
|
22
|
+
lakeShore: 4,
|
|
23
|
+
terrainChange: 8,
|
|
24
|
+
extra: 16,
|
|
25
|
+
};
|
|
26
|
+
const BORDER_FEATURES = Object.keys(BORDER_FEATURE_BITS);
|
|
27
|
+
/** Barrier set: the plan's measurement (rivers, steep gradients) plus lake shores and extras. */
|
|
28
|
+
const BARRIER_MASK = 1 | 2 | 4 | 16;
|
|
29
|
+
/** Natural set: the barrier set plus terrain-class changes. */
|
|
30
|
+
const NATURAL_MASK = 31;
|
|
31
|
+
/** Province kinds used by the metric. Only land provinces have land borders. */
|
|
32
|
+
export const BORDER_PROVINCE_KIND = { unknown: 0, land: 1, sea: 2, lake: 3 };
|
|
33
|
+
/** Targets for an engineered world: the design's thresholds, measured against vanilla. */
|
|
34
|
+
export const BORDER_QUALITY_TARGETS = {
|
|
35
|
+
/** Enrichment over chance at least this (vanilla 1.35 to 1.46). */
|
|
36
|
+
minEnrichment: 1.6,
|
|
37
|
+
/** Locally straight sample fraction at most this (vanilla country borders 0.11). */
|
|
38
|
+
maxStraightness: 0.12,
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Review targets per layer. Province borders are shorter than the straightness window, so almost
|
|
42
|
+
* every sample is straight (vanilla 0.67) and no straightness target applies to them. State
|
|
43
|
+
* borders are shorter than region and country borders and vanilla's are straighter (0.20).
|
|
44
|
+
*/
|
|
45
|
+
export const BORDER_LAYER_TARGETS = {
|
|
46
|
+
province: { minEnrichment: BORDER_QUALITY_TARGETS.minEnrichment },
|
|
47
|
+
state: { minEnrichment: BORDER_QUALITY_TARGETS.minEnrichment, maxStraightness: 0.25 },
|
|
48
|
+
region: BORDER_QUALITY_TARGETS,
|
|
49
|
+
country: BORDER_QUALITY_TARGETS,
|
|
50
|
+
};
|
|
51
|
+
export function borderTargetsForLayer(layer) {
|
|
52
|
+
return BORDER_LAYER_TARGETS[layer] ?? { minEnrichment: BORDER_QUALITY_TARGETS.minEnrichment };
|
|
53
|
+
}
|
|
54
|
+
const DEFAULT_MAX_EDGES = 8_000_000;
|
|
55
|
+
const MAX_RUN_LIMIT = 100;
|
|
56
|
+
const MAX_LAYERS = 8;
|
|
57
|
+
function resolveOptions(options) {
|
|
58
|
+
const resolved = {
|
|
59
|
+
riverMaxIndex: options.riverMaxIndex ?? 11,
|
|
60
|
+
riverRadius: options.riverRadius ?? 2,
|
|
61
|
+
slopeGradient: options.slopeGradient ?? 2,
|
|
62
|
+
slopeRadius: options.slopeRadius ?? 1,
|
|
63
|
+
lakeRadius: options.lakeRadius ?? 1,
|
|
64
|
+
terrainRadius: options.terrainRadius ?? 1,
|
|
65
|
+
extraRadius: options.extraRadius ?? 1,
|
|
66
|
+
sampleStride: options.sampleStride ?? 7,
|
|
67
|
+
windowRadius: options.windowRadius ?? 24,
|
|
68
|
+
straightRmsPx: options.straightRmsPx ?? 0.9,
|
|
69
|
+
minWindowEdges: options.minWindowEdges ?? 12,
|
|
70
|
+
simplifyTolerance: options.simplifyTolerance ?? 3,
|
|
71
|
+
longRunPx: options.longRunPx ?? 60,
|
|
72
|
+
worstRunLimit: options.worstRunLimit ?? 20,
|
|
73
|
+
minRunEdges: options.minRunEdges ?? 12,
|
|
74
|
+
naturalGapEdges: options.naturalGapEdges ?? 3,
|
|
75
|
+
minBorderEdges: options.minBorderEdges ?? 24,
|
|
76
|
+
minBaseRate: options.minBaseRate ?? 0.02,
|
|
77
|
+
maxEdges: options.maxEdges ?? DEFAULT_MAX_EDGES,
|
|
78
|
+
collectPaint: options.collectPaint ?? false,
|
|
79
|
+
signal: options.signal,
|
|
80
|
+
};
|
|
81
|
+
const integers = [
|
|
82
|
+
['riverMaxIndex', resolved.riverMaxIndex, 0, 255],
|
|
83
|
+
['riverRadius', resolved.riverRadius, 0, 8],
|
|
84
|
+
['slopeGradient', resolved.slopeGradient, 1, 255],
|
|
85
|
+
['slopeRadius', resolved.slopeRadius, 0, 8],
|
|
86
|
+
['lakeRadius', resolved.lakeRadius, 0, 8],
|
|
87
|
+
['terrainRadius', resolved.terrainRadius, 0, 8],
|
|
88
|
+
['extraRadius', resolved.extraRadius, 0, 8],
|
|
89
|
+
['sampleStride', resolved.sampleStride, 1, 1_000],
|
|
90
|
+
['windowRadius', resolved.windowRadius, 4, 256],
|
|
91
|
+
['minWindowEdges', resolved.minWindowEdges, 3, 10_000],
|
|
92
|
+
['longRunPx', resolved.longRunPx, 4, 100_000],
|
|
93
|
+
['worstRunLimit', resolved.worstRunLimit, 0, MAX_RUN_LIMIT],
|
|
94
|
+
['minRunEdges', resolved.minRunEdges, 1, 1_000_000],
|
|
95
|
+
['naturalGapEdges', resolved.naturalGapEdges, 0, 1_000],
|
|
96
|
+
['minBorderEdges', resolved.minBorderEdges, 1, 100_000_000],
|
|
97
|
+
['maxEdges', resolved.maxEdges, 1, 50_000_000],
|
|
98
|
+
];
|
|
99
|
+
for (const [name, value, minimum, maximum] of integers)
|
|
100
|
+
if (!Number.isInteger(value) || value < minimum || value > maximum)
|
|
101
|
+
throw new ServiceError('MAP_BORDER_QUALITY_OPTION_INVALID', `Border quality option ${name} must be an integer from ${minimum} to ${maximum}`, { option: name, value });
|
|
102
|
+
for (const [name, value] of [
|
|
103
|
+
['straightRmsPx', resolved.straightRmsPx],
|
|
104
|
+
['simplifyTolerance', resolved.simplifyTolerance],
|
|
105
|
+
['minBaseRate', resolved.minBaseRate],
|
|
106
|
+
])
|
|
107
|
+
if (!Number.isFinite(value) || value <= 0 || value > 1_000)
|
|
108
|
+
throw new ServiceError('MAP_BORDER_QUALITY_OPTION_INVALID', `Border quality option ${name} must be a positive finite number`, { option: name, value });
|
|
109
|
+
return resolved;
|
|
110
|
+
}
|
|
111
|
+
export function borderPlaneFromBytes(width, height, bytes) {
|
|
112
|
+
if (bytes.length !== width * height)
|
|
113
|
+
throw new ServiceError('MAP_BORDER_PLANE_INVALID', 'Border quality plane byte length does not match its dimensions', { width, height, bytes: bytes.length });
|
|
114
|
+
return {
|
|
115
|
+
width,
|
|
116
|
+
height,
|
|
117
|
+
readRow(y, target) {
|
|
118
|
+
target.set(bytes.subarray(y * width, (y + 1) * width));
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/** An 8-bit indexed BMP read in place; the palette index is the plane value. */
|
|
123
|
+
export function borderPlaneFromBmp(image) {
|
|
124
|
+
if (image.bitsPerPixel !== 8)
|
|
125
|
+
throw new ServiceError('MAP_BORDER_PLANE_INVALID', 'Border quality planes need an 8-bit indexed bitmap', { bitsPerPixel: image.bitsPerPixel });
|
|
126
|
+
return {
|
|
127
|
+
width: image.width,
|
|
128
|
+
height: image.height,
|
|
129
|
+
readRow(y, target) {
|
|
130
|
+
const stored = image.topDown ? y : image.height - 1 - y;
|
|
131
|
+
const start = image.pixelOffset + stored * image.rowStride;
|
|
132
|
+
target.set(image.bytes.subarray(start, start + image.width));
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
class Pace {
|
|
137
|
+
signal;
|
|
138
|
+
#last = performance.now();
|
|
139
|
+
constructor(signal) {
|
|
140
|
+
this.signal = signal;
|
|
141
|
+
}
|
|
142
|
+
async tick() {
|
|
143
|
+
this.signal?.throwIfAborted();
|
|
144
|
+
if (performance.now() - this.#last < 12)
|
|
145
|
+
return;
|
|
146
|
+
await yieldToEventLoop();
|
|
147
|
+
this.signal?.throwIfAborted();
|
|
148
|
+
this.#last = performance.now();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
class RowRing {
|
|
152
|
+
#rows;
|
|
153
|
+
#tags;
|
|
154
|
+
constructor(count, width) {
|
|
155
|
+
this.#rows = Array.from({ length: count }, () => new Uint8Array(width));
|
|
156
|
+
this.#tags = new Int32Array(count).fill(-(2 ** 30));
|
|
157
|
+
}
|
|
158
|
+
get(y, fill) {
|
|
159
|
+
const slot = mod(y, this.#rows.length);
|
|
160
|
+
const row = this.#rows[slot];
|
|
161
|
+
if (this.#tags[slot] !== y) {
|
|
162
|
+
fill(row);
|
|
163
|
+
this.#tags[slot] = y;
|
|
164
|
+
}
|
|
165
|
+
return row;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** Horizontal OR over [x - radius, x + radius] with wrap at the seam. */
|
|
169
|
+
function dilateRow(source, target, radius) {
|
|
170
|
+
const width = source.length;
|
|
171
|
+
if (radius === 0) {
|
|
172
|
+
target.set(source);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
let count = 0;
|
|
176
|
+
for (let k = -radius; k <= radius; k += 1)
|
|
177
|
+
count += source[(k + width * 16) % width];
|
|
178
|
+
for (let x = 0; x < width; x += 1) {
|
|
179
|
+
target[x] = count > 0 ? 1 : 0;
|
|
180
|
+
count += source[(x + radius + 1) % width] - source[(x - radius + width * 16) % width];
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Per-row feature flags computed on the fly from the source planes. Every feature keeps only the
|
|
185
|
+
* few rows its radius needs, so memory is a handful of rows and never a second map-sized raster.
|
|
186
|
+
*/
|
|
187
|
+
class FeatureRows {
|
|
188
|
+
active;
|
|
189
|
+
#width;
|
|
190
|
+
#height;
|
|
191
|
+
#input;
|
|
192
|
+
#options;
|
|
193
|
+
#heightRows;
|
|
194
|
+
#terrainRows;
|
|
195
|
+
#scratch;
|
|
196
|
+
#baseRing;
|
|
197
|
+
#base;
|
|
198
|
+
#radius;
|
|
199
|
+
constructor(input, options) {
|
|
200
|
+
this.#input = input;
|
|
201
|
+
this.#options = options;
|
|
202
|
+
this.#width = input.width;
|
|
203
|
+
this.#height = input.height;
|
|
204
|
+
const planes = input.features ?? {};
|
|
205
|
+
this.active = {
|
|
206
|
+
river: planes.rivers !== undefined,
|
|
207
|
+
slope: planes.heights !== undefined,
|
|
208
|
+
lakeShore: input.provinceKinds.includes(BORDER_PROVINCE_KIND.lake),
|
|
209
|
+
terrainChange: planes.terrain !== undefined,
|
|
210
|
+
extra: planes.extra !== undefined,
|
|
211
|
+
};
|
|
212
|
+
this.#radius = {
|
|
213
|
+
river: options.riverRadius,
|
|
214
|
+
slope: options.slopeRadius,
|
|
215
|
+
lakeShore: options.lakeRadius,
|
|
216
|
+
terrainChange: options.terrainRadius,
|
|
217
|
+
extra: options.extraRadius,
|
|
218
|
+
};
|
|
219
|
+
this.#scratch = new Uint8Array(input.width);
|
|
220
|
+
this.#base = new Uint8Array(input.width);
|
|
221
|
+
this.#heightRows = this.active.slope ? new RowRing(4, input.width) : undefined;
|
|
222
|
+
this.#terrainRows = this.active.terrainChange ? new RowRing(4, input.width) : undefined;
|
|
223
|
+
this.#baseRing = Object.fromEntries(BORDER_FEATURES.map((feature) => [
|
|
224
|
+
feature,
|
|
225
|
+
this.active[feature] ? new RowRing(2 * this.#radius[feature] + 3, input.width) : undefined,
|
|
226
|
+
]));
|
|
227
|
+
}
|
|
228
|
+
#isLand(offset) {
|
|
229
|
+
const id = this.#input.provinceIds[offset];
|
|
230
|
+
return id >= 0 && this.#input.provinceKinds[id] === BORDER_PROVINCE_KIND.land;
|
|
231
|
+
}
|
|
232
|
+
#rawHeights(y) {
|
|
233
|
+
return this.#heightRows.get(y, (target) => this.#input.features.heights.readRow(y, target));
|
|
234
|
+
}
|
|
235
|
+
#rawTerrain(y) {
|
|
236
|
+
return this.#terrainRows.get(y, (target) => this.#input.features.terrain.plane.readRow(y, target));
|
|
237
|
+
}
|
|
238
|
+
/** Dilated (horizontally only) feature indicator for row y; zeros outside the map. */
|
|
239
|
+
#dilatedRow(feature, y) {
|
|
240
|
+
return this.#baseRing[feature].get(y, (target) => {
|
|
241
|
+
if (y < 0 || y >= this.#height) {
|
|
242
|
+
target.fill(0);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const width = this.#width;
|
|
246
|
+
const base = this.#base;
|
|
247
|
+
const planes = this.#input.features ?? {};
|
|
248
|
+
const rowOffset = y * width;
|
|
249
|
+
switch (feature) {
|
|
250
|
+
case 'river': {
|
|
251
|
+
planes.rivers.readRow(y, this.#scratch);
|
|
252
|
+
for (let x = 0; x < width; x += 1)
|
|
253
|
+
base[x] = this.#scratch[x] <= this.#options.riverMaxIndex ? 1 : 0;
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
case 'extra': {
|
|
257
|
+
planes.extra.readRow(y, this.#scratch);
|
|
258
|
+
for (let x = 0; x < width; x += 1)
|
|
259
|
+
base[x] = this.#scratch[x] !== 0 ? 1 : 0;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
case 'lakeShore': {
|
|
263
|
+
const ids = this.#input.provinceIds;
|
|
264
|
+
const kinds = this.#input.provinceKinds;
|
|
265
|
+
for (let x = 0; x < width; x += 1) {
|
|
266
|
+
const id = ids[rowOffset + x];
|
|
267
|
+
base[x] = id >= 0 && kinds[id] === BORDER_PROVINCE_KIND.lake ? 1 : 0;
|
|
268
|
+
}
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
case 'slope': {
|
|
272
|
+
const here = this.#rawHeights(y);
|
|
273
|
+
const above = y > 0 ? this.#rawHeights(y - 1) : here;
|
|
274
|
+
const below = y < this.#height - 1 ? this.#rawHeights(y + 1) : here;
|
|
275
|
+
// The central-difference gradient magnitude per pixel, compared as a square.
|
|
276
|
+
const limit = (2 * this.#options.slopeGradient) ** 2;
|
|
277
|
+
for (let x = 0; x < width; x += 1) {
|
|
278
|
+
if (!this.#isLand(rowOffset + x)) {
|
|
279
|
+
base[x] = 0;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const left = x === 0 ? width - 1 : x - 1;
|
|
283
|
+
const right = x === width - 1 ? 0 : x + 1;
|
|
284
|
+
const dx = here[right] - here[left];
|
|
285
|
+
const dy = below[x] - above[x];
|
|
286
|
+
base[x] = dx * dx + dy * dy >= limit ? 1 : 0;
|
|
287
|
+
}
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
case 'terrainChange': {
|
|
291
|
+
const classes = this.#input.features.terrain.classes;
|
|
292
|
+
const here = this.#rawTerrain(y);
|
|
293
|
+
const above = y > 0 ? this.#rawTerrain(y - 1) : undefined;
|
|
294
|
+
const below = y < this.#height - 1 ? this.#rawTerrain(y + 1) : undefined;
|
|
295
|
+
const classOf = (row, rowStart, x) => this.#isLand(rowStart + x) ? classes[row[x]] : 255;
|
|
296
|
+
for (let x = 0; x < width; x += 1) {
|
|
297
|
+
const own = classOf(here, rowOffset, x);
|
|
298
|
+
if (own === 255) {
|
|
299
|
+
base[x] = 0;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const left = x === 0 ? width - 1 : x - 1;
|
|
303
|
+
const right = x === width - 1 ? 0 : x + 1;
|
|
304
|
+
let changed = false;
|
|
305
|
+
for (const other of [
|
|
306
|
+
classOf(here, rowOffset, left),
|
|
307
|
+
classOf(here, rowOffset, right),
|
|
308
|
+
above === undefined ? 255 : classOf(above, rowOffset - width, x),
|
|
309
|
+
below === undefined ? 255 : classOf(below, rowOffset + width, x),
|
|
310
|
+
])
|
|
311
|
+
if (other !== 255 && other !== own)
|
|
312
|
+
changed = true;
|
|
313
|
+
base[x] = changed ? 1 : 0;
|
|
314
|
+
}
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
dilateRow(base, target, this.#radius[feature]);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
/** Feature bits of row `y` (all zero outside the map). */
|
|
322
|
+
rowFlags(y, out) {
|
|
323
|
+
out.fill(0);
|
|
324
|
+
if (y < 0 || y >= this.#height)
|
|
325
|
+
return;
|
|
326
|
+
const width = this.#width;
|
|
327
|
+
for (const feature of BORDER_FEATURES) {
|
|
328
|
+
if (!this.active[feature])
|
|
329
|
+
continue;
|
|
330
|
+
const bit = BORDER_FEATURE_BITS[feature];
|
|
331
|
+
const radius = this.#radius[feature];
|
|
332
|
+
for (let dy = -radius; dy <= radius; dy += 1) {
|
|
333
|
+
const row = this.#dilatedRow(feature, y + dy);
|
|
334
|
+
for (let x = 0; x < width; x += 1)
|
|
335
|
+
if (row[x] !== 0)
|
|
336
|
+
out[x] = out[x] | bit;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function validateInput(input) {
|
|
342
|
+
assertRenderDimensions(input.width, input.height, 'border quality raster', {
|
|
343
|
+
maximumPixels: 13_238_272,
|
|
344
|
+
});
|
|
345
|
+
if (input.provinceIds.length !== input.width * input.height)
|
|
346
|
+
throw new ServiceError('MAP_BORDER_QUALITY_INPUT_INVALID', 'Border quality province raster does not match its dimensions', { width: input.width, height: input.height, length: input.provinceIds.length });
|
|
347
|
+
if (input.layers.length > MAX_LAYERS)
|
|
348
|
+
throw new ServiceError('MAP_BORDER_QUALITY_INPUT_INVALID', 'Too many border layers', {
|
|
349
|
+
layers: input.layers.length,
|
|
350
|
+
maximum: MAX_LAYERS,
|
|
351
|
+
});
|
|
352
|
+
const names = new Set();
|
|
353
|
+
for (const layer of input.layers) {
|
|
354
|
+
if (names.has(layer.name))
|
|
355
|
+
throw new ServiceError('MAP_BORDER_QUALITY_INPUT_INVALID', 'Border layer names must be unique', {
|
|
356
|
+
layer: layer.name,
|
|
357
|
+
});
|
|
358
|
+
names.add(layer.name);
|
|
359
|
+
if (layer.labels.length < input.provinceKinds.length)
|
|
360
|
+
throw new ServiceError('MAP_BORDER_QUALITY_INPUT_INVALID', 'A border layer needs one label per province id', { layer: layer.name, labels: layer.labels.length, provinces: input.provinceKinds.length });
|
|
361
|
+
}
|
|
362
|
+
const planes = input.features ?? {};
|
|
363
|
+
for (const [name, plane] of [
|
|
364
|
+
['heights', planes.heights],
|
|
365
|
+
['rivers', planes.rivers],
|
|
366
|
+
['terrain', planes.terrain?.plane],
|
|
367
|
+
['extra', planes.extra],
|
|
368
|
+
]) {
|
|
369
|
+
if (plane === undefined)
|
|
370
|
+
continue;
|
|
371
|
+
if (plane.width !== input.width || plane.height !== input.height)
|
|
372
|
+
throw new ServiceError('MAP_BORDER_QUALITY_INPUT_INVALID', `The ${name} plane must match the province raster size`, { plane: name, width: plane.width, height: plane.height });
|
|
373
|
+
}
|
|
374
|
+
if (planes.terrain !== undefined && planes.terrain.classes.length !== 256)
|
|
375
|
+
throw new ServiceError('MAP_BORDER_QUALITY_INPUT_INVALID', 'The terrain class table needs 256 entries');
|
|
376
|
+
}
|
|
377
|
+
/** One streamed pass over the raster: collect every land border edge and the chance baseline. */
|
|
378
|
+
export async function buildBorderEdgeTable(input, options = {}) {
|
|
379
|
+
const resolved = resolveOptions(options);
|
|
380
|
+
validateInput(input);
|
|
381
|
+
const { width, height, provinceIds, provinceKinds } = input;
|
|
382
|
+
const rows = new FeatureRows(input, resolved);
|
|
383
|
+
const pace = new Pace(resolved.signal);
|
|
384
|
+
let capacity = Math.min(65_536, resolved.maxEdges);
|
|
385
|
+
let keys = new Int32Array(capacity);
|
|
386
|
+
let provinceA = new Int32Array(capacity);
|
|
387
|
+
let provinceB = new Int32Array(capacity);
|
|
388
|
+
let features = new Uint8Array(capacity);
|
|
389
|
+
let edgeCount = 0;
|
|
390
|
+
const push = (key, a, b, bits) => {
|
|
391
|
+
if (edgeCount === capacity) {
|
|
392
|
+
if (capacity >= resolved.maxEdges)
|
|
393
|
+
throw new ServiceError('MAP_BORDER_QUALITY_BUDGET_EXCEEDED', 'The map has more land border edges than the fixed border quality budget allows', { maximumEdges: resolved.maxEdges });
|
|
394
|
+
capacity = Math.min(resolved.maxEdges, capacity * 2);
|
|
395
|
+
const grow = (array) => {
|
|
396
|
+
const bigger = new array.constructor(capacity);
|
|
397
|
+
bigger.set(array);
|
|
398
|
+
return bigger;
|
|
399
|
+
};
|
|
400
|
+
keys = grow(keys);
|
|
401
|
+
provinceA = grow(provinceA);
|
|
402
|
+
provinceB = grow(provinceB);
|
|
403
|
+
features = grow(features);
|
|
404
|
+
}
|
|
405
|
+
keys[edgeCount] = key;
|
|
406
|
+
provinceA[edgeCount] = a;
|
|
407
|
+
provinceB[edgeCount] = b;
|
|
408
|
+
features[edgeCount] = bits;
|
|
409
|
+
edgeCount += 1;
|
|
410
|
+
};
|
|
411
|
+
const coastEdgesByProvince = new Uint32Array(provinceKinds.length);
|
|
412
|
+
const pairsByFeature = {
|
|
413
|
+
river: 0,
|
|
414
|
+
slope: 0,
|
|
415
|
+
lakeShore: 0,
|
|
416
|
+
terrainChange: 0,
|
|
417
|
+
extra: 0,
|
|
418
|
+
};
|
|
419
|
+
let landPixels = 0;
|
|
420
|
+
let landPairs = 0;
|
|
421
|
+
let barrierPairs = 0;
|
|
422
|
+
let naturalPairs = 0;
|
|
423
|
+
let current = new Uint8Array(width);
|
|
424
|
+
let next = new Uint8Array(width);
|
|
425
|
+
rows.rowFlags(0, current);
|
|
426
|
+
const isLand = (id) => id >= 0 && id < provinceKinds.length && provinceKinds[id] === BORDER_PROVINCE_KIND.land;
|
|
427
|
+
const isWater = (id) => id >= 0 &&
|
|
428
|
+
id < provinceKinds.length &&
|
|
429
|
+
(provinceKinds[id] === BORDER_PROVINCE_KIND.sea ||
|
|
430
|
+
provinceKinds[id] === BORDER_PROVINCE_KIND.lake);
|
|
431
|
+
const visitPair = (a, b, key, bits) => {
|
|
432
|
+
if (isLand(b)) {
|
|
433
|
+
landPairs += 1;
|
|
434
|
+
if ((bits & BARRIER_MASK) !== 0)
|
|
435
|
+
barrierPairs += 1;
|
|
436
|
+
if ((bits & NATURAL_MASK) !== 0)
|
|
437
|
+
naturalPairs += 1;
|
|
438
|
+
for (const feature of BORDER_FEATURES)
|
|
439
|
+
if ((bits & BORDER_FEATURE_BITS[feature]) !== 0)
|
|
440
|
+
pairsByFeature[feature] += 1;
|
|
441
|
+
if (a !== b)
|
|
442
|
+
push(key, a, b, bits & NATURAL_MASK);
|
|
443
|
+
}
|
|
444
|
+
else if (isWater(b)) {
|
|
445
|
+
coastEdgesByProvince[a] = coastEdgesByProvince[a] + 1;
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
for (let y = 0; y < height; y += 1) {
|
|
449
|
+
if (y % 16 === 0)
|
|
450
|
+
await pace.tick();
|
|
451
|
+
if (y + 1 < height)
|
|
452
|
+
rows.rowFlags(y + 1, next);
|
|
453
|
+
const rowStart = y * width;
|
|
454
|
+
for (let x = 0; x < width; x += 1) {
|
|
455
|
+
const pixel = rowStart + x;
|
|
456
|
+
const id = provinceIds[pixel];
|
|
457
|
+
if (!isLand(id)) {
|
|
458
|
+
// A water pixel's land neighbours are counted from the land side below.
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
landPixels += 1;
|
|
462
|
+
const east = x === width - 1 ? 0 : x + 1;
|
|
463
|
+
visitPair(id, provinceIds[rowStart + east], pixel * 2, current[x] | current[east]);
|
|
464
|
+
if (y + 1 < height)
|
|
465
|
+
visitPair(id, provinceIds[pixel + width], pixel * 2 + 1, current[x] | next[x]);
|
|
466
|
+
}
|
|
467
|
+
// Coast edges to the north and west of land come from water pixels looking at land.
|
|
468
|
+
for (let x = 0; x < width; x += 1) {
|
|
469
|
+
const id = provinceIds[rowStart + x];
|
|
470
|
+
if (!isWater(id))
|
|
471
|
+
continue;
|
|
472
|
+
const east = x === width - 1 ? 0 : x + 1;
|
|
473
|
+
const eastId = provinceIds[rowStart + east];
|
|
474
|
+
if (isLand(eastId))
|
|
475
|
+
coastEdgesByProvince[eastId] = coastEdgesByProvince[eastId] + 1;
|
|
476
|
+
if (y + 1 < height) {
|
|
477
|
+
const southId = provinceIds[rowStart + width + x];
|
|
478
|
+
if (isLand(southId))
|
|
479
|
+
coastEdgesByProvince[southId] = coastEdgesByProvince[southId] + 1;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
[current, next] = [next, current];
|
|
483
|
+
}
|
|
484
|
+
return {
|
|
485
|
+
width,
|
|
486
|
+
height,
|
|
487
|
+
edgeCount,
|
|
488
|
+
keys: keys.slice(0, edgeCount),
|
|
489
|
+
provinceA: provinceA.slice(0, edgeCount),
|
|
490
|
+
provinceB: provinceB.slice(0, edgeCount),
|
|
491
|
+
features: features.slice(0, edgeCount),
|
|
492
|
+
provinceIds,
|
|
493
|
+
provinceKinds,
|
|
494
|
+
landPixels,
|
|
495
|
+
landPairs,
|
|
496
|
+
barrierPairs,
|
|
497
|
+
naturalPairs,
|
|
498
|
+
pairsByFeature,
|
|
499
|
+
coastEdgesByProvince,
|
|
500
|
+
inputs: {
|
|
501
|
+
heights: input.features?.heights !== undefined,
|
|
502
|
+
rivers: input.features?.rivers !== undefined,
|
|
503
|
+
terrain: input.features?.terrain !== undefined,
|
|
504
|
+
extra: input.features?.extra !== undefined,
|
|
505
|
+
},
|
|
506
|
+
options: resolved,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
function round(value, places = 4) {
|
|
510
|
+
const scale = 10 ** places;
|
|
511
|
+
return Math.round(value * scale) / scale;
|
|
512
|
+
}
|
|
513
|
+
function share(count, total) {
|
|
514
|
+
return total === 0 ? null : round(count / total);
|
|
515
|
+
}
|
|
516
|
+
function mod(value, size) {
|
|
517
|
+
return ((value % size) + size) % size;
|
|
518
|
+
}
|
|
519
|
+
/** Labels with every non-land province excluded, as an array indexed by province id. */
|
|
520
|
+
function effectiveLabels(table, labels) {
|
|
521
|
+
const result = new Int32Array(table.provinceKinds.length);
|
|
522
|
+
for (let id = 0; id < result.length; id += 1)
|
|
523
|
+
result[id] = table.provinceKinds[id] === BORDER_PROVINCE_KIND.land ? labels[id] : -1;
|
|
524
|
+
return result;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Score one grouping of provinces against a prebuilt edge table in one pass over its edges, with
|
|
528
|
+
* no raster access. Territory assignment calls this for each candidate grouping.
|
|
529
|
+
*/
|
|
530
|
+
export function scoreBorderAssignment(table, labels) {
|
|
531
|
+
const effective = effectiveLabels(table, labels);
|
|
532
|
+
let borderEdges = 0;
|
|
533
|
+
let barrierEdges = 0;
|
|
534
|
+
let naturalEdges = 0;
|
|
535
|
+
let unlabelledEdges = 0;
|
|
536
|
+
for (let index = 0; index < table.edgeCount; index += 1) {
|
|
537
|
+
const left = effective[table.provinceA[index]];
|
|
538
|
+
const right = effective[table.provinceB[index]];
|
|
539
|
+
if (left < 0 || right < 0) {
|
|
540
|
+
unlabelledEdges += 1;
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (left === right)
|
|
544
|
+
continue;
|
|
545
|
+
borderEdges += 1;
|
|
546
|
+
const bits = table.features[index];
|
|
547
|
+
if ((bits & BARRIER_MASK) !== 0)
|
|
548
|
+
barrierEdges += 1;
|
|
549
|
+
if ((bits & NATURAL_MASK) !== 0)
|
|
550
|
+
naturalEdges += 1;
|
|
551
|
+
}
|
|
552
|
+
const baseRate = table.landPairs === 0 ? 0 : table.barrierPairs / table.landPairs;
|
|
553
|
+
const alignment = borderEdges === 0 ? null : barrierEdges / borderEdges;
|
|
554
|
+
return {
|
|
555
|
+
borderEdges,
|
|
556
|
+
barrierEdges,
|
|
557
|
+
naturalEdges,
|
|
558
|
+
unlabelledEdges,
|
|
559
|
+
alignment: alignment === null ? null : round(alignment),
|
|
560
|
+
enrichment: alignment === null || baseRate < table.options.minBaseRate
|
|
561
|
+
? null
|
|
562
|
+
: round(alignment / baseRate),
|
|
563
|
+
naturalShare: share(naturalEdges, borderEdges),
|
|
564
|
+
baseRate: round(baseRate),
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
function selectEdges(table, labels) {
|
|
568
|
+
const effective = effectiveLabels(table, labels);
|
|
569
|
+
let labelBound = 0;
|
|
570
|
+
for (const value of effective)
|
|
571
|
+
if (value >= labelBound)
|
|
572
|
+
labelBound = value + 1;
|
|
573
|
+
const indices = new Int32Array(table.edgeCount);
|
|
574
|
+
let count = 0;
|
|
575
|
+
let unlabelled = 0;
|
|
576
|
+
for (let index = 0; index < table.edgeCount; index += 1) {
|
|
577
|
+
const left = effective[table.provinceA[index]];
|
|
578
|
+
const right = effective[table.provinceB[index]];
|
|
579
|
+
if (left < 0 || right < 0)
|
|
580
|
+
unlabelled += 1;
|
|
581
|
+
else if (left !== right) {
|
|
582
|
+
indices[count] = index;
|
|
583
|
+
count += 1;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return { indices: indices.slice(0, count), count, unlabelled, labels: effective, labelBound };
|
|
587
|
+
}
|
|
588
|
+
/** Pair key of the two groups on either side of an edge (ordered), or -1 if not a border. */
|
|
589
|
+
function pairKeyOf(table, selection, pixel, direction) {
|
|
590
|
+
const width = table.width;
|
|
591
|
+
const x = pixel % width;
|
|
592
|
+
const other = direction === 0 ? (x === width - 1 ? pixel - (width - 1) : pixel + 1) : pixel + width;
|
|
593
|
+
if (other >= table.provinceIds.length)
|
|
594
|
+
return -1;
|
|
595
|
+
const a = table.provinceIds[pixel];
|
|
596
|
+
const b = table.provinceIds[other];
|
|
597
|
+
if (a < 0 || b < 0 || a >= selection.labels.length || b >= selection.labels.length)
|
|
598
|
+
return -1;
|
|
599
|
+
const left = selection.labels[a];
|
|
600
|
+
const right = selection.labels[b];
|
|
601
|
+
if (left < 0 || right < 0 || left === right)
|
|
602
|
+
return -1;
|
|
603
|
+
return left < right ? left * selection.labelBound + right : right * selection.labelBound + left;
|
|
604
|
+
}
|
|
605
|
+
async function measureStraightness(table, selection, pace) {
|
|
606
|
+
const { width, height } = table;
|
|
607
|
+
const options = table.options;
|
|
608
|
+
const count = selection.count;
|
|
609
|
+
const radius = options.windowRadius;
|
|
610
|
+
const doubledWidth = width * 2;
|
|
611
|
+
const midX = new Int32Array(count);
|
|
612
|
+
const midY = new Int32Array(count);
|
|
613
|
+
const pairKey = new Float64Array(count);
|
|
614
|
+
for (let n = 0; n < count; n += 1) {
|
|
615
|
+
const key = table.keys[selection.indices[n]];
|
|
616
|
+
const pixel = key >> 1;
|
|
617
|
+
const x = pixel % width;
|
|
618
|
+
const y = (pixel - x) / width;
|
|
619
|
+
if ((key & 1) === 0) {
|
|
620
|
+
midX[n] = (2 * (x + 1)) % doubledWidth;
|
|
621
|
+
midY[n] = 2 * y + 1;
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
midX[n] = 2 * x + 1;
|
|
625
|
+
midY[n] = 2 * (y + 1);
|
|
626
|
+
}
|
|
627
|
+
pairKey[n] = pairKeyOf(table, selection, pixel, key & 1);
|
|
628
|
+
}
|
|
629
|
+
const cellsX = Math.ceil(width / radius);
|
|
630
|
+
const cellsY = Math.ceil(height / radius);
|
|
631
|
+
const cellStart = new Int32Array(cellsX * cellsY + 1);
|
|
632
|
+
const cellOf = new Int32Array(count);
|
|
633
|
+
for (let n = 0; n < count; n += 1) {
|
|
634
|
+
const cell = Math.min(cellsY - 1, Math.floor(midY[n] / 2 / radius)) * cellsX +
|
|
635
|
+
Math.min(cellsX - 1, Math.floor(midX[n] / 2 / radius));
|
|
636
|
+
cellOf[n] = cell;
|
|
637
|
+
cellStart[cell + 1] = cellStart[cell + 1] + 1;
|
|
638
|
+
}
|
|
639
|
+
for (let cell = 0; cell < cellsX * cellsY; cell += 1)
|
|
640
|
+
cellStart[cell + 1] = cellStart[cell + 1] + cellStart[cell];
|
|
641
|
+
const fill = cellStart.slice(0, cellsX * cellsY);
|
|
642
|
+
const cellEdges = new Int32Array(count);
|
|
643
|
+
for (let n = 0; n < count; n += 1) {
|
|
644
|
+
const cell = cellOf[n];
|
|
645
|
+
cellEdges[fill[cell]] = n;
|
|
646
|
+
fill[cell] = fill[cell] + 1;
|
|
647
|
+
}
|
|
648
|
+
const windowSquared = (2 * radius) ** 2;
|
|
649
|
+
const rmsLimit = options.straightRmsPx;
|
|
650
|
+
let samples = 0;
|
|
651
|
+
let straight = 0;
|
|
652
|
+
let skipped = 0;
|
|
653
|
+
const columns = [];
|
|
654
|
+
for (let n = 0; n < count; n += options.sampleStride) {
|
|
655
|
+
if ((n & 255) === 0)
|
|
656
|
+
await pace.tick();
|
|
657
|
+
const cell = cellOf[n];
|
|
658
|
+
const cellX = cell % cellsX;
|
|
659
|
+
const cellY = (cell - cellX) / cellsX;
|
|
660
|
+
columns.length = 0;
|
|
661
|
+
for (const dx of [-1, 0, 1]) {
|
|
662
|
+
const column = mod(cellX + dx, cellsX);
|
|
663
|
+
if (!columns.includes(column))
|
|
664
|
+
columns.push(column);
|
|
665
|
+
}
|
|
666
|
+
const key = pairKey[n];
|
|
667
|
+
const originX = midX[n];
|
|
668
|
+
const originY = midY[n];
|
|
669
|
+
let points = 0;
|
|
670
|
+
let sumX = 0;
|
|
671
|
+
let sumY = 0;
|
|
672
|
+
let sumXX = 0;
|
|
673
|
+
let sumYY = 0;
|
|
674
|
+
let sumXY = 0;
|
|
675
|
+
for (let row = Math.max(0, cellY - 1); row <= Math.min(cellsY - 1, cellY + 1); row += 1) {
|
|
676
|
+
for (const column of columns) {
|
|
677
|
+
const slot = row * cellsX + column;
|
|
678
|
+
for (let at = cellStart[slot]; at < cellStart[slot + 1]; at += 1) {
|
|
679
|
+
const other = cellEdges[at];
|
|
680
|
+
if (pairKey[other] !== key)
|
|
681
|
+
continue;
|
|
682
|
+
let dx = midX[other] - originX;
|
|
683
|
+
if (dx > width)
|
|
684
|
+
dx -= doubledWidth;
|
|
685
|
+
else if (dx < -width)
|
|
686
|
+
dx += doubledWidth;
|
|
687
|
+
const dy = midY[other] - originY;
|
|
688
|
+
if (dx * dx + dy * dy > windowSquared)
|
|
689
|
+
continue;
|
|
690
|
+
const px = dx / 2;
|
|
691
|
+
const py = dy / 2;
|
|
692
|
+
points += 1;
|
|
693
|
+
sumX += px;
|
|
694
|
+
sumY += py;
|
|
695
|
+
sumXX += px * px;
|
|
696
|
+
sumYY += py * py;
|
|
697
|
+
sumXY += px * py;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
if (points < options.minWindowEdges) {
|
|
702
|
+
skipped += 1;
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
const meanX = sumX / points;
|
|
706
|
+
const meanY = sumY / points;
|
|
707
|
+
const varianceX = sumXX / points - meanX * meanX;
|
|
708
|
+
const varianceY = sumYY / points - meanY * meanY;
|
|
709
|
+
const covariance = sumXY / points - meanX * meanY;
|
|
710
|
+
const smallest = (varianceX +
|
|
711
|
+
varianceY -
|
|
712
|
+
Math.sqrt((varianceX - varianceY) ** 2 + 4 * covariance * covariance)) /
|
|
713
|
+
2;
|
|
714
|
+
samples += 1;
|
|
715
|
+
if (Math.sqrt(Math.max(0, smallest)) < rmsLimit)
|
|
716
|
+
straight += 1;
|
|
717
|
+
}
|
|
718
|
+
return { samples, straight, skipped };
|
|
719
|
+
}
|
|
720
|
+
/** Indices (ascending) of the vertices Douglas-Peucker keeps at the given tolerance. */
|
|
721
|
+
function simplifyPolyline(xs, ys, tolerance) {
|
|
722
|
+
const count = xs.length;
|
|
723
|
+
if (count <= 2)
|
|
724
|
+
return count === 0 ? [] : count === 1 ? [0] : [0, 1];
|
|
725
|
+
const keep = new Uint8Array(count);
|
|
726
|
+
keep[0] = 1;
|
|
727
|
+
keep[count - 1] = 1;
|
|
728
|
+
const stack = [0, count - 1];
|
|
729
|
+
while (stack.length > 0) {
|
|
730
|
+
const last = stack.pop();
|
|
731
|
+
const first = stack.pop();
|
|
732
|
+
if (last <= first + 1)
|
|
733
|
+
continue;
|
|
734
|
+
const ax = xs[first];
|
|
735
|
+
const ay = ys[first];
|
|
736
|
+
const dx = xs[last] - ax;
|
|
737
|
+
const dy = ys[last] - ay;
|
|
738
|
+
const length = Math.hypot(dx, dy);
|
|
739
|
+
let farthest = -1;
|
|
740
|
+
let farthestDistance = tolerance;
|
|
741
|
+
for (let at = first + 1; at < last; at += 1) {
|
|
742
|
+
const px = xs[at] - ax;
|
|
743
|
+
const py = ys[at] - ay;
|
|
744
|
+
const distance = length === 0 ? Math.hypot(px, py) : Math.abs(px * dy - py * dx) / length;
|
|
745
|
+
if (distance > farthestDistance) {
|
|
746
|
+
farthestDistance = distance;
|
|
747
|
+
farthest = at;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
if (farthest < 0)
|
|
751
|
+
continue;
|
|
752
|
+
keep[farthest] = 1;
|
|
753
|
+
stack.push(first, farthest, farthest, last);
|
|
754
|
+
}
|
|
755
|
+
const kept = [];
|
|
756
|
+
for (let at = 0; at < count; at += 1)
|
|
757
|
+
if (keep[at] === 1)
|
|
758
|
+
kept.push(at);
|
|
759
|
+
return kept;
|
|
760
|
+
}
|
|
761
|
+
/** Find the edge-table index of an edge key by binary search. */
|
|
762
|
+
function indexOfKey(table, key) {
|
|
763
|
+
let low = 0;
|
|
764
|
+
let high = table.edgeCount - 1;
|
|
765
|
+
while (low <= high) {
|
|
766
|
+
const middle = (low + high) >> 1;
|
|
767
|
+
const value = table.keys[middle];
|
|
768
|
+
if (value === key)
|
|
769
|
+
return middle;
|
|
770
|
+
if (value < key)
|
|
771
|
+
low = middle + 1;
|
|
772
|
+
else
|
|
773
|
+
high = middle - 1;
|
|
774
|
+
}
|
|
775
|
+
return -1;
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Walk every border of the layer as chains of unit segments on the pixel-corner lattice. A chain
|
|
779
|
+
* stops at a junction (a corner where the same pair of groups continues in more than one way) and
|
|
780
|
+
* at the end of the border. Coordinates are unwrapped across the east-west seam.
|
|
781
|
+
*/
|
|
782
|
+
async function traceChains(table, selection, pace, visit) {
|
|
783
|
+
const { width, height } = table;
|
|
784
|
+
const visited = new Uint8Array(Math.ceil((2 * width * height) / 8));
|
|
785
|
+
const isVisited = (key) => (visited[key >> 3] & (1 << (key & 7))) !== 0;
|
|
786
|
+
const mark = (key) => {
|
|
787
|
+
visited[key >> 3] = visited[key >> 3] | (1 << (key & 7));
|
|
788
|
+
};
|
|
789
|
+
const extend = (startX, startY, pairKey) => {
|
|
790
|
+
const out = { xs: [], ys: [], keys: [] };
|
|
791
|
+
let ux = startX;
|
|
792
|
+
let cy = startY;
|
|
793
|
+
for (;;) {
|
|
794
|
+
const nx = mod(ux, width);
|
|
795
|
+
const west = nx === 0 ? width - 1 : nx - 1;
|
|
796
|
+
let found = 0;
|
|
797
|
+
let foundKey = 0;
|
|
798
|
+
let foundX = 0;
|
|
799
|
+
let foundY = 0;
|
|
800
|
+
// up, down, left, right incident segments of the corner (nx, cy)
|
|
801
|
+
const candidates = [
|
|
802
|
+
[cy >= 1, ((cy - 1) * width + west) * 2, ux, cy - 1],
|
|
803
|
+
[cy <= height - 1, (cy * width + west) * 2, ux, cy + 1],
|
|
804
|
+
[cy >= 1 && cy <= height - 1, ((cy - 1) * width + west) * 2 + 1, ux - 1, cy],
|
|
805
|
+
[cy >= 1 && cy <= height - 1, ((cy - 1) * width + nx) * 2 + 1, ux + 1, cy],
|
|
806
|
+
];
|
|
807
|
+
for (const [valid, key, candidateX, candidateY] of candidates) {
|
|
808
|
+
if (!valid || isVisited(key))
|
|
809
|
+
continue;
|
|
810
|
+
if (pairKeyOf(table, selection, key >> 1, key & 1) !== pairKey)
|
|
811
|
+
continue;
|
|
812
|
+
found += 1;
|
|
813
|
+
foundKey = key;
|
|
814
|
+
foundX = candidateX;
|
|
815
|
+
foundY = candidateY;
|
|
816
|
+
}
|
|
817
|
+
if (found !== 1)
|
|
818
|
+
break;
|
|
819
|
+
mark(foundKey);
|
|
820
|
+
out.keys.push(foundKey);
|
|
821
|
+
out.xs.push(foundX);
|
|
822
|
+
out.ys.push(foundY);
|
|
823
|
+
ux = foundX;
|
|
824
|
+
cy = foundY;
|
|
825
|
+
}
|
|
826
|
+
return out;
|
|
827
|
+
};
|
|
828
|
+
for (let n = 0; n < selection.count; n += 1) {
|
|
829
|
+
if ((n & 511) === 0)
|
|
830
|
+
await pace.tick();
|
|
831
|
+
const startKey = table.keys[selection.indices[n]];
|
|
832
|
+
if (isVisited(startKey))
|
|
833
|
+
continue;
|
|
834
|
+
mark(startKey);
|
|
835
|
+
const pixel = startKey >> 1;
|
|
836
|
+
const x = pixel % width;
|
|
837
|
+
const y = (pixel - x) / width;
|
|
838
|
+
const pairKey = pairKeyOf(table, selection, pixel, startKey & 1);
|
|
839
|
+
const vertical = (startKey & 1) === 0;
|
|
840
|
+
const ax = vertical ? x + 1 : x;
|
|
841
|
+
const ay = vertical ? y : y + 1;
|
|
842
|
+
const bx = x + 1;
|
|
843
|
+
const by = y + 1;
|
|
844
|
+
const forward = extend(bx, by, pairKey);
|
|
845
|
+
const backward = extend(ax, ay, pairKey);
|
|
846
|
+
const chain = {
|
|
847
|
+
xs: [...backward.xs.reverse(), ax, bx, ...forward.xs],
|
|
848
|
+
ys: [...backward.ys.reverse(), ay, by, ...forward.ys],
|
|
849
|
+
keys: [...backward.keys.reverse(), startKey, ...forward.keys],
|
|
850
|
+
pairKey,
|
|
851
|
+
};
|
|
852
|
+
visit(chain);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
function chainSides(pairKey, labelBound, describe) {
|
|
856
|
+
const low = Math.floor(pairKey / labelBound);
|
|
857
|
+
const high = pairKey - low * labelBound;
|
|
858
|
+
return { labels: [low, high], sides: [describe(low), describe(high)] };
|
|
859
|
+
}
|
|
860
|
+
function analyseChain(table, chain, accumulators, runLimit) {
|
|
861
|
+
const options = table.options;
|
|
862
|
+
const segments = chain.keys.length;
|
|
863
|
+
const natural = new Uint8Array(segments);
|
|
864
|
+
for (let at = 0; at < segments; at += 1) {
|
|
865
|
+
const index = indexOfKey(table, chain.keys[at]);
|
|
866
|
+
natural[at] = index >= 0 && (table.features[index] & NATURAL_MASK) !== 0 ? 1 : 0;
|
|
867
|
+
}
|
|
868
|
+
accumulators.totalChainEdges += segments;
|
|
869
|
+
const kept = simplifyPolyline(chain.xs, chain.ys, options.simplifyTolerance);
|
|
870
|
+
for (let at = 1; at < kept.length; at += 1) {
|
|
871
|
+
const from = kept[at - 1];
|
|
872
|
+
const to = kept[at];
|
|
873
|
+
const chord = Math.hypot(chain.xs[to] - chain.xs[from], chain.ys[to] - chain.ys[from]);
|
|
874
|
+
if (chord <= options.longRunPx)
|
|
875
|
+
continue;
|
|
876
|
+
let onFeature = 0;
|
|
877
|
+
for (let edge = from; edge < to; edge += 1)
|
|
878
|
+
onFeature += natural[edge];
|
|
879
|
+
accumulators.longSegments += 1;
|
|
880
|
+
accumulators.longEdges += to - from;
|
|
881
|
+
if (onFeature * 2 < to - from)
|
|
882
|
+
accumulators.longArtificialEdges += to - from;
|
|
883
|
+
}
|
|
884
|
+
// Artificial runs: maximal stretches of edges on no feature, bridging short natural gaps.
|
|
885
|
+
let start = -1;
|
|
886
|
+
let end = -1;
|
|
887
|
+
const flush = () => {
|
|
888
|
+
if (start < 0)
|
|
889
|
+
return;
|
|
890
|
+
const edges = end - start + 1;
|
|
891
|
+
if (edges >= options.minRunEdges) {
|
|
892
|
+
let onFeature = 0;
|
|
893
|
+
for (let edge = start; edge <= end; edge += 1)
|
|
894
|
+
onFeature += natural[edge];
|
|
895
|
+
accumulators.runCount += 1;
|
|
896
|
+
accumulators.runEdges += edges;
|
|
897
|
+
if (edges > accumulators.longestRun)
|
|
898
|
+
accumulators.longestRun = edges;
|
|
899
|
+
if (runLimit > 0) {
|
|
900
|
+
accumulators.runs.push({ chain, first: start, last: end, edges, natural: onFeature });
|
|
901
|
+
if (accumulators.runs.length > runLimit * 8)
|
|
902
|
+
trimRuns(accumulators, runLimit * 2);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
start = -1;
|
|
906
|
+
end = -1;
|
|
907
|
+
};
|
|
908
|
+
let gap = 0;
|
|
909
|
+
for (let at = 0; at < segments; at += 1) {
|
|
910
|
+
if (natural[at] === 0) {
|
|
911
|
+
if (start < 0)
|
|
912
|
+
start = at;
|
|
913
|
+
end = at;
|
|
914
|
+
gap = 0;
|
|
915
|
+
}
|
|
916
|
+
else if (start >= 0) {
|
|
917
|
+
gap += 1;
|
|
918
|
+
if (gap > options.naturalGapEdges)
|
|
919
|
+
flush();
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
flush();
|
|
923
|
+
}
|
|
924
|
+
function runOrder(left, right) {
|
|
925
|
+
return (right.edges - left.edges ||
|
|
926
|
+
left.chain.keys[left.first] - right.chain.keys[right.first] ||
|
|
927
|
+
left.last - right.last);
|
|
928
|
+
}
|
|
929
|
+
function trimRuns(accumulators, keep) {
|
|
930
|
+
accumulators.runs.sort(runOrder);
|
|
931
|
+
accumulators.runs.length = Math.min(accumulators.runs.length, keep);
|
|
932
|
+
}
|
|
933
|
+
function formatRun(table, candidate, labelBound, describe) {
|
|
934
|
+
const { chain, first, last, edges, natural } = candidate;
|
|
935
|
+
const width = table.width;
|
|
936
|
+
const startX = chain.xs[first];
|
|
937
|
+
const startY = chain.ys[first];
|
|
938
|
+
const endX = chain.xs[last + 1];
|
|
939
|
+
const endY = chain.ys[last + 1];
|
|
940
|
+
const chord = Math.hypot(endX - startX, endY - startY);
|
|
941
|
+
const { labels, sides } = chainSides(chain.pairKey, labelBound, describe);
|
|
942
|
+
return {
|
|
943
|
+
sides,
|
|
944
|
+
labels,
|
|
945
|
+
from: { x: mod(startX, width), y: startY },
|
|
946
|
+
to: { x: mod(endX, width), y: endY },
|
|
947
|
+
edges,
|
|
948
|
+
chord: round(chord, 2),
|
|
949
|
+
straightRatio: round(Math.min(1, chord / edges)),
|
|
950
|
+
naturalShare: round(natural / edges),
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
async function evaluateLayer(table, layer, pace, paint) {
|
|
954
|
+
const options = table.options;
|
|
955
|
+
const describe = layer.describe ?? ((label) => String(label));
|
|
956
|
+
const selection = selectEdges(table, layer.labels);
|
|
957
|
+
const groups = new Set();
|
|
958
|
+
let coastEdges = 0;
|
|
959
|
+
for (const [id, label] of selection.labels.entries())
|
|
960
|
+
if (label >= 0) {
|
|
961
|
+
groups.add(label);
|
|
962
|
+
coastEdges += table.coastEdgesByProvince[id];
|
|
963
|
+
}
|
|
964
|
+
let barrierEdges = 0;
|
|
965
|
+
let naturalEdges = 0;
|
|
966
|
+
const featureEdges = {
|
|
967
|
+
river: 0,
|
|
968
|
+
slope: 0,
|
|
969
|
+
lakeShore: 0,
|
|
970
|
+
terrainChange: 0,
|
|
971
|
+
extra: 0,
|
|
972
|
+
};
|
|
973
|
+
for (let n = 0; n < selection.count; n += 1) {
|
|
974
|
+
const bits = table.features[selection.indices[n]];
|
|
975
|
+
if ((bits & BARRIER_MASK) !== 0)
|
|
976
|
+
barrierEdges += 1;
|
|
977
|
+
if ((bits & NATURAL_MASK) !== 0)
|
|
978
|
+
naturalEdges += 1;
|
|
979
|
+
for (const feature of BORDER_FEATURES)
|
|
980
|
+
if ((bits & BORDER_FEATURE_BITS[feature]) !== 0)
|
|
981
|
+
featureEdges[feature] += 1;
|
|
982
|
+
}
|
|
983
|
+
const borderEdges = selection.count;
|
|
984
|
+
const barrierBase = table.landPairs === 0 ? 0 : table.barrierPairs / table.landPairs;
|
|
985
|
+
const naturalBase = table.landPairs === 0 ? 0 : table.naturalPairs / table.landPairs;
|
|
986
|
+
const noFeatures = !table.inputs.heights &&
|
|
987
|
+
!table.inputs.rivers &&
|
|
988
|
+
!table.inputs.terrain &&
|
|
989
|
+
!table.inputs.extra &&
|
|
990
|
+
table.pairsByFeature.lakeShore === 0;
|
|
991
|
+
const status = borderEdges === 0
|
|
992
|
+
? 'no-borders'
|
|
993
|
+
: borderEdges < options.minBorderEdges
|
|
994
|
+
? 'too-short'
|
|
995
|
+
: noFeatures || barrierBase < options.minBaseRate
|
|
996
|
+
? 'not-meaningful'
|
|
997
|
+
: 'ok';
|
|
998
|
+
const accumulators = {
|
|
999
|
+
longSegments: 0,
|
|
1000
|
+
longEdges: 0,
|
|
1001
|
+
longArtificialEdges: 0,
|
|
1002
|
+
totalChainEdges: 0,
|
|
1003
|
+
runCount: 0,
|
|
1004
|
+
runEdges: 0,
|
|
1005
|
+
longestRun: 0,
|
|
1006
|
+
runs: [],
|
|
1007
|
+
};
|
|
1008
|
+
let straightness = { samples: 0, straight: 0, skipped: 0 };
|
|
1009
|
+
if (borderEdges > 0) {
|
|
1010
|
+
straightness = await measureStraightness(table, selection, pace);
|
|
1011
|
+
await traceChains(table, selection, pace, (chain) => analyseChain(table, chain, accumulators, options.worstRunLimit));
|
|
1012
|
+
}
|
|
1013
|
+
trimRuns(accumulators, options.worstRunLimit);
|
|
1014
|
+
const alignment = borderEdges === 0 ? null : barrierEdges / borderEdges;
|
|
1015
|
+
const naturalShare = borderEdges === 0 ? null : naturalEdges / borderEdges;
|
|
1016
|
+
const worstRuns = accumulators.runs.map((run) => formatRun(table, run, selection.labelBound, describe));
|
|
1017
|
+
if (paint !== undefined)
|
|
1018
|
+
paint.set(layer.name, accumulators.runs.map((run) => Int32Array.from(run.chain.keys.slice(run.first, run.last + 1))));
|
|
1019
|
+
const byFeature = Object.fromEntries(BORDER_FEATURES.map((feature) => [feature, share(featureEdges[feature], borderEdges)]));
|
|
1020
|
+
return {
|
|
1021
|
+
layer: layer.name,
|
|
1022
|
+
status,
|
|
1023
|
+
groups: groups.size,
|
|
1024
|
+
borderEdges,
|
|
1025
|
+
unlabelledEdges: selection.unlabelled,
|
|
1026
|
+
coastEdges,
|
|
1027
|
+
barrier: {
|
|
1028
|
+
alignedEdges: barrierEdges,
|
|
1029
|
+
alignment: alignment === null ? null : round(alignment),
|
|
1030
|
+
baseRate: round(barrierBase),
|
|
1031
|
+
enrichment: alignment === null || barrierBase < options.minBaseRate
|
|
1032
|
+
? null
|
|
1033
|
+
: round(alignment / barrierBase),
|
|
1034
|
+
},
|
|
1035
|
+
natural: {
|
|
1036
|
+
alignedEdges: naturalEdges,
|
|
1037
|
+
share: naturalShare === null ? null : round(naturalShare),
|
|
1038
|
+
baseRate: round(naturalBase),
|
|
1039
|
+
enrichment: naturalShare === null || naturalBase < options.minBaseRate
|
|
1040
|
+
? null
|
|
1041
|
+
: round(naturalShare / naturalBase),
|
|
1042
|
+
byFeature,
|
|
1043
|
+
shareWithCoast: share(naturalEdges + coastEdges, borderEdges + coastEdges),
|
|
1044
|
+
},
|
|
1045
|
+
straightness: {
|
|
1046
|
+
samples: straightness.samples,
|
|
1047
|
+
straight: straightness.straight,
|
|
1048
|
+
skippedShortWindows: straightness.skipped,
|
|
1049
|
+
fraction: share(straightness.straight, straightness.samples),
|
|
1050
|
+
},
|
|
1051
|
+
longRuns: {
|
|
1052
|
+
segments: accumulators.longSegments,
|
|
1053
|
+
edges: accumulators.longEdges,
|
|
1054
|
+
share: share(accumulators.longEdges, accumulators.totalChainEdges),
|
|
1055
|
+
artificialShare: share(accumulators.longArtificialEdges, accumulators.totalChainEdges),
|
|
1056
|
+
},
|
|
1057
|
+
artificialRuns: {
|
|
1058
|
+
count: accumulators.runCount,
|
|
1059
|
+
edges: accumulators.runEdges,
|
|
1060
|
+
longestEdges: accumulators.longestRun,
|
|
1061
|
+
},
|
|
1062
|
+
worstRuns,
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
/** Measure how natural the borders of every requested layer are. Deterministic and bounded. */
|
|
1066
|
+
export async function measureBorderNaturalness(input, options = {}) {
|
|
1067
|
+
const table = await buildBorderEdgeTable(input, options);
|
|
1068
|
+
const pace = new Pace(table.options.signal);
|
|
1069
|
+
const paint = table.options.collectPaint ? new Map() : undefined;
|
|
1070
|
+
const layers = [];
|
|
1071
|
+
for (const layer of input.layers)
|
|
1072
|
+
layers.push(await evaluateLayer(table, layer, pace, paint));
|
|
1073
|
+
const report = {
|
|
1074
|
+
schemaVersion: 1,
|
|
1075
|
+
width: table.width,
|
|
1076
|
+
height: table.height,
|
|
1077
|
+
landPixels: table.landPixels,
|
|
1078
|
+
inputs: table.inputs,
|
|
1079
|
+
parameters: {
|
|
1080
|
+
riverRadius: table.options.riverRadius,
|
|
1081
|
+
slopeGradient: table.options.slopeGradient,
|
|
1082
|
+
slopeRadius: table.options.slopeRadius,
|
|
1083
|
+
sampleStride: table.options.sampleStride,
|
|
1084
|
+
windowRadius: table.options.windowRadius,
|
|
1085
|
+
straightRmsPx: table.options.straightRmsPx,
|
|
1086
|
+
simplifyTolerance: table.options.simplifyTolerance,
|
|
1087
|
+
longRunPx: table.options.longRunPx,
|
|
1088
|
+
},
|
|
1089
|
+
targets: Object.fromEntries(layers.map(({ layer }) => [layer, borderTargetsForLayer(layer)])),
|
|
1090
|
+
layers,
|
|
1091
|
+
};
|
|
1092
|
+
return { report, table, ...(paint === undefined ? {} : { paint }) };
|
|
1093
|
+
}
|
|
1094
|
+
/** Compare a layer with targets; a metric that cannot be judged is not a failure. */
|
|
1095
|
+
export function checkBorderTargets(layer, targets = borderTargetsForLayer(layer.layer)) {
|
|
1096
|
+
const failures = [];
|
|
1097
|
+
const minEnrichment = targets.minEnrichment;
|
|
1098
|
+
if (minEnrichment !== undefined &&
|
|
1099
|
+
layer.barrier.enrichment !== null &&
|
|
1100
|
+
layer.barrier.enrichment < minEnrichment)
|
|
1101
|
+
failures.push({ metric: 'enrichment', value: layer.barrier.enrichment, target: minEnrichment });
|
|
1102
|
+
const maxStraightness = targets.maxStraightness;
|
|
1103
|
+
if (maxStraightness !== undefined &&
|
|
1104
|
+
layer.straightness.fraction !== null &&
|
|
1105
|
+
layer.straightness.fraction > maxStraightness)
|
|
1106
|
+
failures.push({
|
|
1107
|
+
metric: 'straightness',
|
|
1108
|
+
value: layer.straightness.fraction,
|
|
1109
|
+
target: maxStraightness,
|
|
1110
|
+
});
|
|
1111
|
+
const minShare = targets.minNaturalShare;
|
|
1112
|
+
if (minShare !== undefined && layer.natural.share !== null && layer.natural.share < minShare)
|
|
1113
|
+
failures.push({ metric: 'naturalShare', value: layer.natural.share, target: minShare });
|
|
1114
|
+
return { layer: layer.layer, passed: failures.length === 0, failures };
|
|
1115
|
+
}
|
|
1116
|
+
/** The border validators (ADR 0059) as diagnostics: review warnings, not-meaningful and missing inputs. */
|
|
1117
|
+
export function borderQualityDiagnostics(report, targets) {
|
|
1118
|
+
const diagnostics = [];
|
|
1119
|
+
const missing = ['heights', 'rivers', 'terrain'].filter((name) => !report.inputs[name]);
|
|
1120
|
+
if (missing.length > 0)
|
|
1121
|
+
diagnostics.push({
|
|
1122
|
+
code: 'MAP_BORDER_INPUT_MISSING',
|
|
1123
|
+
severity: 'info',
|
|
1124
|
+
category: 'map',
|
|
1125
|
+
message: `Border naturalness was measured without the ${missing.join(', ')} bitmap${missing.length > 1 ? 's' : ''}; those features are not counted`,
|
|
1126
|
+
details: { missing },
|
|
1127
|
+
});
|
|
1128
|
+
for (const layer of report.layers) {
|
|
1129
|
+
if (layer.status === 'not-meaningful')
|
|
1130
|
+
diagnostics.push({
|
|
1131
|
+
code: 'MAP_BORDER_NOT_MEANINGFUL',
|
|
1132
|
+
severity: 'info',
|
|
1133
|
+
category: 'map',
|
|
1134
|
+
message: `The ${layer.layer} border enrichment is not meaningful: only ${(layer.barrier.baseRate * 100).toFixed(1)}% of land lies on a river, slope or lake shore`,
|
|
1135
|
+
details: { layer: layer.layer, baseRate: layer.barrier.baseRate },
|
|
1136
|
+
});
|
|
1137
|
+
if (layer.status === 'no-borders' || layer.status === 'too-short')
|
|
1138
|
+
continue;
|
|
1139
|
+
const check = checkBorderTargets(layer, targets ?? borderTargetsForLayer(layer.layer));
|
|
1140
|
+
if (!check.passed)
|
|
1141
|
+
diagnostics.push({
|
|
1142
|
+
code: 'MAP_BORDER_ARTIFICIAL_REVIEW',
|
|
1143
|
+
severity: 'warning',
|
|
1144
|
+
category: 'map',
|
|
1145
|
+
message: `The ${layer.layer} borders look artificial: ${check.failures
|
|
1146
|
+
.map(({ metric, value, target }) => metric === 'enrichment'
|
|
1147
|
+
? `enrichment ${String(value)} is below ${String(target)}`
|
|
1148
|
+
: metric === 'straightness'
|
|
1149
|
+
? `${String(value)} of samples are locally straight (limit ${String(target)})`
|
|
1150
|
+
: `natural share ${String(value)} is below ${String(target)}`)
|
|
1151
|
+
.join('; ')}`,
|
|
1152
|
+
details: {
|
|
1153
|
+
layer: layer.layer,
|
|
1154
|
+
failures: check.failures,
|
|
1155
|
+
worstRun: layer.worstRuns[0] ?? null,
|
|
1156
|
+
},
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
return diagnostics;
|
|
1160
|
+
}
|
|
1161
|
+
/** The two pixels either side of an edge key (`pixel * 2 + direction`). */
|
|
1162
|
+
export function borderEdgePixels(width, key) {
|
|
1163
|
+
const pixel = key >> 1;
|
|
1164
|
+
const x = pixel % width;
|
|
1165
|
+
return [
|
|
1166
|
+
pixel,
|
|
1167
|
+
(key & 1) === 0 ? (x === width - 1 ? pixel - (width - 1) : pixel + 1) : pixel + width,
|
|
1168
|
+
];
|
|
1169
|
+
}
|
|
1170
|
+
/** Visit every border edge of a grouping with whether it lies on a natural feature. */
|
|
1171
|
+
export function forEachBorderEdge(table, labels, visit) {
|
|
1172
|
+
const effective = effectiveLabels(table, labels);
|
|
1173
|
+
for (let index = 0; index < table.edgeCount; index += 1) {
|
|
1174
|
+
const left = effective[table.provinceA[index]];
|
|
1175
|
+
const right = effective[table.provinceB[index]];
|
|
1176
|
+
if (left < 0 || right < 0 || left === right)
|
|
1177
|
+
continue;
|
|
1178
|
+
visit(table.keys[index], (table.features[index] & NATURAL_MASK) !== 0);
|
|
1179
|
+
}
|
|
1180
|
+
}
|