first commit
This commit is contained in:
commit
32398c13e8
9 changed files with 4125 additions and 0 deletions
46
README.md
Normal file
46
README.md
Normal 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
249
app.js
Normal 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
98
index.html
Normal 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
3024
mapGenerator.js
Normal file
File diff suppressed because it is too large
Load diff
34
names.js
Normal file
34
names.js
Normal 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
493
renderer.js
Normal 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
11
styles.css
Normal 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
18
test.html
Normal 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
152
test.js
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue