first commit

This commit is contained in:
33333-33333 2026-05-20 13:50:56 +09:00
commit 32398c13e8
9 changed files with 4125 additions and 0 deletions

46
README.md Normal file
View file

@ -0,0 +1,46 @@
# Prefecture Map Generator v16 - Causal Terrain Cleanup Edition
This version is designed to run directly in VSCode with **Live Server**. No npm setup is required.
## Run
1. Open this folder in VSCode.
2. Right-click `index.html`.
3. Choose **Open with Live Server**.
## Main changes in v16
- River action is much stronger. `erosionField` and `depositionField` now produce clearer valleys, terraces, and alluvial lowlands.
- Neighboring prefecture regions are generated as background territory with `prefectureRegionId` and `regionalPrefectureBorders`, so the current prefecture is no longer visually isolated.
- River color now uses the same blue family as the sea; hierarchy is expressed by width and opacity.
- CBD/DID cells are sparser and restricted to dense, connected urban fabric.
- Railways, roads, and expressways are pruned for long-distance purpose; compact local tangles are rejected except for environmental ring segments.
- Expressway rings are rare. ICs are sampled at shorter intervals along expressway corridors.
- Bridge and tunnel icon rendering was removed. The legacy arrays remain empty for compatibility.
- Municipal regions are less likely to split small urban areas, while large cities claim larger municipality-like areas.
## Existing systems retained
- River hierarchy: main rivers, tributaries, and small streams.
- Port classes: major, regional, fishing, lake.
- Major rail corridors first, then purposeful branches.
- Expressways avoid city centers; city access is represented through IC access roads.
- Castles are restricted to terrain/historical candidate sites.
- Default generated names and manual overrides are both managed in `names.js`.
- Continuous terrain rendering is always on.
## Editing place names
Edit `names.js`.
```js
export const CUSTOM_NAMES = {
"city-0": "Aohara",
"port-0": "Shirahama",
"castle-0": "Kurono"
};
```
## Tests
Open `test.html` with Live Server to run the browser-side smoke tests, or run `node --check *.js` on the source files.

249
app.js Normal file
View file

@ -0,0 +1,249 @@
import { generateMap } from "./mapGenerator.js";
import { drawMap } from "./renderer.js";
const modes = [
["all", "All"],
["terrain", "Terrain"],
["suitability", "Suitability"],
["history", "Premodern"],
["modern", "Modern"],
["roads", "Roads"],
["development", "Development"],
["landuse", "Land Use"],
["admin", "Municipal Borders"],
];
const state = {
seedText: "114514",
mode: "all",
showFeatures: true,
showLabels: true,
map: null,
};
const canvas = document.getElementById("mapCanvas");
const seedInput = document.getElementById("seed");
const randomSeedButton = document.getElementById("randomSeed");
const showFeaturesInput = document.getElementById("showFeatures");
const showLabelsInput = document.getElementById("showLabels");
const modeGrid = document.getElementById("modeGrid");
const statsEl = document.getElementById("stats");
const idsEl = document.getElementById("nameIds");
const tooltipEl = document.getElementById("mapTooltip");
function parseSeed(seedText) {
const numeric = Number.parseInt(seedText, 10);
if (Number.isFinite(numeric)) return numeric >>> 0;
let hash = 2166136261;
for (const ch of seedText) hash = Math.imul(hash ^ ch.charCodeAt(0), 16777619);
return hash >>> 0;
}
function insideCount(items) {
return items.filter((item) => item.insidePrefecture).length;
}
function outsideCount(items) {
return items.length - insideCount(items);
}
function countText(items) {
return `${insideCount(items)} / outside ${outsideCount(items)}`;
}
function getStats(map) {
return [
["Villages", countText(map.villages)],
["Market Towns", countText(map.markets)],
["Castles", countText(map.castles)],
["Premodern Roads", map.premodernRoads.length],
["Minor Roads", map.minorRoads.length],
["Prefectural Capital", map.prefecturalCapital?.name || "-"],
["Modern Cities", countText(map.modernCities)],
["Ports", `${map.ports.filter((p) => p.portClass === "major").length} major / ${map.ports.filter((p) => p.portClass === "regional").length} regional / ${map.ports.filter((p) => p.portClass === "fishing").length} fishing / ${map.ports.filter((p) => p.portClass === "lake").length} lake`],
["Satellite Cities", countText(map.satelliteCities || [])],
["Population", (map.totalPopulation || 0).toLocaleString()],
["Rivers", `${map.mainRivers.length} main / ${(map.tributaryRivers || []).length} tributary / ${(map.smallStreams || []).length} hidden streams`],
["Neighbor Prefecture Borders", (map.regionalPrefectureBorders || []).length],
["Harbor Works", (map.harborWorks || []).length],
["Rail Lines", map.railways.length + map.branchRailways.length + (map.ringRailways || []).length + map.externalRailways.length],
["Industrial Zones", countText(map.industrialZones)],
["National Roads", map.nationalRoads.length + (map.ringRoads || []).length],
["Expressways", map.expressways.length + (map.ringExpressways || []).length + map.externalExpressways.length],
["External Gateways", map.externalGateways.length],
["Interchanges", countText(map.interchanges)],
["Logistics Parks", countText(map.logisticsParks)],
["New Towns", countText(map.newTowns)],
["Municipalities", map.adminCenters.length],
];
}
function renderStats(map) {
statsEl.innerHTML = "";
for (const [labelText, valueText] of getStats(map)) {
const row = document.createElement("div");
row.className = "stat-row";
const label = document.createElement("span");
label.textContent = labelText;
const value = document.createElement("strong");
value.textContent = String(valueText);
row.append(label, value);
statsEl.append(row);
}
}
function renderNameIds(map) {
idsEl.innerHTML = "";
for (const entity of map.entitiesForNames.slice(0, 120)) {
const row = document.createElement("div");
row.className = "id-row";
const code = document.createElement("code");
code.textContent = entity.id;
const name = document.createElement("span");
const population = entity.population ? ` / ${entity.population.toLocaleString()} people` : "";
name.textContent = `${entity.name} / ${entity.kind}${population}`;
row.append(code, name);
idsEl.append(row);
}
}
function nearestEntity(map, x, y, maxDistance = 5) {
const groups = [
...(map.modernCities || []),
...(map.ports || []),
...(map.stations || []),
...(map.interchanges || []),
...(map.industrialZones || []),
...(map.logisticsParks || []),
...(map.newTowns || []),
...(map.castles || []),
...(map.markets || []),
...(map.villages || []),
...(map.adminCenters || []),
];
let best = null;
let bestD = maxDistance;
for (const item of groups) {
const d = Math.hypot(item.x - x, item.y - y);
if (d < bestD) { best = item; bestD = d; }
}
return best;
}
function landuseName(value) {
return {
0: "Agriculture",
1: "Plain",
2: "Old urban area",
3: "CBD / DID core",
4: "Suburban urban area",
5: "Industrial zone",
6: "Logistics area",
7: "New town",
8: "Roadside development",
9: "Forest / rural land",
}[value] || "Land";
}
function adminName(map, adminId) {
const center = (map.adminCenters || [])[adminId];
return center?.name || (adminId >= 0 ? `Municipality ${adminId + 1}` : "-");
}
function updateTooltip(event) {
if (!state.map || !tooltipEl) return;
const rect = canvas.getBoundingClientRect();
const x = Math.floor((event.clientX - rect.left) / rect.width * state.map.width);
const y = Math.floor((event.clientY - rect.top) / rect.height * state.map.height);
if (x < 0 || y < 0 || x >= state.map.width || y >= state.map.height) {
tooltipEl.classList.remove("visible");
return;
}
const i = y * state.map.width + x;
const entity = nearestEntity(state.map, x, y);
const elevation = state.map.elevation?.[i] ?? 0;
const density = state.map.populationDensity?.[i] ?? 0;
const lines = [
`<strong>${entity ? `${entity.name} / ${entity.kind}` : `${x}, ${y}`}</strong>`,
`Admin: ${adminName(state.map, state.map.adminId?.[i] ?? -1)}`,
`Land: ${state.map.sea?.[i] ? "Sea" : landuseName(state.map.landuse?.[i])}`,
`Elevation: ${elevation.toFixed(3)} / Slope: ${(state.map.slope?.[i] ?? 0).toFixed(3)}`,
`River: ${(state.map.river?.[i] ?? 0).toFixed(2)} / Density: ${density.toFixed(2)}`,
];
if (entity?.population) lines.splice(1, 0, `Population: ${entity.population.toLocaleString()}`);
tooltipEl.innerHTML = lines.join("<br>");
tooltipEl.style.left = `${event.clientX - rect.left + 14}px`;
tooltipEl.style.top = `${event.clientY - rect.top + 14}px`;
tooltipEl.classList.add("visible");
}
function renderModeButtons() { modeGrid.innerHTML = "";
for (const [key, label] of modes) {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.className = key === state.mode ? "mode-button active" : "mode-button";
button.addEventListener("click", () => {
state.mode = key;
renderModeButtons();
redraw();
});
modeGrid.append(button);
}
}
function regenerate() {
state.seedText = seedInput.value;
state.map = generateMap(parseSeed(state.seedText));
renderStats(state.map);
renderNameIds(state.map);
redraw();
}
function redraw() {
if (!state.map) return;
drawMap(canvas, state.map, {
mode: state.mode,
showFeatures: state.showFeatures,
showLabels: state.showLabels,
});
}
function init() {
renderModeButtons();
seedInput.addEventListener("change", regenerate);
seedInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") regenerate();
});
randomSeedButton.addEventListener("click", () => {
seedInput.value = String(Math.floor(Math.random() * 9999999));
regenerate();
});
showFeaturesInput.addEventListener("change", () => {
state.showFeatures = showFeaturesInput.checked;
redraw();
});
showLabelsInput.addEventListener("change", () => {
state.showLabels = showLabelsInput.checked;
redraw();
});
canvas.addEventListener("mousemove", updateTooltip);
canvas.addEventListener("mouseleave", () => tooltipEl?.classList.remove("visible"));
regenerate();
}
init();

