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 }; }