vector-styled

This commit is contained in:
33333-33333 2026-05-22 14:13:35 +09:00
commit 3f2be96395
5 changed files with 291 additions and 35 deletions

View file

@ -6,6 +6,7 @@ export function finishMapOutput({
seed,
options,
terrainTemplate,
seaLevel,
cityPopulationCap,
stationInfluence,
roadInfluence,
@ -207,6 +208,7 @@ export function finishMapOutput({
height: MAP_H,
cellSize: CELL_SIZE,
terrainTemplate,
seaLevel,
prefectureMask,
prefectureBorder,
prefectureRegionId,

View file

@ -12,6 +12,7 @@ export function generateMap(seedInput = 114514, options = {}) {
const terrain = generateTerrainAndRivers(seed);
const {
terrainTemplate,
seaLevel,
elevation,
moisture,
slope,
@ -63,7 +64,7 @@ export function generateMap(seedInput = 114514, options = {}) {
});
return finishMapOutput({
seed, options, terrainTemplate, cityPopulationCap, stationInfluence, roadInfluence, railInfluence2,
seed, options, terrainTemplate, seaLevel, cityPopulationCap, stationInfluence, roadInfluence, railInfluence2,
elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, agriculture, settlementCluster, ridgeField, valleyField, basinField, coastalLowland, flowAccum, erosionField, depositionField,
arcSpineField, branchRidgeField, depositionalLowland, alluvialFanField, deltaField, naturalBarrierScore,
villages, ports, crossings, passes, markets, castles, castleTowns, premodernRoads, minorRoads, modernCities, populationDensity,

View file

@ -1288,6 +1288,7 @@ export function generateTerrainAndRivers(seed) {
return {
terrainTemplate,
seaLevel,
elevation,
moisture,
slope,

View file

@ -34,7 +34,7 @@ export const NAME_KANJI_POOLS = {
],
coastalTerrain: [
"津", "浦", "津", "崎",
"津", "浦", "ヶ浦", "津", "崎",
"島", "磯", "潟", "湊", "津",
"州", "洲", "瀬", "砂", "潮", "塩", "汐",
"泊", "江", "浦", "灘", "入",

View file

@ -1,5 +1,259 @@
import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf } from "./mapUtils.js";
const segmentVectorCache = new WeakMap();
const pathVectorCache = new WeakMap();
const coastlineCache = new WeakMap();
function pointKey(p) {
return `${p[0]},${p[1]}`;
}
function parsePointKey(key) {
return key.split(",").map(Number);
}
function samePoint(a, b, eps = 1e-6) {
return Math.abs(a[0] - b[0]) <= eps && Math.abs(a[1] - b[1]) <= eps;
}
function perpendicularDistance(p, a, b) {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const len2 = dx * dx + dy * dy;
if (len2 <= 1e-9) return Math.hypot(p[0] - a[0], p[1] - a[1]);
const t = clamp(((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2, 0, 1);
const x = a[0] + dx * t;
const y = a[1] + dy * t;
return Math.hypot(p[0] - x, p[1] - y);
}
function simplifyRdp(points, tolerance = 0.05) {
if (!points || points.length <= 2) return points || [];
let bestIndex = -1;
let bestDistance = -1;
const first = points[0];
const last = points[points.length - 1];
for (let i = 1; i < points.length - 1; i++) {
const d = perpendicularDistance(points[i], first, last);
if (d > bestDistance) {
bestDistance = d;
bestIndex = i;
}
}
if (bestDistance <= tolerance) return [first, last];
const left = simplifyRdp(points.slice(0, bestIndex + 1), tolerance);
const right = simplifyRdp(points.slice(bestIndex), tolerance);
return left.slice(0, -1).concat(right);
}
function removeCollinear(points) {
if (!points || points.length <= 2) return points || [];
const closed = samePoint(points[0], points[points.length - 1]);
const core = closed ? points.slice(0, -1) : points.slice();
if (core.length <= 2) return points;
const out = [];
const count = core.length;
for (let i = 0; i < count; i++) {
const prev = core[(i - 1 + count) % count];
const cur = core[i];
const next = core[(i + 1) % count];
if (!closed && (i === 0 || i === count - 1)) {
out.push(cur);
continue;
}
const ax = cur[0] - prev[0];
const ay = cur[1] - prev[1];
const bx = next[0] - cur[0];
const by = next[1] - cur[1];
if (Math.abs(ax * by - ay * bx) > 1e-9) out.push(cur);
}
if (closed && out.length) out.push(out[0]);
return out;
}
function chaikin(points, iterations = 1, closed = false) {
if (!points || points.length < 3 || iterations <= 0) return points || [];
let result = points.slice();
for (let iter = 0; iter < iterations; iter++) {
const src = closed && samePoint(result[0], result[result.length - 1]) ? result.slice(0, -1) : result;
if (src.length < 3) break;
const next = [];
if (!closed) next.push(src[0]);
const limit = closed ? src.length : src.length - 1;
for (let i = 0; i < limit; i++) {
const a = src[i];
const b = src[(i + 1) % src.length];
next.push([a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25]);
next.push([a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75]);
}
if (!closed) next.push(src[src.length - 1]);
if (closed && next.length) next.push(next[0]);
result = next;
}
return result;
}
function traceSegmentChain(edges, adjacency, used, startKey, firstEdgeIndex) {
const line = [parsePointKey(startKey)];
let currentKey = startKey;
let nextEdgeIndex = firstEdgeIndex;
while (nextEdgeIndex !== undefined && nextEdgeIndex !== null && !used[nextEdgeIndex]) {
used[nextEdgeIndex] = 1;
const edge = edges[nextEdgeIndex];
const toKey = edge.aKey === currentKey ? edge.bKey : edge.aKey;
line.push(parsePointKey(toKey));
currentKey = toKey;
if (currentKey === startKey) break;
const degree = adjacency.get(currentKey)?.length || 0;
if (degree !== 2) break;
nextEdgeIndex = (adjacency.get(currentKey) || []).find((idx) => !used[idx]);
}
return line;
}
function segmentChains(segments) {
if (!segments?.length) return [];
const edges = [];
const adjacency = new Map();
for (const seg of segments) {
const aKey = pointKey(seg[0]);
const bKey = pointKey(seg[1]);
if (aKey === bKey) continue;
const edgeIndex = edges.length;
edges.push({ aKey, bKey });
if (!adjacency.has(aKey)) adjacency.set(aKey, []);
if (!adjacency.has(bKey)) adjacency.set(bKey, []);
adjacency.get(aKey).push(edgeIndex);
adjacency.get(bKey).push(edgeIndex);
}
const used = new Uint8Array(edges.length);
const chains = [];
for (let i = 0; i < edges.length; i++) {
if (used[i]) continue;
const edge = edges[i];
const aDegree = adjacency.get(edge.aKey)?.length || 0;
const bDegree = adjacency.get(edge.bKey)?.length || 0;
if (aDegree === 2 && bDegree === 2) continue;
const startKey = aDegree !== 2 ? edge.aKey : edge.bKey;
chains.push(traceSegmentChain(edges, adjacency, used, startKey, i));
}
for (let i = 0; i < edges.length; i++) {
if (used[i]) continue;
chains.push(traceSegmentChain(edges, adjacency, used, edges[i].aKey, i));
}
return chains.filter((line) => line.length >= 2);
}
function vectorizeSegments(segments, { iterations = 2, tolerance = 0.08 } = {}) {
if (!segments?.length) return [];
const cacheKey = `${iterations}:${tolerance}`;
let cachedByOption = segmentVectorCache.get(segments);
if (!cachedByOption) {
cachedByOption = new Map();
segmentVectorCache.set(segments, cachedByOption);
}
if (cachedByOption.has(cacheKey)) return cachedByOption.get(cacheKey);
const polylines = segmentChains(segments).map((line) => {
const cleaned = removeCollinear(line);
const closed = cleaned.length > 2 && samePoint(cleaned[0], cleaned[cleaned.length - 1]);
const smoothed = chaikin(cleaned, iterations, closed);
if (closed) return smoothed;
return simplifyRdp(smoothed, tolerance);
}).filter((line) => line.length >= 2);
cachedByOption.set(cacheKey, polylines);
return polylines;
}
function getCoastlineSegments(map) {
if (!map?.sea) return [];
const cached = coastlineCache.get(map.sea);
if (cached) return cached;
const segments = [];
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
const a = Boolean(map.sea[i]);
if (x + 1 < MAP_W) {
const b = Boolean(map.sea[indexOf(x + 1, y)]);
if (a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
}
if (y + 1 < MAP_H) {
const b = Boolean(map.sea[indexOf(x, y + 1)]);
if (a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]);
}
}
}
coastlineCache.set(map.sea, segments);
return segments;
}
function vectorPath(path) {
if (!path || path.length < 2) return [];
const cached = pathVectorCache.get(path);
if (cached) return cached;
const points = path.map(([x, y]) => [x * CELL_SIZE + CELL_SIZE / 2, y * CELL_SIZE + CELL_SIZE / 2]);
const simplified = simplifyRdp(points, CELL_SIZE * 0.34);
const smoothed = chaikin(simplified, path.length > 6 ? 1 : 0, false);
pathVectorCache.set(path, smoothed);
return smoothed;
}
function drawPolylinePoints(ctx, points) {
if (!points || points.length < 2) return;
ctx.moveTo(points[0][0], points[0][1]);
for (let k = 1; k < points.length; k++) ctx.lineTo(points[k][0], points[k][1]);
}
function drawVectorSegments(ctx, segments, color, width, dashed = false, vectorOptions = {}) {
const { offsetX = 0, offsetY = 0, ...shapeOptions } = vectorOptions || {};
const polylines = vectorizeSegments(segments, shapeOptions);
if (!polylines.length) return;
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = "round";
ctx.lineJoin = "round";
if (dashed) ctx.setLineDash([6, 5]);
for (const line of polylines) {
ctx.beginPath();
ctx.moveTo((line[0][0] + offsetX) * CELL_SIZE, (line[0][1] + offsetY) * CELL_SIZE);
for (let k = 1; k < line.length; k++) ctx.lineTo((line[k][0] + offsetX) * CELL_SIZE, (line[k][1] + offsetY) * CELL_SIZE);
ctx.stroke();
}
ctx.restore();
}
function sampleCellIndex(fx, fy) {
const x = Math.max(0, Math.min(MAP_W - 1, Math.floor(fx)));
const y = Math.max(0, Math.min(MAP_H - 1, Math.floor(fy)));
return indexOf(x, y);
}
function isWaterSample(map, fx, fy) {
// The generated terrain arrays are cell-centered, while pixels are drawn across
// each cell. Water/land classification must therefore follow the discrete sea
// mask, not the interpolated elevation value. Interpolating elevation near a
// coast makes the right/bottom side of land cells inherit sea values and leaves
// visible unpainted strips inside the smoothed coastline.
return Boolean(map.sea[sampleCellIndex(fx, fy)]);
}
function distToNearest(points, x, y, fallback = 999) {
let best = fallback;
for (const p of points) best = Math.min(best, Math.hypot(p.x - x, p.y - y));
@ -16,12 +270,14 @@ function blendOutside(color, isInside) {
}
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 sx = Math.max(0, Math.min(MAP_W - 1, fx));
const sy = Math.max(0, Math.min(MAP_H - 1, fy));
const x0 = Math.floor(sx);
const y0 = Math.floor(sy);
const x1 = Math.max(0, Math.min(MAP_W - 1, x0 + 1));
const y1 = Math.max(0, Math.min(MAP_H - 1, y0 + 1));
const tx = fx - x0;
const ty = fy - y0;
const tx = sx - x0;
const ty = sy - y0;
const a = field[indexOf(x0, y0)];
const b = field[indexOf(x1, y0)];
@ -32,13 +288,13 @@ function fieldSample(field, fx, fy) {
}
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 i = sampleCellIndex(fx, 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);
if (isWaterSample(map, fx, fy)) {
const depth = clamp(((map.seaLevel ?? 0.285) + 0.065 - fieldSample(map.elevation, fx, fy)) * 2.4);
color = [Math.round(170 + depth * 5), Math.round(218 + depth * 10), Math.round(255 - depth * 5)];
} else if (mode === "suitability") {
const a = fieldSample(map.agriculture, fx, fy);
@ -152,7 +408,8 @@ function drawBase(ctx, map, mode, continuousTerrain) {
}
function drawPath(ctx, path, color, width, dashed = false) {
if (!path || path.length < 2) return;
const points = vectorPath(path);
if (points.length < 2) return;
ctx.save();
ctx.lineCap = "round";
ctx.lineJoin = "round";
@ -160,17 +417,15 @@ function drawPath(ctx, path, color, width, dashed = false) {
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);
}
drawPolylinePoints(ctx, points);
ctx.stroke();
ctx.restore();
}
// 魚の骨(私鉄記号)スタイルを描画するための専用関数
function drawRailway(ctx, path, color, lineWidth, tickLen, spacing) {
if (!path || path.length < 2) return;
const points = vectorPath(path);
if (points.length < 2) return;
ctx.save();
ctx.lineCap = "butt";
ctx.lineJoin = "round";
@ -179,33 +434,27 @@ function drawRailway(ctx, path, color, lineWidth, tickLen, spacing) {
// 中心の実線を描画
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);
}
drawPolylinePoints(ctx, points);
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;
let carry = spacing * 0.5;
for (let k = 0; k < points.length - 1; k++) {
const [x1, y1] = points[k];
const [x2, y2] = points[k + 1];
const dx = x2 - x1;
const dy = y2 - y1;
const dist = Math.hypot(dx, dy);
if (dist === 0) continue;
// 法線(直角)ベクトル
if (dist <= 0.001) continue;
const nx = dx / dist;
const ny = dy / dist;
const px = -ny * (tickLen / 2);
const py = nx * (tickLen / 2);
let d = (spacing / 2) + leftover;
let d = carry;
while (d < dist) {
const cx = x1 + nx * d;
const cy = y1 + ny * d;
@ -213,7 +462,7 @@ function drawRailway(ctx, path, color, lineWidth, tickLen, spacing) {
ctx.lineTo(cx - px, cy - py);
d += spacing;
}
leftover = d - dist;
carry = d - dist;
}
ctx.stroke();
ctx.restore();
@ -357,6 +606,9 @@ export function drawMap(canvas, map, options) {
// 1. Base Terrain & Urban
drawBase(ctx, map, mode, true);
drawUrbanAreas(ctx, map, mode);
const coastSegments = getCoastlineSegments(map);
drawVectorSegments(ctx, coastSegments, "rgba(120, 175, 210, 0.22)", 2.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
drawVectorSegments(ctx, coastSegments, "rgba(248, 250, 242, 0.68)", 1.1, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
// 2. Rivers
const waterBlue = "rgba(160, 205, 240, 1)";
@ -371,18 +623,18 @@ export function drawMap(canvas, map, options) {
// 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);
drawVectorSegments(ctx, map.adminBorders, "rgba(255, 255, 255, 0.8)", 2.8, false, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
drawVectorSegments(ctx, map.adminBorders, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
}
if (mode === "admin-debug" || mode === "borders-debug") {
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);
for (const p of map.adminDebug?.lowlandAdminSeeds || []) dot(ctx, p, 3.2, "rgba(255,255,255,0.9)", "rgba(40,150,95,0.95)");
if (map.regionalPrefectureBorders) drawSegments(ctx, map.regionalPrefectureBorders, "rgba(70, 55, 95, 0.95)", 2.4, false);
if (map.regionalPrefectureBorders) drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(70, 55, 95, 0.95)", 2.4, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
}
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);
drawVectorSegments(ctx, map.prefectureBorder, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
drawVectorSegments(ctx, map.prefectureBorder, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
if (!showFeatures) return;