98
index.html Normal file
View file

@ -0,0 +1,98 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Prefecture Map Generator v16</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div class="app">
<main class="layout">
<section class="main-panel">
<header class="header">
<div>
<h1>Prefecture Map Generator v16</h1>
<p>
Terrain-highlighted prefecture generation with terrain-snapped municipalities, a clear prefectural capital,
hidden small streams, city-seeking national roads, and hover tooltips.
</p>
</div>
</header>
<div class="canvas-shell">
<canvas id="mapCanvas" class="map-canvas"></canvas>
<div id="mapTooltip" class="map-tooltip" role="status" aria-live="polite"></div>
</div>
</section>
<aside class="sidebar">
<section class="card">
<label class="label" for="seed">Seed</label>
<input id="seed" class="input" value="114514" />
<button id="randomSeed" type="button" class="primary-button">Generate Random Seed</button>
</section>
<section class="card">
<div class="card-title">Display Layers</div>
<div id="modeGrid" class="mode-grid"></div>
<label class="checkbox-row">
<input id="showFeatures" type="checkbox" checked />
Show features
</label>
<label class="checkbox-row">
<input id="showLabels" type="checkbox" checked />
Show labels
</label>
</section>
<section class="card">
<div class="card-title">Generated Features</div>
<div id="stats" class="stats"></div>
</section>
<section class="card legend">
<div class="card-title">Name Override IDs</div>
<p>Add entries to <code>names.js</code> in <code>CUSTOM_NAMES</code>.</p>
<pre class="example">export const CUSTOM_NAMES = {
"city-0": "Aohara",
"port-0": "Shirahama",
"castle-0": "Kurono"
};</pre>
<div id="nameIds" class="id-list"></div>
</section>
<section class="card legend">
<div class="card-title">Legend</div>
<div class="legend-grid" aria-label="Map legend">
<div class="legend-row"><span class="legend-swatch border-swatch"></span><span>Prefecture region / municipal border</span></div>
<div class="legend-row"><span class="legend-line river-major"></span><span>Main river / tributary</span></div>
<div class="legend-row"><span class="legend-line rail-line"></span><span>Railway / solid ring railway</span></div>
<div class="legend-row"><span class="legend-line road-line"></span><span>National road / trunk ring road</span></div>
<div class="legend-row"><span class="legend-line express-line"></span><span>Expressway / environmental ring segment</span></div>
<div class="legend-row"><span class="legend-line old-road-line"></span><span>Premodern / minor road</span></div>
<div class="legend-row"><span class="legend-icon city-icon"></span><span>Prefectural capital / city</span></div>
<div class="legend-row"><span class="legend-icon port-icon"></span><span>Major / regional / fishing / lake port</span></div>
<div class="legend-row"><span class="legend-icon castle-icon"></span><span>Castle / ruins</span></div>
<div class="legend-row"><span class="legend-swatch cbd-swatch"></span><span>CBD / central city cells</span></div>
<div class="legend-row"><span class="legend-icon satellite-icon"></span><span>Satellite city</span></div>
<div class="legend-row"><span class="legend-icon station-icon"></span><span>Station</span></div>
<div class="legend-row"><span class="legend-icon industry-icon"></span><span>Industry / logistics</span></div>
<div class="legend-row"><span class="legend-line harbor-line"></span><span>Harbor works</span></div>
<div class="legend-row"><span class="legend-icon newtown-icon"></span><span>New town</span></div>
</div>
</section>
<section class="card legend">
<div class="card-title">Notes</div>
<p>Open <code>index.html</code> with Live Server. Open <code>test.html</code> to run browser tests.</p>
</section>
</aside>
</main>
</div>
<script type="module" src="./app.js"></script>
</body>
</html>

