import { CELL_SIZE, MAP_H, MAP_W, clamp, fbm, indexOf, valueNoise } from "./mapUtils.js"; const segmentVectorCache = new WeakMap(); const pathVectorCache = new WeakMap(); const coastlineCache = new WeakMap(); 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 === "suitability") { const a = fieldSample(map.agriculture, fx, fy); const p = fieldSample(map.plain, fx, fy); const f = fieldSample(map.floodplain, fx, fy); color = [ Math.round(240 - p * 15 + f * 10), Math.round(242 + a * 10), Math.round(235 - a * 15 + p * 10), ]; } 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: [242, 248, 238], // rural / natural land 1: [238, 246, 222], // farmland 2: [240, 238, 232], // old urban 3: [245, 230, 220], // CBD / DID core 4: [250, 248, 245], // suburb 5: [235, 235, 240], // industrial 6: [240, 245, 240], // logistics 7: [245, 248, 252], // new town 8: [250, 248, 240], // roadside 9: [225, 238, 220], // 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 drawBase(ctx, map, mode, continuousTerrain) { const width = MAP_W * CELL_SIZE; const height = MAP_H * CELL_SIZE; const img = ctx.createImageData(width, height); const continuousModes = ["terrain", "suitability", "development", "all"]; if (continuousTerrain && continuousModes.includes(mode)) { for (let py = 0; py < height; py++) { const fy = py / CELL_SIZE; for (let px = 0; px < width; px++) { const fx = px / CELL_SIZE; const [r, g, b] = terrainColorContinuous(map, fx, fy, mode === "all" ? "terrain" : mode); const 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; } } } } } ctx.putImageData(img, 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", "roads", "admin"]; if (!visibleModes.includes(mode)) return; const colors = { 2: "rgba(225, 222, 215, 0.6)", 3: "rgba(240, 220, 205, 0.85)", 4: "rgba(242, 240, 235, 0.5)", 5: "rgba(220, 220, 225, 0.6)", 6: "rgba(225, 230, 225, 0.5)", 7: "rgba(235, 240, 245, 0.6)", 8: "rgba(245, 242, 235, 0.5)", }; ctx.save(); for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { const i = indexOf(x, y); if (!map.prefectureMask[i]) continue; const lu = map.landuse[i]; if (!colors[lu]) continue; const px = x * CELL_SIZE; const py = y * CELL_SIZE; ctx.fillStyle = colors[lu]; ctx.fillRect(px, py, CELL_SIZE, CELL_SIZE); } } 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 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); } 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", "roads", "admin-debug", "borders-debug"].includes(mode); const showRoads = ["roads", "all", "development"].includes(mode); const showMinorRoads = ["roads", "all", "modern", "development"].includes(mode); const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode); // 3. Borders 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)"); } const showPrefectureRegions = ["all", "admin", "admin-debug", "borders-debug"].includes(mode); 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 }); } drawVectorSegments(ctx, map.prefectureBorder, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); drawVectorSegments(ctx, map.prefectureBorder, "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"); for (const p of map.modernCities) { const popRadius = p.population ? Math.min(8.5, 3.5 + Math.sqrt(p.population) / 400) : 4.5; dot(ctx, p, popRadius, "rgba(240, 110, 110, 0.95)", "rgba(255,255,255,0.9)"); if (p.isPrefecturalCapital) dot(ctx, p, popRadius + 3.0, "transparent", "rgba(200,80,80,0.9)"); else if (p.isRegionalCapital) dot(ctx, p, popRadius + 2.2, "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)"); 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) { if (mode === "admin") { drawLabels(ctx, map.adminCenters || [], Infinity); return; } if (mode === "admin-debug" || mode === "borders-debug") { drawLabels(ctx, map.adminCenters || [], Infinity); return; } const important = [ ...map.modernCities, ...map.ports, ...(map.satelliteCities || []), ].filter((p) => p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway"); drawLabels(ctx, important, 60); } }