map/renderer.js
2026-05-26 16:56:18 +09:00

977 lines
36 KiB
JavaScript

import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf } from "./mapUtils.js";
const segmentVectorCache = new WeakMap();
const pathVectorCache = new WeakMap();
const coastlineCache = new WeakMap();
const baseImageCache = new WeakMap();
const MAX_BASE_CACHE_IMAGES = 4;
function pointKey(p) {
return `${p[0]},${p[1]}`;
}
function parsePointKey(key) {
return key.split(",").map(Number);
}
function samePoint(a, b, eps = 1e-6) {
return Math.abs(a[0] - b[0]) <= eps && Math.abs(a[1] - b[1]) <= eps;
}
function perpendicularDistance(p, a, b) {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const len2 = dx * dx + dy * dy;
if (len2 <= 1e-9) return Math.hypot(p[0] - a[0], p[1] - a[1]);
const t = clamp(((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2, 0, 1);
const x = a[0] + dx * t;
const y = a[1] + dy * t;
return Math.hypot(p[0] - x, p[1] - y);
}
function simplifyRdp(points, tolerance = 0.05) {
if (!points || points.length <= 2) return points || [];
let bestIndex = -1;
let bestDistance = -1;
const first = points[0];
const last = points[points.length - 1];
for (let i = 1; i < points.length - 1; i++) {
const d = perpendicularDistance(points[i], first, last);
if (d > bestDistance) {
bestDistance = d;
bestIndex = i;
}
}
if (bestDistance <= tolerance) return [first, last];
const left = simplifyRdp(points.slice(0, bestIndex + 1), tolerance);
const right = simplifyRdp(points.slice(bestIndex), tolerance);
return left.slice(0, -1).concat(right);
}
function removeCollinear(points) {
if (!points || points.length <= 2) return points || [];
const closed = samePoint(points[0], points[points.length - 1]);
const core = closed ? points.slice(0, -1) : points.slice();
if (core.length <= 2) return points;
const out = [];
const count = core.length;
for (let i = 0; i < count; i++) {
const prev = core[(i - 1 + count) % count];
const cur = core[i];
const next = core[(i + 1) % count];
if (!closed && (i === 0 || i === count - 1)) {
out.push(cur);
continue;
}
const ax = cur[0] - prev[0];
const ay = cur[1] - prev[1];
const bx = next[0] - cur[0];
const by = next[1] - cur[1];
if (Math.abs(ax * by - ay * bx) > 1e-9) out.push(cur);
}
if (closed && out.length) out.push(out[0]);
return out;
}
function chaikin(points, iterations = 1, closed = false) {
if (!points || points.length < 3 || iterations <= 0) return points || [];
let result = points.slice();
for (let iter = 0; iter < iterations; iter++) {
const src = closed && samePoint(result[0], result[result.length - 1]) ? result.slice(0, -1) : result;
if (src.length < 3) break;
const next = [];
if (!closed) next.push(src[0]);
const limit = closed ? src.length : src.length - 1;
for (let i = 0; i < limit; i++) {
const a = src[i];
const b = src[(i + 1) % src.length];
next.push([a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25]);
next.push([a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75]);
}
if (!closed) next.push(src[src.length - 1]);
if (closed && next.length) next.push(next[0]);
result = next;
}
return result;
}
function traceSegmentChain(edges, adjacency, used, startKey, firstEdgeIndex) {
const line = [parsePointKey(startKey)];
let currentKey = startKey;
let nextEdgeIndex = firstEdgeIndex;
while (nextEdgeIndex !== undefined && nextEdgeIndex !== null && !used[nextEdgeIndex]) {
used[nextEdgeIndex] = 1;
const edge = edges[nextEdgeIndex];
const toKey = edge.aKey === currentKey ? edge.bKey : edge.aKey;
line.push(parsePointKey(toKey));
currentKey = toKey;
if (currentKey === startKey) break;
const degree = adjacency.get(currentKey)?.length || 0;
if (degree !== 2) break;
nextEdgeIndex = (adjacency.get(currentKey) || []).find((idx) => !used[idx]);
}
return line;
}
function segmentChains(segments) {
if (!segments?.length) return [];
const edges = [];
const adjacency = new Map();
for (const seg of segments) {
const aKey = pointKey(seg[0]);
const bKey = pointKey(seg[1]);
if (aKey === bKey) continue;
const edgeIndex = edges.length;
edges.push({ aKey, bKey });
if (!adjacency.has(aKey)) adjacency.set(aKey, []);
if (!adjacency.has(bKey)) adjacency.set(bKey, []);
adjacency.get(aKey).push(edgeIndex);
adjacency.get(bKey).push(edgeIndex);
}
const used = new Uint8Array(edges.length);
const chains = [];
for (let i = 0; i < edges.length; i++) {
if (used[i]) continue;
const edge = edges[i];
const aDegree = adjacency.get(edge.aKey)?.length || 0;
const bDegree = adjacency.get(edge.bKey)?.length || 0;
if (aDegree === 2 && bDegree === 2) continue;
const startKey = aDegree !== 2 ? edge.aKey : edge.bKey;
chains.push(traceSegmentChain(edges, adjacency, used, startKey, i));
}
for (let i = 0; i < edges.length; i++) {
if (used[i]) continue;
chains.push(traceSegmentChain(edges, adjacency, used, edges[i].aKey, i));
}
return chains.filter((line) => line.length >= 2);
}
function vectorizeSegments(segments, { iterations = 2, tolerance = 0.08 } = {}) {
if (!segments?.length) return [];
const cacheKey = `${iterations}:${tolerance}`;
let cachedByOption = segmentVectorCache.get(segments);
if (!cachedByOption) {
cachedByOption = new Map();
segmentVectorCache.set(segments, cachedByOption);
}
if (cachedByOption.has(cacheKey)) return cachedByOption.get(cacheKey);
const polylines = segmentChains(segments).map((line) => {
const cleaned = removeCollinear(line);
const closed = cleaned.length > 2 && samePoint(cleaned[0], cleaned[cleaned.length - 1]);
const smoothed = chaikin(cleaned, iterations, closed);
if (closed) return smoothed;
return simplifyRdp(smoothed, tolerance);
}).filter((line) => line.length >= 2);
cachedByOption.set(cacheKey, polylines);
return polylines;
}
function getCoastlineSegments(map) {
if (!map?.sea) return [];
const cached = coastlineCache.get(map.sea);
if (cached) return cached;
const segments = [];
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
const a = Boolean(map.sea[i]);
if (x + 1 < MAP_W) {
const b = Boolean(map.sea[indexOf(x + 1, y)]);
if (a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
}
if (y + 1 < MAP_H) {
const b = Boolean(map.sea[indexOf(x, y + 1)]);
if (a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]);
}
}
}
coastlineCache.set(map.sea, segments);
return segments;
}
function vectorPath(path) {
if (!path || path.length < 2) return [];
const cached = pathVectorCache.get(path);
if (cached) return cached;
const points = path.map(([x, y]) => [x * CELL_SIZE + CELL_SIZE / 2, y * CELL_SIZE + CELL_SIZE / 2]);
const simplified = simplifyRdp(points, CELL_SIZE * 0.34);
const smoothed = chaikin(simplified, path.length > 6 ? 1 : 0, false);
pathVectorCache.set(path, smoothed);
return smoothed;
}
function drawPolylinePoints(ctx, points) {
if (!points || points.length < 2) return;
ctx.moveTo(points[0][0], points[0][1]);
for (let k = 1; k < points.length; k++) ctx.lineTo(points[k][0], points[k][1]);
}
function drawVectorSegments(ctx, segments, color, width, dashed = false, vectorOptions = {}) {
const { offsetX = 0, offsetY = 0, ...shapeOptions } = vectorOptions || {};
const polylines = vectorizeSegments(segments, shapeOptions);
if (!polylines.length) return;
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = "round";
ctx.lineJoin = "round";
if (dashed) ctx.setLineDash([6, 5]);
for (const line of polylines) {
ctx.beginPath();
ctx.moveTo((line[0][0] + offsetX) * CELL_SIZE, (line[0][1] + offsetY) * CELL_SIZE);
for (let k = 1; k < line.length; k++) ctx.lineTo((line[k][0] + offsetX) * CELL_SIZE, (line[k][1] + offsetY) * CELL_SIZE);
ctx.stroke();
}
ctx.restore();
}
function sampleCellIndex(fx, fy) {
const x = Math.max(0, Math.min(MAP_W - 1, Math.floor(fx)));
const y = Math.max(0, Math.min(MAP_H - 1, Math.floor(fy)));
return indexOf(x, y);
}
function isWaterSample(map, fx, fy) {
// The generated terrain arrays are cell-centered, while pixels are drawn across
// each cell. Water/land classification must therefore follow the discrete sea
// mask, not the interpolated elevation value. Interpolating elevation near a
// coast makes the right/bottom side of land cells inherit sea values and leaves
// visible unpainted strips inside the smoothed coastline.
return Boolean(map.sea[sampleCellIndex(fx, fy)]);
}
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.8 + 50),
Math.round(color[1] * 0.8 + 50),
Math.round(color[2] * 0.8 + 50),
];
}
function fieldSample(field, fx, fy) {
const sx = Math.max(0, Math.min(MAP_W - 1, fx));
const sy = Math.max(0, Math.min(MAP_H - 1, fy));
const x0 = Math.floor(sx);
const y0 = Math.floor(sy);
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 = sx - x0;
const ty = sy - 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 interpolateColorStops(value, stops) {
if (value <= stops[0][0]) return stops[0][1].slice();
for (let i = 1; i < stops.length; i++) {
const [v, c] = stops[i];
const [pv, pc] = stops[i - 1];
if (value <= v) {
const t = clamp((value - pv) / Math.max(0.0001, v - pv));
return [
Math.round(pc[0] + (c[0] - pc[0]) * t),
Math.round(pc[1] + (c[1] - pc[1]) * t),
Math.round(pc[2] + (c[2] - pc[2]) * t),
];
}
}
return stops[stops.length - 1][1].slice();
}
function terrainColorContinuous(map, fx, fy, mode) {
const i = sampleCellIndex(fx, fy);
const isInside = Boolean(map.prefectureMask[i]);
let color;
if (isWaterSample(map, fx, fy)) {
const depth = clamp(((map.seaLevel ?? 0.285) + 0.065 - fieldSample(map.elevation, fx, fy)) * 2.4);
color = [Math.round(170 + depth * 5), Math.round(218 + depth * 10), Math.round(255 - depth * 5)];
} else if (mode === "development") {
const dCity = distToNearest(map.modernCities, fx, fy);
const urban = clamp(1 - dCity / 25);
const density = map.populationDensity ? fieldSample(map.populationDensity, fx, fy) : urban;
const base = 235;
color = [
Math.round(base + density * 20),
Math.round(base + density * 5),
Math.round(230 + density * 10),
];
} else {
// 地形の基底色は標高のみに従わせる。
// 谷や微地形の見え方は陰影側で制御し、谷底だけが不自然に茶色化しないようにする。
const e = fieldSample(map.elevation, fx, fy);
color = interpolateColorStops(clamp(e), [
[0.20, [231, 236, 223]],
[0.30, [223, 231, 214]],
[0.40, [213, 223, 201]],
[0.50, [204, 215, 188]],
[0.58, [195, 207, 173]],
[0.65, [185, 196, 158]],
[0.71, [177, 181, 141]],
[0.76, [169, 164, 125]],
[0.81, [157, 145, 105]],
[0.86, [144, 128, 89]],
[0.91, [130, 111, 79]],
[0.95, [118, 103, 89]],
[0.985, [146, 141, 133]],
[1.00, [183, 179, 171]],
]);
}
return blendOutside(color, isInside);
}
function terrainShadeContinuous(map, fx, fy) {
const step = 0.50;
const eC = fieldSample(map.elevation, fx, fy);
const eL = fieldSample(map.elevation, fx - step, fy);
const eR = fieldSample(map.elevation, fx + step, fy);
const eU = fieldSample(map.elevation, fx, fy - step);
const eD = fieldSample(map.elevation, fx, fy + step);
// x は東向き, y は南向き。法線は (-dz/dx, -dz/dy, 1)。
// 描画では地形生成上の差分をやや誇張し、粗いDEMでも山腹の起伏を読ませる。
const dzdx = (eR - eL) / (step * 2);
const dzdy = (eD - eU) / (step * 2);
const nx = -dzdx * 4.4;
const ny = -dzdy * 4.4;
const nz = 1.0;
const nLen = Math.hypot(nx, ny, nz) || 1;
const lx = -0.5;
const ly = -0.5;
const lz = 0.7071067811865476;
const hill = clamp((nx * lx + ny * ly + nz * lz) / nLen * 0.58 + 0.48);
const slope = map.slope ? fieldSample(map.slope, fx, fy) : 0;
const valley = map.valleyField ? fieldSample(map.valleyField, fx, fy) : 0;
const ravine = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy) : 0;
const tex = map.surfaceTextureField ? fieldSample(map.surfaceTextureField, fx, fy) : 0;
const rvL = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx - 0.90, fy) : 0;
const rvR = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx + 0.90, fy) : 0;
const rvU = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy - 0.90) : 0;
const rvD = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy + 0.90) : 0;
const ravineRelief = (rvL - rvR) * 0.26 + (rvU - rvD) * 0.20;
const concavity = clamp(((eL + eR + eU + eD) * 0.25 - eC) * 9.0, -0.18, 0.18);
// 谷底の色保持は少し残すが、以前より圧縮を弱めて陰影の振幅を大きくする。
const valleyFloor = clamp((valley - 0.18) * 1.45) * clamp((0.24 - slope) * 3.6);
let shade = 0.66 + hill * 0.50 + ravineRelief - ravine * 0.12 - tex * 0.042 - concavity * 0.16 + slope * 0.030;
if (shade < 1) shade = 1 - (1 - shade) * (1 - valleyFloor * 0.26);
else shade = 1 + (shade - 1) * (1 - valleyFloor * 0.12);
return clamp(shade, 0.54, 1.26);
}
function discreteColor(map, x, y, mode) {
const i = indexOf(x, y);
let color;
if (map.sea[i]) {
color = [160, 205, 239];
} else if (mode === "landuse") {
const colors = {
0: [244, 247, 240], // rural / natural land
1: [222, 236, 188], // farmland
2: [232, 222, 214], // old urban
3: [221, 188, 184], // CBD / DID core
4: [235, 225, 236], // suburb
5: [223, 224, 232], // industrial
6: [232, 237, 232], // logistics
7: [231, 236, 246], // new town
8: [243, 233, 210], // roadside
9: [221, 236, 216], // forest / mountain land
};
color = colors[map.landuse[i]] || colors[0];
} else if (mode === "admin") {
const palette = [
[250, 245, 242], [245, 250, 245], [245, 245, 252],
[252, 250, 242], [250, 245, 250], [242, 250, 250]
];
const a = map.adminId[i];
color = a >= 0 ? palette[a % palette.length] : [240, 242, 240];
} else {
color = terrainColorContinuous(map, x, y, "terrain");
}
return blendOutside(color, Boolean(map.prefectureMask[i]));
}
function baseCacheKey(mode, continuousTerrain) {
const continuousModes = ["terrain", "development", "all"];
if (continuousTerrain && continuousModes.includes(mode)) {
return `continuous:${mode === "all" ? "terrain" : mode}`;
}
return `discrete:${mode}`;
}
function getCachedBaseImage(ctx, map, mode, continuousTerrain) {
let cache = baseImageCache.get(map);
if (!cache) {
cache = new Map();
baseImageCache.set(map, cache);
}
const key = baseCacheKey(mode, continuousTerrain);
let image = cache.get(key);
if (image) return image;
const width = MAP_W * CELL_SIZE;
const height = MAP_H * CELL_SIZE;
const img = ctx.createImageData(width, height);
const continuousModes = ["terrain", "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 shade = terrainShadeContinuous(map, fx, fy);
const ii = (py * width + px) * 4;
img.data[ii] = Math.round(r * shade);
img.data[ii + 1] = Math.round(g * shade);
img.data[ii + 2] = Math.round(b * shade);
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;
}
}
}
}
}
cache.set(key, img);
if (cache.size > MAX_BASE_CACHE_IMAGES) cache.delete(cache.keys().next().value);
return img;
}
function drawBase(ctx, map, mode, continuousTerrain) {
ctx.putImageData(getCachedBaseImage(ctx, map, mode, continuousTerrain), 0, 0);
}
function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) {
if (!path || path.length < 2) return;
ctx.save();
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (let k = 0; k < path.length - 1; k++) {
const [x1, y1] = path[k];
const [x2, y2] = path[k + 1];
const i1 = indexOf(x1, y1);
const i2 = indexOf(x2, y2);
const strength = Math.max((map.river?.[i1] || 0) + (map.flowAccum?.[i1] || 0) * 0.95, (map.river?.[i2] || 0) + (map.flowAccum?.[i2] || 0) * 0.95);
ctx.strokeStyle = color;
ctx.globalAlpha = alpha;
ctx.lineWidth = widthFn(strength, k / Math.max(1, path.length - 1));
ctx.beginPath();
ctx.moveTo(x1 * CELL_SIZE + CELL_SIZE / 2, y1 * CELL_SIZE + CELL_SIZE / 2);
ctx.lineTo(x2 * CELL_SIZE + CELL_SIZE / 2, y2 * CELL_SIZE + CELL_SIZE / 2);
ctx.stroke();
}
ctx.restore();
}
function drawPath(ctx, path, color, width, dashed = false) {
const points = vectorPath(path);
if (points.length < 2) return;
ctx.save();
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = color;
ctx.lineWidth = width;
if (dashed) ctx.setLineDash([8, 6]);
ctx.beginPath();
drawPolylinePoints(ctx, points);
ctx.stroke();
ctx.restore();
}
// 魚の骨(私鉄記号)スタイルを描画するための専用関数
function drawRailway(ctx, path, color, lineWidth, tickLen, spacing) {
const points = vectorPath(path);
if (points.length < 2) return;
ctx.save();
ctx.lineCap = "butt";
ctx.lineJoin = "round";
ctx.strokeStyle = color;
// 中心の実線を描画
ctx.lineWidth = lineWidth;
ctx.beginPath();
drawPolylinePoints(ctx, points);
ctx.stroke();
// 棘(クロスハッチ)を描画
ctx.lineWidth = 1.0;
ctx.beginPath();
let carry = spacing * 0.5;
for (let k = 0; k < points.length - 1; k++) {
const [x1, y1] = points[k];
const [x2, y2] = points[k + 1];
const dx = x2 - x1;
const dy = y2 - y1;
const dist = Math.hypot(dx, dy);
if (dist <= 0.001) continue;
const nx = dx / dist;
const ny = dy / dist;
const px = -ny * (tickLen / 2);
const py = nx * (tickLen / 2);
let d = carry;
while (d < dist) {
const cx = x1 + nx * d;
const cy = y1 + ny * d;
ctx.moveTo(cx + px, cy + py);
ctx.lineTo(cx - px, cy - py);
d += spacing;
}
carry = d - dist;
}
ctx.stroke();
ctx.restore();
}
function drawSegments(ctx, segments, color, width, dashed = false) {
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = "round";
ctx.lineJoin = "round";
if (dashed) ctx.setLineDash([6, 5]);
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", "admin"];
if (!visibleModes.includes(mode)) return;
const detailedColors = {
2: "rgba(223, 214, 206, 0.72)",
3: "rgba(215, 175, 172, 0.88)",
4: "rgba(231, 219, 231, 0.68)",
5: "rgba(218, 218, 226, 0.64)",
6: "rgba(225, 230, 225, 0.56)",
7: "rgba(229, 234, 242, 0.64)",
8: "rgba(231, 219, 231, 0.68)",
};
const cityColor = "rgba(232, 222, 228, 0.60)";
const cbdColor = "rgba(215, 175, 172, 0.84)";
ctx.save();
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
const areaMask = map.humanRegionMask || map.prefectureMask;
if (areaMask && !areaMask[i]) continue;
const lu = map.landuse[i];
let fill = null;
if (mode === "landuse") fill = detailedColors[lu] || null;
else if (lu === 3) fill = cbdColor;
else if (lu === 2 || lu === 4 || lu === 5 || lu === 6 || lu === 7 || lu === 8) fill = cityColor;
if (!fill) continue;
const px = x * CELL_SIZE;
const py = y * CELL_SIZE;
ctx.fillStyle = fill;
ctx.fillRect(px, py, CELL_SIZE, CELL_SIZE);
}
}
ctx.restore();
}
function drawDebugCells(ctx, map, field, color) {
if (!field) return;
ctx.save();
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
const debugMask = map.humanRegionMask || map.prefectureMask;
if (!debugMask[i] || map.sea[i]) continue;
const v = clamp(field[i] || 0, 0, 1);
if (v <= 0.12) continue;
ctx.fillStyle = color(v);
ctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE);
}
}
ctx.restore();
}
function prefectureRegionColor(id) {
const palette = [
[234, 220, 214], [218, 232, 218], [218, 224, 238], [238, 232, 208],
[232, 218, 232], [214, 232, 234], [235, 224, 216], [222, 236, 210],
];
return palette[Math.abs(id) % palette.length];
}
function drawPrefectureRegionFill(ctx, map, mode) {
if (!["all", "admin", "admin-debug", "borders-debug"].includes(mode)) return;
const ids = map.prefectureRegionId;
if (!ids) return;
const alpha = mode === "borders-debug" ? 0.34 : mode === "admin-debug" ? 0.26 : 0.18;
ctx.save();
ctx.globalAlpha = alpha;
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
const id = ids[i];
if (map.sea[i] || id < 0) continue;
const [r, g, b] = prefectureRegionColor(id);
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
ctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE);
}
}
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.2;
ctx.strokeStyle = stroke;
ctx.stroke();
}
function boxesOverlap(a, b, pad = 3) {
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 = "600 11.5px ui-sans-serif, system-ui, -apple-system, 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, -4], [7, 13], [-textW - 7, -4], [-textW - 7, 13],
[-textW / 2, -12], [-textW / 2, 20], [12, 4], [-textW - 12, 4],
];
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 + 4 };
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.lineJoin = "round";
ctx.lineWidth = 3.5;
ctx.strokeStyle = "rgba(255, 255, 255, 0.95)";
ctx.strokeText(p.name, x, y);
ctx.fillStyle = p.isPrefecturalCapital ? "#111111" : "#333333";
ctx.fillText(p.name, x, y);
occupied.push(box);
ctx.restore();
return true;
}
ctx.restore();
return false;
}
function drawLabels(ctx, points, limit = Infinity) {
const occupied = [];
const prioritized = points
.filter((p) => p?.name)
.map((p) => ({ ...p, labelPriority: (p.labelPriorityBase || 0) + (p.isPrefecturalCapital ? 1000 : 0) + (p.population || 0) / 900 + (p.portClass === "major" ? 170 : p.portClass === "regional" ? 95 : 0) }))
.sort((a, b) => b.labelPriority - a.labelPriority);
for (const p of prioritized.slice(0, limit)) labelWithCollision(ctx, p, occupied);
}
function drawScaleBar(ctx) {
const kmPerCell = 1;
const targetKm = 50;
const lengthCells = Math.max(8, Math.round(targetKm / kmPerCell));
const lengthPx = lengthCells * CELL_SIZE;
const margin = 14;
const x = margin;
const y = margin + 18;
ctx.save();
ctx.lineCap = "butt";
ctx.strokeStyle = "rgba(0,0,0,0.78)";
ctx.lineWidth = 2.2;
ctx.fillStyle = "rgba(255,255,255,0.92)";
ctx.fillRect(x - 8, y - 18, lengthPx + 16, 30);
ctx.strokeStyle = "rgba(80,80,80,0.22)";
ctx.strokeRect(x - 8, y - 18, lengthPx + 16, 30);
ctx.strokeStyle = "rgba(30,30,30,0.82)";
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + lengthPx, y);
ctx.stroke();
for (let t = 0; t <= 2; t++) {
const tx = x + (lengthPx * t) / 2;
ctx.beginPath();
ctx.moveTo(tx, y - 5);
ctx.lineTo(tx, y + 5);
ctx.stroke();
}
ctx.fillStyle = "rgba(20,20,20,0.88)";
ctx.font = "600 11px ui-sans-serif, system-ui, -apple-system, sans-serif";
ctx.textAlign = "center";
ctx.fillText(`${targetKm} km`, x + lengthPx / 2, y - 7);
ctx.restore();
}
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 width = MAP_W * CELL_SIZE;
const height = MAP_H * CELL_SIZE;
canvas.width = width;
canvas.height = height;
// 1. Base Terrain & Urban
drawBase(ctx, map, mode, true);
drawUrbanAreas(ctx, map, mode);
const coastSegments = getCoastlineSegments(map);
drawVectorSegments(ctx, coastSegments, "rgba(120, 175, 210, 0.22)", 2.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
drawVectorSegments(ctx, coastSegments, "rgba(248, 250, 242, 0.68)", 1.1, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
// 2. Rivers
const waterBlue = "rgba(160, 205, 240, 1)";
const mediumBlue = "rgba(160, 205, 240, 0.88)";
const riverStrengthForPath = (path) => {
if (!path || path.length === 0) return 0;
let peak = 0;
let tail = 0;
const tailStart = Math.max(0, path.length - Math.min(path.length, 8));
let tailCount = 0;
for (let k = 0; k < path.length; k++) {
const [x, y] = path[k];
const i = indexOf(x, y);
const strength = (map.river?.[i] || 0) + (map.flowAccum?.[i] || 0) * 0.75;
peak = Math.max(peak, strength);
if (k >= tailStart) {
tail += strength;
tailCount++;
}
}
return Math.max(peak, tail / Math.max(1, tailCount));
};
// Draw a dendritic river network. Width is intentionally separated by
// river order: small streams are hairline/low-alpha, tributaries are thin,
// and only trunk rivers get a modestly wider stroke.
for (const path of map.smallStreams || []) {
const strength = riverStrengthForPath(path);
if ((path?.length || 0) < 5 || strength < 0.045) continue;
drawRiverPath(ctx, map, path, "rgba(150, 198, 235, 1)", (s) => s > 0.45 ? 0.58 : s > 0.22 ? 0.48 : 0.36, 0.34);
}
for (const path of map.tributaryRivers || []) {
const strength = riverStrengthForPath(path);
if ((path?.length || 0) < 9 || strength < 0.45) continue;
drawRiverPath(ctx, map, path, mediumBlue, (s, t) => {
const downstreamBoost = 0.92 + t * 0.18;
if (s > 1.65) return 1.35 * downstreamBoost;
if (s > 0.95) return 1.12 * downstreamBoost;
return 0.94 * downstreamBoost;
}, 0.92);
}
for (const path of map.mainRivers || []) {
const strength = riverStrengthForPath(path);
if ((path?.length || 0) < 9) continue;
drawRiverPath(ctx, map, path, waterBlue, (s, t) => {
const downstreamBoost = 0.96 + t * 0.24;
if (s > 2.35) return 2.15 * downstreamBoost;
if (s > 1.45) return 1.86 * downstreamBoost;
return 1.55 * downstreamBoost;
}, 1.0);
}
const showHistory = ["history", "all", "terrain"].includes(mode);
const showModern = ["modern", "all", "development", "landuse", "admin-debug", "borders-debug"].includes(mode);
const showRoads = ["all", "development"].includes(mode);
const showMinorRoads = ["all", "modern", "development"].includes(mode);
const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode);
// 3. Borders
const showPrefectureRegions = ["all", "admin", "admin-debug", "borders-debug"].includes(mode);
if (showPrefectureRegions) drawPrefectureRegionFill(ctx, map, mode);
if (showAdmin && map.adminBorders) {
drawVectorSegments(ctx, map.adminBorders, "rgba(255, 255, 255, 0.8)", 2.8, false, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
drawVectorSegments(ctx, map.adminBorders, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
}
if (mode === "admin-debug" || mode === "borders-debug") {
// Keep the natural barrier heatmap subtle. A dense cell fill can look like
// artificial horizontal hatching, so only strong terrain dividers are shown.
drawDebugCells(ctx, map, map.naturalBarrierScore, (v) => v < 0.42 ? "rgba(0,0,0,0)" : `rgba(255, 120, 40, ${0.025 + v * 0.075})`);
if (map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(45, 95, 160, 0.72)", 1.0, true);
for (const p of map.adminDebug?.lowlandAdminSeeds || []) dot(ctx, p, 3.2, "rgba(255,255,255,0.9)", "rgba(40,150,95,0.95)");
}
if (showPrefectureRegions && map.regionalPrefectureBorders) {
drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(255, 255, 255, 0.90)", 4.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(82, 60, 102, 0.96)", 1.8, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
}
if (!showPrefectureRegions) {
const finalPrefectureBorders = (map.regionalPrefectureBorders && map.regionalPrefectureBorders.length) ? map.regionalPrefectureBorders : map.prefectureBorder;
drawVectorSegments(ctx, finalPrefectureBorders, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
drawVectorSegments(ctx, finalPrefectureBorders, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
}
if (!showFeatures) return;
// 4. Transport casings. Layer order: local roads, trunk roads, railways, expressways.
if (showHistory) {
for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(210, 210, 210, 0.7)", 2.5);
}
if (showMinorRoads) {
for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(205, 205, 205, 0.60)", 2.35);
}
if (showRoads) {
for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8);
for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8);
for (const path of map.externalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8);
}
if (showModern || showRoads) {
for (const path of map.railways) drawPath(ctx, path, "rgba(255, 255, 255, 0.78)", 3.6);
for (const path of map.branchRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.64)", 2.6);
for (const path of map.externalRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.78)", 3.6);
}
if (showRoads) {
for (const path of map.expressways) drawPath(ctx, path, "rgba(105, 125, 105, 0.78)", 4.6);
for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(105, 125, 105, 0.78)", 4.6);
}
// 5. Transport fills. Expressways remain above railways; railways sit above ordinary roads.
if (showHistory) {
for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(255, 255, 255, 1)", 1.2, false);
}
if (showMinorRoads) {
for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(255, 255, 255, 0.94)", 1.1, false);
}
if (showRoads) {
for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0);
for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0);
for (const path of map.externalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0);
}
if (showModern || showRoads) {
for (const path of map.railways) drawRailway(ctx, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0);
for (const path of map.branchRailways) drawRailway(ctx, path, "rgba(125, 125, 125, 1)", 1.05, 4.0, 6.0);
for (const path of map.externalRailways) drawRailway(ctx, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0);
}
if (showRoads) {
for (const path of map.expressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4);
for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4);
}
// 6. Icons & Labels
if (["admin", "admin-debug", "borders-debug"].includes(mode)) {
for (const p of map.adminCenters || []) dot(ctx, p, 2.8, "rgba(255,255,255,0.96)", "rgba(75,60,90,0.95)");
}
if (showModern) {
for (const p of map.stations) dot(ctx, p, 2.5, "#fff", "#444");
const allLayerTowns = mode === "all"
? [
...(map.markets || []).filter((p) => (p.population || 0) >= 5000),
...(map.villages || []).filter((p) => (p.population || 0) >= 5000),
].filter((p) => !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y))
: [];
for (const p of allLayerTowns) dot(ctx, p, 3.1, "rgba(244, 164, 118, 0.92)", "rgba(255,255,255,0.88)");
for (const p of map.modernCities) {
const popRadius = p.population ? Math.min(9.4, 3.8 + Math.sqrt(p.population) / 360) : 4.8;
dot(ctx, p, popRadius, "rgba(240, 110, 110, 0.95)", "rgba(255,255,255,0.9)");
if (p.isPrefecturalCapital) dot(ctx, p, popRadius + 3.4, "transparent", "rgba(200,80,80,0.9)");
else if (p.isRegionalCapital) dot(ctx, p, popRadius + 2.4, "transparent", "rgba(190,95,95,0.62)");
}
for (const p of map.interchanges || []) dot(ctx, p, 2.7, "rgba(250, 250, 245, 0.95)", "rgba(95, 125, 95, 0.95)");
for (const p of map.logisticsParks || []) dot(ctx, p, 2.4, "rgba(235, 238, 230, 0.95)", "rgba(105, 125, 105, 0.88)");
if (mode === "admin-debug" || mode === "borders-debug") {
for (const p of map.externalGateways || []) dot(ctx, p, 5.5, "rgba(255,255,255,0.95)", "rgba(40,80,170,0.95)");
for (const p of map.transportDebug?.missingRequiredNodes || []) dot(ctx, p, 6.5, "rgba(255,80,80,0.95)", "rgba(80,20,20,0.95)");
}
}
if (showLabels) {
const prefectureLabels = showPrefectureRegions ? (map.prefectureRegions || []).map((p) => ({ ...p, labelPriorityBase: p.labelPriorityBase || 900 })) : [];
if (mode === "admin") {
drawLabels(ctx, [...prefectureLabels, ...(map.adminCenters || [])], Infinity);
drawScaleBar(ctx);
return;
}
if (mode === "admin-debug" || mode === "borders-debug") {
drawLabels(ctx, [...prefectureLabels, ...(map.adminCenters || [])], Infinity);
drawScaleBar(ctx);
return;
}
const allLayerTowns = mode === "all"
? [
...(map.markets || []).filter((p) => (p.population || 0) >= 5000),
...(map.villages || []).filter((p) => (p.population || 0) >= 5000),
]
.filter((p) => !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y))
.map((p) => ({ ...p, forceAllLayerTownLabel: true, labelPriorityBase: p.kind === "Village" || p.kind === "Valley Village" || p.kind === "Coastal Village" ? 75 : 135 }))
: [];
const important = [
...prefectureLabels,
...map.modernCities,
...map.ports,
...(map.logisticsParks || []).map((p) => ({ ...p, labelPriorityBase: 60 })),
...(map.satelliteCities || []),
...allLayerTowns,
].filter((p) => p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000);
drawLabels(ctx, important, mode === "all" ? 85 : 60);
}
drawScaleBar(ctx);
}