3024
mapGenerator.js Normal file

File diff suppressed because it is too large Load diff

34
names.js Normal file
View file

@ -0,0 +1,34 @@
// Default and custom place-name resources.
// Edit only this file to change generated names or override individual IDs.
export const NAME_PARTS = {
prefixes: ["青", "白", "黒", "赤", "東", "西", "南", "北", "中", "大", "小", "浅", "高", "川", "山", "森", "松", "原", "桜", "海", "新", "古", "上", "下"],
infixes: ["野", "原", "川", "浜", "崎", "丘", "谷", "峰", "浦", "沢", "島", "岡", "井", "瀬", "関", "橋", "口", "田", "津", "森", "台", "見"],
suffixes: ["町", "市", "郷", "台", "丘", "浦", "港", "里", "野", "沢", "原", "橋", "森", "浜", "崎", "峠"],
};
export const KIND_SUFFIXES = {
city: " City",
port: " Port",
market: " Town",
village: " Village",
castle: " Castle",
castleTown: " Castle Town",
castleRuin: " Castle Ruins",
station: " Station",
interchange: "IC",
industrial: " Industrial Park",
logistics: " Logistics Park",
newtown: " New Town",
satellite: " Satellite City",
pass: " Pass",
crossing: " Crossing",
gateway: " Gate",
admin: " District",
};
export const CUSTOM_NAMES = {
// "city-0": "Aohara",
// "port-0": "Shirahama",
// "castle-0": "Kurono",
};

493
renderer.js Normal file
View file

