460 lines
16 KiB
JavaScript
460 lines
16 KiB
JavaScript
import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf } from "./mapUtils.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.8 + 50),
|
|
Math.round(color[1] * 0.8 + 50),
|
|
Math.round(color[2] * 0.8 + 50),
|
|
];
|
|
}
|
|
|
|
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]) {
|
|
const depth = clamp((0.35 - 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);
|
|
if (e > 0.82) color = [210, 205, 195];
|
|
else if (e > 0.68) color = [218, 215, 205];
|
|
else if (e > 0.52) color = [220, 225, 210];
|
|
else if (e > 0.34) color = [225, 230, 215];
|
|
else if (e > 0.24) color = [230, 235, 220];
|
|
else color = [238, 242, 228];
|
|
}
|
|
|
|
return blendOutside(color, isInside);
|
|
}
|
|
|
|
function discreteColor(map, x, y, mode) {
|
|
const i = indexOf(x, y);
|
|
let color;
|
|
|
|
if (map.sea[i]) {
|
|
color = [170, 218, 255];
|
|
} else if (mode === "landuse") {
|
|
const colors = {
|
|
0: [242, 248, 238],
|
|
1: [248, 250, 245],
|
|
2: [240, 238, 232],
|
|
3: [245, 230, 220],
|
|
4: [250, 248, 245],
|
|
5: [235, 235, 240],
|
|
6: [240, 245, 240],
|
|
7: [245, 248, 252],
|
|
8: [250, 248, 240],
|
|
9: [240, 245, 238],
|
|
};
|
|
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 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.95 + (eR - eL) * 0.6 + (eD - eU) * 0.4, 0.85, 1.08);
|
|
|
|
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 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([8, 6]);
|
|
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 drawRailway(ctx, path, color, lineWidth, tickLen, spacing) {
|
|
if (!path || path.length < 2) return;
|
|
ctx.save();
|
|
ctx.lineCap = "butt";
|
|
ctx.lineJoin = "round";
|
|
ctx.strokeStyle = color;
|
|
|
|
// 中心の実線を描画
|
|
ctx.lineWidth = lineWidth;
|
|
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.lineWidth = 1.0;
|
|
ctx.beginPath();
|
|
let leftover = 0;
|
|
for (let k = 0; k < path.length - 1; k++) {
|
|
const x1 = path[k][0] * CELL_SIZE + CELL_SIZE / 2;
|
|
const y1 = path[k][1] * CELL_SIZE + CELL_SIZE / 2;
|
|
const x2 = path[k+1][0] * CELL_SIZE + CELL_SIZE / 2;
|
|
const y2 = path[k+1][1] * CELL_SIZE + CELL_SIZE / 2;
|
|
const dx = x2 - x1;
|
|
const dy = y2 - y1;
|
|
const dist = Math.hypot(dx, dy);
|
|
if (dist === 0) continue;
|
|
|
|
// 法線(直角)ベクトル
|
|
const nx = dx / dist;
|
|
const ny = dy / dist;
|
|
const px = -ny * (tickLen / 2);
|
|
const py = nx * (tickLen / 2);
|
|
|
|
let d = (spacing / 2) + leftover;
|
|
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;
|
|
}
|
|
leftover = 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);
|
|
if (!map.prefectureMask[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);
|
|
|
|
// 2. Rivers
|
|
const waterBlue = "rgba(160, 205, 240, 1)";
|
|
for (const path of map.tributaryRivers || map.riverPaths || []) drawPath(ctx, path, "rgba(160, 205, 240, 0.8)", 1.5);
|
|
for (const path of map.mainRivers) drawPath(ctx, path, waterBlue, 3.5);
|
|
|
|
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 showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode);
|
|
|
|
// 3. Borders
|
|
if (showAdmin && map.adminBorders) {
|
|
drawSegments(ctx, map.adminBorders, "rgba(255, 255, 255, 0.8)", 2.8, false);
|
|
drawSegments(ctx, map.adminBorders, "rgba(150, 140, 150, 0.9)", 1.2, true);
|
|
}
|
|
if (mode === "admin-debug" || mode === "borders-debug") {
|
|
drawDebugCells(ctx, map, map.naturalBarrierScore, (v) => `rgba(255, 120, 40, ${0.06 + v * 0.18})`);
|
|
if (map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(60, 110, 170, 0.42)", 0.8, true);
|
|
if (map.regionalPrefectureBorders) drawSegments(ctx, map.regionalPrefectureBorders, "rgba(70, 55, 95, 0.95)", 2.4, false);
|
|
}
|
|
|
|
drawSegments(ctx, map.prefectureBorder, "rgba(255, 255, 255, 0.95)", 5.0, false);
|
|
drawSegments(ctx, map.prefectureBorder, "rgba(110, 90, 110, 1)", 2.2, true);
|
|
|
|
if (!showFeatures) return;
|
|
|
|
// 4. Casings (Outlines)
|
|
if (showHistory) {
|
|
// 古い道は白の実線が引き立つように淡いケーシングを敷く
|
|
for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(210, 210, 210, 0.7)", 2.5);
|
|
}
|
|
|
|
if (showModern || showRoads) {
|
|
// 鉄道のケーシング(白背景を敷いて視認性を保つ)
|
|
for (const path of map.railways) drawPath(ctx, path, "rgba(255, 255, 255, 0.75)", 3.5);
|
|
for (const path of map.branchRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.6)", 2.5);
|
|
for (const path of map.externalRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.75)", 3.5);
|
|
|
|
// 幹線道路のケーシング(色を濃く)
|
|
if (showRoads) {
|
|
for (const path of map.expressways) drawPath(ctx, path, "rgba(80, 140, 100, 1)", 5.0);
|
|
for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(80, 140, 100, 1)", 5.0);
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 5. Fills (Inner colors) & Fishbones
|
|
if (showHistory) {
|
|
for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(255, 255, 255, 1)", 1.2, false);
|
|
}
|
|
|
|
if (showModern || showRoads) {
|
|
// 鉄道の骨線描画(色, 線幅, 棘の長さ, 棘の間隔)
|
|
for (const path of map.railways) drawRailway(ctx, path, "rgba(110, 110, 110, 1)", 1.5, 5.0, 7.0);
|
|
for (const path of map.branchRailways) drawRailway(ctx, path, "rgba(140, 140, 140, 1)", 1.0, 4.0, 6.0);
|
|
for (const path of map.externalRailways) drawRailway(ctx, path, "rgba(110, 110, 110, 1)", 1.5, 5.0, 7.0);
|
|
|
|
// 幹線道路の塗り(色を濃く)
|
|
if (showRoads) {
|
|
for (const path of map.expressways) drawPath(ctx, path, "rgba(110, 185, 130, 1)", 3.0);
|
|
for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(110, 185, 130, 1)", 3.0);
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 6. Icons & Labels
|
|
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)");
|
|
}
|
|
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 || []), ...(map.externalGateways || [])], Infinity);
|
|
return;
|
|
}
|
|
const important = [
|
|
...map.modernCities,
|
|
...map.ports,
|
|
...(map.satelliteCities || []),
|
|
].filter((p) => p.insidePrefecture || p.kind === "External Gateway");
|
|
drawLabels(ctx, important, 60);
|
|
}
|
|
}
|