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.
- package/CHANGELOG.md +25 -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/operation-context.js +17 -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/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 +15 -2
- package/dist/hoi4_agent_tools/map/operations.js +111 -8
- 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 +138 -5
- 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 +191 -16
- package/dist/hoi4_agent_tools/map/tool-operations.js +208 -2
- 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 +24 -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-partition.js +119 -0
- package/dist/hoi4_agent_tools/map/world-provinces.js +131 -56
- package/dist/hoi4_agent_tools/map/world.js +462 -124
- 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 +209 -2
- 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/map.md +258 -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 +2156 -259
- 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,1467 @@
|
|
|
1
|
+
import { compareCodeUnits, hashCanonical } from '../../core/canonical.js';
|
|
2
|
+
import { sortDiagnostics, } from '../../core/diagnostics.js';
|
|
3
|
+
import { GEOGRAPHY_MAX_FEATURES, GEOGRAPHY_MAX_POINTS, } from '../../schemas/geography.js';
|
|
4
|
+
import { boundsOf, findSelfIntersection, interiorPoint, ringCentroid, ringContains, ringsIntersect, signedArea, unionBounds, } from './geometry.js';
|
|
5
|
+
import { areaBoundaryDistance, findSurface, ResolvedGeography, resolvedAreaContains, } from './model.js';
|
|
6
|
+
import { analysisGrid, nearestOnPolyline, sampleAlong, scanAreaStrip, } from './raster.js';
|
|
7
|
+
/** Values the resolved model uses where the input leaves a field out. Part of the contract. */
|
|
8
|
+
export const GEOGRAPHY_DEFAULTS = {
|
|
9
|
+
sea: { depthClass: 'open', ice: 0, kind: 'sea' },
|
|
10
|
+
land: { elevationBase: 0.15, coastDetail: 0.5 },
|
|
11
|
+
range: {
|
|
12
|
+
halfWidth: 0.012,
|
|
13
|
+
peak: 0.7,
|
|
14
|
+
profile: 'ridge',
|
|
15
|
+
asymmetry: 0,
|
|
16
|
+
spurs: 0.5,
|
|
17
|
+
barrier: 'none',
|
|
18
|
+
},
|
|
19
|
+
pass: { width: 0.006, depth: 0.6, fort: 0, reach: 0.02 },
|
|
20
|
+
volcano: { radius: 0.01, peak: 0.85, craterShare: 0.2, active: false },
|
|
21
|
+
plateau: { edge: 'ramp' },
|
|
22
|
+
river: { size: 3, delta: false },
|
|
23
|
+
lake: { depthClass: 'open', gameplay: 'lake' },
|
|
24
|
+
biome: { density: 0.6, z: 0 },
|
|
25
|
+
strait: { rule: 'open', width: 0.01 },
|
|
26
|
+
straitReach: 0.1,
|
|
27
|
+
lakeShoreReach: 0.01,
|
|
28
|
+
site: { importance: 1 },
|
|
29
|
+
road: { importance: 1 },
|
|
30
|
+
landTolerance: 0.003,
|
|
31
|
+
climate: {
|
|
32
|
+
windFrom: 270,
|
|
33
|
+
temperature: { warm: 24, cold: -10 },
|
|
34
|
+
warmBandY: 0.5,
|
|
35
|
+
rainScale: 1,
|
|
36
|
+
seasonality: 0.5,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
export const GEOGRAPHY_DIAGNOSTIC_LIMIT = 1_000;
|
|
40
|
+
const DISC_VERTICES = 64;
|
|
41
|
+
// cos and sin of 2 pi / 64, rotated repeatedly so no trigonometric call is needed.
|
|
42
|
+
const DISC_COS = 0.9951847266721969;
|
|
43
|
+
const DISC_SIN = 0.0980171403295606;
|
|
44
|
+
function opt(key, value) {
|
|
45
|
+
return (value === undefined ? {} : { [key]: value });
|
|
46
|
+
}
|
|
47
|
+
function toPoint(point) {
|
|
48
|
+
return Array.isArray(point) ? { x: point[0], y: point[1] } : point;
|
|
49
|
+
}
|
|
50
|
+
function sortedUnique(values) {
|
|
51
|
+
return [...new Set(values ?? [])].sort(compareCodeUnits);
|
|
52
|
+
}
|
|
53
|
+
function byId(items) {
|
|
54
|
+
return [...(items ?? [])].sort((a, b) => compareCodeUnits(a.id, b.id));
|
|
55
|
+
}
|
|
56
|
+
function countPoints(model) {
|
|
57
|
+
let total = 0;
|
|
58
|
+
const rings = (area) => {
|
|
59
|
+
for (const ring of area.polygons ?? [])
|
|
60
|
+
total += ring.length;
|
|
61
|
+
for (const ring of area.holes ?? [])
|
|
62
|
+
total += ring.length;
|
|
63
|
+
};
|
|
64
|
+
for (const item of [
|
|
65
|
+
...(model.seas ?? []),
|
|
66
|
+
...(model.lands ?? []),
|
|
67
|
+
...(model.regions ?? []),
|
|
68
|
+
...(model.plateaus ?? []),
|
|
69
|
+
...(model.lakes ?? []),
|
|
70
|
+
...(model.biomes ?? []),
|
|
71
|
+
])
|
|
72
|
+
rings(item);
|
|
73
|
+
for (const range of model.ranges ?? [])
|
|
74
|
+
total += range.path.length;
|
|
75
|
+
for (const river of model.rivers ?? [])
|
|
76
|
+
total += 2 + (river.via?.length ?? 0);
|
|
77
|
+
total += (model.volcanoes?.length ?? 0) + (model.sites?.length ?? 0);
|
|
78
|
+
total += (model.places?.length ?? 0) + (model.passes?.length ?? 0);
|
|
79
|
+
for (const road of model.roads ?? [])
|
|
80
|
+
total += road.path.length;
|
|
81
|
+
for (const strait of model.straits ?? [])
|
|
82
|
+
total += 2 + (strait.path?.length ?? 0);
|
|
83
|
+
return total;
|
|
84
|
+
}
|
|
85
|
+
function featureCount(model) {
|
|
86
|
+
return ((model.seas?.length ?? 0) +
|
|
87
|
+
(model.lands?.length ?? 0) +
|
|
88
|
+
(model.regions?.length ?? 0) +
|
|
89
|
+
(model.ranges?.length ?? 0) +
|
|
90
|
+
(model.passes?.length ?? 0) +
|
|
91
|
+
(model.volcanoes?.length ?? 0) +
|
|
92
|
+
(model.plateaus?.length ?? 0) +
|
|
93
|
+
(model.rivers?.length ?? 0) +
|
|
94
|
+
(model.lakes?.length ?? 0) +
|
|
95
|
+
(model.biomes?.length ?? 0) +
|
|
96
|
+
(model.straits?.length ?? 0) +
|
|
97
|
+
(model.sites?.length ?? 0) +
|
|
98
|
+
(model.roads?.length ?? 0) +
|
|
99
|
+
(model.places?.length ?? 0));
|
|
100
|
+
}
|
|
101
|
+
function allCollinear(xs, ys) {
|
|
102
|
+
for (let index = 2; index < xs.length; index++) {
|
|
103
|
+
const cross = (xs[1] - xs[0]) * (ys[index] - ys[0]) - (ys[1] - ys[0]) * (xs[index] - xs[0]);
|
|
104
|
+
if (Math.abs(cross) > 1e-15)
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
function ringFrom(points, outer, label, sink, featureId) {
|
|
110
|
+
const xs = [], ys = [];
|
|
111
|
+
let duplicates = 0;
|
|
112
|
+
for (const raw of points) {
|
|
113
|
+
const { x, y } = toPoint(raw);
|
|
114
|
+
if (xs.length > 0 && xs.at(-1) === x && ys.at(-1) === y) {
|
|
115
|
+
duplicates++;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
xs.push(x);
|
|
119
|
+
ys.push(y);
|
|
120
|
+
}
|
|
121
|
+
if (xs.length > 1 && xs[0] === xs.at(-1) && ys[0] === ys.at(-1)) {
|
|
122
|
+
xs.pop();
|
|
123
|
+
ys.pop();
|
|
124
|
+
duplicates++;
|
|
125
|
+
}
|
|
126
|
+
if (duplicates > 0)
|
|
127
|
+
sink('MAP_GEOGRAPHY_RING_DUPLICATE_VERTEX', 'warning', `${label} of ${featureId} repeats ${duplicates} vertex(es), including a closing copy of the first; the ring is closed implicitly, so they were removed`, { featureId, duplicates });
|
|
128
|
+
const area = signedArea(xs, ys);
|
|
129
|
+
// A crossing ring (a bowtie can have a zero shoelace sum) is reported as such, not as empty.
|
|
130
|
+
const collinear = xs.length >= 3 && allCollinear(xs, ys);
|
|
131
|
+
const crossing = xs.length >= 3 && !collinear && findSelfIntersection(xs, ys, true) !== undefined;
|
|
132
|
+
if (crossing)
|
|
133
|
+
sink('MAP_GEOGRAPHY_RING_SELF_INTERSECTION', 'error', `${label} of ${featureId} crosses, touches or doubles back on itself`, { featureId });
|
|
134
|
+
if (xs.length < 3 || collinear || (!crossing && Math.abs(area) < 1e-12)) {
|
|
135
|
+
sink('MAP_GEOGRAPHY_RING_DEGENERATE', 'error', `${label} of ${featureId} has fewer than 3 distinct vertices or no area`, { featureId, vertices: xs.length });
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
// Orient outers with a positive shoelace sum and holes with a negative one.
|
|
139
|
+
if (outer !== area > 0) {
|
|
140
|
+
xs.reverse();
|
|
141
|
+
ys.reverse();
|
|
142
|
+
}
|
|
143
|
+
const fx = Float64Array.from(xs), fy = Float64Array.from(ys);
|
|
144
|
+
return { xs: fx, ys: fy, bounds: boundsOf(fx, fy), signedArea: signedArea(fx, fy) };
|
|
145
|
+
}
|
|
146
|
+
function ringInside(inner, outer) {
|
|
147
|
+
return ringContains(outer.xs, outer.ys, inner.xs[0], inner.ys[0]);
|
|
148
|
+
}
|
|
149
|
+
function buildArea(featureId, polygons, holes, sink) {
|
|
150
|
+
const outers = [];
|
|
151
|
+
const holeRings = [];
|
|
152
|
+
for (const [index, polygon] of polygons.entries()) {
|
|
153
|
+
const ring = ringFrom(polygon, true, `Polygon ${index + 1}`, sink, featureId);
|
|
154
|
+
if (ring !== undefined)
|
|
155
|
+
outers.push(ring);
|
|
156
|
+
}
|
|
157
|
+
for (const [index, hole] of holes.entries()) {
|
|
158
|
+
const ring = ringFrom(hole, false, `Hole ${index + 1}`, sink, featureId);
|
|
159
|
+
if (ring !== undefined)
|
|
160
|
+
holeRings.push(ring);
|
|
161
|
+
}
|
|
162
|
+
if (outers.length === 0)
|
|
163
|
+
return undefined;
|
|
164
|
+
const overlap = (a, b) => ringsIntersect(a.xs, a.ys, b.xs, b.ys) || ringInside(a, b) || ringInside(b, a);
|
|
165
|
+
for (let i = 0; i < outers.length; i++)
|
|
166
|
+
for (let j = i + 1; j < outers.length; j++)
|
|
167
|
+
if (overlap(outers[i], outers[j]))
|
|
168
|
+
sink('MAP_GEOGRAPHY_POLYGONS_OVERLAP', 'error', `Polygons ${i + 1} and ${j + 1} of ${featureId} overlap or touch; describe one shape per polygon and use holes for cut-outs`, { featureId, polygons: [i + 1, j + 1] });
|
|
169
|
+
for (const [index, hole] of holeRings.entries()) {
|
|
170
|
+
const container = outers.find((outer) => ringInside(hole, outer) && !ringsIntersect(outer.xs, outer.ys, hole.xs, hole.ys));
|
|
171
|
+
if (container === undefined)
|
|
172
|
+
sink('MAP_GEOGRAPHY_HOLE_OUTSIDE', 'error', `Hole ${index + 1} of ${featureId} is not strictly inside one polygon`, { featureId, hole: index + 1 });
|
|
173
|
+
for (let other = index + 1; other < holeRings.length; other++)
|
|
174
|
+
if (overlap(hole, holeRings[other]))
|
|
175
|
+
sink('MAP_GEOGRAPHY_POLYGONS_OVERLAP', 'error', `Holes ${index + 1} and ${other + 1} of ${featureId} overlap or touch`, { featureId, holes: [index + 1, other + 1] });
|
|
176
|
+
}
|
|
177
|
+
const fraction = Math.max(0, outers.reduce((sum, ring) => sum + ring.signedArea, 0) +
|
|
178
|
+
holeRings.reduce((sum, ring) => sum + ring.signedArea, 0));
|
|
179
|
+
const bounds = unionBounds(outers.map((ring) => ring.bounds));
|
|
180
|
+
// Centroid of the largest polygon places the label point inside the dominant part.
|
|
181
|
+
const largest = outers.reduce((a, b) => (b.signedArea > a.signedArea ? b : a));
|
|
182
|
+
const [, centroidY] = ringCentroid(largest.xs, largest.ys);
|
|
183
|
+
const [ix, iy] = interiorPoint([largest, ...holeRings.filter((h) => ringInside(h, largest))], centroidY, largest.bounds);
|
|
184
|
+
return { outers, holes: holeRings, bounds, areaFraction: fraction, interior: { x: ix, y: iy } };
|
|
185
|
+
}
|
|
186
|
+
function buildPolyline(featureId, raw, closed, defaults, frame, sink) {
|
|
187
|
+
const xs = [], ys = [], halfWidth = [], peak = [];
|
|
188
|
+
let duplicates = 0;
|
|
189
|
+
for (const point of raw) {
|
|
190
|
+
const x = Array.isArray(point) ? point[0] : point.x;
|
|
191
|
+
const y = Array.isArray(point) ? point[1] : point.y;
|
|
192
|
+
if (xs.length > 0 && xs.at(-1) === x && ys.at(-1) === y) {
|
|
193
|
+
duplicates++;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
xs.push(x);
|
|
197
|
+
ys.push(y);
|
|
198
|
+
halfWidth.push(Array.isArray(point)
|
|
199
|
+
? defaults.halfWidth
|
|
200
|
+
: (point.halfWidth ?? defaults.halfWidth));
|
|
201
|
+
peak.push(Array.isArray(point) ? defaults.peak : (point.peak ?? defaults.peak));
|
|
202
|
+
}
|
|
203
|
+
if (closed && xs.length > 1 && xs[0] === xs.at(-1) && ys[0] === ys.at(-1)) {
|
|
204
|
+
xs.pop();
|
|
205
|
+
ys.pop();
|
|
206
|
+
halfWidth.pop();
|
|
207
|
+
peak.pop();
|
|
208
|
+
duplicates++;
|
|
209
|
+
}
|
|
210
|
+
if (duplicates > 0)
|
|
211
|
+
sink('MAP_GEOGRAPHY_PATH_DUPLICATE_VERTEX', 'warning', `Path of ${featureId} repeats ${duplicates} consecutive vertex(es); they were removed`, { featureId, duplicates });
|
|
212
|
+
if (xs.length < (closed ? 3 : 2)) {
|
|
213
|
+
sink('MAP_GEOGRAPHY_PATH_DEGENERATE', 'error', `Path of ${featureId} needs at least ${closed ? 3 : 2} distinct vertices`, { featureId, vertices: xs.length });
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
const fx = Float64Array.from(xs), fy = Float64Array.from(ys);
|
|
217
|
+
const segments = closed ? fx.length : fx.length - 1;
|
|
218
|
+
const arc = new Float64Array(segments + 1);
|
|
219
|
+
const aspect = frame.height / frame.width;
|
|
220
|
+
for (let index = 0; index < segments; index++) {
|
|
221
|
+
const next = (index + 1) % fx.length;
|
|
222
|
+
const dx = fx[next] - fx[index], dy = (fy[next] - fy[index]) * aspect;
|
|
223
|
+
arc[index + 1] = arc[index] + Math.sqrt(dx * dx + dy * dy);
|
|
224
|
+
}
|
|
225
|
+
if (findSelfIntersection(fx, fy, closed) !== undefined)
|
|
226
|
+
sink('MAP_GEOGRAPHY_PATH_SELF_INTERSECTION', 'warning', `Path of ${featureId} crosses itself`, {
|
|
227
|
+
featureId,
|
|
228
|
+
});
|
|
229
|
+
return {
|
|
230
|
+
xs: fx,
|
|
231
|
+
ys: fy,
|
|
232
|
+
halfWidth: Float64Array.from(halfWidth),
|
|
233
|
+
peak: Float64Array.from(peak),
|
|
234
|
+
closed,
|
|
235
|
+
bounds: boundsOf(fx, fy),
|
|
236
|
+
arc,
|
|
237
|
+
length: arc[segments],
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function discArea(center, radius, frame) {
|
|
241
|
+
const xs = [], ys = [];
|
|
242
|
+
let cos = 1, sin = 0;
|
|
243
|
+
const ry = radius * (frame.width / frame.height);
|
|
244
|
+
for (let index = 0; index < DISC_VERTICES; index++) {
|
|
245
|
+
xs.push(center.x + radius * cos);
|
|
246
|
+
ys.push(center.y + ry * sin);
|
|
247
|
+
const nextCos = cos * DISC_COS - sin * DISC_SIN;
|
|
248
|
+
sin = sin * DISC_COS + cos * DISC_SIN;
|
|
249
|
+
cos = nextCos;
|
|
250
|
+
}
|
|
251
|
+
const fx = Float64Array.from(xs), fy = Float64Array.from(ys);
|
|
252
|
+
const ring = {
|
|
253
|
+
xs: fx,
|
|
254
|
+
ys: fy,
|
|
255
|
+
bounds: boundsOf(fx, fy),
|
|
256
|
+
signedArea: signedArea(fx, fy),
|
|
257
|
+
};
|
|
258
|
+
if (ring.signedArea < 0) {
|
|
259
|
+
fx.reverse();
|
|
260
|
+
fy.reverse();
|
|
261
|
+
}
|
|
262
|
+
const oriented = {
|
|
263
|
+
xs: fx,
|
|
264
|
+
ys: fy,
|
|
265
|
+
bounds: ring.bounds,
|
|
266
|
+
signedArea: Math.abs(ring.signedArea),
|
|
267
|
+
};
|
|
268
|
+
return {
|
|
269
|
+
outers: [oriented],
|
|
270
|
+
holes: [],
|
|
271
|
+
bounds: oriented.bounds,
|
|
272
|
+
areaFraction: oriented.signedArea,
|
|
273
|
+
interior: { ...center },
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
function baseOf(feature) {
|
|
277
|
+
return {
|
|
278
|
+
id: feature.id,
|
|
279
|
+
tags: sortedUnique(feature.tags),
|
|
280
|
+
...opt('name', feature.name),
|
|
281
|
+
...opt('note', feature.note),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
function polylineSegmentLength(a, b, frame) {
|
|
285
|
+
const dx = b.x - a.x, dy = (b.y - a.y) * (frame.height / frame.width);
|
|
286
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
287
|
+
}
|
|
288
|
+
function pairKey(a, b) {
|
|
289
|
+
return a < b ? a * 65_536 + b : b * 65_536 + a;
|
|
290
|
+
}
|
|
291
|
+
/** Plain-object form of the resolved model: primary fields only, arrays sorted, no derived data. */
|
|
292
|
+
function canonicalModel(frame, parts) {
|
|
293
|
+
const ring = (r) => Array.from(r.xs, (x, index) => [x, r.ys[index]]);
|
|
294
|
+
const area = (a) => ({ polygons: a.outers.map(ring), holes: a.holes.map(ring) });
|
|
295
|
+
const line = (l) => ({
|
|
296
|
+
closed: l.closed,
|
|
297
|
+
points: Array.from(l.xs, (x, index) => [x, l.ys[index], l.halfWidth[index], l.peak[index]]),
|
|
298
|
+
});
|
|
299
|
+
const end = (e) => e.type === 'point' ? { x: e.x, y: e.y } : { [e.type]: e.id };
|
|
300
|
+
const base = (f) => ({
|
|
301
|
+
id: f.id,
|
|
302
|
+
name: f.name,
|
|
303
|
+
tags: f.tags,
|
|
304
|
+
note: f.note,
|
|
305
|
+
});
|
|
306
|
+
return {
|
|
307
|
+
version: 1,
|
|
308
|
+
frame,
|
|
309
|
+
name: parts.name,
|
|
310
|
+
description: parts.description,
|
|
311
|
+
seas: parts.seas.map((f) => ({
|
|
312
|
+
...base(f),
|
|
313
|
+
...area(f.area),
|
|
314
|
+
depthClass: f.depthClass,
|
|
315
|
+
ice: f.ice,
|
|
316
|
+
kind: f.seaKind,
|
|
317
|
+
})),
|
|
318
|
+
lands: parts.lands.map((f) => ({
|
|
319
|
+
...base(f),
|
|
320
|
+
...area(f.area),
|
|
321
|
+
kind: f.landKind,
|
|
322
|
+
region: f.regionSource === 'explicit' ? f.regionId : undefined,
|
|
323
|
+
biome: f.biome,
|
|
324
|
+
fertility: f.fertility,
|
|
325
|
+
elevationBase: f.elevationBase,
|
|
326
|
+
coastDetail: f.coastDetail,
|
|
327
|
+
})),
|
|
328
|
+
regions: parts.regions.map((f) => ({
|
|
329
|
+
...base(f),
|
|
330
|
+
...(f.area === undefined ? {} : area(f.area)),
|
|
331
|
+
terrain: f.terrain,
|
|
332
|
+
provincesPerState: f.provincesPerState,
|
|
333
|
+
provinceAreaPx: f.provinceAreaPx,
|
|
334
|
+
})),
|
|
335
|
+
ranges: parts.ranges.map((f) => ({
|
|
336
|
+
...base(f),
|
|
337
|
+
...line(f.path),
|
|
338
|
+
profile: f.profile,
|
|
339
|
+
asymmetry: f.asymmetry,
|
|
340
|
+
snowline: f.snowline,
|
|
341
|
+
spurs: f.spurs,
|
|
342
|
+
barrier: f.barrier,
|
|
343
|
+
})),
|
|
344
|
+
passes: parts.passes.map((f) => ({
|
|
345
|
+
...base(f),
|
|
346
|
+
range: f.rangeId,
|
|
347
|
+
at: f.at,
|
|
348
|
+
width: f.width,
|
|
349
|
+
depth: f.depth,
|
|
350
|
+
fort: f.fort,
|
|
351
|
+
})),
|
|
352
|
+
volcanoes: parts.volcanoes.map((f) => ({
|
|
353
|
+
...base(f),
|
|
354
|
+
at: f.at,
|
|
355
|
+
radius: f.radius,
|
|
356
|
+
peak: f.peak,
|
|
357
|
+
crater: f.crater,
|
|
358
|
+
active: f.active,
|
|
359
|
+
})),
|
|
360
|
+
plateaus: parts.plateaus.map((f) => ({
|
|
361
|
+
...base(f),
|
|
362
|
+
...area(f.area),
|
|
363
|
+
height: f.height,
|
|
364
|
+
edge: f.edge,
|
|
365
|
+
})),
|
|
366
|
+
rivers: parts.rivers.map((f) => ({
|
|
367
|
+
...base(f),
|
|
368
|
+
source: end(f.source),
|
|
369
|
+
mouth: end(f.mouth),
|
|
370
|
+
via: f.via,
|
|
371
|
+
size: f.size,
|
|
372
|
+
delta: f.delta,
|
|
373
|
+
})),
|
|
374
|
+
lakes: parts.lakes.map((f) => ({
|
|
375
|
+
...base(f),
|
|
376
|
+
...area(f.area),
|
|
377
|
+
shape: f.shape,
|
|
378
|
+
depthClass: f.depthClass,
|
|
379
|
+
outflow: f.outflowId,
|
|
380
|
+
gameplay: f.gameplay,
|
|
381
|
+
})),
|
|
382
|
+
biomes: parts.biomes.map((f) => ({
|
|
383
|
+
...base(f),
|
|
384
|
+
...area(f.area),
|
|
385
|
+
kind: f.biome,
|
|
386
|
+
density: f.density,
|
|
387
|
+
fertility: f.fertility,
|
|
388
|
+
z: f.z,
|
|
389
|
+
})),
|
|
390
|
+
straits: parts.straits.map((f) => ({
|
|
391
|
+
...base(f),
|
|
392
|
+
between: f.between.map(end),
|
|
393
|
+
ends: f.ends,
|
|
394
|
+
path: f.path?.map((point) => [point.x, point.y]),
|
|
395
|
+
rule: f.rule,
|
|
396
|
+
width: f.width,
|
|
397
|
+
})),
|
|
398
|
+
sites: parts.sites.map((f) => ({
|
|
399
|
+
...base(f),
|
|
400
|
+
kind: f.siteKind,
|
|
401
|
+
at: f.at,
|
|
402
|
+
importance: f.importance,
|
|
403
|
+
country: f.country,
|
|
404
|
+
onWater: f.onWater,
|
|
405
|
+
lake: f.lakeId,
|
|
406
|
+
})),
|
|
407
|
+
roads: parts.roads.map((f) => ({ ...base(f), ...line(f.path), importance: f.importance })),
|
|
408
|
+
places: parts.places.map((f) => ({ ...base(f), kind: f.placeKind, at: f.at })),
|
|
409
|
+
climate: parts.climate,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
function makeGeography(data) {
|
|
413
|
+
return new ResolvedGeography(data);
|
|
414
|
+
}
|
|
415
|
+
function emptyClimate() {
|
|
416
|
+
return {
|
|
417
|
+
...GEOGRAPHY_DEFAULTS.climate,
|
|
418
|
+
temperature: { ...GEOGRAPHY_DEFAULTS.climate.temperature },
|
|
419
|
+
declared: false,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Resolve a validated-by-schema geography model against a pixel frame: sort every feature by id,
|
|
424
|
+
* fill documented defaults, orient rings, derive areas and cross-references, and report every
|
|
425
|
+
* defect as a `MAP_GEOGRAPHY_*` diagnostic. The same input and frame always give the same model,
|
|
426
|
+
* the same `hash` and the same diagnostics, whatever order the features were written in.
|
|
427
|
+
*/
|
|
428
|
+
export function resolveGeography(model, options) {
|
|
429
|
+
const frame = options.frame;
|
|
430
|
+
const found = [];
|
|
431
|
+
const sink = (code, severity, message, details) => {
|
|
432
|
+
found.push({
|
|
433
|
+
code,
|
|
434
|
+
severity,
|
|
435
|
+
category: 'map',
|
|
436
|
+
message,
|
|
437
|
+
...(options.operationId === undefined ? {} : { operationId: options.operationId }),
|
|
438
|
+
...(details === undefined ? {} : { details }),
|
|
439
|
+
});
|
|
440
|
+
};
|
|
441
|
+
const edgeBand = {
|
|
442
|
+
westEastPx: Math.max(1, options.edge?.bandPx ?? Math.max(192, Math.round(0.04 * frame.width))),
|
|
443
|
+
northSouthPx: Math.max(0, options.edge?.polarPx ?? 64),
|
|
444
|
+
};
|
|
445
|
+
const finish = (geography) => {
|
|
446
|
+
const diagnostics = limitDiagnostics(sortDiagnostics(found));
|
|
447
|
+
return {
|
|
448
|
+
geography,
|
|
449
|
+
diagnostics,
|
|
450
|
+
valid: !diagnostics.some(({ severity }) => severity === 'error' || severity === 'blocker'),
|
|
451
|
+
edgeBand,
|
|
452
|
+
};
|
|
453
|
+
};
|
|
454
|
+
const features = featureCount(model);
|
|
455
|
+
const points = countPoints(model);
|
|
456
|
+
if (features > GEOGRAPHY_MAX_FEATURES || points > GEOGRAPHY_MAX_POINTS) {
|
|
457
|
+
if (features > GEOGRAPHY_MAX_FEATURES)
|
|
458
|
+
sink('MAP_GEOGRAPHY_LIMIT_FEATURES', 'error', `The model holds ${features} features; the limit is ${GEOGRAPHY_MAX_FEATURES}`, { features, limit: GEOGRAPHY_MAX_FEATURES });
|
|
459
|
+
if (points > GEOGRAPHY_MAX_POINTS)
|
|
460
|
+
sink('MAP_GEOGRAPHY_LIMIT_POINTS', 'error', `The model holds ${points} vertices and points; the limit is ${GEOGRAPHY_MAX_POINTS}`, { points, limit: GEOGRAPHY_MAX_POINTS });
|
|
461
|
+
const climate = emptyClimate();
|
|
462
|
+
return finish(makeGeography({
|
|
463
|
+
frame,
|
|
464
|
+
hash: hashCanonical({ rejected: 'limits', frame }),
|
|
465
|
+
seas: [],
|
|
466
|
+
lands: [],
|
|
467
|
+
regions: [],
|
|
468
|
+
ranges: [],
|
|
469
|
+
passes: [],
|
|
470
|
+
volcanoes: [],
|
|
471
|
+
plateaus: [],
|
|
472
|
+
rivers: [],
|
|
473
|
+
lakes: [],
|
|
474
|
+
biomes: [],
|
|
475
|
+
straits: [],
|
|
476
|
+
sites: [],
|
|
477
|
+
roads: [],
|
|
478
|
+
places: [],
|
|
479
|
+
climate,
|
|
480
|
+
pointCount: 0,
|
|
481
|
+
}));
|
|
482
|
+
}
|
|
483
|
+
// Ids are global: the first occurrence in each kind order wins, later ones are dropped.
|
|
484
|
+
const kinds = new Map();
|
|
485
|
+
const claim = (kind, items) => {
|
|
486
|
+
const kept = [];
|
|
487
|
+
for (const item of byId(items)) {
|
|
488
|
+
const previous = kinds.get(item.id);
|
|
489
|
+
if (previous !== undefined) {
|
|
490
|
+
sink('MAP_GEOGRAPHY_ID_DUPLICATE', 'error', `Feature id ${item.id} is used by a ${previous} and a ${kind}; ids must be unique across the model`, { featureId: item.id, kinds: [previous, kind] });
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
kinds.set(item.id, kind);
|
|
494
|
+
kept.push(item);
|
|
495
|
+
}
|
|
496
|
+
return kept;
|
|
497
|
+
};
|
|
498
|
+
const seaInputs = claim('sea', model.seas);
|
|
499
|
+
const landInputs = claim('land', model.lands);
|
|
500
|
+
const regionInputs = claim('region', model.regions);
|
|
501
|
+
const rangeInputs = claim('range', model.ranges);
|
|
502
|
+
const passInputs = claim('pass', model.passes);
|
|
503
|
+
const volcanoInputs = claim('volcano', model.volcanoes);
|
|
504
|
+
const plateauInputs = claim('plateau', model.plateaus);
|
|
505
|
+
const riverInputs = claim('river', model.rivers);
|
|
506
|
+
const lakeInputs = claim('lake', model.lakes);
|
|
507
|
+
const biomeInputs = claim('biome', model.biomes);
|
|
508
|
+
const straitInputs = claim('strait', model.straits);
|
|
509
|
+
const siteInputs = claim('site', model.sites);
|
|
510
|
+
const roadInputs = claim('road', model.roads);
|
|
511
|
+
const placeInputs = claim('place', model.places);
|
|
512
|
+
const seas = [];
|
|
513
|
+
for (const input of seaInputs) {
|
|
514
|
+
const area = buildArea(input.id, input.polygons, input.holes ?? [], sink);
|
|
515
|
+
if (area === undefined)
|
|
516
|
+
continue;
|
|
517
|
+
seas.push({
|
|
518
|
+
...baseOf(input),
|
|
519
|
+
kind: 'sea',
|
|
520
|
+
area,
|
|
521
|
+
depthClass: input.depthClass ?? GEOGRAPHY_DEFAULTS.sea.depthClass,
|
|
522
|
+
ice: input.ice ?? GEOGRAPHY_DEFAULTS.sea.ice,
|
|
523
|
+
seaKind: input.kind ?? GEOGRAPHY_DEFAULTS.sea.kind,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
const regions = [];
|
|
527
|
+
const regionAreas = new Map();
|
|
528
|
+
for (const input of regionInputs) {
|
|
529
|
+
let area;
|
|
530
|
+
if (input.polygons !== undefined) {
|
|
531
|
+
area = buildArea(input.id, input.polygons, input.holes ?? [], sink);
|
|
532
|
+
if (area !== undefined)
|
|
533
|
+
regionAreas.set(input.id, area);
|
|
534
|
+
}
|
|
535
|
+
else if (input.holes !== undefined)
|
|
536
|
+
sink('MAP_GEOGRAPHY_HOLES_WITHOUT_POLYGONS', 'error', `Region ${input.id} lists holes but no polygons`, {
|
|
537
|
+
featureId: input.id,
|
|
538
|
+
});
|
|
539
|
+
const terrain = Object.fromEntries(Object.entries(input.terrain ?? {}).filter(([, share]) => share !== undefined));
|
|
540
|
+
const shareSum = Object.values(terrain).reduce((sum, share) => sum + share, 0);
|
|
541
|
+
if (shareSum > 1.05)
|
|
542
|
+
sink('MAP_GEOGRAPHY_REGION_TERRAIN_SUM', 'warning', `The terrain shares of region ${input.id} add up to ${shareSum.toFixed(2)}, above 1`, { featureId: input.id, sum: shareSum });
|
|
543
|
+
const perState = input.density?.provincesPerState;
|
|
544
|
+
if (perState !== undefined && !(perState[0] <= perState[1] && perState[1] <= perState[2]))
|
|
545
|
+
sink('MAP_GEOGRAPHY_REGION_DENSITY_ORDER', 'error', `Region ${input.id} lists provincesPerState as [smallest, usual, largest] but they are not in order`, { featureId: input.id });
|
|
546
|
+
regions.push({
|
|
547
|
+
...baseOf(input),
|
|
548
|
+
kind: 'region',
|
|
549
|
+
...opt('area', area),
|
|
550
|
+
landIds: [],
|
|
551
|
+
...(Object.keys(terrain).length === 0 ? {} : { terrain }),
|
|
552
|
+
...(perState === undefined ? {} : { provincesPerState: perState }),
|
|
553
|
+
...opt('provinceAreaPx', input.density?.provinceAreaPx),
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
const lands = [];
|
|
557
|
+
const landDrafts = [];
|
|
558
|
+
for (const input of landInputs) {
|
|
559
|
+
const area = buildArea(input.id, input.polygons, input.holes ?? [], sink);
|
|
560
|
+
if (area === undefined)
|
|
561
|
+
continue;
|
|
562
|
+
const land = {
|
|
563
|
+
...baseOf(input),
|
|
564
|
+
kind: 'land',
|
|
565
|
+
area,
|
|
566
|
+
elevationBase: input.elevationBase ?? GEOGRAPHY_DEFAULTS.land.elevationBase,
|
|
567
|
+
coastDetail: input.coastDetail ?? GEOGRAPHY_DEFAULTS.land.coastDetail,
|
|
568
|
+
...opt('landKind', input.kind),
|
|
569
|
+
...opt('biome', input.biome),
|
|
570
|
+
...opt('fertility', input.fertility),
|
|
571
|
+
};
|
|
572
|
+
landDrafts.push({ land, declared: input.region });
|
|
573
|
+
}
|
|
574
|
+
// Region assignment: an explicit id must name a region; otherwise containment decides.
|
|
575
|
+
for (const { land, declared } of landDrafts) {
|
|
576
|
+
let regionId;
|
|
577
|
+
let regionSource;
|
|
578
|
+
if (declared !== undefined) {
|
|
579
|
+
const kind = kinds.get(declared);
|
|
580
|
+
if (kind === undefined)
|
|
581
|
+
sink('MAP_GEOGRAPHY_REF_UNKNOWN', 'error', `Land ${land.id} names region ${declared}, which does not exist`, {
|
|
582
|
+
featureId: land.id,
|
|
583
|
+
field: 'region',
|
|
584
|
+
reference: declared,
|
|
585
|
+
});
|
|
586
|
+
else if (kind !== 'region')
|
|
587
|
+
sink('MAP_GEOGRAPHY_REF_KIND', 'error', `Land ${land.id} names ${declared} as its region, but that is a ${kind}`, {
|
|
588
|
+
featureId: land.id,
|
|
589
|
+
field: 'region',
|
|
590
|
+
reference: declared,
|
|
591
|
+
actualKind: kind,
|
|
592
|
+
});
|
|
593
|
+
else {
|
|
594
|
+
regionId = declared;
|
|
595
|
+
regionSource = 'explicit';
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
else
|
|
599
|
+
for (const region of regions) {
|
|
600
|
+
const area = region.area;
|
|
601
|
+
if (area === undefined)
|
|
602
|
+
continue;
|
|
603
|
+
if (resolvedAreaContains(area, land.area.interior.x, land.area.interior.y)) {
|
|
604
|
+
regionId = region.id;
|
|
605
|
+
regionSource = 'containment';
|
|
606
|
+
break;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
lands.push({
|
|
610
|
+
...land,
|
|
611
|
+
...(regionId === undefined ? {} : { regionId, regionSource: regionSource }),
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
for (const [index, region] of regions.entries())
|
|
615
|
+
regions[index] = {
|
|
616
|
+
...region,
|
|
617
|
+
landIds: lands.filter((land) => land.regionId === region.id).map((land) => land.id),
|
|
618
|
+
};
|
|
619
|
+
for (const region of regions)
|
|
620
|
+
if (region.area === undefined && region.landIds.length === 0)
|
|
621
|
+
sink('MAP_GEOGRAPHY_REGION_EMPTY', 'warning', `Region ${region.id} has no polygons and no land names it`, {
|
|
622
|
+
featureId: region.id,
|
|
623
|
+
});
|
|
624
|
+
const ranges = [];
|
|
625
|
+
for (const input of rangeInputs) {
|
|
626
|
+
const halfWidth = input.halfWidth ?? GEOGRAPHY_DEFAULTS.range.halfWidth;
|
|
627
|
+
const peak = input.peak ?? GEOGRAPHY_DEFAULTS.range.peak;
|
|
628
|
+
const path = buildPolyline(input.id, input.path, input.closed ?? false, { halfWidth, peak }, frame, sink);
|
|
629
|
+
if (path === undefined)
|
|
630
|
+
continue;
|
|
631
|
+
ranges.push({
|
|
632
|
+
...baseOf(input),
|
|
633
|
+
kind: 'range',
|
|
634
|
+
path,
|
|
635
|
+
profile: input.profile ?? GEOGRAPHY_DEFAULTS.range.profile,
|
|
636
|
+
asymmetry: input.asymmetry ?? GEOGRAPHY_DEFAULTS.range.asymmetry,
|
|
637
|
+
spurs: input.spurs ?? GEOGRAPHY_DEFAULTS.range.spurs,
|
|
638
|
+
barrier: input.barrier ?? GEOGRAPHY_DEFAULTS.range.barrier,
|
|
639
|
+
passIds: [],
|
|
640
|
+
...opt('snowline', input.snowline),
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
const passes = [];
|
|
644
|
+
for (const input of passInputs) {
|
|
645
|
+
if (input.at !== undefined && input.position !== undefined) {
|
|
646
|
+
sink('MAP_GEOGRAPHY_PASS_SHAPE', 'error', `Pass ${input.id} gives both at and position; give one`, { featureId: input.id });
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
if (input.at === undefined && input.position === undefined) {
|
|
650
|
+
sink('MAP_GEOGRAPHY_PASS_SHAPE', 'error', `Pass ${input.id} needs either range with at, or a position`, { featureId: input.id });
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
if (input.at !== undefined && input.range === undefined) {
|
|
654
|
+
sink('MAP_GEOGRAPHY_PASS_SHAPE', 'error', `Pass ${input.id} gives at without the range it is measured along`, { featureId: input.id });
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
let range;
|
|
658
|
+
if (input.range !== undefined) {
|
|
659
|
+
const kind = kinds.get(input.range);
|
|
660
|
+
range = ranges.find((candidate) => candidate.id === input.range);
|
|
661
|
+
if (kind === undefined)
|
|
662
|
+
sink('MAP_GEOGRAPHY_REF_UNKNOWN', 'error', `Pass ${input.id} names range ${input.range}, which does not exist`, { featureId: input.id, field: 'range', reference: input.range });
|
|
663
|
+
else if (kind !== 'range')
|
|
664
|
+
sink('MAP_GEOGRAPHY_REF_KIND', 'error', `Pass ${input.id} names ${input.range} as its range, but that is a ${kind}`, { featureId: input.id, field: 'range', reference: input.range, actualKind: kind });
|
|
665
|
+
if (range === undefined)
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
let at;
|
|
669
|
+
let position;
|
|
670
|
+
let offset = 0;
|
|
671
|
+
let positionSource;
|
|
672
|
+
if (input.position !== undefined) {
|
|
673
|
+
positionSource = 'point';
|
|
674
|
+
const given = toPoint(input.position);
|
|
675
|
+
// Without a named range the nearest range whose footprint reaches the point is used.
|
|
676
|
+
let best;
|
|
677
|
+
for (const candidate of range === undefined ? ranges : [range]) {
|
|
678
|
+
const projection = nearestOnPolyline(candidate.path, frame, given.x, given.y);
|
|
679
|
+
if (best === undefined || projection.distance < best.projection.distance)
|
|
680
|
+
best = { range: candidate, projection };
|
|
681
|
+
}
|
|
682
|
+
if (best === undefined) {
|
|
683
|
+
sink('MAP_GEOGRAPHY_PASS_OFF_RANGE', 'error', `Pass ${input.id} has no range to lie on`, {
|
|
684
|
+
featureId: input.id,
|
|
685
|
+
});
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
const reach = Math.max(GEOGRAPHY_DEFAULTS.pass.reach, 2 * best.projection.halfWidth);
|
|
689
|
+
if (best.projection.distance > reach) {
|
|
690
|
+
sink('MAP_GEOGRAPHY_PASS_OFF_RANGE', 'error', `Pass ${input.id} is ${best.projection.distance.toFixed(4)} map widths from ${best.range.id}, beyond the ${reach.toFixed(4)} its footprint reaches`, {
|
|
691
|
+
featureId: input.id,
|
|
692
|
+
range: best.range.id,
|
|
693
|
+
distance: best.projection.distance,
|
|
694
|
+
reach,
|
|
695
|
+
});
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
range = best.range;
|
|
699
|
+
at = best.projection.at;
|
|
700
|
+
position = { x: best.projection.x, y: best.projection.y };
|
|
701
|
+
offset = best.projection.distance;
|
|
702
|
+
}
|
|
703
|
+
else {
|
|
704
|
+
positionSource = 'range-fraction';
|
|
705
|
+
at = input.at;
|
|
706
|
+
const sample = sampleAlong(range.path, frame, at);
|
|
707
|
+
position = { x: sample.x, y: sample.y };
|
|
708
|
+
}
|
|
709
|
+
passes.push({
|
|
710
|
+
...baseOf(input),
|
|
711
|
+
kind: 'pass',
|
|
712
|
+
rangeId: range.id,
|
|
713
|
+
at,
|
|
714
|
+
width: input.width ?? GEOGRAPHY_DEFAULTS.pass.width,
|
|
715
|
+
depth: input.depth ?? GEOGRAPHY_DEFAULTS.pass.depth,
|
|
716
|
+
fort: input.fort ?? GEOGRAPHY_DEFAULTS.pass.fort,
|
|
717
|
+
position,
|
|
718
|
+
positionSource,
|
|
719
|
+
offset,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
for (const [index, range] of ranges.entries()) {
|
|
723
|
+
const own = passes
|
|
724
|
+
.filter((pass) => pass.rangeId === range.id)
|
|
725
|
+
.sort((a, b) => a.at - b.at || compareCodeUnits(a.id, b.id));
|
|
726
|
+
ranges[index] = { ...range, passIds: own.map((pass) => pass.id) };
|
|
727
|
+
const length = range.path.length;
|
|
728
|
+
for (let i = 0; i < own.length; i++)
|
|
729
|
+
for (let j = i + 1; j < own.length; j++) {
|
|
730
|
+
const gap = (own[j].at - own[i].at) * length;
|
|
731
|
+
if (gap < (own[i].width + own[j].width) / 2)
|
|
732
|
+
sink('MAP_GEOGRAPHY_PASS_OVERLAP', 'warning', `Passes ${own[i].id} and ${own[j].id} on ${range.id} overlap`, {
|
|
733
|
+
featureId: own[j].id,
|
|
734
|
+
other: own[i].id,
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
const plateaus = [];
|
|
739
|
+
for (const input of plateauInputs) {
|
|
740
|
+
const area = buildArea(input.id, input.polygons, input.holes ?? [], sink);
|
|
741
|
+
if (area === undefined)
|
|
742
|
+
continue;
|
|
743
|
+
plateaus.push({
|
|
744
|
+
...baseOf(input),
|
|
745
|
+
kind: 'plateau',
|
|
746
|
+
area,
|
|
747
|
+
height: input.height,
|
|
748
|
+
edge: input.edge ?? GEOGRAPHY_DEFAULTS.plateau.edge,
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
const lakes = [];
|
|
752
|
+
const lakeDrafts = [];
|
|
753
|
+
for (const input of lakeInputs) {
|
|
754
|
+
const hasPolygons = input.polygons !== undefined;
|
|
755
|
+
const hasDisc = input.center !== undefined || input.radius !== undefined;
|
|
756
|
+
if (hasPolygons === hasDisc ||
|
|
757
|
+
(hasDisc && (input.center === undefined || input.radius === undefined))) {
|
|
758
|
+
sink('MAP_GEOGRAPHY_LAKE_SHAPE', 'error', `Lake ${input.id} needs either polygons or both center and radius, not both and not neither`, { featureId: input.id });
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
let area;
|
|
762
|
+
if (hasPolygons)
|
|
763
|
+
area = buildArea(input.id, input.polygons, input.holes ?? [], sink);
|
|
764
|
+
else {
|
|
765
|
+
area = discArea(toPoint(input.center), input.radius, frame);
|
|
766
|
+
const b = area.bounds;
|
|
767
|
+
if (b.x0 < 0 || b.y0 < 0 || b.x1 > 1 || b.y1 > 1) {
|
|
768
|
+
sink('MAP_GEOGRAPHY_LAKE_OUT_OF_FRAME', 'error', `Lake ${input.id} extends outside the world frame`, {
|
|
769
|
+
featureId: input.id,
|
|
770
|
+
});
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
if (area === undefined)
|
|
775
|
+
continue;
|
|
776
|
+
lakeDrafts.push({
|
|
777
|
+
...baseOf(input),
|
|
778
|
+
kind: 'lake',
|
|
779
|
+
area,
|
|
780
|
+
shape: hasPolygons ? 'polygon' : 'disc',
|
|
781
|
+
depthClass: input.depthClass ?? GEOGRAPHY_DEFAULTS.lake.depthClass,
|
|
782
|
+
gameplay: input.gameplay ?? GEOGRAPHY_DEFAULTS.lake.gameplay,
|
|
783
|
+
...(input.outflow === undefined ? {} : { outflowId: input.outflow }),
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
lakes.push(...lakeDrafts);
|
|
787
|
+
// Points that stand on a coast may fall a hair outside a simplified polygon; they count as land
|
|
788
|
+
// when a coastline lies within the tolerance.
|
|
789
|
+
const aspect = frame.height / frame.width;
|
|
790
|
+
const tolerance = GEOGRAPHY_DEFAULTS.landTolerance;
|
|
791
|
+
const coastDistance = (x, y, limit) => {
|
|
792
|
+
let best;
|
|
793
|
+
for (const land of lands) {
|
|
794
|
+
const b = land.area.bounds;
|
|
795
|
+
if (x < b.x0 - limit ||
|
|
796
|
+
x > b.x1 + limit ||
|
|
797
|
+
y < b.y0 - limit / aspect ||
|
|
798
|
+
y > b.y1 + limit / aspect)
|
|
799
|
+
continue;
|
|
800
|
+
const distance = areaBoundaryDistance(land.area, x, y, aspect);
|
|
801
|
+
if (distance <= limit && (best === undefined || distance < best.distance))
|
|
802
|
+
best = { id: land.id, distance };
|
|
803
|
+
}
|
|
804
|
+
return best;
|
|
805
|
+
};
|
|
806
|
+
const landAtOrNear = (x, y) => {
|
|
807
|
+
const here = findSurface({ lakes, lands, seas }, x, y);
|
|
808
|
+
if (here.surface === 'land')
|
|
809
|
+
return here.id;
|
|
810
|
+
// A lake carves land: its water is never land, however close its shore.
|
|
811
|
+
return here.surface === 'lake' ? undefined : coastDistance(x, y, tolerance)?.id;
|
|
812
|
+
};
|
|
813
|
+
const volcanoes = [];
|
|
814
|
+
for (const input of volcanoInputs) {
|
|
815
|
+
const at = toPoint(input.at);
|
|
816
|
+
const radius = input.radius ?? GEOGRAPHY_DEFAULTS.volcano.radius;
|
|
817
|
+
const crater = input.crater ?? radius * GEOGRAPHY_DEFAULTS.volcano.craterShare;
|
|
818
|
+
if (crater > radius)
|
|
819
|
+
sink('MAP_GEOGRAPHY_VOLCANO_CRATER_RADIUS', 'error', `Volcano ${input.id} has a crater wider than its cone`, { featureId: input.id });
|
|
820
|
+
const here = findSurface({ lakes, lands, seas }, at.x, at.y);
|
|
821
|
+
const landId = here.surface === 'land' ? here.id : landAtOrNear(at.x, at.y);
|
|
822
|
+
if (landId === undefined)
|
|
823
|
+
sink('MAP_GEOGRAPHY_VOLCANO_NOT_ON_LAND', 'error', `Volcano ${input.id} stands on ${here.surface}, not land`, { featureId: input.id });
|
|
824
|
+
volcanoes.push({
|
|
825
|
+
...baseOf(input),
|
|
826
|
+
kind: 'volcano',
|
|
827
|
+
at,
|
|
828
|
+
radius,
|
|
829
|
+
peak: input.peak ?? GEOGRAPHY_DEFAULTS.volcano.peak,
|
|
830
|
+
crater,
|
|
831
|
+
active: input.active ?? GEOGRAPHY_DEFAULTS.volcano.active,
|
|
832
|
+
...(landId === undefined ? {} : { landId }),
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
for (let i = 0; i < volcanoes.length; i++)
|
|
836
|
+
for (let j = i + 1; j < volcanoes.length; j++) {
|
|
837
|
+
const a = volcanoes[i], b = volcanoes[j];
|
|
838
|
+
if (polylineSegmentLength(a.at, b.at, frame) < Math.max(a.radius, b.radius))
|
|
839
|
+
sink('MAP_GEOGRAPHY_VOLCANO_OVERLAP', 'warning', `Volcanoes ${a.id} and ${b.id} are closer than their larger radius`, {
|
|
840
|
+
featureId: b.id,
|
|
841
|
+
other: a.id,
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
const biomes = [];
|
|
845
|
+
for (const input of biomeInputs) {
|
|
846
|
+
const area = buildArea(input.id, input.polygons, input.holes ?? [], sink);
|
|
847
|
+
if (area === undefined)
|
|
848
|
+
continue;
|
|
849
|
+
biomes.push({
|
|
850
|
+
...baseOf(input),
|
|
851
|
+
kind: 'biome',
|
|
852
|
+
area,
|
|
853
|
+
biome: input.kind,
|
|
854
|
+
density: input.density ?? GEOGRAPHY_DEFAULTS.biome.density,
|
|
855
|
+
z: input.z ?? GEOGRAPHY_DEFAULTS.biome.z,
|
|
856
|
+
...opt('fertility', input.fertility),
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
biomes.sort((a, b) => a.z - b.z || compareCodeUnits(a.id, b.id));
|
|
860
|
+
// Rivers: resolve ends against lakes, seas and other rivers.
|
|
861
|
+
const lakeById = new Map(lakes.map((lake) => [lake.id, lake]));
|
|
862
|
+
const kindOf = (id) => kinds.get(id);
|
|
863
|
+
const refError = (featureId, field, reference, expected) => {
|
|
864
|
+
const actual = kindOf(reference);
|
|
865
|
+
if (actual === undefined) {
|
|
866
|
+
sink('MAP_GEOGRAPHY_REF_UNKNOWN', 'error', `${featureId} names ${reference} in ${field}, which does not exist`, {
|
|
867
|
+
featureId,
|
|
868
|
+
field,
|
|
869
|
+
reference,
|
|
870
|
+
});
|
|
871
|
+
return true;
|
|
872
|
+
}
|
|
873
|
+
if (actual !== expected) {
|
|
874
|
+
sink('MAP_GEOGRAPHY_REF_KIND', 'error', `${featureId} names ${reference} in ${field} as a ${expected}, but it is a ${actual}`, {
|
|
875
|
+
featureId,
|
|
876
|
+
field,
|
|
877
|
+
reference,
|
|
878
|
+
actualKind: actual,
|
|
879
|
+
});
|
|
880
|
+
return true;
|
|
881
|
+
}
|
|
882
|
+
return false;
|
|
883
|
+
};
|
|
884
|
+
const surface = (x, y) => findSurface({ lakes, lands, seas }, x, y);
|
|
885
|
+
const rivers = [];
|
|
886
|
+
const riverMouthRefs = new Map();
|
|
887
|
+
for (const input of riverInputs) {
|
|
888
|
+
let source;
|
|
889
|
+
if ('lake' in input.source && !Array.isArray(input.source)) {
|
|
890
|
+
const id = input.source.lake;
|
|
891
|
+
if (!refError(input.id, 'source', id, 'lake')) {
|
|
892
|
+
const lake = lakeById.get(id);
|
|
893
|
+
if (lake !== undefined)
|
|
894
|
+
source = { type: 'lake', id, x: lake.area.interior.x, y: lake.area.interior.y };
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
else {
|
|
898
|
+
const point = toPoint(input.source);
|
|
899
|
+
const at = surface(point.x, point.y).surface;
|
|
900
|
+
if (at !== 'land' && at !== 'lake' && landAtOrNear(point.x, point.y) === undefined)
|
|
901
|
+
sink('MAP_GEOGRAPHY_RIVER_SOURCE_NOT_LAND', 'error', `River ${input.id} starts in ${at}, not on land`, { featureId: input.id });
|
|
902
|
+
source = { type: 'point', x: point.x, y: point.y };
|
|
903
|
+
}
|
|
904
|
+
let mouth;
|
|
905
|
+
const rawMouth = input.mouth;
|
|
906
|
+
if (!Array.isArray(rawMouth) && 'sea' in rawMouth) {
|
|
907
|
+
if (!refError(input.id, 'mouth', rawMouth.sea, 'sea'))
|
|
908
|
+
mouth = { type: 'sea', id: rawMouth.sea };
|
|
909
|
+
}
|
|
910
|
+
else if (!Array.isArray(rawMouth) && 'lake' in rawMouth) {
|
|
911
|
+
if (!refError(input.id, 'mouth', rawMouth.lake, 'lake')) {
|
|
912
|
+
const lake = lakeById.get(rawMouth.lake);
|
|
913
|
+
if (lake !== undefined)
|
|
914
|
+
mouth = { type: 'lake', id: lake.id, x: lake.area.interior.x, y: lake.area.interior.y };
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
else if (!Array.isArray(rawMouth) && 'river' in rawMouth) {
|
|
918
|
+
if (rawMouth.river === input.id)
|
|
919
|
+
sink('MAP_GEOGRAPHY_RIVER_CYCLE', 'error', `River ${input.id} cannot flow into itself`, {
|
|
920
|
+
featureId: input.id,
|
|
921
|
+
});
|
|
922
|
+
else if (!refError(input.id, 'mouth', rawMouth.river, 'river')) {
|
|
923
|
+
mouth = { type: 'river', id: rawMouth.river };
|
|
924
|
+
riverMouthRefs.set(input.id, rawMouth.river);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
else {
|
|
928
|
+
const point = toPoint(rawMouth);
|
|
929
|
+
const at = surface(point.x, point.y).surface;
|
|
930
|
+
const onFrameEdge = point.x <= 0 || point.x >= 1 || point.y <= 0 || point.y >= 1;
|
|
931
|
+
// A mouth drawn on the shore may sit just inside a simplified coast.
|
|
932
|
+
if (at === 'land' && !onFrameEdge && coastDistance(point.x, point.y, tolerance) === undefined)
|
|
933
|
+
sink('MAP_GEOGRAPHY_RIVER_MOUTH_NOT_WATER', 'error', `River ${input.id} ends on land; its mouth must be in water or on the frame edge`, {
|
|
934
|
+
featureId: input.id,
|
|
935
|
+
// How far inland the mouth is, so a converter can snap it to the coast.
|
|
936
|
+
inlandDistance: coastDistance(point.x, point.y, 1)?.distance ?? null,
|
|
937
|
+
nearestLand: coastDistance(point.x, point.y, 1)?.id ?? null,
|
|
938
|
+
});
|
|
939
|
+
mouth = { type: 'point', x: point.x, y: point.y };
|
|
940
|
+
}
|
|
941
|
+
const via = (input.via ?? []).map(toPoint);
|
|
942
|
+
for (const [index, point] of via.entries())
|
|
943
|
+
if (surface(point.x, point.y).surface !== 'land' &&
|
|
944
|
+
landAtOrNear(point.x, point.y) === undefined)
|
|
945
|
+
sink('MAP_GEOGRAPHY_RIVER_VIA_NOT_LAND', 'warning', `Via point ${index + 1} of river ${input.id} is not on land`, { featureId: input.id, via: index + 1 });
|
|
946
|
+
if (source === undefined || mouth === undefined)
|
|
947
|
+
continue;
|
|
948
|
+
const anchors = [
|
|
949
|
+
...('x' in source ? [{ x: source.x, y: source.y }] : []),
|
|
950
|
+
...via,
|
|
951
|
+
...('x' in mouth ? [{ x: mouth.x, y: mouth.y }] : []),
|
|
952
|
+
];
|
|
953
|
+
let anchorLength = 0;
|
|
954
|
+
for (let index = 1; index < anchors.length; index++)
|
|
955
|
+
anchorLength += polylineSegmentLength(anchors[index - 1], anchors[index], frame);
|
|
956
|
+
rivers.push({
|
|
957
|
+
...baseOf(input),
|
|
958
|
+
kind: 'river',
|
|
959
|
+
source,
|
|
960
|
+
mouth,
|
|
961
|
+
via,
|
|
962
|
+
size: input.size ?? GEOGRAPHY_DEFAULTS.river.size,
|
|
963
|
+
delta: input.delta ?? GEOGRAPHY_DEFAULTS.river.delta,
|
|
964
|
+
tributaryIds: [],
|
|
965
|
+
anchorLength,
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
// Cycle detection along river-into-river mouths.
|
|
969
|
+
for (const river of rivers) {
|
|
970
|
+
const seen = new Set([river.id]);
|
|
971
|
+
let next = riverMouthRefs.get(river.id);
|
|
972
|
+
while (next !== undefined) {
|
|
973
|
+
if (seen.has(next)) {
|
|
974
|
+
sink('MAP_GEOGRAPHY_RIVER_CYCLE', 'error', `River ${river.id} flows into itself through ${[...seen].join(', ')}`, {
|
|
975
|
+
featureId: river.id,
|
|
976
|
+
cycle: [...seen],
|
|
977
|
+
});
|
|
978
|
+
break;
|
|
979
|
+
}
|
|
980
|
+
seen.add(next);
|
|
981
|
+
next = riverMouthRefs.get(next);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
for (const [index, river] of rivers.entries())
|
|
985
|
+
rivers[index] = {
|
|
986
|
+
...river,
|
|
987
|
+
tributaryIds: rivers
|
|
988
|
+
.filter((other) => other.mouth.type === 'river' && other.mouth.id === river.id)
|
|
989
|
+
.map((other) => other.id),
|
|
990
|
+
};
|
|
991
|
+
// Lakes: outflow, containing land.
|
|
992
|
+
for (const [index, lake] of lakes.entries()) {
|
|
993
|
+
const input = lakeInputs.find((candidate) => candidate.id === lake.id);
|
|
994
|
+
if (input.outflow !== undefined && !refError(lake.id, 'outflow', input.outflow, 'river')) {
|
|
995
|
+
const river = rivers.find((candidate) => candidate.id === input.outflow);
|
|
996
|
+
if (river !== undefined && !(river.source.type === 'lake' && river.source.id === lake.id))
|
|
997
|
+
sink('MAP_GEOGRAPHY_LAKE_OUTFLOW_MISMATCH', 'warning', `Lake ${lake.id} names river ${river.id} as its outflow, but that river does not start at the lake`, {
|
|
998
|
+
featureId: lake.id,
|
|
999
|
+
reference: river.id,
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
for (const river of rivers)
|
|
1003
|
+
if (river.source.type === 'lake' && river.source.id === lake.id && input.outflow !== river.id)
|
|
1004
|
+
sink('MAP_GEOGRAPHY_LAKE_OUTFLOW_MISMATCH', 'info', `River ${river.id} starts at lake ${lake.id}, which does not name it as its outflow`, {
|
|
1005
|
+
featureId: river.id,
|
|
1006
|
+
reference: lake.id,
|
|
1007
|
+
});
|
|
1008
|
+
let outside = 0;
|
|
1009
|
+
for (const ring of lake.area.outers)
|
|
1010
|
+
for (let v = 0; v < ring.xs.length; v++)
|
|
1011
|
+
if (!landContains(lands, ring.xs[v], ring.ys[v]))
|
|
1012
|
+
outside++;
|
|
1013
|
+
if (outside > 0)
|
|
1014
|
+
sink('MAP_GEOGRAPHY_LAKE_NOT_IN_LAND', 'error', `Lake ${lake.id} has ${outside} vertex(es) outside every land; a lake must lie inside land (use a sea for water that meets the coast)`, {
|
|
1015
|
+
featureId: lake.id,
|
|
1016
|
+
vertices: outside,
|
|
1017
|
+
});
|
|
1018
|
+
const landId = lands.find((land) => containsInterior(land.area, lake.area.interior))?.id;
|
|
1019
|
+
lakes[index] = { ...lake, ...(landId === undefined ? {} : { landId }) };
|
|
1020
|
+
const pixels = lake.area.areaFraction * frame.width * frame.height;
|
|
1021
|
+
if (pixels < 60)
|
|
1022
|
+
sink('MAP_GEOGRAPHY_LAKE_TOO_SMALL', 'warning', `Lake ${lake.id} covers about ${Math.round(pixels)} pixels; lakes under 60 pixels are dropped`, {
|
|
1023
|
+
featureId: lake.id,
|
|
1024
|
+
pixels: Math.round(pixels),
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
// Straits.
|
|
1028
|
+
const nearestLand = (x, y, limit) => landIdAt(lands, x, y) ?? coastDistance(x, y, limit)?.id;
|
|
1029
|
+
const straits = [];
|
|
1030
|
+
for (const input of straitInputs) {
|
|
1031
|
+
if (input.between === undefined && input.path === undefined) {
|
|
1032
|
+
sink('MAP_GEOGRAPHY_STRAIT_SHAPE', 'error', `Strait ${input.id} needs between (two lands or points) or a path through the water`, { featureId: input.id });
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
const path = (input.path ?? []).map(toPoint);
|
|
1036
|
+
if (path.length > 0) {
|
|
1037
|
+
const onLand = path.filter((point) => surface(point.x, point.y).surface === 'land').length;
|
|
1038
|
+
if (onLand * 2 > path.length)
|
|
1039
|
+
sink('MAP_GEOGRAPHY_STRAIT_PATH_ON_LAND', 'warning', `${onLand} of ${path.length} path points of strait ${input.id} lie on land; the path should run through the water`, { featureId: input.id, onLand });
|
|
1040
|
+
}
|
|
1041
|
+
const ends = [];
|
|
1042
|
+
let failed = false;
|
|
1043
|
+
let derived = false;
|
|
1044
|
+
if (input.between !== undefined) {
|
|
1045
|
+
for (const [index, raw] of input.between.entries()) {
|
|
1046
|
+
if (!Array.isArray(raw) && 'land' in raw) {
|
|
1047
|
+
if (refError(input.id, `between[${index}]`, raw.land, 'land'))
|
|
1048
|
+
failed = true;
|
|
1049
|
+
else
|
|
1050
|
+
ends.push({ type: 'land', id: raw.land });
|
|
1051
|
+
}
|
|
1052
|
+
else {
|
|
1053
|
+
const point = toPoint(raw);
|
|
1054
|
+
const landId = landAtOrNear(point.x, point.y);
|
|
1055
|
+
if (landId === undefined) {
|
|
1056
|
+
sink('MAP_GEOGRAPHY_STRAIT_ENDPOINT', 'error', `Strait ${input.id} end ${index + 1} is not on land`, { featureId: input.id, end: index + 1 });
|
|
1057
|
+
failed = true;
|
|
1058
|
+
}
|
|
1059
|
+
ends.push({
|
|
1060
|
+
type: 'point',
|
|
1061
|
+
x: point.x,
|
|
1062
|
+
y: point.y,
|
|
1063
|
+
...(landId === undefined ? {} : { landId }),
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
else {
|
|
1069
|
+
// Only a path: the lands nearest its two ends are the shores it separates.
|
|
1070
|
+
derived = true;
|
|
1071
|
+
for (const [index, point] of [path[0], path.at(-1)].entries()) {
|
|
1072
|
+
const landId = nearestLand(point.x, point.y, GEOGRAPHY_DEFAULTS.straitReach);
|
|
1073
|
+
if (landId === undefined) {
|
|
1074
|
+
sink('MAP_GEOGRAPHY_STRAIT_ENDPOINT', 'error', `No land lies within ${GEOGRAPHY_DEFAULTS.straitReach} map widths of end ${index + 1} of strait ${input.id}`, { featureId: input.id, end: index + 1 });
|
|
1075
|
+
failed = true;
|
|
1076
|
+
}
|
|
1077
|
+
else
|
|
1078
|
+
ends.push({ type: 'land', id: landId });
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
if (failed || ends.length !== 2)
|
|
1082
|
+
continue;
|
|
1083
|
+
const first = ends[0], second = ends[1];
|
|
1084
|
+
const idOf = (end) => (end.type === 'land' ? end.id : end.landId);
|
|
1085
|
+
if (idOf(first) !== undefined && idOf(first) === idOf(second))
|
|
1086
|
+
sink('MAP_GEOGRAPHY_STRAIT_SAME_LAND', derived ? 'warning' : 'error', `Strait ${input.id} joins ${idOf(first)} to itself; a strait separates two lands`, { featureId: input.id, land: idOf(first), derived });
|
|
1087
|
+
if (first.type === 'point' && second.type === 'point') {
|
|
1088
|
+
let water = false;
|
|
1089
|
+
for (let step = 1; step < 32 && !water; step++) {
|
|
1090
|
+
const f = step / 32;
|
|
1091
|
+
if (surface(first.x + (second.x - first.x) * f, first.y + (second.y - first.y) * f)
|
|
1092
|
+
.surface !== 'land')
|
|
1093
|
+
water = true;
|
|
1094
|
+
}
|
|
1095
|
+
if (!water)
|
|
1096
|
+
sink('MAP_GEOGRAPHY_STRAIT_NO_WATER', 'warning', `The straight line of strait ${input.id} crosses no water`, { featureId: input.id });
|
|
1097
|
+
}
|
|
1098
|
+
straits.push({
|
|
1099
|
+
...baseOf(input),
|
|
1100
|
+
kind: 'strait',
|
|
1101
|
+
between: [first, second],
|
|
1102
|
+
ends: derived ? 'derived' : 'declared',
|
|
1103
|
+
...(path.length === 0 ? {} : { path }),
|
|
1104
|
+
rule: input.rule ?? GEOGRAPHY_DEFAULTS.strait.rule,
|
|
1105
|
+
width: input.width ?? GEOGRAPHY_DEFAULTS.strait.width,
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
// Sites.
|
|
1109
|
+
const sites = [];
|
|
1110
|
+
for (const input of siteInputs) {
|
|
1111
|
+
const at = toPoint(input.at);
|
|
1112
|
+
const here = surface(at.x, at.y);
|
|
1113
|
+
const onWater = input.onWater ?? false;
|
|
1114
|
+
const landId = here.surface === 'land' ? here.id : landAtOrNear(at.x, at.y);
|
|
1115
|
+
let lakeId;
|
|
1116
|
+
if (input.lake !== undefined && !refError(input.id, 'lake', input.lake, 'lake'))
|
|
1117
|
+
lakeId = input.lake;
|
|
1118
|
+
// A port of a lake stands on its shore, which a drawn polygon often covers.
|
|
1119
|
+
const lakeShore = lakeId !== undefined &&
|
|
1120
|
+
(() => {
|
|
1121
|
+
const lake = lakeById.get(lakeId);
|
|
1122
|
+
return (lake !== undefined &&
|
|
1123
|
+
(resolvedAreaContains(lake.area, at.x, at.y) ||
|
|
1124
|
+
areaBoundaryDistance(lake.area, at.x, at.y, aspect) <=
|
|
1125
|
+
GEOGRAPHY_DEFAULTS.lakeShoreReach));
|
|
1126
|
+
})();
|
|
1127
|
+
if (!onWater && landId === undefined && !lakeShore)
|
|
1128
|
+
sink('MAP_GEOGRAPHY_SITE_IN_WATER', 'error', `Site ${input.id} (${input.kind}) is in ${here.surface}, not on land; set onWater for a site that stands in water, or lake for a port on a lake shore`, { featureId: input.id });
|
|
1129
|
+
const regionId = landId === undefined
|
|
1130
|
+
? undefined
|
|
1131
|
+
: (lands.find((land) => land.id === landId)?.regionId ??
|
|
1132
|
+
regions.find((region) => region.area !== undefined && containsInterior(region.area, at))
|
|
1133
|
+
?.id);
|
|
1134
|
+
sites.push({
|
|
1135
|
+
...baseOf(input),
|
|
1136
|
+
kind: 'site',
|
|
1137
|
+
siteKind: input.kind,
|
|
1138
|
+
at,
|
|
1139
|
+
importance: input.importance ?? GEOGRAPHY_DEFAULTS.site.importance,
|
|
1140
|
+
onWater,
|
|
1141
|
+
...opt('country', input.country),
|
|
1142
|
+
...(landId === undefined ? {} : { landId }),
|
|
1143
|
+
...(regionId === undefined ? {} : { regionId }),
|
|
1144
|
+
...(lakeId === undefined ? {} : { lakeId }),
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
for (let i = 0; i < sites.length; i++)
|
|
1148
|
+
for (let j = i + 1; j < sites.length; j++)
|
|
1149
|
+
if (Math.abs(sites[i].at.x - sites[j].at.x) < 1e-6 &&
|
|
1150
|
+
Math.abs(sites[i].at.y - sites[j].at.y) < 1e-6)
|
|
1151
|
+
sink('MAP_GEOGRAPHY_SITE_DUPLICATE_POSITION', 'warning', `Sites ${sites[i].id} and ${sites[j].id} share a position`, { featureId: sites[j].id, other: sites[i].id });
|
|
1152
|
+
// Roads and named places.
|
|
1153
|
+
const roads = [];
|
|
1154
|
+
for (const input of roadInputs) {
|
|
1155
|
+
const path = buildPolyline(input.id, input.path, false, { halfWidth: 0.001, peak: 0 }, frame, sink);
|
|
1156
|
+
if (path === undefined)
|
|
1157
|
+
continue;
|
|
1158
|
+
roads.push({
|
|
1159
|
+
...baseOf(input),
|
|
1160
|
+
kind: 'road',
|
|
1161
|
+
path,
|
|
1162
|
+
importance: input.importance ?? GEOGRAPHY_DEFAULTS.road.importance,
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
const places = [];
|
|
1166
|
+
for (const input of placeInputs) {
|
|
1167
|
+
const at = toPoint(input.at);
|
|
1168
|
+
places.push({
|
|
1169
|
+
...baseOf(input),
|
|
1170
|
+
kind: 'place',
|
|
1171
|
+
placeKind: input.kind,
|
|
1172
|
+
at,
|
|
1173
|
+
surface: surface(at.x, at.y).surface,
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
// Climate.
|
|
1177
|
+
const climateInput = model.climate;
|
|
1178
|
+
const climate = climateInput === undefined
|
|
1179
|
+
? emptyClimate()
|
|
1180
|
+
: {
|
|
1181
|
+
windFrom: climateInput.windFrom ?? GEOGRAPHY_DEFAULTS.climate.windFrom,
|
|
1182
|
+
temperature: {
|
|
1183
|
+
warm: climateInput.temperature?.warm ?? GEOGRAPHY_DEFAULTS.climate.temperature.warm,
|
|
1184
|
+
cold: climateInput.temperature?.cold ?? GEOGRAPHY_DEFAULTS.climate.temperature.cold,
|
|
1185
|
+
},
|
|
1186
|
+
warmBandY: climateInput.warmBandY ?? GEOGRAPHY_DEFAULTS.climate.warmBandY,
|
|
1187
|
+
rainScale: climateInput.rainScale ?? GEOGRAPHY_DEFAULTS.climate.rainScale,
|
|
1188
|
+
seasonality: climateInput.seasonality ?? GEOGRAPHY_DEFAULTS.climate.seasonality,
|
|
1189
|
+
declared: true,
|
|
1190
|
+
};
|
|
1191
|
+
if (climate.temperature.cold > climate.temperature.warm)
|
|
1192
|
+
sink('MAP_GEOGRAPHY_CLIMATE_TEMPERATURE_ORDER', 'error', 'climate.temperature.cold must not exceed climate.temperature.warm', {});
|
|
1193
|
+
// Whole-model checks on a coarse grid.
|
|
1194
|
+
const grid = analysisGrid(frame);
|
|
1195
|
+
coarseChecks({ grid, lands, seas, lakes, biomes, ranges, frame, sink });
|
|
1196
|
+
edgeChecks({
|
|
1197
|
+
lands,
|
|
1198
|
+
plateaus,
|
|
1199
|
+
ranges,
|
|
1200
|
+
volcanoes,
|
|
1201
|
+
sites,
|
|
1202
|
+
passes,
|
|
1203
|
+
rivers,
|
|
1204
|
+
frame,
|
|
1205
|
+
edgeBand,
|
|
1206
|
+
policy: options.edge,
|
|
1207
|
+
sink,
|
|
1208
|
+
grid,
|
|
1209
|
+
});
|
|
1210
|
+
if (lands.length === 0 && options.hasReference !== true)
|
|
1211
|
+
sink('MAP_GEOGRAPHY_NO_LAND', 'error', 'The model has no lands and no reference image supplies a coast, so there is no world to build', {});
|
|
1212
|
+
const names = new Map();
|
|
1213
|
+
for (const feature of [
|
|
1214
|
+
...seas,
|
|
1215
|
+
...lands,
|
|
1216
|
+
...regions,
|
|
1217
|
+
...ranges,
|
|
1218
|
+
...rivers,
|
|
1219
|
+
...lakes,
|
|
1220
|
+
...sites,
|
|
1221
|
+
...volcanoes,
|
|
1222
|
+
...straits,
|
|
1223
|
+
]) {
|
|
1224
|
+
if (feature.name === undefined)
|
|
1225
|
+
continue;
|
|
1226
|
+
const key = `${feature.kind}:${feature.name}`;
|
|
1227
|
+
const other = names.get(key);
|
|
1228
|
+
if (other !== undefined)
|
|
1229
|
+
sink('MAP_GEOGRAPHY_NAME_DUPLICATE', 'warning', `${feature.kind} ${feature.id} repeats the name "${feature.name}" of ${other}`, {
|
|
1230
|
+
featureId: feature.id,
|
|
1231
|
+
other,
|
|
1232
|
+
});
|
|
1233
|
+
else
|
|
1234
|
+
names.set(key, feature.id);
|
|
1235
|
+
}
|
|
1236
|
+
const parts = {
|
|
1237
|
+
frame,
|
|
1238
|
+
seas,
|
|
1239
|
+
lands,
|
|
1240
|
+
regions,
|
|
1241
|
+
ranges,
|
|
1242
|
+
passes,
|
|
1243
|
+
volcanoes,
|
|
1244
|
+
plateaus,
|
|
1245
|
+
rivers,
|
|
1246
|
+
lakes,
|
|
1247
|
+
biomes,
|
|
1248
|
+
straits,
|
|
1249
|
+
sites,
|
|
1250
|
+
roads,
|
|
1251
|
+
places,
|
|
1252
|
+
climate,
|
|
1253
|
+
...opt('name', model.name),
|
|
1254
|
+
...opt('description', model.description),
|
|
1255
|
+
};
|
|
1256
|
+
const hash = hashCanonical(canonicalModel(frame, parts));
|
|
1257
|
+
const geography = makeGeography({ ...parts, hash, pointCount: points });
|
|
1258
|
+
return finish(geography);
|
|
1259
|
+
}
|
|
1260
|
+
function containsInterior(area, point) {
|
|
1261
|
+
return resolvedAreaContains(area, point.x, point.y);
|
|
1262
|
+
}
|
|
1263
|
+
function landIdAt(lands, x, y) {
|
|
1264
|
+
for (const land of lands)
|
|
1265
|
+
if (containsInterior(land.area, { x, y }))
|
|
1266
|
+
return land.id;
|
|
1267
|
+
return undefined;
|
|
1268
|
+
}
|
|
1269
|
+
function landContains(lands, x, y) {
|
|
1270
|
+
return landIdAt(lands, x, y) !== undefined;
|
|
1271
|
+
}
|
|
1272
|
+
function limitDiagnostics(sorted) {
|
|
1273
|
+
if (sorted.length <= GEOGRAPHY_DIAGNOSTIC_LIMIT)
|
|
1274
|
+
return sorted;
|
|
1275
|
+
const kept = sorted.slice(0, GEOGRAPHY_DIAGNOSTIC_LIMIT - 1);
|
|
1276
|
+
const omitted = sorted.slice(GEOGRAPHY_DIAGNOSTIC_LIMIT - 1);
|
|
1277
|
+
const counts = {};
|
|
1278
|
+
for (const item of omitted)
|
|
1279
|
+
counts[item.code] = (counts[item.code] ?? 0) + 1;
|
|
1280
|
+
const worst = omitted.some(({ severity }) => severity === 'error' || severity === 'blocker')
|
|
1281
|
+
? 'error'
|
|
1282
|
+
: omitted.some(({ severity }) => severity === 'warning')
|
|
1283
|
+
? 'warning'
|
|
1284
|
+
: 'info';
|
|
1285
|
+
kept.push({
|
|
1286
|
+
code: 'MAP_GEOGRAPHY_DIAGNOSTICS_TRUNCATED',
|
|
1287
|
+
severity: worst,
|
|
1288
|
+
category: 'map',
|
|
1289
|
+
message: `${omitted.length} further geography diagnostics were omitted`,
|
|
1290
|
+
details: { omitted: omitted.length, byCode: counts },
|
|
1291
|
+
});
|
|
1292
|
+
return kept;
|
|
1293
|
+
}
|
|
1294
|
+
function coarseChecks(input) {
|
|
1295
|
+
const { grid, lands, seas, biomes, sink } = input;
|
|
1296
|
+
const cells = grid.width * grid.height;
|
|
1297
|
+
const strip = { y0: 0, y1: grid.height };
|
|
1298
|
+
// Land ownership: who painted each cell, so overlaps are measurable.
|
|
1299
|
+
const landOwner = new Int32Array(cells).fill(-1);
|
|
1300
|
+
const landOverlap = new Map();
|
|
1301
|
+
const landCells = [];
|
|
1302
|
+
for (const [index, land] of lands.entries()) {
|
|
1303
|
+
let count = 0;
|
|
1304
|
+
scanAreaStrip(land.area, grid, strip, (y, x0, x1) => {
|
|
1305
|
+
for (let x = x0; x < x1; x++) {
|
|
1306
|
+
const cell = y * grid.width + x;
|
|
1307
|
+
const owner = landOwner[cell];
|
|
1308
|
+
if (owner >= 0 && owner !== index) {
|
|
1309
|
+
const key = pairKey(owner, index);
|
|
1310
|
+
landOverlap.set(key, (landOverlap.get(key) ?? 0) + 1);
|
|
1311
|
+
}
|
|
1312
|
+
else
|
|
1313
|
+
landOwner[cell] = index;
|
|
1314
|
+
count++;
|
|
1315
|
+
}
|
|
1316
|
+
});
|
|
1317
|
+
landCells.push(count);
|
|
1318
|
+
}
|
|
1319
|
+
for (const [key, overlap] of [...landOverlap].sort((a, b) => a[0] - b[0])) {
|
|
1320
|
+
const i = Math.floor(key / 65_536), j = key % 65_536;
|
|
1321
|
+
const smaller = Math.max(1, Math.min(landCells[i], landCells[j]));
|
|
1322
|
+
if (overlap / smaller > 0.01 && overlap >= 3)
|
|
1323
|
+
sink('MAP_GEOGRAPHY_LAND_OVERLAP', 'warning', `Lands ${lands[i].id} and ${lands[j].id} overlap by ${Math.round((100 * overlap) / smaller)}% of the smaller; the union is used`, {
|
|
1324
|
+
featureId: lands[j].id,
|
|
1325
|
+
other: lands[i].id,
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
const isLand = (cell) => landOwner[cell] >= 0;
|
|
1329
|
+
for (const sea of seas) {
|
|
1330
|
+
let total = 0, onLand = 0;
|
|
1331
|
+
scanAreaStrip(sea.area, grid, strip, (y, x0, x1) => {
|
|
1332
|
+
for (let x = x0; x < x1; x++) {
|
|
1333
|
+
total++;
|
|
1334
|
+
if (isLand(y * grid.width + x))
|
|
1335
|
+
onLand++;
|
|
1336
|
+
}
|
|
1337
|
+
});
|
|
1338
|
+
if (total > 0 && onLand / total > 0.1)
|
|
1339
|
+
sink('MAP_GEOGRAPHY_SEA_OVER_LAND', 'warning', `Sea ${sea.id} lies ${Math.round((100 * onLand) / total)}% over land; land wins where they overlap`, {
|
|
1340
|
+
featureId: sea.id,
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
// Biomes: equal-priority overlap of different biomes is a contradiction.
|
|
1344
|
+
const groups = new Map();
|
|
1345
|
+
for (const biome of biomes) {
|
|
1346
|
+
const list = groups.get(biome.z);
|
|
1347
|
+
if (list === undefined)
|
|
1348
|
+
groups.set(biome.z, [biome]);
|
|
1349
|
+
else
|
|
1350
|
+
list.push(biome);
|
|
1351
|
+
}
|
|
1352
|
+
const wateryAllowed = new Set(['ice', 'marsh']);
|
|
1353
|
+
for (const biome of biomes) {
|
|
1354
|
+
let total = 0, onLand = 0;
|
|
1355
|
+
scanAreaStrip(biome.area, grid, strip, (y, x0, x1) => {
|
|
1356
|
+
for (let x = x0; x < x1; x++) {
|
|
1357
|
+
total++;
|
|
1358
|
+
if (isLand(y * grid.width + x))
|
|
1359
|
+
onLand++;
|
|
1360
|
+
}
|
|
1361
|
+
});
|
|
1362
|
+
if (total >= 3 && onLand / total < 0.5 && !wateryAllowed.has(biome.biome))
|
|
1363
|
+
sink('MAP_GEOGRAPHY_BIOME_OVER_WATER', 'error', `Biome ${biome.id} (${biome.biome}) lies ${Math.round((100 * (total - onLand)) / total)}% over water`, {
|
|
1364
|
+
featureId: biome.id,
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
for (const [z, list] of [...groups].sort((a, b) => a[0] - b[0])) {
|
|
1368
|
+
if (list.length < 2)
|
|
1369
|
+
continue;
|
|
1370
|
+
const owner = new Int32Array(cells).fill(-1);
|
|
1371
|
+
const conflicts = new Map();
|
|
1372
|
+
for (const [index, biome] of list.entries())
|
|
1373
|
+
scanAreaStrip(biome.area, grid, strip, (y, x0, x1) => {
|
|
1374
|
+
for (let x = x0; x < x1; x++) {
|
|
1375
|
+
const cell = y * grid.width + x;
|
|
1376
|
+
const previous = owner[cell];
|
|
1377
|
+
if (previous >= 0 && list[previous].biome !== biome.biome) {
|
|
1378
|
+
const key = pairKey(previous, index);
|
|
1379
|
+
conflicts.set(key, (conflicts.get(key) ?? 0) + 1);
|
|
1380
|
+
}
|
|
1381
|
+
if (previous < 0)
|
|
1382
|
+
owner[cell] = index;
|
|
1383
|
+
}
|
|
1384
|
+
});
|
|
1385
|
+
for (const [key, count] of [...conflicts].sort((a, b) => a[0] - b[0])) {
|
|
1386
|
+
if (count < 3)
|
|
1387
|
+
continue;
|
|
1388
|
+
const a = list[Math.floor(key / 65_536)], b = list[key % 65_536];
|
|
1389
|
+
sink('MAP_GEOGRAPHY_BIOME_CONFLICT', 'error', `Biomes ${a.id} (${a.biome}) and ${b.id} (${b.biome}) overlap with the same priority z=${z}; give one a higher z`, {
|
|
1390
|
+
featureId: b.id,
|
|
1391
|
+
other: a.id,
|
|
1392
|
+
z,
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
// A range mostly over water is almost always a coordinate mistake.
|
|
1397
|
+
for (const range of input.ranges) {
|
|
1398
|
+
let water = 0;
|
|
1399
|
+
for (let index = 0; index < range.path.xs.length; index++) {
|
|
1400
|
+
const cx = Math.min(grid.width - 1, Math.floor(range.path.xs[index] * grid.width));
|
|
1401
|
+
const cy = Math.min(grid.height - 1, Math.floor(range.path.ys[index] * grid.height));
|
|
1402
|
+
if (!isLand(cy * grid.width + cx))
|
|
1403
|
+
water++;
|
|
1404
|
+
}
|
|
1405
|
+
if (lands.length > 0 && water / range.path.xs.length > 0.5)
|
|
1406
|
+
sink('MAP_GEOGRAPHY_RANGE_ON_WATER', 'warning', `More than half the vertices of range ${range.id} are not on land`, {
|
|
1407
|
+
featureId: range.id,
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
function edgeChecks(input) {
|
|
1412
|
+
const { frame, edgeBand, sink, grid } = input;
|
|
1413
|
+
// A rim wall ends land inside the band by design, and `none` opts out of the check.
|
|
1414
|
+
if (input.policy?.mode === 'none' || input.policy?.mode === 'rim')
|
|
1415
|
+
return;
|
|
1416
|
+
const severity = input.policy?.mode === 'sea-band' ? 'error' : 'warning';
|
|
1417
|
+
const fx = edgeBand.westEastPx / frame.width;
|
|
1418
|
+
const fy = edgeBand.northSouthPx / frame.height;
|
|
1419
|
+
const inBand = (x, y) => x < fx || x > 1 - fx || y < fy || y > 1 - fy;
|
|
1420
|
+
const report = (kind, id) => sink('MAP_GEOGRAPHY_FEATURE_IN_EDGE_BAND', severity, `${kind} ${id} reaches the edge band (${edgeBand.westEastPx} pixels east and west, ${edgeBand.northSouthPx} north and south); land there is cut by the world wrap`, { featureId: id, bandPx: edgeBand.westEastPx, polarPx: edgeBand.northSouthPx });
|
|
1421
|
+
const strip = { y0: 0, y1: grid.height };
|
|
1422
|
+
const bandColumns = Math.ceil(fx * grid.width), bandRows = Math.ceil(fy * grid.height);
|
|
1423
|
+
const touchesBand = (area) => {
|
|
1424
|
+
if (!(area.bounds.x0 < fx ||
|
|
1425
|
+
area.bounds.x1 > 1 - fx ||
|
|
1426
|
+
area.bounds.y0 < fy ||
|
|
1427
|
+
area.bounds.y1 > 1 - fy))
|
|
1428
|
+
return false;
|
|
1429
|
+
let touched = false;
|
|
1430
|
+
scanAreaStrip(area, grid, strip, (y, x0, x1) => {
|
|
1431
|
+
if (touched)
|
|
1432
|
+
return;
|
|
1433
|
+
if (y < bandRows ||
|
|
1434
|
+
y >= grid.height - bandRows ||
|
|
1435
|
+
x0 < bandColumns ||
|
|
1436
|
+
x1 > grid.width - bandColumns)
|
|
1437
|
+
touched = true;
|
|
1438
|
+
});
|
|
1439
|
+
return touched;
|
|
1440
|
+
};
|
|
1441
|
+
for (const land of input.lands)
|
|
1442
|
+
if (touchesBand(land.area))
|
|
1443
|
+
report('Land', land.id);
|
|
1444
|
+
for (const plateau of input.plateaus)
|
|
1445
|
+
if (touchesBand(plateau.area))
|
|
1446
|
+
report('Plateau', plateau.id);
|
|
1447
|
+
for (const range of input.ranges)
|
|
1448
|
+
for (let index = 0; index < range.path.xs.length; index++)
|
|
1449
|
+
if (inBand(range.path.xs[index], range.path.ys[index])) {
|
|
1450
|
+
report('Range', range.id);
|
|
1451
|
+
break;
|
|
1452
|
+
}
|
|
1453
|
+
for (const volcano of input.volcanoes)
|
|
1454
|
+
if (inBand(volcano.at.x, volcano.at.y))
|
|
1455
|
+
report('Volcano', volcano.id);
|
|
1456
|
+
for (const site of input.sites)
|
|
1457
|
+
if (inBand(site.at.x, site.at.y))
|
|
1458
|
+
report('Site', site.id);
|
|
1459
|
+
for (const pass of input.passes)
|
|
1460
|
+
if (inBand(pass.position.x, pass.position.y))
|
|
1461
|
+
report('Pass', pass.id);
|
|
1462
|
+
for (const river of input.rivers) {
|
|
1463
|
+
const points = [river.source, ...river.via.map((p) => ({ type: 'point', ...p }))];
|
|
1464
|
+
if (points.some((p) => 'x' in p && inBand(p.x, p.y)))
|
|
1465
|
+
report('River', river.id);
|
|
1466
|
+
}
|
|
1467
|
+
}
|