road tweak

This commit is contained in:
33333-33333 2026-05-27 00:13:13 +09:00
commit 0359bb2445
5 changed files with 1266 additions and 200 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,7 @@
import { createNameDebug } from "./names.js";
import { CELL_SIZE, INF, MAP_H, MAP_W, clamp, indexOf, inside, rand } from "./mapUtils.js";
import { CELL_SIZE, INF, MAP_H, MAP_W, MinHeap, clamp, indexOf, inside, rand, xyOf } from "./mapUtils.js";
import { applyOutputOptions, attachIdsAndNames, influenceFromPaths, tagInsidePrefecture } from "./mapGeneratorHelpers.js";
import { routeQualityAcceptable } from "./mapTransport.js";
const MUNICIPAL_SUFFIX_RE = /[市町村区]$/u;
@ -475,65 +476,133 @@ export function finishMapOutput({
]);
function addMunicipalCenterLocalAccess() {
if (!transportDebug) return;
const debugLayers = transportDebug.layers || (transportDebug.layers = { repairedSegments: [], unservedSettlements: [] });
const debugLayers = transportDebug ? (transportDebug.layers || (transportDebug.layers = { repairedSegments: [], unservedSettlements: [] })) : { repairedSegments: [], unservedSettlements: [] };
debugLayers.repairedSegments ||= [];
debugLayers.unservedSettlements ||= [];
const accessInfluence = influenceFromPaths([...nationalRoads, ...railways, ...externalRoads, ...minorRoads], 7);
const localPenalty = influenceFromPaths(minorRoads, 4);
const influenceCache = new Map();
const signature = (paths) => `${paths?.length || 0}:${(paths || []).reduce((sum, path) => sum + (path?.length || 0), 0)}`;
const cachedInfluence = (paths, radius, label) => {
const key = `${label}:${radius}:${signature(paths)}`;
let grid = influenceCache.get(key);
if (!grid) {
grid = influenceFromPaths(paths, radius);
influenceCache.set(key, grid);
}
return grid;
};
const accessInfluence = cachedInfluence([...nationalRoads, ...railways, ...externalRoads, ...minorRoads], 8, "municipal-access");
const localPenalty = cachedInfluence(minorRoads, 4, "municipal-minor");
const perPrefectureQuota = new Map();
const candidates = adminCenters
.filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)] && accessInfluence[indexOf(p.x, p.y)] < 0.16)
.sort((a, b) => (b.municipalityPopulation || 0) - (a.municipalityPopulation || 0))
.slice(0, 45);
.filter((p) => {
if (!inside(p.x, p.y) || sea[indexOf(p.x, p.y)]) return false;
const i = indexOf(p.x, p.y);
const meaningful = (p.municipalityPopulation || 0) >= 4500 || (populationDensity?.[i] || 0) > 0.08 || (p.representativeFeatureName && accessInfluence[i] < 0.22);
return meaningful && accessInfluence[i] < 0.42;
})
.sort((a, b) => {
const ai = indexOf(a.x, a.y);
const bi = indexOf(b.x, b.y);
const as = (a.municipalityPopulation || 0) + Math.max(0, 0.42 - accessInfluence[ai]) * 145000;
const bs = (b.municipalityPopulation || 0) + Math.max(0, 0.42 - accessInfluence[bi]) * 145000;
return bs - as;
})
.filter((p) => {
const prefId = municipalityToPrefectureId?.[p.municipalityId] ?? -1;
const used = perPrefectureQuota.get(prefId) || 0;
if (used >= 18) return false;
perPrefectureQuota.set(prefId, used + 1);
return true;
})
.slice(0, 95);
function routeAccess(start) {
const maxSteps = 72;
const out = [];
const visited = new Set([`${start.x},${start.y}`]);
let x = start.x;
let y = start.y;
let lastKey = "";
for (let step = 0; step < maxSteps; step++) {
const i = indexOf(x, y);
if (step > 3 && (accessInfluence[i] > 0.20 || localPenalty[i] > 0.10)) return out;
let best = null;
let bestScore = INF;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
function addLocalPenalty(path, radius = 4, strength = 0.20) {
for (const [x, y] of path || []) {
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const nx = x + dx;
const ny = y + dy;
if (!inside(nx, ny)) continue;
const d = Math.hypot(dx, dy);
if (d > radius) continue;
const i = indexOf(nx, ny);
if (sea[i]) continue;
localPenalty[i] = Math.max(localPenalty[i], strength * (1 - d / radius));
}
}
}
}
function routeAccess(start) {
const startIndex = indexOf(start.x, start.y);
if (sea[startIndex]) return [];
const score = new Float32Array(MAP_W * MAP_H);
const cameFrom = new Int32Array(MAP_W * MAP_H);
const closed = new Uint8Array(MAP_W * MAP_H);
score.fill(INF);
cameFrom.fill(-1);
const heap = new MinHeap();
score[startIndex] = 0;
heap.push({ i: startIndex, f: 0 });
const maxExpanded = Math.min(MAP_W * MAP_H, 24000);
let goal = -1;
let expanded = 0;
while (heap.length && expanded++ < maxExpanded) {
const current = heap.pop();
if (!current || closed[current.i]) continue;
closed[current.i] = 1;
const [cx, cy] = xyOf(current.i);
const straightDistance = Math.hypot(cx - start.x, cy - start.y);
if (straightDistance > 3 && (accessInfluence[current.i] > 0.11 || localPenalty[current.i] > 0.06)) {
goal = current.i;
break;
}
if (straightDistance > 150) continue;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = cx + dx;
const ny = cy + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (sea[ni]) continue;
if (visited.has(`${nx},${ny}`)) continue;
const cost =
1.1 +
slope[ni] * 1.4 +
ridgeField[ni] * 0.55 +
Math.max(0, elevation[ni] - 0.70) * 1.2 -
Math.max(roadInfluence[ni], railInfluence2[ni]) * 2.8 -
plain[ni] * 0.28 -
valleyField[ni] * 0.18 -
coastalLowland[ni] * 0.16 +
localPenalty[ni] * 0.55;
if (cost < bestScore) {
bestScore = cost;
best = [nx, ny];
if (closed[ni] || sea[ni]) continue;
const step = Math.hypot(dx, dy);
const highPenalty = Math.max(0, elevation[ni] - 0.64);
const targetAttraction = Math.max(accessInfluence[ni] * 2.4, localPenalty[ni] * 1.25, roadInfluence[ni] * 1.8, railInfluence2[ni] * 1.2);
const terrainCost =
1.0 +
slope[ni] * 1.20 +
ridgeField[ni] * 0.52 +
highPenalty * 1.55 -
plain[ni] * 0.38 -
valleyField[ni] * 0.42 -
coastalLowland[ni] * 0.18 +
Math.max(0, localPenalty[ni] - 0.18) * 0.38 -
targetAttraction;
const nd = score[current.i] + Math.max(0.16, terrainCost) * step;
if (nd < score[ni]) {
score[ni] = nd;
cameFrom[ni] = current.i;
heap.push({ i: ni, f: nd });
}
}
}
if (!best) break;
x = best[0];
y = best[1];
const key = `${x},${y}`;
visited.add(key);
if (key !== lastKey) {
out.push([x, y]);
lastKey = key;
}
}
return [];
if (goal < 0) return [];
const path = [];
for (let p = goal; p >= 0; p = cameFrom[p]) {
path.push(xyOf(p));
if (p === startIndex) break;
}
path.reverse();
if (path.length < 4 || path.length > 112) return [];
return routeQualityAcceptable(path, { sea, elevation, slope, potential: populationDensity, penalty: localPenalty, highElevationThreshold: 0.78, steepThreshold: 0.52 }, {
minLength: 4,
maxLength: 112,
maxCompactness: 4.0,
maxHighElevationShare: 0.22,
maxSteepShare: 0.50,
}) ? path : [];
}
for (const center of candidates) {
@ -541,6 +610,7 @@ export function finishMapOutput({
debugLayers.unservedSettlements.push({ x: center.x, y: center.y, kind: "Municipal Center", mode: "municipal-access", repaired: path.length >= 4 });
if (path.length < 4) continue;
minorRoads.push(path);
addLocalPenalty(path);
debugLayers.repairedSegments.push({ mode: "municipal-local", path, from: "municipal-center", to: "network" });
}
}

99
mapTransport.js Normal file
View file

@ -0,0 +1,99 @@
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 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;
}