@ -0,0 +1,493 @@
import { MAP_W, MAP_H, CELL_SIZE, indexOf } from "./mapGenerator.js";
function clamp(v, a = 0, b = 1) {
return Math.max(a, Math.min(b, v));
}
function distToNearest(points, x, y, fallback = 999) {
let best = fallback;
for (const p of points) best = Math.min(best, Math.hypot(p.x - x, p.y - y));
return best;
}
function blendOutside(color, isInside) {
if (isInside) return color;
return [
Math.round(color[0] * 0.55 + 112),
Math.round(color[1] * 0.55 + 112),
Math.round(color[2] * 0.55 + 112),
];
}
function fieldSample(field, fx, fy) {
const x0 = Math.max(0, Math.min(MAP_W - 1, Math.floor(fx)));
const y0 = Math.max(0, Math.min(MAP_H - 1, Math.floor(fy)));
const x1 = Math.max(0, Math.min(MAP_W - 1, x0 + 1));
const y1 = Math.max(0, Math.min(MAP_H - 1, y0 + 1));
const tx = fx - x0;
const ty = fy - y0;
const a = field[indexOf(x0, y0)];
const b = field[indexOf(x1, y0)];
const c = field[indexOf(x0, y1)];
const d = field[indexOf(x1, y1)];
return ((a * (1 - tx) + b * tx) * (1 - ty)) + ((c * (1 - tx) + d * tx) * ty);
}
function terrainColorContinuous(map, fx, fy, mode) {
const i = indexOf(Math.max(0, Math.min(MAP_W - 1, Math.floor(fx))), Math.max(0, Math.min(MAP_H - 1, Math.floor(fy))));
const isInside = Boolean(map.prefectureMask[i]);
let color;
if (map.sea[i]) {
// Google Map風の海の色明るい青
const depth = clamp((0.35 - fieldSample(map.elevation, fx, fy)) * 2.4);
color = [Math.round(170 + depth * 10), Math.round(211 + depth * 15), Math.round(223 + depth * 20)];
} else if (mode === "suitability") {
const a = fieldSample(map.agriculture, fx, fy);
const p = fieldSample(map.plain, fx, fy);
const f = fieldSample(map.floodplain, fx, fy);
color = [
Math.round(230 - p * 25 + f * 15),
Math.round(235 + a * 15),
Math.round(210 - a * 25 + p * 20),
];
} else if (mode === "development") {
const x = Math.floor(fx);
const y = Math.floor(fy);
const baseIndex = indexOf(Math.max(0, Math.min(MAP_W - 1, x)), Math.max(0, Math.min(MAP_H - 1, y)));
const dCity = distToNearest(map.modernCities, fx, fy);
const dInd = distToNearest(map.industrialZones, fx, fy);
const dLog = distToNearest(map.logisticsParks, fx, fy);
const dNew = distToNearest(map.newTowns, fx, fy);
const urban = clamp(1 - dCity / 25);
const density = map.populationDensity ? fieldSample(map.populationDensity, fx, fy) : urban;
const industrial = clamp(1 - dInd / 10);
const logistics = clamp(1 - dLog / 9);
const newTown = clamp(1 - dNew / 9);
const base = 214 + map.plain[baseIndex] * 22;
color = [
Math.round(base + density * 30 + urban * 8 + industrial * 14),
Math.round(base + density * 10 + logistics * 15 + newTown * 12),
Math.round(208 + map.agriculture[baseIndex] * 22 + density * 18 + newTown * 22),
];
} else {
// 起伏の大きさが読めるよう、標高段彩をやや強める
const e = fieldSample(map.elevation, fx, fy);
const m = fieldSample(map.moisture, fx, fy);
if (e > 0.82) color = [178, 170, 160];
else if (e > 0.68) color = [198, 188, 164];
else if (e > 0.52) color = [205, 222 + m * 5, 184];
else if (e > 0.34) color = [224, 238 + m * 6, 206];
else if (e > 0.24) color = [236, 245 + m * 5, 220];
else color = [218, 232 + m * 6, 206];
}
return blendOutside(color, isInside);
}
function discreteColor(map, x, y, mode) {
const i = indexOf(x, y);
let color;
if (map.sea[i]) {
// Google Map風の海色
color = [170, 218, 255];
} else if (mode === "landuse") {
const colors = {
0: [230, 242, 220], // 農地
1: [235, 245, 225], // 平地
2: [235, 230, 220], // 旧市街
3: [224, 202, 190], // 中心市街地 / CBD
4: [245, 240, 230], // 郊外
5: [220, 220, 225], // 工業地域
6: [225, 235, 225], // 物流エリア
7: [238, 242, 248], // ニュータウン
8: [248, 242, 230], // 沿道開発
9: [225, 238, 220], // その他
};
color = colors[map.landuse[i]] || colors[0];
} else if (mode === "admin") {
const palette = [
[245, 235, 230],
[235, 245, 235],
[240, 240, 250],
[250, 245, 230],
[245, 240, 248],
[230, 245, 245],
[250, 240, 240],
[240, 250, 235],
];
const a = map.adminId[i];
color = a >= 0 ? palette[a % palette.length] : [220, 225, 220];
} else {
color = terrainColorContinuous(map, x, y, "terrain");
}
return blendOutside(color, Boolean(map.prefectureMask[i]));
}
function drawBase(ctx, map, mode, continuousTerrain) {
const width = MAP_W * CELL_SIZE;
const height = MAP_H * CELL_SIZE;
const img = ctx.createImageData(width, height);
const continuousModes = ["terrain", "suitability", "development", "all"];
if (continuousTerrain && continuousModes.includes(mode)) {
for (let py = 0; py < height; py++) {
const fy = py / CELL_SIZE;
for (let px = 0; px < width; px++) {
const fx = px / CELL_SIZE;
const [r, g, b] = terrainColorContinuous(map, fx, fy, mode === "all" ? "terrain" : mode);
const eL = fieldSample(map.elevation, fx - 0.6, fy);
const eR = fieldSample(map.elevation, fx + 0.6, fy);
const eU = fieldSample(map.elevation, fx, fy - 0.6);
const eD = fieldSample(map.elevation, fx, fy + 0.6);
const shade = clamp(0.9 + (eL - eR) * 1.0 + (eU - eD) * 0.65, 0.72, 1.18);
const elevation = fieldSample(map.elevation, fx, fy);
const contour = Math.abs((elevation * 16) - Math.round(elevation * 16));
const majorContour = Math.abs((elevation * 8) - Math.round(elevation * 8));
const isLand = !map.sea[indexOf(Math.max(0, Math.min(MAP_W - 1, Math.floor(fx))), Math.max(0, Math.min(MAP_H - 1, Math.floor(fy))))];
const contourFactor = isLand && majorContour < 0.022 ? 0.86 : isLand && contour < 0.028 ? 0.94 : 1;
const ii = (py * width + px) * 4;
img.data[ii] = Math.round(r * shade * contourFactor);
img.data[ii + 1] = Math.round(g * shade * contourFactor);
img.data[ii + 2] = Math.round(b * shade * contourFactor);
img.data[ii + 3] = 255;
}
}
} else {
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const [r, g, b] = discreteColor(map, x, y, mode);
for (let dy = 0; dy < CELL_SIZE; dy++) {
for (let dx = 0; dx < CELL_SIZE; dx++) {
const ii = ((y * CELL_SIZE + dy) * width + (x * CELL_SIZE + dx)) * 4;
img.data[ii] = r;
img.data[ii + 1] = g;
img.data[ii + 2] = b;
img.data[ii + 3] = 255;
}
}
}
}
}
ctx.putImageData(img, 0, 0);
}
function drawPath(ctx, path, color, width, dashed = false) {
if (!path || path.length < 2) return;
ctx.save();
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = color;
ctx.lineWidth = width;
if (dashed) ctx.setLineDash([6, 5]);
ctx.beginPath();
ctx.moveTo(path[0][0] * CELL_SIZE + CELL_SIZE / 2, path[0][1] * CELL_SIZE + CELL_SIZE / 2);
for (let k = 1; k < path.length; k++) {
ctx.lineTo(path[k][0] * CELL_SIZE + CELL_SIZE / 2, path[k][1] * CELL_SIZE + CELL_SIZE / 2);
}
ctx.stroke();
ctx.restore();
}
function drawSegments(ctx, segments, color, width, dashed = false) {
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = width;
if (dashed) ctx.setLineDash([4, 4]);
for (const seg of segments) {
ctx.beginPath();
ctx.moveTo(seg[0][0] * CELL_SIZE, seg[0][1] * CELL_SIZE);
ctx.lineTo(seg[1][0] * CELL_SIZE, seg[1][1] * CELL_SIZE);
ctx.stroke();
}
ctx.restore();
}
function drawUrbanAreas(ctx, map, mode) {
const visibleModes = ["all", "modern", "development", "landuse", "roads", "admin"];
if (!visibleModes.includes(mode)) return;
// Google Map風の都市部の色
const colors = {
2: "rgba(235, 230, 220, 0.75)", // 旧市街 - 薄いベージュ
3: "rgba(224, 202, 190, 0.9)", // 中心市街地 / CBD - cell fill
4: "rgba(245, 242, 235, 0.7)", // 郊外 - 薄いクリーム
5: "rgba(220, 220, 228, 0.8)", // 工業地域 - 薄いグレー
6: "rgba(225, 235, 228, 0.75)", // 物流 - 薄い緑グレー
7: "rgba(238, 242, 250, 0.75)", // ニュータウン - 薄い青白
8: "rgba(248, 245, 238, 0.7)", // 沿道 - クリーム
};
ctx.save();
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (!map.prefectureMask[i]) continue;
const lu = map.landuse[i];
if (!colors[lu]) continue;
const px = x * CELL_SIZE;
const py = y * CELL_SIZE;
ctx.fillStyle = colors[lu];
ctx.fillRect(px, py, CELL_SIZE, CELL_SIZE);
const h = ((x * 92821 + y * 68917 + lu * 131) >>> 0);
if (lu === 3 || lu === 2 || lu === 5 || lu === 6) {
// 建物の表現を控えめに
ctx.fillStyle = lu === 3 ? "rgba(200,200,200,0.25)" : "rgba(210,210,210,0.2)";
if (h % 3 !== 0) ctx.fillRect(px + 1, py + 1, 2, 2);
if (h % 5 !== 0) ctx.fillRect(px + 3, py + 2, 2, 2);
if (h % 7 !== 0) ctx.fillRect(px + 2, py + 4, 2, 1.5);
} else if (lu === 4 || lu === 7) {
ctx.strokeStyle = lu === 7 ? "rgba(220,220,230,0.15)" : "rgba(200,190,180,0.15)";
ctx.lineWidth = 0.8;
ctx.beginPath();
if (h % 2 === 0) {
ctx.moveTo(px + 1, py + 1);
ctx.lineTo(px + CELL_SIZE - 1, py + 1);
ctx.moveTo(px + 1, py + 4);
ctx.lineTo(px + CELL_SIZE - 1, py + 4);
} else {
ctx.moveTo(px + 1, py + 1);
ctx.lineTo(px + 1, py + CELL_SIZE - 1);
ctx.moveTo(px + 4, py + 1);
ctx.lineTo(px + 4, py + CELL_SIZE - 1);
}
ctx.stroke();
} else if (lu === 8) {
ctx.strokeStyle = "rgba(180,170,160,0.2)";
ctx.lineWidth = 0.9;
ctx.beginPath();
ctx.moveTo(px + 1, py + 3);
ctx.lineTo(px + CELL_SIZE - 1, py + 3);
ctx.stroke();
}
}
}
ctx.restore();
}
function dot(ctx, p, radius, fill, stroke = "white") {
ctx.beginPath();
ctx.arc(p.x * CELL_SIZE + CELL_SIZE / 2, p.y * CELL_SIZE + CELL_SIZE / 2, radius, 0, Math.PI * 2);
ctx.fillStyle = fill;
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = stroke;
ctx.stroke();
}
function squareIcon(ctx, p, size, fill, stroke = "white") {
const x = p.x * CELL_SIZE + CELL_SIZE / 2;
const y = p.y * CELL_SIZE + CELL_SIZE / 2;
ctx.save();
ctx.fillStyle = fill;
ctx.strokeStyle = stroke;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.rect(x - size / 2, y - size / 2, size, size);
ctx.fill();
ctx.stroke();
ctx.restore();
}
function triangleIcon(ctx, p, size, fill, stroke = "white") {
const x = p.x * CELL_SIZE + CELL_SIZE / 2;
const y = p.y * CELL_SIZE + CELL_SIZE / 2;
ctx.save();
ctx.fillStyle = fill;
ctx.strokeStyle = stroke;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(x, y - size / 2);
ctx.lineTo(x + size / 2, y + size / 2);
ctx.lineTo(x - size / 2, y + size / 2);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.restore();
}
function railStationIcon(ctx, p) {
squareIcon(ctx, p, 5.2, "rgba(255,255,255,0.96)", "rgba(55,55,55,0.92)");
}
function drawHarborWorks(ctx, map) {
if (!map.harborWorks) return;
ctx.save();
ctx.strokeStyle = "rgba(95, 120, 150, 0.9)";
ctx.lineWidth = 2.2;
ctx.lineCap = "round";
for (const harbor of map.harborWorks) {
for (const seg of harbor.segments || []) {
ctx.beginPath();
ctx.moveTo(seg[0][0] * CELL_SIZE + CELL_SIZE / 2, seg[0][1] * CELL_SIZE + CELL_SIZE / 2);
ctx.lineTo(seg[1][0] * CELL_SIZE + CELL_SIZE / 2, seg[1][1] * CELL_SIZE + CELL_SIZE / 2);
ctx.stroke();
}
}
ctx.restore();
}
function boxesOverlap(a, b, pad = 2) {
return !(a.x2 + pad < b.x1 || a.x1 - pad > b.x2 || a.y2 + pad < b.y1 || a.y1 - pad > b.y2);
}
function labelWithCollision(ctx, p, occupied) {
if (!p.name) return false;
ctx.save();
ctx.font = "11px ui-sans-serif, system-ui, sans-serif";
const baseX = p.x * CELL_SIZE + CELL_SIZE / 2;
const baseY = p.y * CELL_SIZE + CELL_SIZE / 2;
const textW = ctx.measureText(p.name).width;
const textH = 12;
const candidates = [
[7, -5], [7, 12], [-textW - 7, -5], [-textW - 7, 12],
[-textW / 2, -13], [-textW / 2, 20], [12, 2], [-textW - 12, 2],
];
for (const [ox, oy] of candidates) {
const x = baseX + ox;
const y = baseY + oy;
const box = { x1: x - 2, y1: y - textH, x2: x + textW + 2, y2: y + 3 };
if (box.x1 < 0 || box.y1 < 0 || box.x2 > MAP_W * CELL_SIZE || box.y2 > MAP_H * CELL_SIZE) continue;
if (occupied.some((b) => boxesOverlap(box, b))) continue;
ctx.lineWidth = 3;
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.fillStyle = "rgba(40,40,40,0.95)";
ctx.strokeText(p.name, x, y);
ctx.fillText(p.name, x, y);
occupied.push(box);
ctx.restore();
return true;
}
ctx.restore();
return false;
}
function drawLabels(ctx, points) {
const occupied = [];
const prioritized = points
.filter((p) => p?.name)
.map((p) => ({ ...p, labelPriority: (p.isPrefecturalCapital ? 1000 : 0) + (p.population || 0) / 1000 + (p.kind === "Major Port" ? 160 : 0) + (p.kind === "Market Town" ? 55 : 0) + (p.kind?.includes("Castle") ? 45 : 0) }))
.sort((a, b) => b.labelPriority - a.labelPriority);
for (const p of prioritized) labelWithCollision(ctx, p, occupied);
}
export function drawMap(canvas, map, options) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const mode = options.mode || "all";
const showFeatures = options.showFeatures !== false;
const showLabels = options.showLabels !== false;
const continuousTerrain = true;
const width = MAP_W * CELL_SIZE;
const height = MAP_H * CELL_SIZE;
canvas.width = width;
canvas.height = height;
drawBase(ctx, map, mode, continuousTerrain);
drawUrbanAreas(ctx, map, mode);
// Rivers use the same hue family as the sea; hierarchy is expressed by width/opacity.
const waterBlue = "rgba(170, 218, 255, 0.95)";
// Small streams remain in the data model but are not drawn by default.
for (const path of map.tributaryRivers || map.riverPaths || []) drawPath(ctx, path, "rgba(170, 218, 255, 0.78)", 1.35);
for (const path of map.mainRivers) drawPath(ctx, path, waterBlue, 3.2);
drawHarborWorks(ctx, map);
if (map.regionalPrefectureBorders) drawSegments(ctx, map.regionalPrefectureBorders, "rgba(95,95,95,0.30)", 1.0);
drawSegments(ctx, map.prefectureBorder, "rgba(30,30,30,0.82)", 2.4);
drawSegments(ctx, map.prefectureBorder, "rgba(255,255,255,0.74)", 1.05);
if (!showFeatures) return;
const showHistory = ["history", "all", "terrain", "suitability"].includes(mode);
const showModern = ["modern", "all", "development", "landuse"].includes(mode);
const showRoads = ["roads", "all", "development", "landuse"].includes(mode);
const showAdmin = ["admin", "all"].includes(mode);
if (showAdmin) drawSegments(ctx, map.adminBorders, "rgba(120,120,120,0.65)", 1.3);
if (showHistory) {
for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(150, 120, 90, 0.55)", 1.45, true);
for (const path of map.minorRoads) drawPath(ctx, path, "rgba(180, 150, 120, 0.62)", 1.05);
}
if (showModern) {
// 鉄道 - 濃いグレー
for (const path of map.railways) drawPath(ctx, path, "rgba(80, 80, 80, 0.9)", 2.8);
for (const path of map.ringRailways || []) drawPath(ctx, path, "rgba(70, 70, 70, 0.82)", 2.1);
for (const path of map.branchRailways) drawPath(ctx, path, "rgba(100, 100, 100, 0.82)", 1.9);
for (const path of map.externalRailways) drawPath(ctx, path, "rgba(90, 90, 90, 0.9)", 2.4);
}
if (showRoads) {
// Google Map風の道路 - 白と黄色とオレンジ
for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(252, 210, 90, 0.85)", 2.2);
for (const path of map.icAccessRoads || []) drawPath(ctx, path, "rgba(255, 230, 150, 0.82)", 1.45);
for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(252, 210, 90, 0.85)", 2.0);
for (const path of map.expressways) drawPath(ctx, path, "rgba(245, 140, 60, 0.95)", 4.0);
for (const path of map.ringExpressways || []) drawPath(ctx, path, "rgba(245, 140, 60, 0.95)", 3.3);
for (const path of map.externalRoads) drawPath(ctx, path, "rgba(252, 210, 90, 0.85)", 2.5);
for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(245, 140, 60, 0.95)", 4.2);
}
if (showHistory) {
for (const p of map.villages) dot(ctx, p, 1.9, "rgba(120, 100, 80, 0.72)");
for (const p of map.markets) dot(ctx, p, 4.8, "rgba(200, 130, 80, 0.95)");
for (const p of map.ports) {
const color = p.portClass === "major" ? "rgba(40, 105, 190, 0.98)" : p.portClass === "regional" ? "rgba(70, 130, 200, 0.95)" : p.portClass === "lake" ? "rgba(80, 155, 180, 0.92)" : "rgba(95, 150, 195, 0.82)";
triangleIcon(ctx, p, p.portClass === "major" ? 8.2 : 6.5, color);
}
for (const p of map.crossings) dot(ctx, p, 3.5, "rgba(255, 240, 180, 0.95)", "rgba(100,90,70,0.8)");
for (const p of map.passes) dot(ctx, p, 3.9, "rgba(150, 120, 180, 0.95)");
for (const p of map.castles) squareIcon(ctx, p, 7.0, "rgba(180, 70, 70, 0.96)");
for (const p of map.castleRuins) dot(ctx, p, 3.2, "rgba(110, 90, 90, 0.9)", "rgba(220,220,220,0.8)");
}
if (showModern) {
for (const p of map.industrialZones) squareIcon(ctx, p, 6.5, "rgba(140, 140, 150, 0.96)");
for (const p of map.stations) railStationIcon(ctx, p);
for (const p of map.satelliteCities || []) dot(ctx, p, 4.8, "rgba(215, 95, 145, 0.95)");
for (const p of map.newTowns) triangleIcon(ctx, p, 6.5, "rgba(180, 210, 240, 0.95)");
for (const p of map.modernCities) {
const popRadius = p.population ? Math.min(9.2, 3.4 + Math.sqrt(p.population) / 360) : 4.7;
const rankBoost = p.isPrefecturalCapital || p.rank === "Prefectural Capital" ? 1.6 : p.rank === "Regional Center" ? 0.45 : 0;
dot(ctx, p, popRadius + rankBoost, "rgba(230, 100, 100, 0.95)");
if (p.isPrefecturalCapital) dot(ctx, p, popRadius + 4.2, "rgba(255,255,255,0.0)", "rgba(180,60,60,0.95)");
}
}
if (showRoads) {
for (const p of map.logisticsParks) squareIcon(ctx, p, 6.2, "rgba(110, 170, 140, 0.96)");
for (const p of map.interchanges) dot(ctx, p, 4.1, "rgba(255, 255, 255, 0.98)", "rgba(220, 90, 60, 0.95)");
for (const p of map.externalGateways) dot(ctx, p, 4.4, "rgba(255, 250, 200, 0.98)", "rgba(60,60,60,0.92)");
}
if (showLabels) {
const important = [
...map.modernCities,
...map.ports,
...map.markets.slice(0, 10),
...map.castles.slice(0, 8),
...(map.satelliteCities || []),
...map.newTowns,
...map.externalGateways,
].filter((p) => p.insidePrefecture || p.kind === "External Gateway");
drawLabels(ctx, important);
}
}

