2026-05-29 18:50:54 +09:00
|
|
|
import { CELL_SIZE, MAP_H, MAP_W, clamp, valueNoise } from "./mapUtils.js";
|
2026-05-20 13:50:56 +09:00
|
|
|
|
2026-05-22 14:13:35 +09:00
|
|
|
|
|
|
|
|
const segmentVectorCache = new WeakMap();
|
|
|
|
|
const pathVectorCache = new WeakMap();
|
|
|
|
|
const coastlineCache = new WeakMap();
|
2026-05-29 14:31:42 +09:00
|
|
|
const rasterBorderCache = new WeakMap();
|
2026-05-26 16:56:18 +09:00
|
|
|
const baseImageCache = new WeakMap();
|
|
|
|
|
const MAX_BASE_CACHE_IMAGES = 4;
|
2026-05-22 14:13:35 +09:00
|
|
|
|
2026-05-29 14:31:42 +09:00
|
|
|
function mapWidth(map) {
|
|
|
|
|
return Math.max(1, Math.floor(Number.isFinite(map?.width) ? map.width : MAP_W));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapHeight(map) {
|
|
|
|
|
return Math.max(1, Math.floor(Number.isFinite(map?.height) ? map.height : MAP_H));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cellIndex(map, x, y) {
|
|
|
|
|
return y * mapWidth(map) + x;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function insideMap(map, x, y) {
|
|
|
|
|
return x >= 0 && y >= 0 && x < mapWidth(map) && y < mapHeight(map);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapPixelWidth(map) {
|
|
|
|
|
return mapWidth(map) * CELL_SIZE;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapPixelHeight(map) {
|
|
|
|
|
return mapHeight(map) * CELL_SIZE;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 14:13:35 +09:00
|
|
|
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 = [];
|
2026-05-29 14:31:42 +09:00
|
|
|
const w = mapWidth(map);
|
|
|
|
|
const h = mapHeight(map);
|
|
|
|
|
for (let y = 0; y < h; y++) {
|
|
|
|
|
for (let x = 0; x < w; x++) {
|
|
|
|
|
const i = cellIndex(map, x, y);
|
2026-05-22 14:13:35 +09:00
|
|
|
const a = Boolean(map.sea[i]);
|
2026-05-29 14:31:42 +09:00
|
|
|
if (x + 1 < w) {
|
|
|
|
|
const b = Boolean(map.sea[cellIndex(map, x + 1, y)]);
|
2026-05-22 14:13:35 +09:00
|
|
|
if (a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
|
|
|
|
|
}
|
2026-05-29 14:31:42 +09:00
|
|
|
if (y + 1 < h) {
|
|
|
|
|
const b = Boolean(map.sea[cellIndex(map, x, y + 1)]);
|
2026-05-22 14:13:35 +09:00
|
|
|
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]);
|
2026-05-28 00:30:09 +09:00
|
|
|
// 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);
|
2026-05-27 00:13:13 +09:00
|
|
|
pathVectorCache.set(path, simplified);
|
|
|
|
|
return simplified;
|
2026-05-22 14:13:35 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 14:31:42 +09:00
|
|
|
function sampleCellIndex(map, fx, fy) {
|
|
|
|
|
const x = Math.max(0, Math.min(mapWidth(map) - 1, Math.floor(fx)));
|
|
|
|
|
const y = Math.max(0, Math.min(mapHeight(map) - 1, Math.floor(fy)));
|
|
|
|
|
return cellIndex(map, x, y);
|
2026-05-22 14:13:35 +09:00
|
|
|
}
|
|
|
|
|
|
2026-05-28 00:30:09 +09:00
|
|
|
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;
|
2026-05-29 14:31:42 +09:00
|
|
|
for (const [ox, oy] of offsets) sum += fieldSample(map, map.sea, fx + ox, fy + oy);
|
2026-05-28 00:30:09 +09:00
|
|
|
return clamp(sum / offsets.length);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 14:13:35 +09:00
|
|
|
function isWaterSample(map, fx, fy) {
|
2026-05-28 00:30:09 +09:00
|
|
|
return seaCoverageSample(map, fx, fy) >= 0.50;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 18:50:54 +09:00
|
|
|
function terrainWorldPoint(map, fx, fy) {
|
|
|
|
|
return {
|
|
|
|
|
x: fx + (map?.worldCamera?.x || 0),
|
|
|
|
|
y: fy + (map?.worldCamera?.y || 0),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function waterVisualTone(map, fx, fy) {
|
|
|
|
|
const p = terrainWorldPoint(map, fx, fy);
|
|
|
|
|
const seed = (map?.seed || 0) >>> 0;
|
|
|
|
|
const broad = valueNoise(p.x, p.y, seed ^ 0x5d2a9b31, 74);
|
|
|
|
|
const mid = valueNoise(p.x, p.y, seed ^ 0x8f4c2d19, 29);
|
|
|
|
|
return clamp(broad * 0.72 + mid * 0.28);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function waterVisualColor(map, fx, fy, waterCoverage = null) {
|
|
|
|
|
const coverage = waterCoverage ?? seaCoverageSample(map, fx, fy);
|
|
|
|
|
const tone = waterVisualTone(map, fx, fy) - 0.5;
|
|
|
|
|
const offshore = clamp((coverage - 0.42) / 0.58);
|
|
|
|
|
const depth = clamp(0.48 + offshore * 0.22 + tone * 0.10);
|
|
|
|
|
return [
|
|
|
|
|
Math.round(174 - depth * 6),
|
|
|
|
|
Math.round(203 + depth * 2),
|
|
|
|
|
Math.round(224 + depth * 4),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function waterVisualShade(map, fx, fy) {
|
|
|
|
|
const tone = waterVisualTone(map, fx * 0.85 + 11.7, fy * 0.85 - 3.1) - 0.5;
|
|
|
|
|
return clamp(0.992 + tone * 0.018, 0.976, 1.012);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-28 00:30:09 +09:00
|
|
|
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),
|
|
|
|
|
];
|
2026-05-22 14:13:35 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 13:50:56 +09:00
|
|
|
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) {
|
2026-05-23 18:06:01 +09:00
|
|
|
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),
|
|
|
|
|
];
|
2026-05-20 13:50:56 +09:00
|
|
|
}
|
|
|
|
|
|
2026-05-29 14:31:42 +09:00
|
|
|
function fieldSample(map, field, fx, fy) {
|
|
|
|
|
if (!field) return 0;
|
|
|
|
|
const sx = Math.max(0, Math.min(mapWidth(map) - 1, fx));
|
|
|
|
|
const sy = Math.max(0, Math.min(mapHeight(map) - 1, fy));
|
2026-05-22 14:13:35 +09:00
|
|
|
const x0 = Math.floor(sx);
|
|
|
|
|
const y0 = Math.floor(sy);
|
2026-05-29 14:31:42 +09:00
|
|
|
const x1 = Math.max(0, Math.min(mapWidth(map) - 1, x0 + 1));
|
|
|
|
|
const y1 = Math.max(0, Math.min(mapHeight(map) - 1, y0 + 1));
|
2026-05-22 14:13:35 +09:00
|
|
|
const tx = sx - x0;
|
|
|
|
|
const ty = sy - y0;
|
2026-05-20 13:50:56 +09:00
|
|
|
|
2026-05-29 14:31:42 +09:00
|
|
|
const a = field[cellIndex(map, x0, y0)] || 0;
|
|
|
|
|
const b = field[cellIndex(map, x1, y0)] || 0;
|
|
|
|
|
const c = field[cellIndex(map, x0, y1)] || 0;
|
|
|
|
|
const d = field[cellIndex(map, x1, y1)] || 0;
|
2026-05-20 13:50:56 +09:00
|
|
|
|
|
|
|
|
return ((a * (1 - tx) + b * tx) * (1 - ty)) + ((c * (1 - tx) + d * tx) * ty);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 18:06:01 +09:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 13:50:56 +09:00
|
|
|
function terrainColorContinuous(map, fx, fy, mode) {
|
2026-05-29 14:31:42 +09:00
|
|
|
const i = sampleCellIndex(map, fx, fy);
|
2026-05-20 13:50:56 +09:00
|
|
|
const isInside = Boolean(map.prefectureMask[i]);
|
|
|
|
|
|
|
|
|
|
let color;
|
|
|
|
|
|
2026-05-28 00:30:09 +09:00
|
|
|
const waterCoverage = seaCoverageSample(map, fx, fy);
|
2026-05-29 18:50:54 +09:00
|
|
|
// Water must not inherit terrain DEM hill bands. Use a stable world-coordinate
|
|
|
|
|
// visual water tone instead of elevation-derived depth; otherwise patched sea
|
|
|
|
|
// areas expose rectangular/horizontal generation artefacts.
|
|
|
|
|
const waterColor = waterVisualColor(map, fx, fy, waterCoverage);
|
2026-05-28 00:30:09 +09:00
|
|
|
|
|
|
|
|
let landColor;
|
|
|
|
|
if (mode === "development") {
|
2026-05-20 13:50:56 +09:00
|
|
|
const dCity = distToNearest(map.modernCities, fx, fy);
|
|
|
|
|
const urban = clamp(1 - dCity / 25);
|
2026-05-29 14:31:42 +09:00
|
|
|
const density = map.populationDensity ? fieldSample(map, map.populationDensity, fx, fy) : urban;
|
2026-05-22 02:11:18 +09:00
|
|
|
const base = 235;
|
2026-05-28 00:30:09 +09:00
|
|
|
landColor = [
|
2026-05-22 02:11:18 +09:00
|
|
|
Math.round(base + density * 20),
|
|
|
|
|
Math.round(base + density * 5),
|
|
|
|
|
Math.round(230 + density * 10),
|
2026-05-20 13:50:56 +09:00
|
|
|
];
|
|
|
|
|
} else {
|
2026-05-23 18:06:01 +09:00
|
|
|
// 地形の基底色は標高のみに従わせる。
|
|
|
|
|
// 谷や微地形の見え方は陰影側で制御し、谷底だけが不自然に茶色化しないようにする。
|
2026-05-29 14:31:42 +09:00
|
|
|
const e = fieldSample(map, map.elevation, fx, fy);
|
2026-05-28 00:30:09 +09:00
|
|
|
landColor = interpolateColorStops(clamp(e), [
|
2026-05-23 18:06:01 +09:00
|
|
|
[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]],
|
|
|
|
|
]);
|
2026-05-20 13:50:56 +09:00
|
|
|
}
|
|
|
|
|
|
2026-05-29 22:00:42 +09:00
|
|
|
const centerWater = Boolean(map.sea?.[i]);
|
|
|
|
|
if (centerWater) {
|
|
|
|
|
// Keep the visible coastline and the filled water side derived from the same
|
|
|
|
|
// sea mask. Only a very narrow anti-aliased edge borrows land color; broad
|
|
|
|
|
// land/sea averaging made coast strokes disagree with the underlying fill.
|
|
|
|
|
const edgeLand = clamp((0.58 - waterCoverage) / 0.26);
|
|
|
|
|
color = edgeLand > 0 ? mixRgb(waterColor, landColor, edgeLand * 0.42) : waterColor;
|
|
|
|
|
} else {
|
|
|
|
|
const shore = clamp((waterCoverage - 0.10) / 0.42);
|
|
|
|
|
const shoreColor = [224, 229, 213];
|
|
|
|
|
color = shore > 0 ? mixRgb(landColor, shoreColor, shore * 0.34) : landColor;
|
|
|
|
|
}
|
2026-05-20 13:50:56 +09:00
|
|
|
return blendOutside(color, isInside);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 18:06:01 +09:00
|
|
|
function terrainShadeContinuous(map, fx, fy) {
|
2026-05-29 22:00:42 +09:00
|
|
|
const i = sampleCellIndex(map, fx, fy);
|
2026-05-29 18:50:54 +09:00
|
|
|
const waterCoverage = seaCoverageSample(map, fx, fy);
|
2026-05-29 22:00:42 +09:00
|
|
|
if (map.sea?.[i] || waterCoverage >= 0.82) return waterVisualShade(map, fx, fy);
|
2026-05-29 18:50:54 +09:00
|
|
|
|
2026-05-24 17:38:51 +09:00
|
|
|
const step = 0.50;
|
2026-05-29 14:31:42 +09:00
|
|
|
const eC = fieldSample(map, map.elevation, fx, fy);
|
|
|
|
|
const eL = fieldSample(map, map.elevation, fx - step, fy);
|
|
|
|
|
const eR = fieldSample(map, map.elevation, fx + step, fy);
|
|
|
|
|
const eU = fieldSample(map, map.elevation, fx, fy - step);
|
|
|
|
|
const eD = fieldSample(map, map.elevation, fx, fy + step);
|
2026-05-23 18:06:01 +09:00
|
|
|
|
|
|
|
|
// x は東向き, y は南向き。法線は (-dz/dx, -dz/dy, 1)。
|
2026-05-24 17:38:51 +09:00
|
|
|
// 描画では地形生成上の差分をやや誇張し、粗いDEMでも山腹の起伏を読ませる。
|
|
|
|
|
const dzdx = (eR - eL) / (step * 2);
|
|
|
|
|
const dzdy = (eD - eU) / (step * 2);
|
|
|
|
|
const nx = -dzdx * 4.4;
|
|
|
|
|
const ny = -dzdy * 4.4;
|
2026-05-23 18:06:01 +09:00
|
|
|
const nz = 1.0;
|
|
|
|
|
const nLen = Math.hypot(nx, ny, nz) || 1;
|
|
|
|
|
|
|
|
|
|
const lx = -0.5;
|
|
|
|
|
const ly = -0.5;
|
|
|
|
|
const lz = 0.7071067811865476;
|
2026-05-24 17:38:51 +09:00
|
|
|
const hill = clamp((nx * lx + ny * ly + nz * lz) / nLen * 0.58 + 0.48);
|
2026-05-23 18:06:01 +09:00
|
|
|
|
2026-05-29 14:31:42 +09:00
|
|
|
const slope = map.slope ? fieldSample(map, map.slope, fx, fy) : 0;
|
|
|
|
|
const valley = map.valleyField ? fieldSample(map, map.valleyField, fx, fy) : 0;
|
|
|
|
|
const ravine = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx, fy) : 0;
|
|
|
|
|
const tex = map.surfaceTextureField ? fieldSample(map, map.surfaceTextureField, fx, fy) : 0;
|
|
|
|
|
const rvL = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx - 0.90, fy) : 0;
|
|
|
|
|
const rvR = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx + 0.90, fy) : 0;
|
|
|
|
|
const rvU = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx, fy - 0.90) : 0;
|
|
|
|
|
const rvD = map.visibleRavineField ? fieldSample(map, map.visibleRavineField, fx, fy + 0.90) : 0;
|
2026-05-24 17:38:51 +09:00
|
|
|
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);
|
2026-05-23 18:06:01 +09:00
|
|
|
|
2026-05-24 17:38:51 +09:00
|
|
|
// 谷底の色保持は少し残すが、以前より圧縮を弱めて陰影の振幅を大きくする。
|
|
|
|
|
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);
|
2026-05-23 18:06:01 +09:00
|
|
|
|
2026-05-24 17:38:51 +09:00
|
|
|
return clamp(shade, 0.54, 1.26);
|
2026-05-23 18:06:01 +09:00
|
|
|
}
|
|
|
|
|
|
2026-05-20 13:50:56 +09:00
|
|
|
function discreteColor(map, x, y, mode) {
|
2026-05-29 14:31:42 +09:00
|
|
|
const i = cellIndex(map, x, y);
|
2026-05-20 13:50:56 +09:00
|
|
|
let color;
|
|
|
|
|
|
|
|
|
|
if (map.sea[i]) {
|
2026-05-23 18:06:01 +09:00
|
|
|
color = [160, 205, 239];
|
2026-05-20 13:50:56 +09:00
|
|
|
} else if (mode === "landuse") {
|
|
|
|
|
const colors = {
|
2026-05-24 21:21:17 +09:00
|
|
|
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
|
2026-05-20 13:50:56 +09:00
|
|
|
};
|
|
|
|
|
color = colors[map.landuse[i]] || colors[0];
|
|
|
|
|
} else if (mode === "admin") {
|
|
|
|
|
const palette = [
|
2026-05-22 02:11:18 +09:00
|
|
|
[250, 245, 242], [245, 250, 245], [245, 245, 252],
|
|
|
|
|
[252, 250, 242], [250, 245, 250], [242, 250, 250]
|
2026-05-20 13:50:56 +09:00
|
|
|
];
|
|
|
|
|
const a = map.adminId[i];
|
2026-05-22 02:11:18 +09:00
|
|
|
color = a >= 0 ? palette[a % palette.length] : [240, 242, 240];
|
2026-05-20 13:50:56 +09:00
|
|
|
} else {
|
|
|
|
|
color = terrainColorContinuous(map, x, y, "terrain");
|
|
|
|
|
}
|
|
|
|
|
return blendOutside(color, Boolean(map.prefectureMask[i]));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 14:31:42 +09:00
|
|
|
function baseCacheKey(mode, continuousTerrain, renderScale = 1) {
|
2026-05-26 16:56:18 +09:00
|
|
|
const continuousModes = ["terrain", "development", "all"];
|
2026-05-29 14:31:42 +09:00
|
|
|
const scaleKey = Math.round((renderScale || 1) * 20) / 20;
|
2026-05-26 16:56:18 +09:00
|
|
|
if (continuousTerrain && continuousModes.includes(mode)) {
|
2026-05-29 14:31:42 +09:00
|
|
|
return `continuous:${mode === "all" ? "terrain" : mode}:${scaleKey}`;
|
2026-05-26 16:56:18 +09:00
|
|
|
}
|
2026-05-29 14:31:42 +09:00
|
|
|
return `discrete:${mode}:${scaleKey}`;
|
2026-05-26 16:56:18 +09:00
|
|
|
}
|
|
|
|
|
|
2026-05-29 14:31:42 +09:00
|
|
|
function getCachedBaseCanvas(ctx, map, mode, continuousTerrain, renderScale = 1) {
|
2026-05-26 16:56:18 +09:00
|
|
|
let cache = baseImageCache.get(map);
|
|
|
|
|
if (!cache) {
|
|
|
|
|
cache = new Map();
|
|
|
|
|
baseImageCache.set(map, cache);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 14:31:42 +09:00
|
|
|
const key = baseCacheKey(mode, continuousTerrain, renderScale);
|
|
|
|
|
let canvas = cache.get(key);
|
|
|
|
|
if (canvas) return canvas;
|
2026-05-26 16:56:18 +09:00
|
|
|
|
2026-05-29 14:31:42 +09:00
|
|
|
const sourceWidth = mapPixelWidth(map);
|
|
|
|
|
const sourceHeight = mapPixelHeight(map);
|
|
|
|
|
const targetScale = Math.max(0.35, Math.min(1, renderScale || 1));
|
|
|
|
|
const width = Math.max(1, Math.round(sourceWidth * targetScale));
|
|
|
|
|
const height = Math.max(1, Math.round(sourceHeight * targetScale));
|
2026-05-20 13:50:56 +09:00
|
|
|
const img = ctx.createImageData(width, height);
|
2026-05-24 22:52:22 +09:00
|
|
|
const continuousModes = ["terrain", "development", "all"];
|
2026-05-20 13:50:56 +09:00
|
|
|
|
|
|
|
|
if (continuousTerrain && continuousModes.includes(mode)) {
|
|
|
|
|
for (let py = 0; py < height; py++) {
|
2026-05-29 14:31:42 +09:00
|
|
|
const fy = (py / Math.max(1, height)) * mapHeight(map);
|
2026-05-20 13:50:56 +09:00
|
|
|
for (let px = 0; px < width; px++) {
|
2026-05-29 14:31:42 +09:00
|
|
|
const fx = (px / Math.max(1, width)) * mapWidth(map);
|
2026-05-20 13:50:56 +09:00
|
|
|
const [r, g, b] = terrainColorContinuous(map, fx, fy, mode === "all" ? "terrain" : mode);
|
2026-05-23 18:06:01 +09:00
|
|
|
const shade = terrainShadeContinuous(map, fx, fy);
|
2026-05-20 13:50:56 +09:00
|
|
|
|
|
|
|
|
const ii = (py * width + px) * 4;
|
2026-05-22 02:11:18 +09:00
|
|
|
img.data[ii] = Math.round(r * shade);
|
|
|
|
|
img.data[ii + 1] = Math.round(g * shade);
|
|
|
|
|
img.data[ii + 2] = Math.round(b * shade);
|
2026-05-20 13:50:56 +09:00
|
|
|
img.data[ii + 3] = 255;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
2026-05-29 14:31:42 +09:00
|
|
|
const w = mapWidth(map);
|
|
|
|
|
const h = mapHeight(map);
|
|
|
|
|
for (let y = 0; y < h; y++) {
|
|
|
|
|
for (let x = 0; x < w; x++) {
|
2026-05-20 13:50:56 +09:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-29 14:31:42 +09:00
|
|
|
|
|
|
|
|
canvas = document.createElement("canvas");
|
|
|
|
|
canvas.width = width;
|
|
|
|
|
canvas.height = height;
|
|
|
|
|
const bctx = canvas.getContext("2d");
|
|
|
|
|
bctx.putImageData(img, 0, 0);
|
|
|
|
|
cache.set(key, canvas);
|
2026-05-26 16:56:18 +09:00
|
|
|
if (cache.size > MAX_BASE_CACHE_IMAGES) cache.delete(cache.keys().next().value);
|
2026-05-29 14:31:42 +09:00
|
|
|
return canvas;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function drawBase(ctx, map, mode, continuousTerrain, renderScale = 1) {
|
|
|
|
|
// putImageData ignores the current transform, so it made the terrain layer
|
|
|
|
|
// appear unzoomed while vector layers scaled. Cache the raster into an
|
|
|
|
|
// offscreen canvas and draw it with drawImage so wheel zoom applies to terrain.
|
|
|
|
|
ctx.imageSmoothingEnabled = true;
|
|
|
|
|
ctx.drawImage(getCachedBaseCanvas(ctx, map, mode, continuousTerrain, renderScale), 0, 0, mapPixelWidth(map), mapPixelHeight(map));
|
2026-05-26 16:56:18 +09:00
|
|
|
}
|
|
|
|
|
|
2026-05-29 14:31:42 +09:00
|
|
|
|
|
|
|
|
function getRasterBorderSegments(map, fieldName) {
|
|
|
|
|
const field = map?.[fieldName];
|
|
|
|
|
if (!field) return [];
|
|
|
|
|
let cacheByField = rasterBorderCache.get(field);
|
|
|
|
|
if (cacheByField?.segments) return cacheByField.segments;
|
|
|
|
|
const segments = [];
|
|
|
|
|
const w = mapWidth(map);
|
|
|
|
|
const h = mapHeight(map);
|
|
|
|
|
for (let y = 0; y < h; y++) {
|
|
|
|
|
for (let x = 0; x < w; x++) {
|
|
|
|
|
const i = cellIndex(map, x, y);
|
|
|
|
|
const id = field[i];
|
|
|
|
|
if (id < 0 || map.sea?.[i]) continue;
|
|
|
|
|
if (x + 1 < w) {
|
|
|
|
|
const ri = cellIndex(map, x + 1, y);
|
|
|
|
|
const rid = field[ri];
|
|
|
|
|
if (!map.sea?.[ri] && rid >= 0 && rid !== id) segments.push([[x + 0.5, y], [x + 0.5, y + 1]]);
|
|
|
|
|
}
|
|
|
|
|
if (y + 1 < h) {
|
|
|
|
|
const di = cellIndex(map, x, y + 1);
|
|
|
|
|
const did = field[di];
|
|
|
|
|
if (!map.sea?.[di] && did >= 0 && did !== id) segments.push([[x, y + 0.5], [x + 1, y + 0.5]]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
rasterBorderCache.set(field, { segments });
|
|
|
|
|
return segments;
|
2026-05-20 13:50:56 +09:00
|
|
|
}
|
|
|
|
|
|
2026-05-29 18:50:54 +09:00
|
|
|
function getDisplayBorderSegments(map, fieldName, precomputedSegments, mode) {
|
|
|
|
|
// Raw raster borders expose every one-cell ID fragment and also reveal patch
|
|
|
|
|
// ownership seams as long rectangular prefecture/municipality lines. Keep
|
|
|
|
|
// that extractor debug-only; normal modes must use prepared vector layers.
|
|
|
|
|
if (mode === "borders-debug") return getRasterBorderSegments(map, fieldName);
|
|
|
|
|
return Array.isArray(precomputedSegments) ? precomputedSegments : [];
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 18:06:01 +09:00
|
|
|
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];
|
2026-05-29 14:31:42 +09:00
|
|
|
const i1 = cellIndex(map, Math.round(x1), Math.round(y1));
|
|
|
|
|
const i2 = cellIndex(map, Math.round(x2), Math.round(y2));
|
2026-05-23 18:06:01 +09:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-28 15:48:42 +09:00
|
|
|
function drawPath(ctx, path, color, width, dashed = false) {
|
|
|
|
|
const points = vectorPath(path);
|
2026-05-22 14:13:35 +09:00
|
|
|
if (points.length < 2) return;
|
2026-05-20 13:50:56 +09:00
|
|
|
ctx.save();
|
|
|
|
|
ctx.lineCap = "round";
|
|
|
|
|
ctx.lineJoin = "round";
|
|
|
|
|
ctx.strokeStyle = color;
|
|
|
|
|
ctx.lineWidth = width;
|
2026-05-22 02:11:18 +09:00
|
|
|
if (dashed) ctx.setLineDash([8, 6]);
|
2026-05-20 13:50:56 +09:00
|
|
|
ctx.beginPath();
|
2026-05-22 14:13:35 +09:00
|
|
|
drawPolylinePoints(ctx, points);
|
2026-05-20 13:50:56 +09:00
|
|
|
ctx.stroke();
|
|
|
|
|
ctx.restore();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-28 00:30:09 +09:00
|
|
|
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;
|
2026-05-29 14:31:42 +09:00
|
|
|
const land = insideMap(map, x, y) && !map.sea[cellIndex(map, x, y)];
|
2026-05-28 00:30:09 +09:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-28 15:48:42 +09:00
|
|
|
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);
|
2026-05-28 00:30:09 +09:00
|
|
|
}
|
|
|
|
|
|
2026-05-22 02:11:18 +09:00
|
|
|
// 魚の骨(私鉄記号)スタイルを描画するための専用関数
|
|
|
|
|
function drawRailway(ctx, path, color, lineWidth, tickLen, spacing) {
|
2026-05-22 14:13:35 +09:00
|
|
|
const points = vectorPath(path);
|
|
|
|
|
if (points.length < 2) return;
|
2026-05-22 02:11:18 +09:00
|
|
|
ctx.save();
|
|
|
|
|
ctx.lineCap = "butt";
|
|
|
|
|
ctx.lineJoin = "round";
|
|
|
|
|
ctx.strokeStyle = color;
|
2026-05-20 17:15:09 +09:00
|
|
|
|
2026-05-22 02:11:18 +09:00
|
|
|
// 中心の実線を描画
|
|
|
|
|
ctx.lineWidth = lineWidth;
|
|
|
|
|
ctx.beginPath();
|
2026-05-22 14:13:35 +09:00
|
|
|
drawPolylinePoints(ctx, points);
|
2026-05-22 02:11:18 +09:00
|
|
|
ctx.stroke();
|
2026-05-20 17:15:09 +09:00
|
|
|
|
2026-05-22 02:11:18 +09:00
|
|
|
// 棘(クロスハッチ)を描画
|
|
|
|
|
ctx.lineWidth = 1.0;
|
|
|
|
|
ctx.beginPath();
|
2026-05-22 14:13:35 +09:00
|
|
|
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];
|
2026-05-22 02:11:18 +09:00
|
|
|
const dx = x2 - x1;
|
|
|
|
|
const dy = y2 - y1;
|
|
|
|
|
const dist = Math.hypot(dx, dy);
|
2026-05-22 14:13:35 +09:00
|
|
|
if (dist <= 0.001) continue;
|
|
|
|
|
|
2026-05-22 02:11:18 +09:00
|
|
|
const nx = dx / dist;
|
|
|
|
|
const ny = dy / dist;
|
|
|
|
|
const px = -ny * (tickLen / 2);
|
|
|
|
|
const py = nx * (tickLen / 2);
|
|
|
|
|
|
2026-05-22 14:13:35 +09:00
|
|
|
let d = carry;
|
2026-05-22 02:11:18 +09:00
|
|
|
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;
|
2026-05-20 17:15:09 +09:00
|
|
|
}
|
2026-05-22 14:13:35 +09:00
|
|
|
carry = d - dist;
|
2026-05-20 17:15:09 +09:00
|
|
|
}
|
2026-05-22 02:11:18 +09:00
|
|
|
ctx.stroke();
|
|
|
|
|
ctx.restore();
|
2026-05-20 17:15:09 +09:00
|
|
|
}
|
|
|
|
|
|
2026-05-28 00:30:09 +09:00
|
|
|
function drawLandRailway(ctx, map, path, color, lineWidth, tickLen, spacing) {
|
|
|
|
|
for (const chunk of landOnlySubpaths(map, path, 3)) drawRailway(ctx, chunk, color, lineWidth, tickLen, spacing);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 02:11:18 +09:00
|
|
|
function drawSegments(ctx, segments, color, width, dashed = false) {
|
2026-05-20 13:50:56 +09:00
|
|
|
ctx.save();
|
|
|
|
|
ctx.strokeStyle = color;
|
|
|
|
|
ctx.lineWidth = width;
|
2026-05-20 17:15:09 +09:00
|
|
|
ctx.lineCap = "round";
|
|
|
|
|
ctx.lineJoin = "round";
|
2026-05-22 02:11:18 +09:00
|
|
|
if (dashed) ctx.setLineDash([6, 5]);
|
2026-05-20 17:15:09 +09:00
|
|
|
|
2026-05-20 13:50:56 +09:00
|
|
|
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) {
|
2026-05-24 21:21:17 +09:00
|
|
|
const visibleModes = ["all", "modern", "development", "landuse", "admin"];
|
2026-05-20 13:50:56 +09:00
|
|
|
if (!visibleModes.includes(mode)) return;
|
|
|
|
|
|
2026-05-24 22:52:22 +09:00
|
|
|
const detailedColors = {
|
2026-05-24 21:21:17 +09:00
|
|
|
2: "rgba(223, 214, 206, 0.72)",
|
2026-05-24 22:52:22 +09:00
|
|
|
3: "rgba(215, 175, 172, 0.88)",
|
2026-05-24 21:21:17 +09:00
|
|
|
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)",
|
2026-05-24 22:52:22 +09:00
|
|
|
8: "rgba(231, 219, 231, 0.68)",
|
2026-05-20 13:50:56 +09:00
|
|
|
};
|
2026-05-24 22:52:22 +09:00
|
|
|
const cityColor = "rgba(232, 222, 228, 0.60)";
|
|
|
|
|
const cbdColor = "rgba(215, 175, 172, 0.84)";
|
2026-05-20 13:50:56 +09:00
|
|
|
|
|
|
|
|
ctx.save();
|
2026-05-29 14:31:42 +09:00
|
|
|
const w = mapWidth(map);
|
|
|
|
|
const h = mapHeight(map);
|
|
|
|
|
for (let y = 0; y < h; y++) {
|
|
|
|
|
for (let x = 0; x < w; x++) {
|
|
|
|
|
const i = cellIndex(map, x, y);
|
2026-05-24 21:21:17 +09:00
|
|
|
const areaMask = map.humanRegionMask || map.prefectureMask;
|
|
|
|
|
if (areaMask && !areaMask[i]) continue;
|
2026-05-20 13:50:56 +09:00
|
|
|
const lu = map.landuse[i];
|
2026-05-24 22:52:22 +09:00
|
|
|
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;
|
2026-05-20 13:50:56 +09:00
|
|
|
|
|
|
|
|
const px = x * CELL_SIZE;
|
|
|
|
|
const py = y * CELL_SIZE;
|
2026-05-24 22:52:22 +09:00
|
|
|
ctx.fillStyle = fill;
|
2026-05-20 13:50:56 +09:00
|
|
|
ctx.fillRect(px, py, CELL_SIZE, CELL_SIZE);
|
2026-05-22 02:11:18 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
ctx.restore();
|
|
|
|
|
}
|
2026-05-20 13:50:56 +09:00
|
|
|
|
2026-05-22 02:11:18 +09:00
|
|
|
function drawDebugCells(ctx, map, field, color) {
|
|
|
|
|
if (!field) return;
|
|
|
|
|
ctx.save();
|
2026-05-29 14:31:42 +09:00
|
|
|
const w = mapWidth(map);
|
|
|
|
|
const h = mapHeight(map);
|
|
|
|
|
for (let y = 0; y < h; y++) {
|
|
|
|
|
for (let x = 0; x < w; x++) {
|
|
|
|
|
const i = cellIndex(map, x, y);
|
2026-05-24 17:38:51 +09:00
|
|
|
const debugMask = map.humanRegionMask || map.prefectureMask;
|
|
|
|
|
if (!debugMask[i] || map.sea[i]) continue;
|
2026-05-27 00:13:13 +09:00
|
|
|
const raw = field[i] || 0;
|
|
|
|
|
const v = clamp(raw > 1 ? raw / 255 : raw, 0, 1);
|
2026-05-22 02:11:18 +09:00
|
|
|
if (v <= 0.12) continue;
|
|
|
|
|
ctx.fillStyle = color(v);
|
|
|
|
|
ctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE);
|
2026-05-20 13:50:56 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
ctx.restore();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 21:14:37 +09:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 15:32:27 +09:00
|
|
|
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) {
|
2026-05-26 21:14:37 +09:00
|
|
|
if (!["all", "admin", "borders-debug"].includes(mode)) return;
|
2026-05-26 15:32:27 +09:00
|
|
|
const ids = map.prefectureRegionId;
|
|
|
|
|
if (!ids) return;
|
2026-05-26 21:14:37 +09:00
|
|
|
const alpha = mode === "borders-debug" ? 0.34 : 0.18;
|
2026-05-26 15:32:27 +09:00
|
|
|
ctx.save();
|
|
|
|
|
ctx.globalAlpha = alpha;
|
2026-05-29 14:31:42 +09:00
|
|
|
const w = mapWidth(map);
|
|
|
|
|
const h = mapHeight(map);
|
|
|
|
|
for (let y = 0; y < h; y++) {
|
|
|
|
|
for (let x = 0; x < w; x++) {
|
|
|
|
|
const i = cellIndex(map, x, y);
|
2026-05-26 15:32:27 +09:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 13:50:56 +09:00
|
|
|
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();
|
2026-05-22 02:11:18 +09:00
|
|
|
ctx.lineWidth = 1.2;
|
2026-05-20 13:50:56 +09:00
|
|
|
ctx.strokeStyle = stroke;
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 02:11:18 +09:00
|
|
|
function boxesOverlap(a, b, pad = 3) {
|
2026-05-20 13:50:56 +09:00
|
|
|
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;
|
2026-05-26 21:14:37 +09:00
|
|
|
const isPrefectureLabel = p.kind === "Prefecture" || p.kind === "Current Prefecture" || p.isPrefectureLabel;
|
2026-05-28 00:30:09 +09:00
|
|
|
const isMunicipalityLabel = p.labelStyle === "municipality";
|
2026-05-20 13:50:56 +09:00
|
|
|
ctx.save();
|
2026-05-26 21:14:37 +09:00
|
|
|
ctx.font = isPrefectureLabel
|
|
|
|
|
? "900 20px ui-sans-serif, system-ui, -apple-system, sans-serif"
|
2026-05-28 00:30:09 +09:00
|
|
|
: isMunicipalityLabel
|
|
|
|
|
? "600 10px ui-sans-serif, system-ui, -apple-system, sans-serif"
|
|
|
|
|
: "600 11.5px ui-sans-serif, system-ui, -apple-system, sans-serif";
|
2026-05-20 13:50:56 +09:00
|
|
|
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;
|
2026-05-28 00:30:09 +09:00
|
|
|
const textH = isPrefectureLabel ? 22 : isMunicipalityLabel ? 10 : 12;
|
2026-05-26 21:14:37 +09:00
|
|
|
const candidates = isPrefectureLabel
|
|
|
|
|
? [
|
|
|
|
|
[-textW / 2, 6], [-textW / 2, -12], [-textW / 2, 24],
|
|
|
|
|
[10, 6], [-textW - 10, 6],
|
|
|
|
|
]
|
2026-05-28 00:30:09 +09:00
|
|
|
: 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],
|
|
|
|
|
];
|
2026-05-20 13:50:56 +09:00
|
|
|
|
2026-05-28 00:30:09 +09:00
|
|
|
let fallback = null;
|
2026-05-20 13:50:56 +09:00
|
|
|
for (const [ox, oy] of candidates) {
|
|
|
|
|
const x = baseX + ox;
|
|
|
|
|
const y = baseY + oy;
|
2026-05-26 21:14:37 +09:00
|
|
|
const box = { x1: x - 3, y1: y - textH, x2: x + textW + 3, y2: y + 5 };
|
2026-05-29 14:31:42 +09:00
|
|
|
if (box.x1 < 0 || box.y1 < 0 || box.x2 > (ctx.__mapPixelWidth || MAP_W * CELL_SIZE) || box.y2 > (ctx.__mapPixelHeight || MAP_H * CELL_SIZE)) continue;
|
2026-05-28 00:30:09 +09:00
|
|
|
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) {
|
2026-05-22 02:11:18 +09:00
|
|
|
ctx.lineJoin = "round";
|
2026-05-28 00:30:09 +09:00
|
|
|
ctx.lineWidth = isPrefectureLabel ? 6.2 : isMunicipalityLabel ? 3.0 : 3.5;
|
2026-05-26 21:14:37 +09:00
|
|
|
ctx.strokeStyle = isPrefectureLabel ? "rgba(255, 255, 255, 0.98)" : "rgba(255, 255, 255, 0.95)";
|
2026-05-28 00:30:09 +09:00
|
|
|
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);
|
2026-05-20 13:50:56 +09:00
|
|
|
ctx.restore();
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
ctx.restore();
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-28 00:30:09 +09:00
|
|
|
function drawLabels(ctx, points, limit = Infinity, occupied = null) {
|
|
|
|
|
const used = occupied || [];
|
2026-05-20 13:50:56 +09:00
|
|
|
const prioritized = points
|
|
|
|
|
.filter((p) => p?.name)
|
2026-05-22 02:11:18 +09:00
|
|
|
.map((p) => ({ ...p, labelPriority: (p.labelPriorityBase || 0) + (p.isPrefecturalCapital ? 1000 : 0) + (p.population || 0) / 900 + (p.portClass === "major" ? 170 : p.portClass === "regional" ? 95 : 0) }))
|
2026-05-20 13:50:56 +09:00
|
|
|
.sort((a, b) => b.labelPriority - a.labelPriority);
|
2026-05-28 00:30:09 +09:00
|
|
|
for (const p of prioritized.slice(0, limit)) labelWithCollision(ctx, p, used);
|
|
|
|
|
return used;
|
2026-05-20 13:50:56 +09:00
|
|
|
}
|
|
|
|
|
|
2026-05-29 14:31:42 +09:00
|
|
|
function drawScaleBar(ctx, cellScreenSize = CELL_SIZE) {
|
2026-05-28 15:48:42 +09:00
|
|
|
const kmPerCell = 0.5;
|
2026-05-28 00:30:09 +09:00
|
|
|
const targetKm = 25;
|
2026-05-24 21:21:17 +09:00
|
|
|
const lengthCells = Math.max(8, Math.round(targetKm / kmPerCell));
|
2026-05-29 14:31:42 +09:00
|
|
|
const lengthPx = lengthCells * cellScreenSize;
|
2026-05-24 21:21:17 +09:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 13:50:56 +09:00
|
|
|
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;
|
2026-05-28 19:37:28 +09:00
|
|
|
const continuousTerrain = options.continuousTerrain !== false;
|
2026-05-29 14:31:42 +09:00
|
|
|
const outputWidth = MAP_W * CELL_SIZE;
|
|
|
|
|
const outputHeight = MAP_H * CELL_SIZE;
|
|
|
|
|
const sourceWidth = mapPixelWidth(map);
|
|
|
|
|
const sourceHeight = mapPixelHeight(map);
|
|
|
|
|
const drawScale = Math.min(outputWidth / Math.max(1, sourceWidth), outputHeight / Math.max(1, sourceHeight));
|
|
|
|
|
const drawOffsetX = (outputWidth - sourceWidth * drawScale) * 0.5;
|
|
|
|
|
const drawOffsetY = (outputHeight - sourceHeight * drawScale) * 0.5;
|
|
|
|
|
const cellScreenSize = CELL_SIZE * drawScale;
|
|
|
|
|
const terrainRenderScale = continuousTerrain ? clamp(drawScale * (options.fastTerrain ? 0.48 : 1.35), 0.30, 1) : 1;
|
|
|
|
|
|
|
|
|
|
if (canvas.width !== outputWidth) canvas.width = outputWidth;
|
|
|
|
|
if (canvas.height !== outputHeight) canvas.height = outputHeight;
|
|
|
|
|
ctx.clearRect(0, 0, outputWidth, outputHeight);
|
2026-05-28 23:51:55 +09:00
|
|
|
ctx.save();
|
2026-05-29 14:31:42 +09:00
|
|
|
ctx.translate(drawOffsetX, drawOffsetY);
|
|
|
|
|
ctx.scale(drawScale, drawScale);
|
|
|
|
|
ctx.__mapPixelWidth = sourceWidth;
|
|
|
|
|
ctx.__mapPixelHeight = sourceHeight;
|
2026-05-28 23:51:55 +09:00
|
|
|
const finish = () => {
|
|
|
|
|
ctx.restore();
|
2026-05-29 14:31:42 +09:00
|
|
|
drawScaleBar(ctx, cellScreenSize);
|
|
|
|
|
delete ctx.__mapPixelWidth;
|
|
|
|
|
delete ctx.__mapPixelHeight;
|
2026-05-28 23:51:55 +09:00
|
|
|
};
|
2026-05-20 13:50:56 +09:00
|
|
|
|
2026-05-22 02:11:18 +09:00
|
|
|
// 1. Base Terrain & Urban
|
2026-05-29 14:31:42 +09:00
|
|
|
drawBase(ctx, map, mode, continuousTerrain, terrainRenderScale);
|
2026-05-20 13:50:56 +09:00
|
|
|
drawUrbanAreas(ctx, map, mode);
|
2026-05-22 14:13:35 +09:00
|
|
|
const coastSegments = getCoastlineSegments(map);
|
2026-05-23 18:06:01 +09:00
|
|
|
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 });
|
2026-05-20 13:50:56 +09:00
|
|
|
|
2026-05-22 02:11:18 +09:00
|
|
|
// 2. Rivers
|
2026-05-26 21:14:37 +09:00
|
|
|
const waterBlue = "rgba(116, 165, 202, 0.92)";
|
|
|
|
|
const mediumBlue = "rgba(132, 184, 220, 0.78)";
|
2026-05-23 18:06:01 +09:00
|
|
|
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];
|
2026-05-29 14:31:42 +09:00
|
|
|
const i = cellIndex(map, Math.round(x), Math.round(y));
|
2026-05-23 18:06:01 +09:00
|
|
|
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;
|
2026-05-26 21:14:37 +09:00
|
|
|
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);
|
2026-05-23 18:06:01 +09:00
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
}
|
2026-05-20 13:50:56 +09:00
|
|
|
|
2026-05-28 00:30:09 +09:00
|
|
|
const showHistory = mode === "history";
|
2026-05-26 21:14:37 +09:00
|
|
|
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);
|
2026-05-28 00:30:09 +09:00
|
|
|
const showPremodernRoads = ["history", "all", "transport-debug"].includes(mode);
|
2026-05-26 21:14:37 +09:00
|
|
|
const showAdmin = ["admin", "all", "borders-debug"].includes(mode);
|
2026-05-20 13:50:56 +09:00
|
|
|
|
2026-05-22 02:11:18 +09:00
|
|
|
// 3. Borders
|
2026-05-26 21:14:37 +09:00
|
|
|
const showPrefectureRegions = ["all", "admin", "borders-debug"].includes(mode);
|
2026-05-26 15:32:27 +09:00
|
|
|
if (showPrefectureRegions) drawPrefectureRegionFill(ctx, map, mode);
|
|
|
|
|
|
2026-05-29 18:50:54 +09:00
|
|
|
const adminBorderSegments = getDisplayBorderSegments(map, "adminId", map.adminBorders, mode);
|
|
|
|
|
const prefectureBorderSegments = getDisplayBorderSegments(map, "prefectureRegionId", map.regionalPrefectureBorders, mode);
|
2026-05-29 14:31:42 +09:00
|
|
|
if (showAdmin && adminBorderSegments?.length) {
|
|
|
|
|
drawVectorSegments(ctx, adminBorderSegments, "rgba(255, 255, 255, 0.8)", 2.8, false, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
|
|
|
|
|
drawVectorSegments(ctx, adminBorderSegments, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
|
2026-05-22 02:11:18 +09:00
|
|
|
}
|
2026-05-26 21:14:37 +09:00
|
|
|
if (mode === "borders-debug") {
|
2026-05-24 17:38:51 +09:00
|
|
|
// 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);
|
2026-05-22 13:57:52 +09:00
|
|
|
for (const p of map.adminDebug?.lowlandAdminSeeds || []) dot(ctx, p, 3.2, "rgba(255,255,255,0.9)", "rgba(40,150,95,0.95)");
|
2026-05-21 22:03:14 +09:00
|
|
|
}
|
2026-05-26 21:14:37 +09:00
|
|
|
if (showTransportDebug) drawTransportDebug(ctx, map);
|
2026-05-24 19:33:09 +09:00
|
|
|
|
2026-05-29 14:31:42 +09:00
|
|
|
if (showPrefectureRegions && prefectureBorderSegments?.length) {
|
|
|
|
|
drawVectorSegments(ctx, prefectureBorderSegments, "rgba(255, 255, 255, 0.90)", 4.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
|
|
|
|
drawVectorSegments(ctx, prefectureBorderSegments, "rgba(82, 60, 102, 0.96)", 1.8, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
2026-05-24 19:33:09 +09:00
|
|
|
}
|
|
|
|
|
|
2026-05-26 15:32:27 +09:00
|
|
|
if (!showPrefectureRegions) {
|
2026-05-29 14:31:42 +09:00
|
|
|
const finalPrefectureBorders = (prefectureBorderSegments && prefectureBorderSegments.length) ? prefectureBorderSegments : map.prefectureBorder;
|
2026-05-26 16:56:18 +09:00
|
|
|
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 });
|
2026-05-26 15:32:27 +09:00
|
|
|
}
|
2026-05-22 02:11:18 +09:00
|
|
|
|
2026-05-28 23:51:55 +09:00
|
|
|
if (!showFeatures) {
|
|
|
|
|
finish();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-20 13:50:56 +09:00
|
|
|
|
2026-05-22 13:57:52 +09:00
|
|
|
// 4. Transport casings. Layer order: local roads, trunk roads, railways, expressways.
|
2026-05-28 00:30:09 +09:00
|
|
|
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);
|
2026-05-22 13:57:52 +09:00
|
|
|
}
|
|
|
|
|
if (showRoads) {
|
2026-05-28 00:30:09 +09:00
|
|
|
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);
|
2026-05-22 13:57:52 +09:00
|
|
|
}
|
2026-05-22 02:11:18 +09:00
|
|
|
if (showModern || showRoads) {
|
2026-05-28 00:30:09 +09:00
|
|
|
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);
|
2026-05-22 13:57:52 +09:00
|
|
|
}
|
|
|
|
|
if (showRoads) {
|
2026-05-28 16:45:00 +09:00
|
|
|
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);
|
2026-05-20 13:50:56 +09:00
|
|
|
}
|
|
|
|
|
|
2026-05-22 13:57:52 +09:00
|
|
|
// 5. Transport fills. Expressways remain above railways; railways sit above ordinary roads.
|
2026-05-28 00:30:09 +09:00
|
|
|
if (generalRoadPaths.length) {
|
|
|
|
|
for (const path of generalRoadPaths) drawLandPath(ctx, map, path, localRoadFill, 1.45, false);
|
2026-05-22 13:57:52 +09:00
|
|
|
}
|
|
|
|
|
if (showRoads) {
|
2026-05-28 00:30:09 +09:00
|
|
|
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);
|
2026-05-22 13:57:52 +09:00
|
|
|
}
|
2026-05-22 02:11:18 +09:00
|
|
|
if (showModern || showRoads) {
|
2026-05-28 00:30:09 +09:00
|
|
|
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);
|
2026-05-22 13:57:52 +09:00
|
|
|
}
|
|
|
|
|
if (showRoads) {
|
2026-05-28 16:45:00 +09:00
|
|
|
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);
|
2026-05-20 13:50:56 +09:00
|
|
|
}
|
|
|
|
|
|
2026-05-22 02:11:18 +09:00
|
|
|
// 6. Icons & Labels
|
2026-05-26 21:14:37 +09:00
|
|
|
if (["admin", "borders-debug"].includes(mode)) {
|
2026-05-24 19:33:09 +09:00
|
|
|
for (const p of map.adminCenters || []) dot(ctx, p, 2.8, "rgba(255,255,255,0.96)", "rgba(75,60,90,0.95)");
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-28 15:48:42 +09:00
|
|
|
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)");
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 13:50:56 +09:00
|
|
|
if (showModern) {
|
2026-05-22 02:11:18 +09:00
|
|
|
for (const p of map.stations) dot(ctx, p, 2.5, "#fff", "#444");
|
2026-05-20 13:50:56 +09:00
|
|
|
for (const p of map.modernCities) {
|
2026-05-24 21:21:17 +09:00
|
|
|
const popRadius = p.population ? Math.min(9.4, 3.8 + Math.sqrt(p.population) / 360) : 4.8;
|
2026-05-22 02:11:18 +09:00
|
|
|
dot(ctx, p, popRadius, "rgba(240, 110, 110, 0.95)", "rgba(255,255,255,0.9)");
|
2026-05-24 21:21:17 +09:00
|
|
|
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)");
|
2026-05-22 02:11:18 +09:00
|
|
|
}
|
2026-05-22 13:57:52 +09:00
|
|
|
for (const p of map.interchanges || []) dot(ctx, p, 2.7, "rgba(250, 250, 245, 0.95)", "rgba(95, 125, 95, 0.95)");
|
2026-05-26 16:56:18 +09:00
|
|
|
for (const p of map.logisticsParks || []) dot(ctx, p, 2.4, "rgba(235, 238, 230, 0.95)", "rgba(105, 125, 105, 0.88)");
|
2026-05-26 21:14:37 +09:00
|
|
|
if (mode === "borders-debug") {
|
2026-05-22 02:11:18 +09:00
|
|
|
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)");
|
2026-05-20 13:50:56 +09:00
|
|
|
}
|
2026-05-26 21:14:37 +09:00
|
|
|
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)");
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-20 13:50:56 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (showLabels) {
|
2026-05-28 15:48:42 +09:00
|
|
|
const prefectureLabels = (map.prefectureRegions || []).map((p) => ({ ...p, isPrefectureLabel: true, forceLabel: true, labelPriorityBase: p.labelPriorityBase || 1900 }));
|
2026-05-22 02:11:18 +09:00
|
|
|
if (mode === "admin") {
|
2026-05-28 15:48:42 +09:00
|
|
|
const municipalLabels = (map.adminCenters || [])
|
2026-05-29 15:49:09 +09:00
|
|
|
.filter((p) => p && p.name && Number.isFinite(p.x) && Number.isFinite(p.y) && p.x >= 0 && p.y >= 0 && p.x < map.width && p.y < map.height && map.adminId?.[Math.round(p.y) * map.width + Math.round(p.x)] === (p.adminId ?? p.municipalityId ?? p.adminNumericId))
|
2026-05-28 15:48:42 +09:00
|
|
|
.map((p) => ({ ...p, labelStyle: "municipality", forceLabel: true, labelPriorityBase: 1200 }));
|
|
|
|
|
drawLabels(ctx, [...prefectureLabels, ...municipalLabels], Infinity);
|
2026-05-28 23:51:55 +09:00
|
|
|
finish();
|
2026-05-22 02:11:18 +09:00
|
|
|
return;
|
|
|
|
|
}
|
2026-05-26 21:14:37 +09:00
|
|
|
if (mode === "borders-debug") {
|
2026-05-28 15:48:42 +09:00
|
|
|
drawLabels(ctx, prefectureLabels, Infinity);
|
2026-05-28 23:51:55 +09:00
|
|
|
finish();
|
2026-05-22 02:11:18 +09:00
|
|
|
return;
|
|
|
|
|
}
|
2026-05-20 13:50:56 +09:00
|
|
|
const important = [
|
2026-05-26 15:32:27 +09:00
|
|
|
...prefectureLabels,
|
2026-05-20 13:50:56 +09:00
|
|
|
...map.modernCities,
|
2026-05-28 15:48:42 +09:00
|
|
|
...map.ports,
|
2026-05-20 13:50:56 +09:00
|
|
|
...(map.satelliteCities || []),
|
2026-05-28 15:48:42 +09:00
|
|
|
...settlementIconLabelPoints,
|
2026-05-28 19:37:28 +09:00
|
|
|
].filter((p) => !p.suppressSettlementLabel && (p.isPrefectureLabel || p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000));
|
2026-05-28 15:48:42 +09:00
|
|
|
drawLabels(ctx, important, mode === "all" || mode === "history" ? 78 : 60);
|
2026-05-20 13:50:56 +09:00
|
|
|
}
|
2026-05-28 23:51:55 +09:00
|
|
|
finish();
|
2026-05-20 13:50:56 +09:00
|
|
|
}
|