View file

@ -1,6 +1,6 @@
import { MAP_H, MAP_W, hash2, indexOf, inside } from "./mapUtils.js";
export const CUSTOM_NAME_LIST = ["加茂", "春日", "新庄", "常盤", "追分", "清水", "曙", "暁", "旭", "鳥羽", "長門", "吉野", "吉田", "美郷", "美里", "美山"];
export const CUSTOM_NAME_LIST = ["加茂", "春日", "新庄", "常盤", "追分", "清水", "曙", "暁", "旭", "鳥羽", "長門", "吉野", "吉田", "美郷", "美里", "美山", "八幡", "相生",];
function nameCharCount(value) {
return Array.from(String(value || "")).length;
@ -18,7 +18,7 @@ export const NAME_KANJI_POOLS = {
"大", "小", "上", "下", "中", "奥", "脇",
"東", "西", "南", "北",
"新", "古", "本",
"高", "長", "広", "深", "浅",
"高", "長", "広", "深", "浅", "明", "重", "荒",
"白", "黒", "青", "赤", "藍",
"奥", "前", "後", "内", "外",
"美", "吉", "福", "幸", "徳",
@ -26,7 +26,7 @@ export const NAME_KANJI_POOLS = {
"霞", "朝", "日", "天",
"土", "砂", "石", "岩",
"卯", "辰",
"", "永", "早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌", "徳", "栄"
"駒", "羽", "鳴", "横", "永", "早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌", "徳", "栄"
],
inlandTerrain: [
@ -37,7 +37,7 @@ export const NAME_KANJI_POOLS = {
"塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", "植", "生",
"郷", "里",
"馬", "鹿", "亀", "鷲", "鷹", "鶴", "鴨", "竜", "龍", "牛", "鳥",
"湯",
"湯", "宍",
],
waterTerrain: [
@ -54,16 +54,15 @@ export const NAME_KANJI_POOLS = {
"州", "洲", "瀬", "砂", "潮", "塩", "浜",
"泊", "江", "浦", "灘", "入",
"戸", "門",
"鯵", "鰐", "漁", "魚", "鮫", "鮎",
],
plants: [
"松", "杉", "桜", "梅", "栗",
"竹", "楠", "藤", "萩", "葦",
"菅", "榎", "椿", "桐", "柳",
"橘", "柏", "槙", "柿", "桃",
"梨", "桑", "麻", "芦", "茅",
"粟", "稲", "稗", "米", "飯", "糠", "茜", "葵",
"橘", "柏", "槙", "柿", "桃", "稲", "花", "草", "菊",
"梨", "桑", "麻", "芦", "茅", "根",
"粟", "稲", "稗", "米", "飯", "糠", "茜", "葵", "篠",
"榊", "楢", "檜", "椎", "茨", "葛", "蘆", "菖", "蒲", "蓮", "桑", "瓜"
],
@ -72,9 +71,9 @@ export const NAME_KANJI_POOLS = {
"島",
"江", "瀬", "井", "戸", "口",
"辺", "里", "郷", "村", "町",
"宿", "庄", "台", "坂", "橋",
"本", "内", "窪", "平", "塚",
"畑", "牧", "前", "見", "中", "羽", "生", "塚", "部", "栄", "永",
"宿", "庄", "台", "坂", "橋", "明",
"本", "内", "窪", "平", "塚", "根",
"畑", "牧", "前", "見", "中", "羽", "生", "駒", "塚", "部", "栄", "永", "平",
],
archaicPrefixes: [
@ -118,7 +117,7 @@ export const NAME_KANJI_POOLS = {
],
settlementWords: [
"里", "郷", "村", "町", "宿", "垣", "坪", "軒", "惣",
"里", "郷", "村", "町", "宿", "垣", "坪", "軒", "惣", "條",
"庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂",
"城", "館", "屋", "家", "所",
"市", "場", "関", "地蔵", "辻", "角", "堰", "通", "路", "道", "筋",

View file

@ -208,10 +208,13 @@ function vectorPath(path) {
if (cached) return cached;
const points = path.map(([x, y]) => [x * CELL_SIZE + CELL_SIZE / 2, y * CELL_SIZE + CELL_SIZE / 2]);
const simplified = simplifyRdp(points, CELL_SIZE * 0.34);
const smoothed = chaikin(simplified, path.length > 6 ? 1 : 0, false);
pathVectorCache.set(path, smoothed);
return smoothed;
// Transport routes are already cost-routed on the raster grid. A large RDP
// tolerance erases those small valley/contour bends and makes roads look like
// ruler-straight overlays, so smooth first and simplify only lightly.
const smoothedBase = chaikin(points, path.length > 8 ? 1 : 0, false);
const simplified = simplifyRdp(smoothedBase, CELL_SIZE * 0.14);
pathVectorCache.set(path, simplified);
return simplified;
}
function drawPolylinePoints(ctx, points) {
@ -636,7 +639,8 @@ function drawDebugCells(ctx, map, field, color) {
const i = indexOf(x, y);
const debugMask = map.humanRegionMask || map.prefectureMask;
if (!debugMask[i] || map.sea[i]) continue;
const v = clamp(field[i] || 0, 0, 1);
const raw = field[i] || 0;
const v = clamp(raw > 1 ? raw / 255 : raw, 0, 1);
if (v <= 0.12) continue;
ctx.fillStyle = color(v);
ctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE);
@ -917,7 +921,10 @@ export function drawMap(canvas, map, options) {
for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(210, 210, 210, 0.7)", 2.5);
}
if (showMinorRoads) {
for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(205, 205, 205, 0.60)", 2.35);
// Local roads need a visible casing on pale green lowland/farmland tiles.
// Keep the fill light, but use a warmer grey outline rather than a nearly
// invisible white-on-green stroke.
for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(132, 126, 112, 0.72)", 2.75);
}
if (showRoads) {
for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8);
@ -939,7 +946,7 @@ export function drawMap(canvas, map, options) {
for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(255, 255, 255, 1)", 1.2, false);
}
if (showMinorRoads) {
for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(255, 255, 255, 0.94)", 1.1, false);
for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(255, 253, 244, 0.98)", 1.25, false);
}
if (showRoads) {
for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0);