11
styles.css Normal file
View file

@ -0,0 +1,11 @@
*{box-sizing:border-box} body{margin:0;background:#f5f5f5;color:#2c2c2c;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} button,input{font:inherit} code{background:#e8e8e8;border-radius:4px;padding:1px 4px} .app{min-height:100vh;padding:24px}.layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:16px;max-width:1400px;margin:0 auto}.header{margin-bottom:16px}.header h1{margin:0 0 6px;font-size:24px;letter-spacing:-0.02em;color:#1a1a1a}.header p{margin:0;color:#5a5a5a;line-height:1.65;font-size:14px}.canvas-shell,.card{background:#ffffff;border:1px solid rgb(0 0 0 / 0.12);border-radius:18px;box-shadow:0 2px 8px rgb(0 0 0 / 0.08)}.canvas-shell{padding:12px;overflow:auto;position:relative}.map-canvas{display:block;border-radius:12px;background:#f8f8f8}.sidebar{display:flex;flex-direction:column;gap:14px}.card{padding:16px}.label,.card-title{display:block;margin-bottom:10px;color:#2c2c2c;font-size:14px;font-weight:650}.input{width:100%;border:1px solid rgb(0 0 0 / 0.18);background:#fafafa;color:#2c2c2c;border-radius:12px;padding:9px 11px;outline:none}.input:focus{border-color:rgb(66 133 244 / 0.6)}.primary-button,.mode-button{border:0;border-radius:12px;padding:9px 11px;cursor:pointer}.primary-button{margin-top:10px;width:100%;background:#1a73e8;color:#fff;font-weight:650}.primary-button:hover{background:#1557b0}.mode-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.mode-button{background:#f1f3f4;color:#3c4043;border:1px solid transparent}.mode-button:hover{background:#e8eaed}.mode-button.active{background:#1a73e8;color:#fff;font-weight:650;border:1px solid #1a73e8}.checkbox-row{display:flex;gap:8px;align-items:center;margin-top:12px;color:#3c4043;font-size:14px}.stats{display:flex;flex-direction:column;gap:7px}.stat-row{display:flex;justify-content:space-between;gap:12px;color:#5f6368;font-size:13px;align-items:baseline}.stat-row strong{color:#202124;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}.legend{color:#5f6368;font-size:12px;line-height:1.65}.legend p{margin:8px 0 0}.example{margin:10px 0;padding:10px;background:#f8f9fa;border:1px solid rgb(0 0 0 / 0.1);border-radius:10px;color:#3c4043;overflow:auto}.id-list{max-height:220px;overflow:auto;margin-top:10px;display:flex;flex-direction:column;gap:6px}.id-row{display:grid;grid-template-columns:112px 1fr;gap:8px;align-items:center;color:#5f6368;font-size:12px}@media (max-width:1100px){.layout{grid-template-columns:1fr}}
.legend-grid{display:flex;flex-direction:column;gap:7px;margin-top:8px}.legend-row{display:grid;grid-template-columns:30px 1fr;gap:8px;align-items:center;min-height:20px}.legend-line{display:inline-block;width:28px;height:0;border-top:3px solid #777;border-radius:999px}.legend-swatch{display:inline-block;width:26px;height:14px;border-radius:5px;background:#f5f5f5}.border-swatch{border:2px solid rgba(80,80,80,.8);box-shadow:inset 0 0 0 1px rgba(255,255,255,.9)}.river-major{border-top:4px solid rgba(100,170,210,.95);box-shadow:0 3px 0 rgba(120,180,215,.55)}.rail-line{border-top:3px solid rgba(70,70,70,.95)}.road-line{border-top:3px solid rgba(252,210,90,.95)}.express-line{border-top:5px solid rgba(245,140,60,.95)}.old-road-line{border-top:2px dashed rgba(150,120,90,.75)}.legend-icon{display:inline-block;width:15px;height:15px;justify-self:center;border:2px solid #fff;box-shadow:0 0 0 1px rgb(0 0 0 / .28)}.city-icon{border-radius:50%;background:rgba(230,100,100,.95);width:17px;height:17px}.port-icon{width:0;height:0;border-left:8px solid transparent;border-right:8px solid transparent;border-bottom:15px solid rgba(70,130,200,.95);border-top:0;box-shadow:none;background:transparent}.castle-icon{background:rgba(180,70,70,.96);border-radius:2px}.station-icon{background:#fff;border-color:rgba(60,60,60,.9);border-radius:2px}.industry-icon{background:rgba(110,170,140,.96);border-radius:2px}.newtown-icon{width:0;height:0;border-left:8px solid transparent;border-right:8px solid transparent;border-bottom:15px solid rgba(180,210,240,.95);border-top:0;box-shadow:none;background:transparent}
.legend-swatch{width:18px;height:14px;border-radius:4px;border:1px solid rgb(0 0 0 / 0.18);display:inline-block}.cbd-swatch{background:rgb(224 202 190)}.satellite-icon{background:rgba(215,95,145,0.95);border-radius:999px;border:2px solid #fff}
.legend-line.harbor-line::before{background:#6b86a0;height:3px;top:7px}
.map-tooltip{position:absolute;z-index:20;pointer-events:none;min-width:190px;max-width:270px;background:rgba(255,255,255,.96);border:1px solid rgb(0 0 0 / .16);border-radius:10px;box-shadow:0 8px 24px rgb(0 0 0 / .16);padding:8px 10px;color:#2c2c2c;font-size:12px;line-height:1.5;opacity:0;transform:translateY(2px);transition:opacity .08s ease,transform .08s ease}.map-tooltip.visible{opacity:1;transform:translateY(0)}

18
test.html Normal file
View file

@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Prefecture Map Generator v7 Tests</title>
<style>
body { font-family: ui-sans-serif, system-ui, sans-serif; background: #111; color: #eee; padding: 24px; }
pre { background: #222; border-radius: 8px; padding: 16px; white-space: pre-wrap; }
.ok { color: #9fdf9f; }
.ng { color: #ff9f9f; }
</style>
</head>
<body>
<h1>Tests</h1>
<pre id="result">Running...</pre>
<script type="module" src="./test.js"></script>
</body>
</html>

152
test.js Normal file
View file

@ -0,0 +1,152 @@
import { generateMap, MAP_W, MAP_H, indexOf } from "./mapGenerator.js";
const result = document.getElementById("result");
const logLines = [];
let failed = 0;
function assert(condition, message) {
if (condition) logLines.push(`OK: ${message}`);
else {
failed += 1;
logLines.push(`NG: ${message}`);
}
}
try {
const map = generateMap(12345);
const other = generateMap(54321);
const size = MAP_W * MAP_H;
const urbanCellCount = [...map.landuse].filter((value) => value >= 2 && value <= 8).length;
const cityPopulations = map.modernCities.map((city) => city.population || 0);
const maxPopulation = Math.max(...cityPopulations);
const minPopulation = Math.min(...cityPopulations);
const landElevations = [...map.elevation].filter((_, i) => !map.sea[i]);
const meanElevation = landElevations.reduce((sum, value) => sum + value, 0) / landElevations.length;
const elevationStdDev = Math.sqrt(landElevations.reduce((sum, value) => sum + (value - meanElevation) ** 2, 0) / landElevations.length);
const modernPaths = [
...map.railways,
...map.branchRailways,
...map.externalRailways,
...(map.ringRailways || []),
...map.nationalRoads,
...(map.ringRoads || []),
...map.expressways,
...(map.ringExpressways || []),
...map.externalRoads,
...map.externalExpressways,
];
const endpointDegree = new Map();
for (const path of modernPaths) {
if (path.length < 2) continue;
for (const point of [path[0], path[path.length - 1]]) {
const key = point.join(",");
endpointDegree.set(key, (endpointDegree.get(key) || 0) + 1);
}
}
const maxModernEndpointDegree = Math.max(0, ...endpointDegree.values());
let maxCoastalElevationStep = 0;
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
if (map.sea[i]) continue;
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const ni = indexOf(x + dx, y + dy);
if (map.sea[ni]) maxCoastalElevationStep = Math.max(maxCoastalElevationStep, Math.abs(map.elevation[i] - map.elevation[ni]));
}
}
}
let railExpressHighMountainCells = 0;
for (const path of [...map.railways, ...map.branchRailways, ...(map.ringRailways || []), ...map.externalRailways, ...map.expressways, ...(map.ringExpressways || []), ...map.externalExpressways]) {
for (const [x, y] of path) {
const i = indexOf(x, y);
if (map.elevation[i] > 0.82) railExpressHighMountainCells += 1;
}
}
assert(map.elevation.length === size, "elevation length matches map size");
assert(map.sea.length === size, "sea length matches map size");
assert(map.river.length === size, "river length matches map size");
assert(map.landuse.length === size, "land-use length matches map size");
assert(map.adminId.length === size, "municipal id length matches map size");
assert(map.prefectureMask.length === size, "prefecture mask length matches map size");
assert(map.populationDensity.length === size, "population density length matches map size");
assert(map.ridgeField.length === size && map.valleyField.length === size && map.flowAccum.length === size, "causal terrain fields match map size");
assert(map.erosionField.length === size && map.depositionField.length === size, "erosion and deposition fields match map size");
assert(Array.isArray(map.bridges) && Array.isArray(map.tunnels) && Array.isArray(map.harborWorks), "legacy bridge/tunnel arrays and harbor arrays exist");
assert(map.prefectureRegionId.length === size && Array.isArray(map.regionalPrefectureBorders), "neighbor prefecture regions exist");
assert(Array.isArray(map.tributaryRivers) && Array.isArray(map.smallStreams), "river hierarchy arrays exist");
assert(Array.isArray(map.icAccessRoads), "IC access road array exists");
assert(Array.isArray(map.satelliteCities), "satelliteCities is an array");
assert(Array.isArray(map.ringRoads) && Array.isArray(map.ringExpressways) && Array.isArray(map.ringRailways), "ring transport arrays exist");
assert(Array.isArray(map.mainRivers), "mainRivers is an array");
assert(Array.isArray(map.minorRoads), "minorRoads is an array");
assert(Array.isArray(map.externalGateways), "externalGateways is an array");
let prefectureComponents = 0;
const seenPrefecture = new Uint8Array(size);
for (let i = 0; i < size; i++) {
if (!map.prefectureMask[i] || seenPrefecture[i]) continue;
prefectureComponents++;
const queue = [i];
seenPrefecture[i] = 1;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const x = cur % MAP_W;
const y = Math.floor(cur / MAP_W);
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue;
const ni = ny * MAP_W + nx;
if (!map.prefectureMask[ni] || seenPrefecture[ni]) continue;
seenPrefecture[ni] = 1;
queue.push(ni);
}
}
}
}
assert(prefectureComponents === 1, "prefecture area is a single connected component");
assert(map.prefectureBorder.length > 0, "prefecture border exists");
assert(map.adminBorders.length > 0, "municipal borders exist");
assert(map.mainRivers.length > 0, "at least one major river exists");
assert(map.tributaryRivers.length > 0, "tributary river network exists");
assert(map.smallStreams.length > 0, "small stream network exists");
assert(map.harborWorks.length <= map.ports.length, "harbor works are attached to ports");
assert(map.ports.some((p) => p.portClass === "major"), "at least one major port is classified");
assert(map.ports.every((p) => ["major", "regional", "fishing", "lake"].includes(p.portClass)), "ports have explicit classes");
assert(map.externalGateways.length > 0, "external gateways exist");
assert(map.minorRoads.length > 0, "minor roads exist");
assert(map.adminCenters.length >= 12, "municipality count is sufficiently large");
assert(urbanCellCount > 1000, "large-city urbanized cells are broad enough");
const cbdCells = [...map.landuse].filter((value) => value === 3).length;
assert(cbdCells > 0, "CBD is represented as land-use cells rather than markers");
assert(map.modernCities.every((city) => Number.isFinite(city.population) && city.population > 0), "modern cities have population properties");
assert(maxPopulation / Math.max(1, minPopulation) > 3, "city populations vary strongly");
assert(map.totalPopulation >= cityPopulations.reduce((sum, value) => sum + value, 0), "total population includes city and satellite populations");
assert(Math.max(...map.populationDensity) > 0.9, "population density is normalized and populated");
assert(elevationStdDev > 0.18, "terrain relief has sufficient contrast");
assert(maxCoastalElevationStep < 0.12, "coastline and elevation do not create cliff artifacts");
assert(railExpressHighMountainCells === 0, "railways and expressways avoid huge mountain cells");
assert(maxModernEndpointDegree <= 10, "modern transport endpoints are not over-centralized");
assert(map.entitiesForNames.every((item) => item.id && item.name), "nameable entities have ids and names");
assert(
map.adminCenters.length !== other.adminCenters.length ||
map.villages.length !== other.villages.length ||
map.markets.length !== other.markets.length,
"feature counts vary between seeds"
);
const againA = generateMap(999);
const againB = generateMap(999);
assert(JSON.stringify(againA.modernCities) === JSON.stringify(againB.modernCities), "generation is deterministic for the same seed");
result.className = failed === 0 ? "ok" : "ng";
result.textContent = `${failed === 0 ? "All tests passed." : `${failed} tests failed.`}\n\n${logLines.join("\n")}`;
} catch (error) {
result.className = "ng";
result.textContent = String(error?.stack || error);
}