map/renderer.js
2026-05-30 03:25:19 +09:00

1439 lines
55 KiB
JavaScript

import { CELL_SIZE, MAP_H, MAP_W, clamp, valueNoise } from "./mapUtils.js";
const segmentVectorCache = new WeakMap();
const segmentVectorStableCache = new Map();
const pathVectorCache = new WeakMap();
const coastlineCache = new WeakMap();
const rasterBorderCache = new WeakMap();
const baseImageCache = new Map();
const urbanOverlayCache = new Map();
const prefectureFillCache = new Map();
const MAX_BASE_CACHE_IMAGES = 18;
const MAX_OVERLAY_CACHE_IMAGES = 18;
const MAX_SEGMENT_VECTOR_CACHE = 48;
const CONTINUOUS_BASE_MODES = ["terrain", "development", "all"];
function nowMs() {
return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
}
function fieldRefSignature(map) {
const refs = [
map?.elevation, map?.slope, map?.sea, map?.prefectureMask, map?.humanRegionMask,
map?.landuse, map?.populationDensity, map?.prefectureRegionId, map?.adminId,
];
return refs.map((field) => ArrayBuffer.isView(field) ? `${field.constructor.name}:${field.length}` : "-").join("|");
}
function generatedRectSignature(map) {
const rects = Array.isArray(map?.generatedRects) ? map.generatedRects : [];
const last = rects[rects.length - 1] || null;
if (!last) return "0";
return `${rects.length}:${last.x0 || 0},${last.y0 || 0},${last.x1 || 0},${last.y1 || 0}:${last.terrainType || ""}:${last.variant || ""}`;
}
function stableViewportCachePrefix(map) {
const camera = map?.worldCamera || {};
const origin = map?.worldOrigin || {};
return [
map?.baseSeed ?? map?.seed ?? 0,
map?.effectiveSeed ?? "",
map?.generationContext?.variant ?? "",
mapWidth(map),
mapHeight(map),
Math.round(camera.x || 0),
Math.round(camera.y || 0),
Math.round(origin.x || 0),
Math.round(origin.y || 0),
generatedRectSignature(map),
fieldRefSignature(map),
].join(":");
}
function cappedSet(cache, key, value, maxSize) {
cache.set(key, value);
while (cache.size > maxSize) cache.delete(cache.keys().next().value);
return value;
}
function segmentContentSignature(segments) {
const count = segments?.length || 0;
if (!count) return "0";
const step = Math.max(1, Math.floor(count / 24));
const parts = [String(count)];
for (let i = 0; i < count; i += step) {
const seg = segments[i];
parts.push(`${seg?.[0]?.[0] || 0},${seg?.[0]?.[1] || 0},${seg?.[1]?.[0] || 0},${seg?.[1]?.[1] || 0}`);
}
const last = segments[count - 1];
parts.push(`${last?.[0]?.[0] || 0},${last?.[0]?.[1] || 0},${last?.[1]?.[0] || 0},${last?.[1]?.[1] || 0}`);
return parts.join("|");
}
function mapWidth(map) {
return Math.max(1, Math.floor(Number.isFinite(map?.width) ? map.width : MAP_W));
}
function mapHeight(map) {
return Math.max(1, Math.floor(Number.isFinite(map?.height) ? map.height : MAP_H));
}
function cellIndex(map, x, y) {
return y * mapWidth(map) + x;
}
function insideMap(map, x, y) {
return x >= 0 && y >= 0 && x < mapWidth(map) && y < mapHeight(map);
}
function mapPixelWidth(map) {
return mapWidth(map) * CELL_SIZE;
}
function mapPixelHeight(map) {
return mapHeight(map) * CELL_SIZE;
}
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 stableKey = `${cacheKey}:${segmentContentSignature(segments)}`;
const stableCached = segmentVectorStableCache.get(stableKey);
if (stableCached) {
cachedByOption.set(cacheKey, stableCached);
return stableCached;
}
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);
cappedSet(segmentVectorStableCache, stableKey, polylines, MAX_SEGMENT_VECTOR_CACHE);
return polylines;
}
function getCoastlineSegments(map) {
if (!map?.sea) return [];
const cached = coastlineCache.get(map.sea);
if (cached) return cached;
const segments = [];
const w = mapWidth(map);
const h = mapHeight(map);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = cellIndex(map, x, y);
const a = Boolean(map.sea[i]);
if (x + 1 < w) {
const b = Boolean(map.sea[cellIndex(map, x + 1, y)]);
if (a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
}
if (y + 1 < h) {
const b = Boolean(map.sea[cellIndex(map, 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]);
// Transport routes are raster-routed, so shallow diagonal corridors can look
// like stair steps. Two light Chaikin passes remove that visual artifact
// while a small RDP tolerance keeps valley and coastline bends intact.
const smoothIterations = path.length > 12 ? 2 : path.length > 6 ? 1 : 0;
const smoothedBase = chaikin(points, smoothIterations, false);
const simplified = simplifyRdp(smoothedBase, CELL_SIZE * 0.10);
pathVectorCache.set(path, simplified);
return simplified;
}
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(map, fx, fy) {
const x = Math.max(0, Math.min(mapWidth(map) - 1, Math.floor(fx)));
const y = Math.max(0, Math.min(mapHeight(map) - 1, Math.floor(fy)));
return cellIndex(map, x, y);
}
function seaCoverageSample(map, fx, fy) {
if (!map?.sea) return 0;
// Coastlines are raster-derived, but the renderer should not expose the raw
// cell stair-steps. Sample a small footprint around each pixel and blend the
// land/sea color at the edge; this keeps the mask stable while giving the
// visible coastline a vector-like anti-aliased curve.
const offsets = [
[0, 0], [-0.34, 0], [0.34, 0], [0, -0.34], [0, 0.34],
[-0.26, -0.26], [0.26, -0.26], [-0.26, 0.26], [0.26, 0.26],
];
let sum = 0;
for (const [ox, oy] of offsets) sum += fieldSample(map, map.sea, fx + ox, fy + oy);
return clamp(sum / offsets.length);
}
function isWaterSample(map, fx, fy) {
return seaCoverageSample(map, fx, fy) >= 0.50;
}
function terrainWorldPoint(map, fx, fy) {
return {
x: fx + (map?.worldCamera?.x || 0),
y: fy + (map?.worldCamera?.y || 0),
};
}
function waterVisualTone(map, fx, fy) {
const p = terrainWorldPoint(map, fx, fy);
const seed = (map?.seed || 0) >>> 0;
const broad = valueNoise(p.x, p.y, seed ^ 0x5d2a9b31, 74);
const mid = valueNoise(p.x, p.y, seed ^ 0x8f4c2d19, 29);
return clamp(broad * 0.72 + mid * 0.28);
}
function waterVisualColor(map, fx, fy, waterCoverage = null) {
const coverage = waterCoverage ?? seaCoverageSample(map, fx, fy);
const tone = waterVisualTone(map, fx, fy) - 0.5;
const offshore = clamp((coverage - 0.42) / 0.58);
const depth = clamp(0.48 + offshore * 0.22 + tone * 0.10);
return [
Math.round(174 - depth * 6),
Math.round(203 + depth * 2),
Math.round(224 + depth * 4),
];
}
function waterVisualShade(map, fx, fy) {
const tone = waterVisualTone(map, fx * 0.85 + 11.7, fy * 0.85 - 3.1) - 0.5;
return clamp(0.992 + tone * 0.018, 0.976, 1.012);
}
function mixRgb(a, b, t) {
return [
Math.round(a[0] + (b[0] - a[0]) * t),
Math.round(a[1] + (b[1] - a[1]) * t),
Math.round(a[2] + (b[2] - a[2]) * t),
];
}
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(map, field, fx, fy) {
if (!field) return 0;
const sx = Math.max(0, Math.min(mapWidth(map) - 1, fx));
const sy = Math.max(0, Math.min(mapHeight(map) - 1, fy));
const x0 = Math.floor(sx);
const y0 = Math.floor(sy);
const x1 = Math.max(0, Math.min(mapWidth(map) - 1, x0 + 1));
const y1 = Math.max(0, Math.min(mapHeight(map) - 1, y0 + 1));
const tx = sx - x0;
const ty = sy - y0;
const a = field[cellIndex(map, x0, y0)] || 0;
const b = field[cellIndex(map, x1, y0)] || 0;
const c = field[cellIndex(map, x0, y1)] || 0;
const d = field[cellIndex(map, x1, y1)] || 0;
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(map, fx, fy);
const isInside = Boolean(map.prefectureMask[i]);
let color;
const waterCoverage = seaCoverageSample(map, fx, fy);
// Water must not inherit terrain DEM hill bands. Use a stable world-coordinate
// visual water tone instead of elevation-derived depth; otherwise patched sea
// areas expose rectangular/horizontal generation artefacts.
const waterColor = waterVisualColor(map, fx, fy, waterCoverage);
let landColor;
if (mode === "development") {
const dCity = distToNearest(map.modernCities, fx, fy);
const urban = clamp(1 - dCity / 25);
const density = map.populationDensity ? fieldSample(map, map.populationDensity, fx, fy) : urban;
const base = 235;
landColor = [
Math.round(base + density * 20),
Math.round(base + density * 5),
Math.round(230 + density * 10),
];
} else {
// 地形の基底色は標高のみに従わせる。
// 谷や微地形の見え方は陰影側で制御し、谷底だけが不自然に茶色化しないようにする。
const e = fieldSample(map, map.elevation, fx, fy);
landColor = 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]],
]);
}
const centerWater = Boolean(map.sea?.[i]);
if (centerWater) {
// Keep the visible coastline and the filled water side derived from the same
// sea mask. Only a very narrow anti-aliased edge borrows land color; broad
// land/sea averaging made coast strokes disagree with the underlying fill.
const edgeLand = clamp((0.58 - waterCoverage) / 0.26);
color = edgeLand > 0 ? mixRgb(waterColor, landColor, edgeLand * 0.42) : waterColor;
} else {
const shore = clamp((waterCoverage - 0.10) / 0.42);
const shoreColor = [224, 229, 213];
color = shore > 0 ? mixRgb(landColor, shoreColor, shore * 0.34) : landColor;
}
return blendOutside(color, isInside);
}
function terrainShadeContinuous(map, fx, fy) {
const i = sampleCellIndex(map, fx, fy);
const waterCoverage = seaCoverageSample(map, fx, fy);
if (map.sea?.[i] || waterCoverage >= 0.82) return waterVisualShade(map, fx, fy);
const step = 0.50;
const eC = fieldSample(map, map.elevation, fx, fy);
const eL = fieldSample(map, map.elevation, fx - step, fy);
const eR = fieldSample(map, map.elevation, fx + step, fy);
const eU = fieldSample(map, map.elevation, fx, fy - step);
const eD = fieldSample(map, 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, map.slope, fx, fy) : 0;
const valley = map.valleyField ? fieldSample(map, map.valleyField, fx, fy) : 0;
const ravine = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx, fy) : 0;
const tex = map.surfaceTextureField ? fieldSample(map, map.surfaceTextureField, fx, fy) : 0;
const rvL = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx - 0.90, fy) : 0;
const rvR = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx + 0.90, fy) : 0;
const rvU = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx, fy - 0.90) : 0;
const rvD = map.visibleRavineField ? fieldSample(map, 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 = cellIndex(map, 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(map, mode, continuousTerrain, renderScale = 1) {
const prefix = stableViewportCachePrefix(map);
if (continuousTerrain && CONTINUOUS_BASE_MODES.includes(mode)) {
const scaleKey = Math.round((renderScale || 1) * 20) / 20;
return `${prefix}:continuous:${mode === "all" ? "terrain" : mode}:${scaleKey}`;
}
return `${prefix}:discrete:${mode}:1`;
}
function getCachedBaseCanvas(ctx, map, mode, continuousTerrain, renderScale = 1) {
const key = baseCacheKey(map, mode, continuousTerrain, renderScale);
let canvas = baseImageCache.get(key);
if (canvas) return canvas;
const sourceWidth = mapPixelWidth(map);
const sourceHeight = mapPixelHeight(map);
const continuous = continuousTerrain && CONTINUOUS_BASE_MODES.includes(mode);
const targetScale = continuous ? Math.max(0.35, Math.min(1, renderScale || 1)) : 1;
const width = Math.max(1, Math.round(sourceWidth * targetScale));
const height = Math.max(1, Math.round(sourceHeight * targetScale));
const img = ctx.createImageData(width, height);
if (continuous) {
for (let py = 0; py < height; py++) {
const fy = (py / Math.max(1, height)) * mapHeight(map);
for (let px = 0; px < width; px++) {
const fx = (px / Math.max(1, width)) * mapWidth(map);
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 {
const w = mapWidth(map);
const h = mapHeight(map);
for (let y = 0; y < h; y++) {
for (let x = 0; x < 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;
}
}
}
}
}
canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const bctx = canvas.getContext("2d");
bctx.putImageData(img, 0, 0);
return cappedSet(baseImageCache, key, canvas, MAX_BASE_CACHE_IMAGES);
}
function drawBase(ctx, map, mode, continuousTerrain, renderScale = 1) {
// putImageData ignores the current transform, so it made the terrain layer
// appear unzoomed while vector layers scaled. Cache the raster into an
// offscreen canvas and draw it with drawImage so wheel zoom applies to terrain.
ctx.imageSmoothingEnabled = true;
ctx.drawImage(getCachedBaseCanvas(ctx, map, mode, continuousTerrain, renderScale), 0, 0, mapPixelWidth(map), mapPixelHeight(map));
}
function getRasterBorderSegments(map, fieldName) {
const field = map?.[fieldName];
if (!field) return [];
let cacheByField = rasterBorderCache.get(field);
if (cacheByField?.segments) return cacheByField.segments;
const segments = [];
const w = mapWidth(map);
const h = mapHeight(map);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = cellIndex(map, x, y);
const id = field[i];
if (id < 0 || map.sea?.[i]) continue;
if (x + 1 < w) {
const ri = cellIndex(map, x + 1, y);
const rid = field[ri];
if (!map.sea?.[ri] && rid >= 0 && rid !== id) segments.push([[x + 0.5, y], [x + 0.5, y + 1]]);
}
if (y + 1 < h) {
const di = cellIndex(map, x, y + 1);
const did = field[di];
if (!map.sea?.[di] && did >= 0 && did !== id) segments.push([[x, y + 0.5], [x + 1, y + 0.5]]);
}
}
}
rasterBorderCache.set(field, { segments });
return segments;
}
function getDisplayBorderSegments(map, fieldName, precomputedSegments, mode) {
// Raw raster borders expose every one-cell ID fragment and also reveal patch
// ownership seams as long rectangular prefecture/municipality lines. Keep
// that extractor debug-only; normal modes must use prepared vector layers.
if (mode === "borders-debug") return getRasterBorderSegments(map, fieldName);
return Array.isArray(precomputedSegments) ? precomputedSegments : [];
}
function borderSegmentMidpoint(seg) {
return { x: ((seg?.[0]?.[0] || 0) + (seg?.[1]?.[0] || 0)) * 0.5, y: ((seg?.[0]?.[1] || 0) + (seg?.[1]?.[1] || 0)) * 0.5 };
}
function borderSegmentOrientation(seg) {
return Math.atan2((seg?.[1]?.[1] || 0) - (seg?.[0]?.[1] || 0), (seg?.[1]?.[0] || 0) - (seg?.[0]?.[0] || 0));
}
function borderAngleDistance(a, b) {
let d = Math.abs(a - b) % Math.PI;
if (d > Math.PI / 2) d = Math.PI - d;
return d;
}
function borderPointSegmentDistance(px, py, ax, ay, bx, by) {
const dx = bx - ax, dy = by - ay;
const len2 = dx * dx + dy * dy;
if (len2 <= 1e-9) return Math.hypot(px - ax, py - ay);
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2));
return Math.hypot(px - (ax + dx * t), py - (ay + dy * t));
}
function borderSegmentsNear(a, b, tolerance = 1.35) {
if (!a || !b) return false;
if (borderAngleDistance(borderSegmentOrientation(a), borderSegmentOrientation(b)) > 0.72) return false;
const am = borderSegmentMidpoint(a);
const bm = borderSegmentMidpoint(b);
if (Math.hypot(am.x - bm.x, am.y - bm.y) <= tolerance) return true;
const ax = a?.[0]?.[0] || 0, ay = a?.[0]?.[1] || 0;
const bx = a?.[1]?.[0] || 0, by = a?.[1]?.[1] || 0;
const cx = b?.[0]?.[0] || 0, cy = b?.[0]?.[1] || 0;
const dx = b?.[1]?.[0] || 0, dy = b?.[1]?.[1] || 0;
return Math.min(
borderPointSegmentDistance(ax, ay, cx, cy, dx, dy),
borderPointSegmentDistance(bx, by, cx, cy, dx, dy),
borderPointSegmentDistance(cx, cy, ax, ay, bx, by),
borderPointSegmentDistance(dx, dy, ax, ay, bx, by)
) <= tolerance;
}
function suppressMunicipalBordersNearPrefectures(adminSegments, prefectureSegments) {
if (!adminSegments?.length || !prefectureSegments?.length) return adminSegments || [];
const tolerance = 1.35;
const cellSize = tolerance;
const grid = new Map();
const cell = (v) => Math.floor(v / cellSize);
for (const seg of prefectureSegments) {
const m = borderSegmentMidpoint(seg);
const key = `${cell(m.x)},${cell(m.y)}`;
const bucket = grid.get(key) || [];
bucket.push(seg);
grid.set(key, bucket);
}
const out = [];
for (const seg of adminSegments) {
const m = borderSegmentMidpoint(seg);
const gx = cell(m.x), gy = cell(m.y);
let near = false;
for (let yy = gy - 2; yy <= gy + 2 && !near; yy++) {
for (let xx = gx - 2; xx <= gx + 2 && !near; xx++) {
for (const pref of grid.get(`${xx},${yy}`) || []) {
if (borderSegmentsNear(seg, pref, tolerance)) { near = true; break; }
}
}
}
if (!near) out.push(seg);
}
return out;
}
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 = cellIndex(map, Math.round(x1), Math.round(y1));
const i2 = cellIndex(map, Math.round(x2), Math.round(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 landOnlySubpaths(map, path, minCells = 2) {
if (!path || path.length < 2 || !map?.sea) return path?.length >= minCells ? [path] : [];
const chunks = [];
let cur = [];
for (const p of path) {
const [x, y] = p;
const land = insideMap(map, x, y) && !map.sea[cellIndex(map, x, y)];
if (land) {
cur.push(p);
} else if (cur.length >= minCells) {
chunks.push(cur);
cur = [];
} else {
cur = [];
}
}
if (cur.length >= minCells) chunks.push(cur);
return chunks;
}
function drawLandPath(ctx, map, path, color, width, dashed = false, minCells = 2) {
for (const chunk of landOnlySubpaths(map, path, minCells)) drawPath(ctx, chunk, color, width, dashed);
}
// 魚の骨(私鉄記号)スタイルを描画するための専用関数
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 drawLandRailway(ctx, map, path, color, lineWidth, tickLen, spacing) {
for (const chunk of landOnlySubpaths(map, path, 3)) drawRailway(ctx, chunk, color, lineWidth, tickLen, spacing);
}
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 key = `${stableViewportCachePrefix(map)}:urban:${mode}`;
let canvas = urbanOverlayCache.get(key);
if (canvas) {
ctx.drawImage(canvas, 0, 0, mapPixelWidth(map), mapPixelHeight(map));
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)";
canvas = document.createElement("canvas");
canvas.width = mapPixelWidth(map);
canvas.height = mapPixelHeight(map);
const bctx = canvas.getContext("2d");
if (!bctx) return;
const w = mapWidth(map);
const h = mapHeight(map);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = cellIndex(map, 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;
bctx.fillStyle = fill;
bctx.fillRect(px, py, CELL_SIZE, CELL_SIZE);
}
}
cappedSet(urbanOverlayCache, key, canvas, MAX_OVERLAY_CACHE_IMAGES);
ctx.drawImage(canvas, 0, 0, mapPixelWidth(map), mapPixelHeight(map));
}
function drawDebugCells(ctx, map, field, color) {
if (!field) return;
ctx.save();
const w = mapWidth(map);
const h = mapHeight(map);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = cellIndex(map, x, y);
const debugMask = map.humanRegionMask || map.prefectureMask;
if (!debugMask[i] || map.sea[i]) continue;
const raw = field[i] || 0;
const v = clamp(raw > 1 ? raw / 255 : raw, 0, 1);
if (v <= 0.12) continue;
ctx.fillStyle = color(v);
ctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE);
}
}
ctx.restore();
}
function drawTransportDebug(ctx, map) {
const layers = map.transportDebug?.layers;
if (!layers) return;
drawDebugCells(ctx, map, layers.expresswayPotential, (v) => v < 0.16 ? "rgba(0,0,0,0)" : `rgba(80, 190, 110, ${0.035 + v * 0.13})`);
drawDebugCells(ctx, map, layers.railPotential, (v) => v < 0.16 ? "rgba(0,0,0,0)" : `rgba(70, 120, 230, ${0.035 + v * 0.13})`);
drawDebugCells(ctx, map, layers.nationalRoadPotential, (v) => v < 0.16 ? "rgba(0,0,0,0)" : `rgba(245, 205, 65, ${0.030 + v * 0.12})`);
drawDebugCells(ctx, map, layers.slopeSeaPenalty, (v) => v < 0.45 ? "rgba(0,0,0,0)" : `rgba(80, 30, 30, ${0.025 + v * 0.10})`);
const componentColors = {
expressway: "rgba(60, 165, 80, 0.42)",
rail: "rgba(65, 95, 210, 0.42)",
national: "rgba(210, 155, 20, 0.42)",
};
ctx.save();
for (const comp of layers.components || []) {
ctx.fillStyle = componentColors[comp.mode] || "rgba(150,150,150,0.35)";
for (const [x, y] of comp.cells || []) ctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE);
}
ctx.restore();
for (const repair of layers.repairedSegments || []) {
const color = repair.mode === "expressway" ? "rgba(0, 115, 40, 0.95)"
: repair.mode === "rail" ? "rgba(35, 70, 210, 0.95)"
: repair.mode === "national" ? "rgba(180, 120, 0, 0.95)"
: "rgba(210, 35, 155, 0.92)";
drawPath(ctx, repair.path, "rgba(255,255,255,0.92)", 5.0);
drawPath(ctx, repair.path, color, 2.4);
}
}
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", "borders-debug"].includes(mode)) return;
const ids = map.prefectureRegionId;
if (!ids) return;
const key = `${stableViewportCachePrefix(map)}:prefecture-fill:${mode}`;
let canvas = prefectureFillCache.get(key);
if (canvas) {
ctx.drawImage(canvas, 0, 0, mapPixelWidth(map), mapPixelHeight(map));
return;
}
const alpha = mode === "borders-debug" ? 0.34 : 0.18;
canvas = document.createElement("canvas");
canvas.width = mapPixelWidth(map);
canvas.height = mapPixelHeight(map);
const bctx = canvas.getContext("2d");
if (!bctx) return;
bctx.globalAlpha = alpha;
const w = mapWidth(map);
const h = mapHeight(map);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = cellIndex(map, x, y);
const id = ids[i];
if (map.sea[i] || id < 0) continue;
const [r, g, b] = prefectureRegionColor(id);
bctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
bctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE);
}
}
cappedSet(prefectureFillCache, key, canvas, MAX_OVERLAY_CACHE_IMAGES);
ctx.drawImage(canvas, 0, 0, mapPixelWidth(map), mapPixelHeight(map));
}
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;
const isPrefectureLabel = p.kind === "Prefecture" || p.kind === "Current Prefecture" || p.isPrefectureLabel;
const isMunicipalityLabel = p.labelStyle === "municipality";
ctx.save();
ctx.font = isPrefectureLabel
? "900 20px ui-sans-serif, system-ui, -apple-system, sans-serif"
: isMunicipalityLabel
? "600 10px ui-sans-serif, system-ui, -apple-system, sans-serif"
: "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 = isPrefectureLabel ? 22 : isMunicipalityLabel ? 10 : 12;
const candidates = isPrefectureLabel
? [
[-textW / 2, 6], [-textW / 2, -12], [-textW / 2, 24],
[10, 6], [-textW - 10, 6],
]
: isMunicipalityLabel
? [
[6, -4], [6, 11], [-textW - 6, -4], [-textW - 6, 11],
[-textW / 2, -10], [-textW / 2, 17], [10, 3], [-textW - 10, 3],
[4, -12], [-textW - 4, -12], [4, 18], [-textW - 4, 18],
]
: [
[7, -4], [7, 13], [-textW - 7, -4], [-textW - 7, 13],
[-textW / 2, -12], [-textW / 2, 20], [12, 4], [-textW - 12, 4],
];
let fallback = null;
for (const [ox, oy] of candidates) {
const x = baseX + ox;
const y = baseY + oy;
const box = { x1: x - 3, y1: y - textH, x2: x + textW + 3, y2: y + 5 };
if (box.x1 < 0 || box.y1 < 0 || box.x2 > (ctx.__mapPixelWidth || MAP_W * CELL_SIZE) || box.y2 > (ctx.__mapPixelHeight || MAP_H * CELL_SIZE)) continue;
const overlaps = occupied.filter((b) => boxesOverlap(box, b, isPrefectureLabel ? 5 : isMunicipalityLabel ? 1 : 3));
if (!overlaps.length) {
ctx.lineJoin = "round";
ctx.lineWidth = isPrefectureLabel ? 6.2 : isMunicipalityLabel ? 3.0 : 3.5;
ctx.strokeStyle = isPrefectureLabel ? "rgba(255, 255, 255, 0.98)" : "rgba(255, 255, 255, 0.95)";
ctx.strokeText(p.name, x, y);
ctx.fillStyle = isPrefectureLabel ? "rgba(62, 38, 112, 0.98)" : p.isPrefecturalCapital ? "#111111" : isMunicipalityLabel ? "rgba(72, 62, 82, 0.92)" : "#333333";
ctx.fillText(p.name, x, y);
occupied.push(box);
ctx.restore();
return true;
}
if (p.forceLabel) {
const score = overlaps.length;
if (!fallback || score < fallback.score) fallback = { x, y, box, score };
}
}
if (p.forceLabel && fallback) {
ctx.lineJoin = "round";
ctx.lineWidth = isPrefectureLabel ? 6.2 : isMunicipalityLabel ? 3.0 : 3.5;
ctx.strokeStyle = isPrefectureLabel ? "rgba(255, 255, 255, 0.98)" : "rgba(255, 255, 255, 0.95)";
ctx.strokeText(p.name, fallback.x, fallback.y);
ctx.fillStyle = isPrefectureLabel ? "rgba(62, 38, 112, 0.98)" : p.isPrefecturalCapital ? "#111111" : isMunicipalityLabel ? "rgba(72, 62, 82, 0.92)" : "#333333";
ctx.fillText(p.name, fallback.x, fallback.y);
occupied.push(fallback.box);
ctx.restore();
return true;
}
ctx.restore();
return false;
}
function drawLabels(ctx, points, limit = Infinity, occupied = null) {
const used = 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, used);
return used;
}
function drawScaleBar(ctx, cellScreenSize = CELL_SIZE) {
const kmPerCell = 0.5;
const targetKm = 25;
const lengthCells = Math.max(8, Math.round(targetKm / kmPerCell));
const lengthPx = lengthCells * cellScreenSize;
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 timings = {};
let timingMark = nowMs();
const markTiming = (key) => {
const t = nowMs();
timings[key] = Math.round((t - timingMark) * 10) / 10;
timingMark = t;
};
const mode = options.mode || "all";
const showFeatures = options.showFeatures !== false;
const showLabels = options.showLabels !== false;
const continuousTerrain = options.continuousTerrain !== false;
const outputWidth = MAP_W * CELL_SIZE;
const outputHeight = MAP_H * CELL_SIZE;
const sourceWidth = mapPixelWidth(map);
const sourceHeight = mapPixelHeight(map);
const drawScale = Math.min(outputWidth / Math.max(1, sourceWidth), outputHeight / Math.max(1, sourceHeight));
const drawOffsetX = (outputWidth - sourceWidth * drawScale) * 0.5;
const drawOffsetY = (outputHeight - sourceHeight * drawScale) * 0.5;
const cellScreenSize = CELL_SIZE * drawScale;
const terrainQualityScale = options.fastTerrain ? 0.48 : 0.62;
const terrainRenderScale = continuousTerrain ? clamp(drawScale * terrainQualityScale, 0.30, options.fastTerrain ? 0.55 : 0.78) : 1;
if (canvas.width !== outputWidth) canvas.width = outputWidth;
if (canvas.height !== outputHeight) canvas.height = outputHeight;
ctx.clearRect(0, 0, outputWidth, outputHeight);
ctx.save();
ctx.translate(drawOffsetX, drawOffsetY);
ctx.scale(drawScale, drawScale);
ctx.__mapPixelWidth = sourceWidth;
ctx.__mapPixelHeight = sourceHeight;
const finish = () => {
ctx.restore();
drawScaleBar(ctx, cellScreenSize);
delete ctx.__mapPixelWidth;
delete ctx.__mapPixelHeight;
timings.scale = Math.round((nowMs() - timingMark) * 10) / 10;
return timings;
};
// 1. Base Terrain & Urban
drawBase(ctx, map, mode, continuousTerrain, terrainRenderScale);
markTiming("baseTerrain");
drawUrbanAreas(ctx, map, mode);
markTiming("urbanFill");
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 });
markTiming("coastline");
// 2. Rivers
const waterBlue = "rgba(116, 165, 202, 0.92)";
const mediumBlue = "rgba(132, 184, 220, 0.78)";
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 = cellIndex(map, Math.round(x), Math.round(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(140, 190, 224, 0.82)", (s) => s > 0.45 ? 0.52 : s > 0.22 ? 0.42 : 0.32, 0.28);
}
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);
}
markTiming("rivers");
const showHistory = mode === "history";
const showTransportDebug = mode === "transport-debug";
const showModern = ["modern", "all", "development", "landuse", "borders-debug", "transport-debug"].includes(mode);
const showRoads = ["all", "development", "transport-debug"].includes(mode);
const showMinorRoads = ["all", "modern", "development", "transport-debug"].includes(mode);
const showPremodernRoads = ["history", "all", "transport-debug"].includes(mode);
const showAdmin = ["admin", "all", "borders-debug"].includes(mode);
// 3. Borders
const showPrefectureRegions = ["all", "admin", "borders-debug"].includes(mode);
if (showPrefectureRegions) drawPrefectureRegionFill(ctx, map, mode);
const prefectureBorderSegments = getDisplayBorderSegments(map, "prefectureRegionId", map.regionalPrefectureBorders, mode);
const rawAdminBorderSegments = getDisplayBorderSegments(map, "adminId", map.adminBorders, mode);
const adminBorderSegments = mode === "borders-debug" ? rawAdminBorderSegments : suppressMunicipalBordersNearPrefectures(rawAdminBorderSegments, prefectureBorderSegments);
if (showAdmin && adminBorderSegments?.length) {
drawVectorSegments(ctx, adminBorderSegments, "rgba(255, 255, 255, 0.8)", 2.8, false, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
drawVectorSegments(ctx, adminBorderSegments, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
}
if (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 (showTransportDebug) drawTransportDebug(ctx, map);
if (showPrefectureRegions && prefectureBorderSegments?.length) {
drawVectorSegments(ctx, prefectureBorderSegments, "rgba(255, 255, 255, 0.90)", 4.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
drawVectorSegments(ctx, prefectureBorderSegments, "rgba(82, 60, 102, 0.96)", 1.8, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
}
if (!showPrefectureRegions) {
const finalPrefectureBorders = (prefectureBorderSegments && prefectureBorderSegments.length) ? prefectureBorderSegments : 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 });
}
markTiming("adminBorders");
if (!showFeatures) {
return finish();
}
// 4. Transport casings. Layer order: local roads, trunk roads, railways, expressways.
const localRoadCasing = "rgba(112, 112, 104, 0.58)";
const localRoadFill = "rgba(255, 255, 255, 0.98)";
const generalRoadPaths = [
...(showPremodernRoads ? (map.premodernRoads || []) : []),
...(showMinorRoads ? (map.minorRoads || []) : []),
];
if (generalRoadPaths.length) {
// Ordinary roads: white centerline with a restrained grey casing. Both the
// current generated local roads and premodernRoads use the same appearance
// in all / transport-debug so the old white layer no longer reads as a
// second road system.
for (const path of generalRoadPaths) drawLandPath(ctx, map, path, localRoadCasing, 3.05);
}
if (showRoads) {
for (const path of map.nationalRoads) drawLandPath(ctx, map, path, "rgba(190, 175, 140, 1)", 3.8);
for (const path of map.ringRoads || []) drawLandPath(ctx, map, path, "rgba(190, 175, 140, 1)", 3.8);
for (const path of map.externalRoads) drawLandPath(ctx, map, path, "rgba(190, 175, 140, 1)", 3.8);
}
if (showModern || showRoads) {
for (const path of map.railways) drawLandPath(ctx, map, path, "rgba(255, 255, 255, 0.78)", 3.6, false, 3);
for (const path of map.branchRailways) drawLandPath(ctx, map, path, "rgba(255, 255, 255, 0.64)", 2.6, false, 3);
for (const path of map.externalRailways) drawLandPath(ctx, map, path, "rgba(255, 255, 255, 0.78)", 3.6, false, 3);
}
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 (generalRoadPaths.length) {
for (const path of generalRoadPaths) drawLandPath(ctx, map, path, localRoadFill, 1.45, false);
}
if (showRoads) {
for (const path of map.nationalRoads) drawLandPath(ctx, map, path, "rgba(245, 225, 130, 1)", 2.0);
for (const path of map.ringRoads || []) drawLandPath(ctx, map, path, "rgba(245, 225, 130, 1)", 2.0);
for (const path of map.externalRoads) drawLandPath(ctx, map, path, "rgba(245, 225, 130, 1)", 2.0);
}
if (showModern || showRoads) {
for (const path of map.railways) drawLandRailway(ctx, map, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0);
for (const path of map.branchRailways) drawLandRailway(ctx, map, path, "rgba(125, 125, 125, 1)", 1.05, 4.0, 6.0);
for (const path of map.externalRailways) drawLandRailway(ctx, map, 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);
}
markTiming("transport");
// 6. Icons & Labels
if (["admin", "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)");
}
const settlementIconLabelPoints = ["all", "modern", "history"].includes(mode)
? [
...(map.markets || []).filter((p) => (p.population || 0) >= 3000),
...(map.villages || []).filter((p) => (p.population || 0) >= 3000),
...(mode === "history" ? (map.ports || []).filter((p) => p.portClass === "major" || p.portClass === "regional") : []),
...(mode === "history" ? (map.castles || []) : []),
].filter((p) => !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y))
.map((p) => ({ ...p, forceAllLayerTownLabel: true, labelPriorityBase: p.labelPriorityBase || (p.kind === "Village" ? 58 : p.kind === "Castle" ? 88 : 66) }))
: [];
if (["all", "modern", "history"].includes(mode)) {
for (const p of settlementIconLabelPoints) dot(ctx, p, p.kind === "Castle" ? 3.2 : 3.1, "rgba(240, 110, 110, 0.95)", "rgba(255,255,255,0.88)");
}
if (showModern) {
for (const p of map.stations) dot(ctx, p, 2.5, "#fff", "#444");
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 === "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 (showTransportDebug) {
for (const p of map.transportDebug?.layers?.unservedSettlements || []) {
dot(ctx, p, p.repaired ? 3.4 : 4.8, p.repaired ? "rgba(255,255,255,0.92)" : "rgba(255,80,140,0.95)", "rgba(125,35,105,0.95)");
}
}
}
markTiming("icons");
if (showLabels) {
const prefectureLabels = (map.prefectureRegions || []).map((p) => ({ ...p, isPrefectureLabel: true, forceLabel: true, labelPriorityBase: p.labelPriorityBase || 1900 }));
if (mode === "admin") {
const municipalLabels = (map.adminCenters || [])
.filter((p) => p && p.name && Number.isFinite(p.x) && Number.isFinite(p.y) && p.x >= 0 && p.y >= 0 && p.x < map.width && p.y < map.height && map.adminId?.[Math.round(p.y) * map.width + Math.round(p.x)] === (p.adminId ?? p.municipalityId ?? p.adminNumericId))
.map((p) => ({ ...p, labelStyle: "municipality", forceLabel: true, labelPriorityBase: 1200 }));
drawLabels(ctx, [...prefectureLabels, ...municipalLabels], Infinity);
markTiming("labels");
return finish();
}
if (mode === "borders-debug") {
drawLabels(ctx, prefectureLabels, Infinity);
markTiming("labels");
return finish();
}
const important = [
...prefectureLabels,
...map.modernCities,
...map.ports,
...(map.satelliteCities || []),
...settlementIconLabelPoints,
].filter((p) => !p.suppressSettlementLabel && (p.isPrefectureLabel || p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000));
drawLabels(ctx, important, mode === "all" || mode === "history" ? 78 : 60);
}
markTiming("labels");
return finish();
}