403 lines
15 KiB
JavaScript
403 lines
15 KiB
JavaScript
import { generateMapAsync } from "./mapGenerator.js";
|
|
import { drawMap } from "./renderer.js";
|
|
import { landuseLabel } from "./landuseCodes.js";
|
|
|
|
const modes = [
|
|
["all", "All"],
|
|
["terrain", "Terrain"],
|
|
["history", "Premodern"],
|
|
["modern", "Modern"],
|
|
["development", "Development"],
|
|
["landuse", "Land Use"],
|
|
["admin", "Municipal Borders"],
|
|
["borders-debug", "Borders Debug"],
|
|
["transport-debug", "Transport Debug"],
|
|
];
|
|
|
|
const state = {
|
|
seedText: "114514",
|
|
generationType: "auto",
|
|
mode: "all",
|
|
showFeatures: true,
|
|
showLabels: true,
|
|
map: null,
|
|
hoverEntities: [],
|
|
};
|
|
|
|
const canvas = document.getElementById("mapCanvas");
|
|
const canvasShell = document.querySelector(".canvas-shell");
|
|
const seedInput = document.getElementById("seed");
|
|
const generationTypeInput = document.getElementById("generationType");
|
|
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 tooltipEl = document.getElementById("mapTooltip");
|
|
const progressEl = document.getElementById("generationProgress");
|
|
const progressStageEl = document.getElementById("generationProgressStage");
|
|
const progressTimingsEl = document.getElementById("generationProgressTimings");
|
|
let generationStartedAt = 0;
|
|
let generationCurrentStage = "";
|
|
let generationTimer = null;
|
|
|
|
const panState = { keys: new Set(), raf: null, lastTime: 0, speedPxPerSecond: 520 };
|
|
|
|
function mapClientToCell(event) {
|
|
if (!state.map) return null;
|
|
const rect = canvas.getBoundingClientRect();
|
|
if (!rect.width || !rect.height) return null;
|
|
const relX = (event.clientX - rect.left) / rect.width;
|
|
const relY = (event.clientY - rect.top) / rect.height;
|
|
return {
|
|
x: Math.floor(relX * state.map.width),
|
|
y: Math.floor(relY * state.map.height),
|
|
};
|
|
}
|
|
|
|
function isEditableTarget(target) {
|
|
if (!target) return false;
|
|
const tag = target.tagName?.toLowerCase?.();
|
|
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
|
|
}
|
|
|
|
function panFrame(time) {
|
|
if (!canvasShell || panState.keys.size === 0) {
|
|
panState.raf = null;
|
|
panState.lastTime = 0;
|
|
return;
|
|
}
|
|
const dt = panState.lastTime ? Math.min(0.05, (time - panState.lastTime) / 1000) : 0;
|
|
panState.lastTime = time;
|
|
let dx = 0;
|
|
let dy = 0;
|
|
if (panState.keys.has("a")) dx -= 1;
|
|
if (panState.keys.has("d")) dx += 1;
|
|
if (panState.keys.has("w")) dy -= 1;
|
|
if (panState.keys.has("s")) dy += 1;
|
|
if (dx || dy) {
|
|
const normalizer = dx && dy ? Math.SQRT1_2 : 1;
|
|
const amount = panState.speedPxPerSecond * dt;
|
|
canvasShell.scrollLeft += dx * normalizer * amount;
|
|
canvasShell.scrollTop += dy * normalizer * amount;
|
|
tooltipEl?.classList.remove("visible");
|
|
}
|
|
panState.raf = requestAnimationFrame(panFrame);
|
|
}
|
|
|
|
function startKeyboardPan() {
|
|
if (panState.raf == null) panState.raf = requestAnimationFrame(panFrame);
|
|
}
|
|
|
|
function handlePanKeyDown(event) {
|
|
const key = event.key?.toLowerCase?.();
|
|
if (!key || !"wasd".includes(key) || isEditableTarget(event.target)) return;
|
|
panState.keys.add(key);
|
|
startKeyboardPan();
|
|
event.preventDefault();
|
|
}
|
|
|
|
function handlePanKeyUp(event) {
|
|
const key = event.key?.toLowerCase?.();
|
|
if (!key || !"wasd".includes(key)) return;
|
|
panState.keys.delete(key);
|
|
event.preventDefault();
|
|
}
|
|
|
|
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 formatMs(ms) {
|
|
if (!Number.isFinite(ms)) return "-";
|
|
return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${Math.round(ms)}ms`;
|
|
}
|
|
|
|
function renderTimingRows(timings = []) {
|
|
if (!progressTimingsEl) return;
|
|
progressTimingsEl.innerHTML = "";
|
|
for (const row of timings) {
|
|
const item = document.createElement("div");
|
|
item.className = "progress-timing-row";
|
|
const label = document.createElement("span");
|
|
label.textContent = row.label;
|
|
const value = document.createElement("strong");
|
|
value.textContent = formatMs(row.ms);
|
|
item.append(label, value);
|
|
progressTimingsEl.append(item);
|
|
}
|
|
}
|
|
|
|
function updateGenerationProgress(event) {
|
|
if (!progressEl) return;
|
|
progressEl.classList.remove("hidden");
|
|
if (event?.status === "start") generationCurrentStage = event.label || "Preparing";
|
|
if (progressStageEl) {
|
|
const elapsed = generationStartedAt ? ` / elapsed ${formatMs(performance.now() - generationStartedAt)}` : "";
|
|
progressStageEl.textContent = event?.status === "done"
|
|
? `Completed: ${event.label} / ${formatMs(event.ms)}${elapsed}`
|
|
: `Running: ${event?.label || generationCurrentStage || "Preparing"}${elapsed}`;
|
|
}
|
|
renderTimingRows(event?.timings || []);
|
|
}
|
|
|
|
function setProgressVisible(visible, message = "Preparing") {
|
|
if (!progressEl) return;
|
|
progressEl.classList.toggle("hidden", !visible);
|
|
if (visible) {
|
|
generationStartedAt = performance.now();
|
|
generationCurrentStage = message;
|
|
if (generationTimer) window.clearInterval(generationTimer);
|
|
generationTimer = window.setInterval(() => {
|
|
if (progressStageEl && !progressEl.classList.contains("hidden")) {
|
|
progressStageEl.textContent = `Running: ${generationCurrentStage || "Preparing"} / elapsed ${formatMs(performance.now() - generationStartedAt)}`;
|
|
}
|
|
}, 100);
|
|
} else if (generationTimer) {
|
|
window.clearInterval(generationTimer);
|
|
generationTimer = null;
|
|
}
|
|
if (progressStageEl) progressStageEl.textContent = message;
|
|
if (visible) renderTimingRows([]);
|
|
}
|
|
|
|
function nextFrame() {
|
|
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
}
|
|
|
|
function getStats(map) {
|
|
return [
|
|
["Map Type", map.terrainTemplate?.terrainTypeLabel || map.terrainDebug?.terrainTypeLabel || "-"],
|
|
["Terrain ID", map.terrainTemplate?.terrainType || map.terrainDebug?.terrainType || "-"],
|
|
["Generation Total", map.generationTotalMs ? formatMs(map.generationTotalMs) : "-"],
|
|
["Geography Basis", map.geographyDebug?.version ? `${map.geographyDebug.version} / ${map.geographyDebug.stage || "-"}` : "-"],
|
|
...(map.generationTimings || []).map((row) => [`Time: ${row.label}`, formatMs(row.ms)]),
|
|
["Villages", countText(map.villages)],
|
|
["Market Towns", countText(map.markets)],
|
|
["Castles", countText(map.castles)],
|
|
["Premodern Roads", map.premodernRoads.length],
|
|
["Minor Roads", map.minorRoads.length],
|
|
["Prefecture", map.prefectureName || "-"],
|
|
["Neighbor Prefectures", (map.neighborPrefectures || []).map((p) => p.name).join(" / ") || "-"],
|
|
["Neighbor Features", map.neighborPrefectureDetails ? `${map.neighborPrefectureDetails.cities?.length || 0} cities / ${map.neighborPrefectureDetails.adminCenters?.length || 0} municipalities / ${map.neighborPrefectureDetails.roads?.length || 0} roads` : "-"],
|
|
["Prefectural Capital", map.prefecturalCapital?.name || "-"],
|
|
["Regional Capitals", (map.modernCities || []).filter((p) => p.isRegionalCapital).length],
|
|
["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],
|
|
["Rail Lines", map.railways.length + map.branchRailways.length + (map.ringRailways || []).length + map.externalRailways.length],
|
|
["Industrial Zones", countText(map.industrialZones)],
|
|
["National Roads", `${map.nationalRoads.length} / pop cover ${Math.round((map.transportDebug?.nationalRoadPopulationCoverage || 0) * 100)}% / uncovered ${(map.transportDebug?.nationalRoadUncoveredPopulation || 0).toLocaleString()}`],
|
|
["General Ring Roads", (map.ringRoads || []).length],
|
|
["Expressways", map.expressways.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],
|
|
["Prefecture source", map.regionalDebug?.prefectureSource ?? "-"],
|
|
];
|
|
}
|
|
|
|
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 buildHoverEntities(map) {
|
|
return [
|
|
...(map.modernCities || []),
|
|
...(map.ports || []),
|
|
...(map.stations || []),
|
|
...(map.interchanges || []),
|
|
...(map.industrialZones || []),
|
|
...(map.logisticsParks || []),
|
|
...(map.newTowns || []),
|
|
...(map.castles || []),
|
|
...(map.markets || []),
|
|
...(map.villages || []),
|
|
...(map.adminCenters || []),
|
|
];
|
|
}
|
|
|
|
function nearestEntity(items, x, y, maxDistance = 5) {
|
|
let best = null;
|
|
let bestD = maxDistance;
|
|
for (const item of items) {
|
|
const d = Math.hypot(item.x - x, item.y - y);
|
|
if (d < bestD) { best = item; bestD = d; }
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function landuseName(value) {
|
|
return landuseLabel(value);
|
|
}
|
|
|
|
function adminName(map, adminId) {
|
|
const center = (map.adminCenters || [])[adminId];
|
|
return center?.name || (adminId >= 0 ? `Municipality ${adminId + 1}` : "-");
|
|
}
|
|
|
|
function adminPopulation(map, adminId) {
|
|
const center = (map.adminCenters || [])[adminId];
|
|
return Number.isFinite(center?.municipalityPopulation) ? center.municipalityPopulation : null;
|
|
}
|
|
|
|
function prefectureNameForCell(map, i) {
|
|
const id = map.prefectureRegionId?.[i] ?? -1;
|
|
const region = (map.prefectureRegions || []).find((p) => p.id === id);
|
|
return region?.name || (id >= 0 ? `Prefecture ${id + 1}` : "-");
|
|
}
|
|
|
|
function updateTooltip(event) {
|
|
if (!state.map || !tooltipEl) return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const cell = mapClientToCell(event);
|
|
if (!cell) return;
|
|
const { x, y } = cell;
|
|
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.hoverEntities, x, y);
|
|
const elevation = state.map.elevation?.[i] ?? 0;
|
|
const density = state.map.populationDensity?.[i] ?? 0;
|
|
const hoveredAdminId = state.map.adminId?.[i] ?? -1;
|
|
const hoveredAdminPopulation = adminPopulation(state.map, hoveredAdminId);
|
|
const lines = [
|
|
`<strong>${entity ? `${entity.name} / ${entity.kind}` : `${x}, ${y}`}</strong>`,
|
|
`Prefecture: ${prefectureNameForCell(state.map, i)}`,
|
|
`Admin: ${adminName(state.map, hoveredAdminId)}`,
|
|
`Admin Pop: ${hoveredAdminPopulation === null ? "-" : hoveredAdminPopulation.toLocaleString()}`,
|
|
`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>");
|
|
const margin = 8;
|
|
const offset = 14;
|
|
const maxLeft = Math.max(margin, rect.width - tooltipEl.offsetWidth - margin);
|
|
const maxTop = Math.max(margin, rect.height - tooltipEl.offsetHeight - margin);
|
|
const desiredLeft = event.clientX - rect.left + offset;
|
|
const desiredTop = event.clientY - rect.top + offset;
|
|
tooltipEl.style.left = `${Math.min(Math.max(margin, desiredLeft), maxLeft)}px`;
|
|
tooltipEl.style.top = `${Math.min(Math.max(margin, desiredTop), maxTop)}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);
|
|
}
|
|
}
|
|
|
|
async function regenerate() {
|
|
state.seedText = seedInput.value;
|
|
state.generationType = generationTypeInput?.value || "auto";
|
|
setProgressVisible(true, "Preparing generation...");
|
|
await nextFrame();
|
|
try {
|
|
state.map = await generateMapAsync(parseSeed(state.seedText), { onProgress: updateGenerationProgress, terrainType: state.generationType });
|
|
state.hoverEntities = buildHoverEntities(state.map);
|
|
renderStats(state.map);
|
|
redraw();
|
|
if (progressStageEl) progressStageEl.textContent = `Done in ${formatMs(state.map.generationTotalMs || 0)}`;
|
|
renderTimingRows(state.map.generationTimings || []);
|
|
window.setTimeout(() => setProgressVisible(false), 900);
|
|
} catch (error) {
|
|
if (progressStageEl) progressStageEl.textContent = `Generation failed: ${error?.message || error}`;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
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();
|
|
});
|
|
|
|
generationTypeInput?.addEventListener("change", 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();
|
|
});
|
|
|
|
canvasShell?.setAttribute("tabindex", "0");
|
|
window.addEventListener("keydown", handlePanKeyDown);
|
|
window.addEventListener("keyup", handlePanKeyUp);
|
|
canvas.addEventListener("mousemove", updateTooltip);
|
|
canvas.addEventListener("mouseleave", () => {
|
|
tooltipEl?.classList.remove("visible");
|
|
});
|
|
|
|
regenerate();
|
|
}
|
|
|
|
init();
|