import { MAP_W, MAP_H, CELL_SIZE, indexOf } from "./mapGenerator.js"; import { clamp } from "./grid.js"; 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.55 + 112), Math.round(color[1] * 0.55 + 112), Math.round(color[2] * 0.55 + 112), ]; } function fieldSample(field, fx, fy) { const x0 = Math.max(0, Math.min(MAP_W - 1, Math.floor(fx))); const y0 = Math.max(0, Math.min(MAP_H - 1, Math.floor(fy))); 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 = fx - x0; const ty = fy - 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 terrainColorContinuous(map, fx, fy, mode) { const i = indexOf(Math.max(0, Math.min(MAP_W - 1, Math.floor(fx))), Math.max(0, Math.min(MAP_H - 1, Math.floor(fy)))); const isInside = Boolean(map.prefectureMask[i]); let color; if (map.sea[i]) { // Google Map風の海の色(明るい青) const depth = clamp((0.35 - fieldSample(map.elevation, fx, fy)) * 2.4); color = [Math.round(170 + depth * 10), Math.round(211 + depth * 15), Math.round(223 + depth * 20)]; } 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(230 - p * 25 + f * 15), Math.round(235 + a * 15), Math.round(210 - a * 25 + p * 20), ]; } else if (mode === "development") { const x = Math.floor(fx); const y = Math.floor(fy); const baseIndex = indexOf(Math.max(0, Math.min(MAP_W - 1, x)), Math.max(0, Math.min(MAP_H - 1, y))); const dCity = distToNearest(map.modernCities, fx, fy); const dInd = distToNearest(map.industrialZones, fx, fy); const dLog = distToNearest(map.logisticsParks, fx, fy); const dNew = distToNearest(map.newTowns, fx, fy); const urban = clamp(1 - dCity / 25); const density = map.populationDensity ? fieldSample(map.populationDensity, fx, fy) : urban; const industrial = clamp(1 - dInd / 10); const logistics = clamp(1 - dLog / 9); const newTown = clamp(1 - dNew / 9); const base = 214 + map.plain[baseIndex] * 22; color = [ Math.round(base + density * 30 + urban * 8 + industrial * 14), Math.round(base + density * 10 + logistics * 15 + newTown * 12), Math.round(208 + map.agriculture[baseIndex] * 22 + density * 18 + newTown * 22), ]; } else { // 起伏の大きさが読めるよう、標高段彩をやや強める const e = fieldSample(map.elevation, fx, fy); const m = fieldSample(map.moisture, fx, fy); if (e > 0.82) color = [178, 170, 160]; else if (e > 0.68) color = [198, 188, 164]; else if (e > 0.52) color = [205, 222 + m * 5, 184]; else if (e > 0.34) color = [224, 238 + m * 6, 206]; else if (e > 0.24) color = [236, 245 + m * 5, 220]; else color = [218, 232 + m * 6, 206]; } return blendOutside(color, isInside); } function discreteColor(map, x, y, mode) { const i = indexOf(x, y); let color; if (map.sea[i]) { // Google Map like styled color = [170, 218, 255]; } else if (mode === "landuse") { const colors = { 0: [230, 242, 220], // farmland 1: [235, 245, 225], // plain 2: [235, 230, 220], // old city 3: [224, 202, 190], // CBD 4: [245, 240, 230], // suburb 5: [220, 220, 225], // industrial area 6: [225, 235, 225], // logistics area 7: [238, 242, 248], // new town 8: [248, 242, 230], // coastal development 9: [225, 238, 220], // others }; color = colors[map.landuse[i]] || colors[0]; } else if (mode === "admin") { const palette = [ [245, 235, 230], [235, 245, 235], [240, 240, 250], [250, 245, 230], [245, 240, 248], [230, 245, 245], [250, 240, 240], [240, 250, 235], ]; const a = map.adminId[i]; color = a >= 0 ? palette[a % palette.length] : [220, 225, 220]; } 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 eL = fieldSample(map.elevation, fx - 0.6, fy); const eR = fieldSample(map.elevation, fx + 0.6, fy); const eU = fieldSample(map.elevation, fx, fy - 0.6); const eD = fieldSample(map.elevation, fx, fy + 0.6); const shade = clamp(0.9 + (eR - eL) * 1.0 + (eD - eU) * 0.65, 0.72, 1.18); const elevation = fieldSample(map.elevation, fx, fy); const contour = Math.abs((elevation * 16) - Math.round(elevation * 16)); const majorContour = Math.abs((elevation * 8) - Math.round(elevation * 8)); const isLand = !map.sea[indexOf(Math.max(0, Math.min(MAP_W - 1, Math.floor(fx))), Math.max(0, Math.min(MAP_H - 1, Math.floor(fy))))]; const contourFactor = isLand && majorContour < 0.022 ? 0.86 : isLand && contour < 0.028 ? 0.94 : 1; const ii = (py * width + px) * 4; img.data[ii] = Math.round(r * shade * contourFactor); img.data[ii + 1] = Math.round(g * shade * contourFactor); img.data[ii + 2] = Math.round(b * shade * contourFactor); 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 drawPath(ctx, path, color, width, dashed = false) { if (!path || path.length < 2) return; ctx.save(); ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.strokeStyle = color; ctx.lineWidth = width; if (dashed) ctx.setLineDash([6, 5]); ctx.beginPath(); ctx.moveTo(path[0][0] * CELL_SIZE + CELL_SIZE / 2, path[0][1] * CELL_SIZE + CELL_SIZE / 2); for (let k = 1; k < path.length; k++) { ctx.lineTo(path[k][0] * CELL_SIZE + CELL_SIZE / 2, path[k][1] * CELL_SIZE + CELL_SIZE / 2); } ctx.stroke(); ctx.restore(); } function segmentPointKey(p) { return `${p[0]},${p[1]}`; } function chainSegments(segments) { const unused = segments.map((seg) => [seg[0], seg[1]]); const chains = []; while (unused.length) { const chain = unused.pop(); let grew = true; while (grew) { grew = false; const head = segmentPointKey(chain[0]); const tail = segmentPointKey(chain[chain.length - 1]); for (let i = unused.length - 1; i >= 0; i--) { const [a, b] = unused[i]; const ak = segmentPointKey(a); const bk = segmentPointKey(b); if (ak === tail) { chain.push(b); unused.splice(i, 1); grew = true; break; } if (bk === tail) { chain.push(a); unused.splice(i, 1); grew = true; break; } if (bk === head) { chain.unshift(a); unused.splice(i, 1); grew = true; break; } if (ak === head) { chain.unshift(b); unused.splice(i, 1); grew = true; break; } } } chains.push(chain); } return chains; } function chaikin(points, passes = 1) { let out = points; for (let pass = 0; pass < passes; pass++) { if (out.length < 3) return out; const next = [out[0]]; for (let i = 0; i < out.length - 1; i++) { const a = out[i]; const b = out[i + 1]; 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]); } next.push(out[out.length - 1]); out = next; } return out; } function drawSegments(ctx, segments, color, width, dashed = false, smooth = false) { ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = "round"; ctx.lineJoin = "round"; if (dashed) ctx.setLineDash([4, 4]); if (smooth) { for (const chain of chainSegments(segments)) { const points = chaikin(chain, 1); if (points.length < 2) continue; ctx.beginPath(); ctx.moveTo(points[0][0] * CELL_SIZE, points[0][1] * CELL_SIZE); for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0] * CELL_SIZE, points[i][1] * CELL_SIZE); ctx.stroke(); } ctx.restore(); return; } 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; // Google Map風の都市部の色 const colors = { 2: "rgba(235, 230, 220, 0.75)", // 旧市街 - 薄いベージュ 3: "rgba(224, 202, 190, 0.9)", // 中心市街地 / CBD - cell fill 4: "rgba(245, 242, 235, 0.7)", // 郊外 - 薄いクリーム 5: "rgba(220, 220, 228, 0.8)", // 工業地域 - 薄いグレー 6: "rgba(225, 235, 228, 0.75)", // 物流 - 薄い緑グレー 7: "rgba(238, 242, 250, 0.75)", // ニュータウン - 薄い青白 8: "rgba(248, 245, 238, 0.7)", // 沿道 - クリーム }; 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); const h = ((x * 92821 + y * 68917 + lu * 131) >>> 0); if (lu === 3 || lu === 2 || lu === 5 || lu === 6) { // 建物の表現を控えめに ctx.fillStyle = lu === 3 ? "rgba(200,200,200,0.25)" : "rgba(210,210,210,0.2)"; if (h % 3 !== 0) ctx.fillRect(px + 1, py + 1, 2, 2); if (h % 5 !== 0) ctx.fillRect(px + 3, py + 2, 2, 2); if (h % 7 !== 0) ctx.fillRect(px + 2, py + 4, 2, 1.5); } else if (lu === 4 || lu === 7) { ctx.strokeStyle = lu === 7 ? "rgba(220,220,230,0.15)" : "rgba(200,190,180,0.15)"; ctx.lineWidth = 0.8; ctx.beginPath(); if (h % 2 === 0) { ctx.moveTo(px + 1, py + 1); ctx.lineTo(px + CELL_SIZE - 1, py + 1); ctx.moveTo(px + 1, py + 4); ctx.lineTo(px + CELL_SIZE - 1, py + 4); } else { ctx.moveTo(px + 1, py + 1); ctx.lineTo(px + 1, py + CELL_SIZE - 1); ctx.moveTo(px + 4, py + 1); ctx.lineTo(px + 4, py + CELL_SIZE - 1); } ctx.stroke(); } else if (lu === 8) { ctx.strokeStyle = "rgba(180,170,160,0.2)"; ctx.lineWidth = 0.9; ctx.beginPath(); ctx.moveTo(px + 1, py + 3); ctx.lineTo(px + CELL_SIZE - 1, py + 3); ctx.stroke(); } } } 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.5; ctx.strokeStyle = stroke; ctx.stroke(); } function squareIcon(ctx, p, size, fill, stroke = "white") { const x = p.x * CELL_SIZE + CELL_SIZE / 2; const y = p.y * CELL_SIZE + CELL_SIZE / 2; ctx.save(); ctx.fillStyle = fill; ctx.strokeStyle = stroke; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.rect(x - size / 2, y - size / 2, size, size); ctx.fill(); ctx.stroke(); ctx.restore(); } function triangleIcon(ctx, p, size, fill, stroke = "white") { const x = p.x * CELL_SIZE + CELL_SIZE / 2; const y = p.y * CELL_SIZE + CELL_SIZE / 2; ctx.save(); ctx.fillStyle = fill; ctx.strokeStyle = stroke; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(x, y - size / 2); ctx.lineTo(x + size / 2, y + size / 2); ctx.lineTo(x - size / 2, y + size / 2); ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.restore(); } function railStationIcon(ctx, p) { squareIcon(ctx, p, 5.2, "rgba(255,255,255,0.96)", "rgba(55,55,55,0.92)"); } function drawHarborWorks(ctx, map) { if (!map.harborWorks) return; ctx.save(); ctx.strokeStyle = "rgba(95, 120, 150, 0.9)"; ctx.lineWidth = 2.2; ctx.lineCap = "round"; for (const harbor of map.harborWorks) { for (const seg of harbor.segments || []) { ctx.beginPath(); ctx.moveTo(seg[0][0] * CELL_SIZE + CELL_SIZE / 2, seg[0][1] * CELL_SIZE + CELL_SIZE / 2); ctx.lineTo(seg[1][0] * CELL_SIZE + CELL_SIZE / 2, seg[1][1] * CELL_SIZE + CELL_SIZE / 2); ctx.stroke(); } } ctx.restore(); } function boxesOverlap(a, b, pad = 2) { 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 = "11px ui-sans-serif, system-ui, 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, -5], [7, 12], [-textW - 7, -5], [-textW - 7, 12], [-textW / 2, -13], [-textW / 2, 20], [12, 2], [-textW - 12, 2], ]; 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 + 3 }; 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.lineWidth = 3; ctx.strokeStyle = "rgba(255,255,255,0.95)"; ctx.fillStyle = "rgba(40,40,40,0.95)"; ctx.strokeText(p.name, x, y); 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.isPrefecturalCapital ? 1000 : 0) + (p.population || 0) / 900 + (p.portClass === "major" ? 170 : p.portClass === "regional" ? 95 : 0) + (p.kind === "Market Town" ? 48 : 0) + (p.kind?.includes("Castle") ? 70 : 0) + (p.kind === "External Gateway" ? 60 : 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 continuousTerrain = true; const width = MAP_W * CELL_SIZE; const height = MAP_H * CELL_SIZE; canvas.width = width; canvas.height = height; drawBase(ctx, map, mode, continuousTerrain); drawUrbanAreas(ctx, map, mode); // Rivers use the same hue family as the sea; hierarchy is expressed by width/opacity. const waterBlue = "rgba(170, 218, 255, 0.95)"; // Small streams remain in the data model but are not drawn by default. for (const path of map.tributaryRivers || map.riverPaths || []) drawPath(ctx, path, "rgba(170, 218, 255, 0.78)", 1.35); for (const path of map.mainRivers) drawPath(ctx, path, waterBlue, 3.2); drawHarborWorks(ctx, map); if (map.regionalPrefectureBorders) drawSegments(ctx, map.regionalPrefectureBorders, mode === "all" ? "rgba(95,95,95,0.18)" : "rgba(95,95,95,0.30)", 1.0, false, mode === "all"); drawSegments(ctx, map.prefectureBorder, "rgba(30,30,30,0.82)", 2.4, false, true); drawSegments(ctx, map.prefectureBorder, "rgba(255,255,255,0.74)", 1.05, false, true); if (!showFeatures) return; const showHistory = ["history", "all", "terrain", "suitability"].includes(mode); const showModern = ["modern", "all", "development", "landuse"].includes(mode); const showRoads = ["roads", "all", "development", "landuse"].includes(mode); const showAdmin = ["admin", "all"].includes(mode); if (showAdmin) drawSegments(ctx, map.adminBorders, mode === "all" ? "rgba(120,120,120,0.28)" : "rgba(120,120,120,0.65)", mode === "all" ? 0.9 : 1.3); if (showHistory) { for (const path of map.premodernRoads) drawPath(ctx, path, mode === "all" ? "rgba(150, 120, 90, 0.34)" : "rgba(150, 120, 90, 0.55)", mode === "all" ? 1.15 : 1.45, true); for (const path of map.minorRoads) drawPath(ctx, path, mode === "all" ? "rgba(180, 150, 120, 0.28)" : "rgba(180, 150, 120, 0.62)", mode === "all" ? 0.8 : 1.05); } if (showModern) { // 鉄道 - 濃いグレー for (const path of map.railways) drawPath(ctx, path, "rgba(80, 80, 80, 0.9)", 2.8); for (const path of map.ringRailways || []) drawPath(ctx, path, "rgba(70, 70, 70, 0.82)", 2.1); for (const path of map.branchRailways) drawPath(ctx, path, "rgba(100, 100, 100, 0.82)", 1.9); for (const path of map.externalRailways) drawPath(ctx, path, "rgba(90, 90, 90, 0.9)", 2.4); } if (showRoads) { // Google Map風の道路 - 白と黄色とオレンジ for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(252, 210, 90, 0.85)", 2.2); for (const path of map.icAccessRoads || []) drawPath(ctx, path, "rgba(255, 230, 150, 0.82)", 1.45); for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(252, 210, 90, 0.85)", 2.0); for (const path of map.expressways) drawPath(ctx, path, "rgba(245, 140, 60, 0.95)", 4.0); for (const path of map.externalRoads) drawPath(ctx, path, "rgba(252, 210, 90, 0.85)", 2.5); for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(245, 140, 60, 0.95)", 4.2); } if (showHistory) { for (const p of map.villages) dot(ctx, p, mode === "all" ? 1.35 : 1.9, mode === "all" ? "rgba(120, 100, 80, 0.42)" : "rgba(120, 100, 80, 0.72)"); for (const p of map.markets) dot(ctx, p, 4.8, "rgba(200, 130, 80, 0.95)"); for (const p of map.ports) { const color = p.portClass === "major" ? "rgba(40, 105, 190, 0.98)" : p.portClass === "regional" ? "rgba(70, 130, 200, 0.95)" : p.portClass === "lake" ? "rgba(80, 155, 180, 0.92)" : "rgba(95, 150, 195, 0.82)"; triangleIcon(ctx, p, p.portClass === "major" ? 8.2 : 6.5, color); } for (const p of map.crossings) dot(ctx, p, 3.5, "rgba(255, 240, 180, 0.95)", "rgba(100,90,70,0.8)"); for (const p of map.passes) dot(ctx, p, 3.9, "rgba(150, 120, 180, 0.95)"); for (const p of map.castles) squareIcon(ctx, p, 7.0, "rgba(180, 70, 70, 0.96)"); for (const p of map.castleRuins) dot(ctx, p, 3.2, "rgba(110, 90, 90, 0.9)", "rgba(220,220,220,0.8)"); } if (showModern) { for (const p of map.industrialZones) squareIcon(ctx, p, 6.5, "rgba(140, 140, 150, 0.96)"); for (const p of map.stations) railStationIcon(ctx, p); for (const p of map.satelliteCities || []) dot(ctx, p, 4.8, "rgba(215, 95, 145, 0.95)"); for (const p of map.newTowns) triangleIcon(ctx, p, 6.5, "rgba(180, 210, 240, 0.95)"); for (const p of map.modernCities) { const popRadius = p.population ? Math.min(9.2, 3.4 + Math.sqrt(p.population) / 360) : 4.7; const rankBoost = p.isPrefecturalCapital || p.rank === "Prefectural Capital" ? 1.6 : p.rank === "Regional Center" ? 0.45 : 0; dot(ctx, p, popRadius + rankBoost, "rgba(230, 100, 100, 0.95)"); if (p.isPrefecturalCapital) dot(ctx, p, popRadius + 4.2, "rgba(255,255,255,0.0)", "rgba(180,60,60,0.95)"); } } if (showRoads) { for (const p of map.logisticsParks) squareIcon(ctx, p, 6.2, "rgba(110, 170, 140, 0.96)"); for (const p of map.interchanges) dot(ctx, p, 4.1, "rgba(255, 255, 255, 0.98)", "rgba(220, 90, 60, 0.95)"); for (const p of map.externalGateways) dot(ctx, p, 4.4, "rgba(255, 250, 200, 0.98)", "rgba(60,60,60,0.92)"); } if (showLabels) { const important = [ ...map.modernCities, ...map.ports, ...map.markets.slice(0, mode === "all" ? 6 : 10), ...map.castles.slice(0, mode === "all" ? 5 : 8), ...(map.satelliteCities || []), ...map.newTowns, ...map.externalGateways, ].filter((p) => p.insidePrefecture || p.kind === "External Gateway"); drawLabels(ctx, important, mode === "all" ? 28 : Infinity); } }