372 lines
15 KiB
JavaScript
372 lines
15 KiB
JavaScript
import { SIZE, clamp, indexOf, inside } from "./mapUtils.js";
|
|
|
|
export function pathSetSignature(paths) {
|
|
let cells = 0;
|
|
let endpoints = 0;
|
|
for (const path of paths || []) {
|
|
cells += path?.length || 0;
|
|
const a = path?.[0];
|
|
const b = path?.[path.length - 1];
|
|
if (a) endpoints = (endpoints + a[0] * 17 + a[1] * 31) | 0;
|
|
if (b) endpoints = (endpoints + b[0] * 47 + b[1] * 73) | 0;
|
|
}
|
|
return `${paths?.length || 0}:${cells}:${endpoints >>> 0}`;
|
|
}
|
|
|
|
export function createPathInfluenceCache(influenceFromPaths) {
|
|
const cache = new Map();
|
|
return (paths, radius, label = "paths") => {
|
|
const key = `${label}:${radius}:${pathSetSignature(paths)}`;
|
|
let grid = cache.get(key);
|
|
if (!grid) {
|
|
grid = influenceFromPaths(paths, radius);
|
|
cache.set(key, grid);
|
|
}
|
|
return grid;
|
|
};
|
|
}
|
|
|
|
export function packDebugField(field) {
|
|
const out = new Uint8Array(SIZE);
|
|
for (let i = 0; i < SIZE; i++) out[i] = Math.round(clamp(field?.[i] || 0) * 255);
|
|
return out;
|
|
}
|
|
|
|
export function pathLengthCells(path) {
|
|
let total = 0;
|
|
for (let i = 1; i < (path?.length || 0); i++) {
|
|
total += Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]);
|
|
}
|
|
return total;
|
|
}
|
|
|
|
export function pathAverageField(path, field) {
|
|
if (!path?.length || !field) return 0;
|
|
let sum = 0;
|
|
let n = 0;
|
|
for (const [x, y] of path) {
|
|
if (!inside(x, y)) continue;
|
|
sum += field[indexOf(x, y)] || 0;
|
|
n++;
|
|
}
|
|
return n ? sum / n : 0;
|
|
}
|
|
|
|
export function markPathInfluence(field, path, radius = 5, strength = 1, sea = null) {
|
|
for (const [px, py] of path || []) {
|
|
for (let dy = -radius; dy <= radius; dy++) {
|
|
for (let dx = -radius; dx <= radius; dx++) {
|
|
if (dx * dx + dy * dy > radius * radius) continue;
|
|
const x = px + dx;
|
|
const y = py + dy;
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
if (sea?.[i]) continue;
|
|
const d = Math.hypot(dx, dy);
|
|
const v = strength * Math.pow(1 - d / Math.max(1, radius), 1.35);
|
|
if (v > field[i]) field[i] = v;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function createIncrementalPathInfluence(initialPaths = [], radius = 5, options = {}) {
|
|
const field = new Float32Array(SIZE);
|
|
const sea = options.sea || null;
|
|
for (const path of initialPaths || []) markPathInfluence(field, path, radius, 1, sea);
|
|
return {
|
|
field,
|
|
add(path, strength = 1, addRadius = radius) {
|
|
markPathInfluence(field, path, addRadius, strength, sea);
|
|
return field;
|
|
},
|
|
};
|
|
}
|
|
|
|
export function sampledNetworkCells(paths, step = 2, sea = null) {
|
|
const cells = [];
|
|
for (let pathId = 0; pathId < (paths || []).length; pathId++) {
|
|
const path = paths[pathId];
|
|
for (let k = 0; k < (path?.length || 0); k += step) {
|
|
const [x, y] = path[k];
|
|
if (inside(x, y) && !sea?.[indexOf(x, y)]) cells.push({ x, y, pathId });
|
|
}
|
|
}
|
|
return cells;
|
|
}
|
|
|
|
export function pathEndpoints(paths) {
|
|
const endpoints = [];
|
|
for (let pathId = 0; pathId < (paths || []).length; pathId++) {
|
|
const path = paths[pathId];
|
|
if (!path || path.length < 2) continue;
|
|
endpoints.push({ x: path[0][0], y: path[0][1], pathId });
|
|
const end = path[path.length - 1];
|
|
endpoints.push({ x: end[0], y: end[1], pathId });
|
|
}
|
|
return endpoints;
|
|
}
|
|
|
|
export function nearestNetworkPoint(source, targets, radius, options = {}) {
|
|
if (!source || !targets?.length) return null;
|
|
const excludeSamePath = options.excludeSamePath !== false;
|
|
const excludePath = options.excludePath;
|
|
let best = null;
|
|
let bestD = radius + 1;
|
|
for (const target of targets) {
|
|
if (excludePath != null && target.pathId === excludePath) continue;
|
|
if (excludeSamePath && source.pathId != null && target.pathId === source.pathId) continue;
|
|
const d = Math.hypot(source.x - target.x, source.y - target.y);
|
|
if (d > 0.01 && d < bestD) {
|
|
bestD = d;
|
|
best = target;
|
|
}
|
|
}
|
|
return best ? { target: best, d: bestD, ...best } : null;
|
|
}
|
|
|
|
export function splitPathToValidCells(path, isValid, minCells = 2) {
|
|
const chunks = [];
|
|
let current = [];
|
|
function pushPoint(x, y) {
|
|
if (!isValid(x, y)) {
|
|
if (current.length >= minCells) chunks.push(current);
|
|
current = [];
|
|
return;
|
|
}
|
|
if (!current.length || current[current.length - 1][0] !== x || current[current.length - 1][1] !== y) current.push([x, y]);
|
|
}
|
|
for (let k = 0; k < (path?.length || 0); k++) {
|
|
const a = path[k];
|
|
const b = path[Math.min(k + 1, path.length - 1)];
|
|
const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1])));
|
|
for (let s = 0; s <= steps; s++) {
|
|
if (k > 0 && s === 0) continue;
|
|
const t = s / steps;
|
|
pushPoint(Math.round(a[0] + (b[0] - a[0]) * t), Math.round(a[1] + (b[1] - a[1]) * t));
|
|
}
|
|
}
|
|
if (current.length >= minCells) chunks.push(current);
|
|
return chunks;
|
|
}
|
|
|
|
export function pathCumulativeLengths(path) {
|
|
const cum = [0];
|
|
for (let k = 1; k < (path?.length || 0); k++) {
|
|
cum.push(cum[cum.length - 1] + Math.hypot(path[k][0] - path[k - 1][0], path[k][1] - path[k - 1][1]));
|
|
}
|
|
return cum;
|
|
}
|
|
|
|
export function pointAtPathDistance(path, cum, dist) {
|
|
if (!path?.length) return null;
|
|
if (dist <= 0) return { x: path[0][0], y: path[0][1], s: 0 };
|
|
const total = cum[cum.length - 1] || 0;
|
|
if (dist >= total) {
|
|
const p = path[path.length - 1];
|
|
return { x: p[0], y: p[1], s: total };
|
|
}
|
|
let k = 1;
|
|
while (k < cum.length && cum[k] < dist) k++;
|
|
const a = path[k - 1];
|
|
const b = path[k];
|
|
const seg = Math.max(0.0001, cum[k] - cum[k - 1]);
|
|
const t = clamp((dist - cum[k - 1]) / seg);
|
|
return { x: Math.round(a[0] + (b[0] - a[0]) * t), y: Math.round(a[1] + (b[1] - a[1]) * t), s: dist };
|
|
}
|
|
|
|
export function meanFieldAround(field, x, y, radius = 8, sea = null) {
|
|
let sum = 0;
|
|
let n = 0;
|
|
const r = Math.ceil(radius);
|
|
for (let dy = -r; dy <= r; dy++) {
|
|
for (let dx = -r; dx <= r; dx++) {
|
|
if (dx * dx + dy * dy > radius * radius) continue;
|
|
const nx = x + dx;
|
|
const ny = y + dy;
|
|
if (!inside(nx, ny)) continue;
|
|
const i = indexOf(nx, ny);
|
|
if (sea?.[i]) continue;
|
|
const w = 1 - Math.hypot(dx, dy) / Math.max(1, radius);
|
|
sum += (field?.[i] || 0) * (0.35 + w);
|
|
n += 0.35 + w;
|
|
}
|
|
}
|
|
return n ? sum / n : 0;
|
|
}
|
|
|
|
export function routeQualityStats(path, fields = {}) {
|
|
if (!path?.length) return { length: 0, compactness: Infinity, highElevationShare: 1, steepShare: 1, waterShare: 1, avgPotential: 0, avgPenalty: 0 };
|
|
const length = pathLengthCells(path);
|
|
const first = path[0];
|
|
const last = path[path.length - 1];
|
|
const direct = first && last ? Math.hypot(first[0] - last[0], first[1] - last[1]) : 0;
|
|
let high = 0;
|
|
let steep = 0;
|
|
let water = 0;
|
|
let potential = 0;
|
|
let penalty = 0;
|
|
let n = 0;
|
|
for (const [x, y] of path) {
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
if (fields.sea?.[i]) water++;
|
|
if ((fields.elevation?.[i] || 0) > (fields.highElevationThreshold ?? 0.72)) high++;
|
|
if ((fields.slope?.[i] || 0) > (fields.steepThreshold ?? 0.44)) steep++;
|
|
potential += fields.potential?.[i] || 0;
|
|
penalty += fields.penalty?.[i] || 0;
|
|
n++;
|
|
}
|
|
return {
|
|
length,
|
|
compactness: direct > 0.001 ? length / direct : Infinity,
|
|
highElevationShare: high / Math.max(1, n),
|
|
steepShare: steep / Math.max(1, n),
|
|
waterShare: water / Math.max(1, n),
|
|
avgPotential: potential / Math.max(1, n),
|
|
avgPenalty: penalty / Math.max(1, n),
|
|
};
|
|
}
|
|
|
|
export function routeQualityAcceptable(path, fields = {}, limits = {}) {
|
|
const q = routeQualityStats(path, fields);
|
|
if (q.length < (limits.minLength ?? 2)) return false;
|
|
if (q.length > (limits.maxLength ?? Infinity)) return false;
|
|
if (q.compactness > (limits.maxCompactness ?? 3.2)) return false;
|
|
if (q.highElevationShare > (limits.maxHighElevationShare ?? 0.0)) return false;
|
|
if (q.steepShare > (limits.maxSteepShare ?? 0.38)) return false;
|
|
if (q.waterShare > (limits.maxWaterShare ?? 0)) return false;
|
|
if (q.avgPenalty > (limits.maxAvgPenalty ?? Infinity) && q.avgPotential < (limits.minPotentialIfPenalty ?? 0.35)) return false;
|
|
if (q.avgPotential < (limits.minAvgPotential ?? 0)) return false;
|
|
return true;
|
|
}
|
|
|
|
export const TRANSPORT_ROUTE_POLICIES = {
|
|
mountain: {
|
|
road: { maxHighAltitudeShare: 0, longLength: 72, longStraightness: 0.90, maxLongMountain: 0.34, extremeLength: 92, extremeStraightness: 0.86, maxExtremeBoundary: 0.20 },
|
|
national: { maxHighAltitudeShare: 0, longLength: 72, longStraightness: 0.90, maxLongMountain: 0.34, extremeLength: 92, extremeStraightness: 0.86, maxExtremeBoundary: 0.20 },
|
|
local: { maxHighAltitudeShare: 0.04, longLength: 72, longStraightness: 0.90, maxLongMountain: 0.34, extremeLength: 92, extremeStraightness: 0.86, maxExtremeBoundary: 0.20 },
|
|
expressway: { maxHighAltitudeShare: 0, maxDenseShare: 0.12, maxCityCoreShare: 0.11, maxVillageCoreShare: 0.08, longLength: 72, longStraightness: 0.90, maxLongMountain: 0.34, extremeLength: 92, extremeStraightness: 0.86, maxExtremeBoundary: 0.20 },
|
|
expresswayMountainOnly: { maxHighAltitudeShare: 0, longLength: 72, longStraightness: 0.90, maxLongMountain: 0.36, extremeLength: 96, extremeStraightness: 0.86, maxExtremeBoundary: 0.22 },
|
|
},
|
|
expresswayAcceptance: {
|
|
strict: { maxCityCoreHits: 0, maxVillageHits: 2, maxDenseShare: 0.16, maxCityCoreShare: 0.13, maxVillageCoreShare: 0.11, maxHighAltitudeShare: 0, maxMountain: 0.64, maxBoundary: 0.44 },
|
|
fallback: { maxCityCoreHits: 0, maxVillageHits: 6, maxDenseShare: 0.24, maxCityCoreShare: 0.20, maxVillageCoreShare: 0.20, maxHighAltitudeShare: 0, maxMountain: 0.74, maxBoundary: 0.56 },
|
|
approach: { maxCityCoreHits: 5, maxVillageHits: 18, maxDenseShare: 0.34, maxCityCoreShare: 0.30, maxVillageCoreShare: 0.32, maxHighAltitudeShare: 0, maxMountain: 0.90, maxBoundary: 0.72 },
|
|
},
|
|
fieldBackbone: {
|
|
expressway: { minLength: 18, maxLengthMultiplier: 2.85, maxLengthAdd: 92, parallelConnected: 0.075, parallelExtra: 0.030 },
|
|
national: { minLength: 5, maxLengthMultiplier: 2.65, maxLengthAdd: 48, maxHighElevationShare: 0.34, maxSteepShare: 0.48, parallelConnected: 0.54, parallelExtra: 0.44 },
|
|
},
|
|
};
|
|
|
|
export function routeGeometry(path) {
|
|
if (!path || path.length < 2) return { len: 0, direct: 0, straightness: 1 };
|
|
const len = pathLengthCells(path);
|
|
const a = path[0];
|
|
const b = path[path.length - 1];
|
|
const direct = Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
return { len, direct, straightness: direct / Math.max(1, len) };
|
|
}
|
|
|
|
export function assessMountainRoute(path, mode, pathTerrainRisk, policies = TRANSPORT_ROUTE_POLICIES) {
|
|
if (!path || path.length < 2) return { ok: false, reason: "empty" };
|
|
const policy = policies.mountain[mode] || policies.mountain.road;
|
|
const { len, straightness } = routeGeometry(path);
|
|
const risk = pathTerrainRisk(path);
|
|
if (risk.highAltitudeShare > policy.maxHighAltitudeShare) return { ok: false, reason: "highAltitude", risk };
|
|
if (policy.maxDenseShare !== undefined && (risk.denseShare > policy.maxDenseShare || risk.cityCoreShare > policy.maxCityCoreShare || risk.villageCoreShare > policy.maxVillageCoreShare)) {
|
|
return { ok: false, reason: "settlementCore", risk };
|
|
}
|
|
if (len > policy.longLength && straightness > policy.longStraightness && risk.mountain > policy.maxLongMountain) return { ok: false, reason: "straightMountain", risk };
|
|
if (len > policy.extremeLength && straightness > policy.extremeStraightness && risk.boundary > policy.maxExtremeBoundary) return { ok: false, reason: "straightBoundary", risk };
|
|
return { ok: true, reason: "ok", risk };
|
|
}
|
|
|
|
export function expresswayAcceptancePolicy(options = {}, policies = TRANSPORT_ROUTE_POLICIES) {
|
|
if (options.allowApproach) return policies.expresswayAcceptance.approach;
|
|
if (options.allowFallback) return policies.expresswayAcceptance.fallback;
|
|
return policies.expresswayAcceptance.strict;
|
|
}
|
|
|
|
export function assessExpresswayRoute(path, options, pathTerrainRisk, expresswayProximityRisk, policies = TRANSPORT_ROUTE_POLICIES) {
|
|
const risk = pathTerrainRisk(path);
|
|
const prox = expresswayProximityRisk(path);
|
|
const policy = expresswayAcceptancePolicy(options, policies);
|
|
if (prox.cityCoreHits > policy.maxCityCoreHits) return { ok: false, reason: "cityCoreHits", risk, prox };
|
|
if (prox.villageHits > policy.maxVillageHits) return { ok: false, reason: "villageHits", risk, prox };
|
|
if (risk.denseShare > policy.maxDenseShare) return { ok: false, reason: "denseShare", risk, prox };
|
|
if (risk.cityCoreShare > policy.maxCityCoreShare) return { ok: false, reason: "cityCoreShare", risk, prox };
|
|
if (risk.villageCoreShare > policy.maxVillageCoreShare) return { ok: false, reason: "villageCoreShare", risk, prox };
|
|
if (risk.highAltitudeShare > policy.maxHighAltitudeShare) return { ok: false, reason: "highAltitude", risk, prox };
|
|
if (risk.mountain > policy.maxMountain) return { ok: false, reason: "mountain", risk, prox };
|
|
if (risk.boundary > policy.maxBoundary) return { ok: false, reason: "boundary", risk, prox };
|
|
return { ok: true, reason: "ok", risk, prox };
|
|
}
|
|
|
|
export function countReason(stats, bucket, reason) {
|
|
if (!stats[bucket]) stats[bucket] = {};
|
|
stats[bucket][reason] = (stats[bucket][reason] || 0) + 1;
|
|
}
|
|
|
|
export function fieldBackbonePolicy(mode, policies = TRANSPORT_ROUTE_POLICIES) {
|
|
return policies.fieldBackbone[mode] || policies.fieldBackbone.national;
|
|
}
|
|
|
|
export function squaredDistance(a, b, x, y) {
|
|
const dx = a - x;
|
|
const dy = b - y;
|
|
return dx * dx + dy * dy;
|
|
}
|
|
|
|
export function makeSpatialIndex(points, cellSize = 16) {
|
|
const buckets = new Map();
|
|
const bucketKey = (x, y) => `${Math.floor(x / cellSize)},${Math.floor(y / cellSize)}`;
|
|
for (const point of points || []) {
|
|
if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) continue;
|
|
const key = bucketKey(point.x, point.y);
|
|
let bucket = buckets.get(key);
|
|
if (!bucket) {
|
|
bucket = [];
|
|
buckets.set(key, bucket);
|
|
}
|
|
bucket.push(point);
|
|
}
|
|
return {
|
|
near(x, y, radius) {
|
|
const out = [];
|
|
const bx0 = Math.floor((x - radius) / cellSize);
|
|
const bx1 = Math.floor((x + radius) / cellSize);
|
|
const by0 = Math.floor((y - radius) / cellSize);
|
|
const by1 = Math.floor((y + radius) / cellSize);
|
|
for (let by = by0; by <= by1; by++) {
|
|
for (let bx = bx0; bx <= bx1; bx++) {
|
|
const bucket = buckets.get(`${bx},${by}`);
|
|
if (bucket) out.push(...bucket);
|
|
}
|
|
}
|
|
return out;
|
|
},
|
|
};
|
|
}
|
|
|
|
export function makeUnionFind(nodes, keyOf) {
|
|
const parent = new Map();
|
|
const find = (key) => {
|
|
let root = parent.get(key) || key;
|
|
if (root !== key) {
|
|
root = find(root);
|
|
parent.set(key, root);
|
|
}
|
|
return root;
|
|
};
|
|
const unite = (a, b) => {
|
|
const ra = find(a);
|
|
const rb = find(b);
|
|
if (ra === rb) return false;
|
|
parent.set(rb, ra);
|
|
return true;
|
|
};
|
|
for (const node of nodes || []) parent.set(keyOf(node), keyOf(node));
|
|
return { find, unite };
|
|
}
|