map/renderer.js
2026-05-20 17:15:09 +09:00

550 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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風の海色
color = [170, 218, 255];
} else if (mode === "landuse") {
const colors = {
0: [230, 242, 220], // 農地
1: [235, 245, 225], // 平地
2: [235, 230, 220], // 旧市街
3: [224, 202, 190], // 中心市街地 / CBD
4: [245, 240, 230], // 郊外
5: [220, 220, 225], // 工業地域
6: [225, 235, 225], // 物流エリア
7: [238, 242, 248], // ニュータウン
8: [248, 242, 230], // 沿道開発
9: [225, 238, 220], // その他
};
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);
}
}