import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf, inside } 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]); // 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(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 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.sea, fx + ox, fy + oy); return clamp(sum / offsets.length); } function isWaterSample(map, fx, fy) { return seaCoverageSample(map, fx, fy) >= 0.50; } 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(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; const waterCoverage = seaCoverageSample(map, fx, fy); const depth = clamp(((map.seaLevel ?? 0.285) + 0.065 - fieldSample(map.elevation, fx, fy)) * 2.4); const waterColor = [Math.round(174 - depth * 7), Math.round(203 + depth * 2), Math.round(224 + depth * 5)]; let landColor; 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; landColor = [ Math.round(base + density * 20), Math.round(base + density * 5), Math.round(230 + density * 10), ]; } else { // 地形の基底色は標高のみに従わせる。 // 谷や微地形の見え方は陰影側で制御し、谷底だけが不自然に茶色化しないようにする。 const e = fieldSample(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 coastBlend = clamp((waterCoverage - 0.36) / 0.28); color = coastBlend > 0 ? mixRgb(landColor, waterColor, coastBlend) : landColor; 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 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 = inside(x, y) && !map.sea[indexOf(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 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 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 alpha = mode === "borders-debug" ? 0.34 : 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; 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 > MAP_W * CELL_SIZE || box.y2 > 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) { const kmPerCell = 0.5; const targetKm = 25; 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 continuousTerrain = options.continuousTerrain !== false; const zoom = Math.min(Math.max(Number(options.zoom) || 1, 0.55), 2.8); const width = MAP_W * CELL_SIZE; const height = MAP_H * CELL_SIZE; if (canvas.width !== width) canvas.width = width; if (canvas.height !== height) canvas.height = height; ctx.clearRect(0, 0, width, height); ctx.save(); ctx.translate(width * (1 - zoom) * 0.5, height * (1 - zoom) * 0.5); ctx.scale(zoom, zoom); const finish = () => { ctx.restore(); drawScaleBar(ctx); }; // 1. Base Terrain & Urban drawBase(ctx, map, mode, continuousTerrain); 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(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 = 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(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); } 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); 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 === "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 && 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) { finish(); return; } // 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); } // 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)"); } } } 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) .map((p) => ({ ...p, labelStyle: "municipality", forceLabel: true, labelPriorityBase: 1200 })); drawLabels(ctx, [...prefectureLabels, ...municipalLabels], Infinity); finish(); return; } if (mode === "borders-debug") { drawLabels(ctx, prefectureLabels, Infinity); finish(); return; } 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); } finish(); }