terrain tweak?
This commit is contained in:
parent
1be3d86da0
commit
8e1ec7b9ac
7 changed files with 3423 additions and 1328 deletions
|
|
@ -1,136 +0,0 @@
|
|||
# 修正版メモ
|
||||
|
||||
## 目的
|
||||
|
||||
日本風地図ジェネレータを、将来のシームレス・パン生成に寄せるため、中央の1県だけに注目する描画を弱め、全域を同格の地図として読める方向へ変更した。
|
||||
あわせて、論理解像度を上げ、地形生成では日本列島風の複数並行脊梁山脈と大規模な沖積低地が出やすくなるよう調整した。
|
||||
|
||||
## 変更点
|
||||
|
||||
### 1. 中央県フォーカスの解除
|
||||
|
||||
- `renderer.js`
|
||||
- `blendOutside()` による県外減光を停止。
|
||||
- 通常表示では `prefectureBorder` ではなく `regionalPrefectureBorders` を主境界として描画。
|
||||
- 中央県境の強調表示は `admin-debug` / `borders-debug` のみに限定。
|
||||
- 隣接県ディテールの `globalAlpha` を `0.78` から `1.0` へ変更。
|
||||
- 隣接県ラベル表示数を増加。
|
||||
- 県名ラベルを `modern` / `roads` / `development` / `landuse` でも表示。
|
||||
|
||||
### 2. 論理解像度の向上
|
||||
|
||||
- `mapUtils.js`
|
||||
- `MAP_W: 172 -> 258`
|
||||
- `MAP_H: 122 -> 183`
|
||||
- `CELL_SIZE: 6 -> 4`
|
||||
- 表示キャンバスサイズは概ね維持しつつ、地形・河川・境界の格子感を減らす構成。
|
||||
|
||||
- `mapOutput.js`
|
||||
- `scaleKmPerCell: 172 / MAP_W` を追加し、スケールバーの見た目を旧スケールに近づけた。
|
||||
|
||||
### 3. 複数並行脊梁山脈
|
||||
|
||||
- `mapTerrain.js`
|
||||
- `spineCount` を従来の `1〜2本` から `2〜4本` に変更。
|
||||
- `spineSpacing` を追加。
|
||||
- 脊梁山脈の向きを海岸・列島軸に沿う方向へ寄せた。
|
||||
- 本数増加に合わせて、1本ごとの幅・強度をやや抑制。
|
||||
- `buildSpineRidges()` を中心線からの並行オフセット配置に変更。
|
||||
|
||||
### 4. 大規模沖積平野
|
||||
|
||||
- `mapTerrain.js`
|
||||
- 強い河川の下流部を抽出し、下流〜河口周辺に広めの堆積低地を追加。
|
||||
- `depositionalLowland`, `deltaField`, `floodplain`, `valleyField`, `basinField` を連動更新。
|
||||
- 標高面も局所的に低く・滑らかに補正し、単なる色塗りではなく地形として平野化しやすくした。
|
||||
|
||||
## 注意点
|
||||
|
||||
- 論理解像度を約2.25倍にしたため、生成時間は旧版より伸びる。
|
||||
- `prefectureMask` 自体は統計・行政デバッグ用に残している。通常描画で目立たせない方針にした。
|
||||
- まだ完全なシームレス・パン生成ではない。今回の変更は、その前段階として「中央県主役の見た目」を解除し、地形解像度と地形構造を整えたもの。
|
||||
|
||||
## 簡易確認
|
||||
|
||||
Node.js で `generateMap()` が実行できることを確認済み。
|
||||
|
||||
```bash
|
||||
node --input-type=module -e "import('./mapPipeline.js').then(({generateMap})=>console.log(generateMap(114514,{slim:true}).width))"
|
||||
```
|
||||
|
||||
## 2026-05-22 地形生成パイプライン再構成
|
||||
|
||||
ユーザー指摘に基づき、地形生成の考え方を「既存ノイズ地形に山脈を足す」方式から、以下の段階式に寄せた。
|
||||
|
||||
1. 全体を海面下の地形として初期化
|
||||
2. 複数の平行な脊梁山脈で地殻を隆起
|
||||
3. 海面付近にランダムな平坦面を残し、氷期の海面低下・再海進後の段丘/沖積低地の種にする
|
||||
4. 大域・中域・微細ノイズを後段で加算
|
||||
5. 河川作用を粗くシミュレートし、急峻な高流量セルでは侵食、低勾配の海岸・盆地・低地では堆積を強化
|
||||
6. その後、人文地理生成へ渡す
|
||||
|
||||
主な実装修正:
|
||||
|
||||
- `mapTerrain.js`
|
||||
- 脊梁山脈の生成を、狭い稜線 + 広い隆起帯の二層構造に変更。
|
||||
- 中央部が一枚の高原に見えないよう、山脈間の低地・盆地を明示的に残すよう修正。
|
||||
- 海岸線生成を、旧来の方向性 `coastPressure` 判定中心から、標高が海面を超えるかどうかを主とする方式へ変更。
|
||||
- 海面付近に `glacialFlatLevel` を導入し、ランダムな平坦面を形成。
|
||||
- 河川網生成前に、流量・勾配に基づく粗い侵食/堆積パスを追加。
|
||||
- 河川経路の後処理で河川が短く切れすぎる問題を緩和し、主要河川が海へ接続しやすいよう修正。
|
||||
|
||||
確認:
|
||||
|
||||
- `generateMap(114514, { slim: true })` の実行を確認。
|
||||
- `terrainDebug.riverConnectivityRate` が `1` になることを確認。
|
||||
- `depositionLowlandArea` が前版より広くなり、沖積低地が出やすくなった。
|
||||
|
||||
## 2026-05-22: 不連続・非中央固定の山脈生成に修正
|
||||
|
||||
### 問題
|
||||
- 脊梁山脈が画面中央を通る連続した帯になりやすかった。
|
||||
- 広い隆起帯が強すぎ、山頂部が平坦な高原に見えた。
|
||||
- 主脊梁以外の補助山地・補助山脈が弱く、地形構造が単調だった。
|
||||
|
||||
### 修正
|
||||
- 主脊梁山脈を 2〜3 系統にし、互いの間隔を広げた。
|
||||
- `spinePosition` と各山脈の横方向オフセットを強め、中央固定を緩和した。
|
||||
- 山脈を一本の連続線ではなく、沿線方向のノイズと波で途切れる構造にした。
|
||||
- 主脊梁の broad uplift を弱め、尾根本体を細くした。
|
||||
- 尾根の蛇行量・曲率・幅変化を強めた。
|
||||
- 渥美半島・北上高地的な補助山地/補助山脈として `auxRanges` を追加した。
|
||||
- 円形山塊の数を増やし、主脊梁周辺に副次的な高地が出やすいようにした。
|
||||
|
||||
### 意図
|
||||
地図上で「太い中央帯」ではなく、
|
||||
|
||||
- 離れた複数の山地帯
|
||||
- 断続的な主稜線
|
||||
- 周辺の補助山地
|
||||
- 山地間の低地・海岸低地
|
||||
|
||||
として読めるようにする。
|
||||
|
||||
## 追加修正: 地形帯の形状を現実の山地分布に寄せる
|
||||
|
||||
前回版では、山脈が周期的な波線として見え、画面中央付近に太い帯状山地が出やすかった。これは、脊梁山脈の中心線を正弦波的に揺らし、細い `arcSpineField` を描画色に強く反映していたため。
|
||||
|
||||
今回の修正では以下を行った。
|
||||
|
||||
- 脊梁山脈の正弦波的な中心線揺れを廃止。
|
||||
- 山脈の途切れは周期波ではなく、低周波ノイズによる山塊の強弱として表現。
|
||||
- 主山脈は中央固定ではなく、島の横断方向に広くオフセットするよう変更。
|
||||
- 中国山地・四国山地のように、2本の山地帯が大きく離れるケースを増加。
|
||||
- 主山脈の長さを短めにし、画面端から端まで連続する一本線になりにくくした。
|
||||
- 渥美半島・北上高地のような補助山地を、主山脈周辺の短い山塊・外帯山地として追加。
|
||||
- 山頂が平らな高原に見える原因だった広域隆起成分を弱め、山地内部に侵食状の起伏を追加。
|
||||
- 描画側では `arcSpineField` の直接発色を弱め、標高・広域山地性・陰影で山地を読ませる方式に変更。
|
||||
- 山地色をやや濃くし、低い緑地との明度差を広げた。
|
||||
|
||||
## 追加修正: alpine_bands
|
||||
|
||||
- 前版で高山帯がほぼ出なくなった問題を修正。
|
||||
- 脊梁山脈・補助山地の隆起量を再調整し、浸食後にも高標高セルが残るようにした。
|
||||
- 河川作用による侵食量をやや抑え、峰・稜線部分だけに「高山帯再強化」パスを追加。
|
||||
- 高山帯が平らな台地にならないよう、標高の持ち上げには局所ノイズを混ぜている。
|
||||
- 地形色パレットを調整し、標高と ridge field が高い地点はより茶色く表示される。
|
||||
479
mapOutput.js
479
mapOutput.js
|
|
@ -1,6 +1,6 @@
|
|||
import { createNameDebug } from "./names.js";
|
||||
import { CELL_SIZE, INF, MAP_H, MAP_W, clamp, indexOf, inside, rand } from "./mapUtils.js";
|
||||
import { aStar, applyOutputOptions, attachIdsAndNames, recalculatePopulationAfterLanduse, tagInsidePrefecture } from "./mapGeneratorHelpers.js";
|
||||
import { applyOutputOptions, attachIdsAndNames, recalculatePopulationAfterLanduse, tagInsidePrefecture } from "./mapGeneratorHelpers.js";
|
||||
|
||||
export function finishMapOutput({
|
||||
seed,
|
||||
|
|
@ -24,6 +24,8 @@ export function finishMapOutput({
|
|||
settlementCluster,
|
||||
ridgeField,
|
||||
valleyField,
|
||||
visibleRavineField,
|
||||
surfaceTextureField,
|
||||
basinField,
|
||||
coastalLowland,
|
||||
flowAccum,
|
||||
|
|
@ -115,396 +117,14 @@ export function finishMapOutput({
|
|||
return out;
|
||||
}
|
||||
|
||||
|
||||
function generatedNameStem(fullName) {
|
||||
return String(fullName || "").replace(/[都道府県市町村区]$/u, "");
|
||||
}
|
||||
|
||||
function terrainSettlementScore(x, y, sideBias = null) {
|
||||
const i = indexOf(x, y);
|
||||
if (!inside(x, y) || sea[i]) return -INF;
|
||||
const edgeBias = sideBias === "north" ? (MAP_H - y) / MAP_H
|
||||
: sideBias === "south" ? y / MAP_H
|
||||
: sideBias === "west" ? (MAP_W - x) / MAP_W
|
||||
: sideBias === "east" ? x / MAP_W
|
||||
: 0;
|
||||
return settlementCluster[i] * 0.55 + plain[i] * 0.42 + agriculture[i] * 0.22 + basinField[i] * 0.20 + coastalLowland[i] * 0.20 + valleyField[i] * 0.16 + edgeBias * 0.08 - slope[i] * 0.54 - ridgeField[i] * 0.22 - Math.max(0, elevation[i] - 0.62) * 1.25;
|
||||
}
|
||||
|
||||
function chooseSpacedPoints(candidates, count, minDistance, seedOffset = 0) {
|
||||
const selected = [];
|
||||
const ordered = candidates
|
||||
.map((p, n) => ({ ...p, score: (p.score || 0) + rand(seed, seedOffset + n * 17 + p.x * 5 + p.y * 7) * 0.06 }))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
for (const p of ordered) {
|
||||
if (selected.some((q) => Math.hypot(q.x - p.x, q.y - p.y) < minDistance)) continue;
|
||||
selected.push(p);
|
||||
if (selected.length >= count) break;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function labelCollisionScore(x, y, avoidPoints) {
|
||||
let nearest = 99;
|
||||
for (const p of avoidPoints) {
|
||||
if (!p) continue;
|
||||
const d = Math.hypot(x - p.x, y - p.y);
|
||||
nearest = Math.min(nearest, d);
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
const MAX_BRIDGE_CELLS = 7;
|
||||
|
||||
function pointPair(p) {
|
||||
if (Array.isArray(p)) return [p[0], p[1]];
|
||||
return [p.x, p.y];
|
||||
}
|
||||
|
||||
function sampledWaterRunBetween(a, b) {
|
||||
if (!a || !b) return 0;
|
||||
const [ax, ay] = pointPair(a);
|
||||
const [bx, by] = pointPair(b);
|
||||
const steps = Math.max(1, Math.ceil(Math.hypot(bx - ax, by - ay) * 1.6));
|
||||
let run = 0;
|
||||
let maxRun = 0;
|
||||
for (let k = 0; k <= steps; k++) {
|
||||
const t = k / steps;
|
||||
const x = clamp(Math.round(ax + (bx - ax) * t), 0, MAP_W - 1);
|
||||
const y = clamp(Math.round(ay + (by - ay) * t), 0, MAP_H - 1);
|
||||
const isWater = sea[indexOf(x, y)];
|
||||
if (isWater) {
|
||||
run++;
|
||||
maxRun = Math.max(maxRun, run);
|
||||
} else {
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
return maxRun;
|
||||
}
|
||||
|
||||
function pathMaxWaterRun(path) {
|
||||
if (!path || path.length < 2) return 0;
|
||||
let maxRun = 0;
|
||||
for (let k = 1; k < path.length; k++) {
|
||||
maxRun = Math.max(maxRun, sampledWaterRunBetween(path[k - 1], path[k]));
|
||||
}
|
||||
return maxRun;
|
||||
}
|
||||
|
||||
function landDetourCost(x, y) {
|
||||
if (!inside(x, y)) return INF;
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) return INF;
|
||||
return Math.max(
|
||||
0.35,
|
||||
1 +
|
||||
slope[i] * 7.6 +
|
||||
Math.max(0, elevation[i] - 0.58) * 10.5 +
|
||||
ridgeField[i] * 2.2 -
|
||||
plain[i] * 0.45 -
|
||||
valleyField[i] * 0.54 -
|
||||
coastalLowland[i] * 0.38
|
||||
);
|
||||
}
|
||||
|
||||
function compactOutputPath(path) {
|
||||
const out = [];
|
||||
let last = "";
|
||||
for (const p of path || []) {
|
||||
const [x, y] = pointPair(p);
|
||||
const key = `${x},${y}`;
|
||||
if (key === last) continue;
|
||||
last = key;
|
||||
out.push([x, y]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function repairLongBridgeSegments(path) {
|
||||
const compact = compactOutputPath(path);
|
||||
if (compact.length < 2) return compact.length >= 3 ? compact : [];
|
||||
const out = [compact[0]];
|
||||
for (let k = 1; k < compact.length; k++) {
|
||||
const from = out[out.length - 1];
|
||||
const to = compact[k];
|
||||
if (sampledWaterRunBetween(from, to) <= MAX_BRIDGE_CELLS) {
|
||||
out.push(to);
|
||||
continue;
|
||||
}
|
||||
const detour = compactOutputPath(aStar({ x: from[0], y: from[1] }, { x: to[0], y: to[1] }, landDetourCost));
|
||||
if (detour.length >= 2 && pathMaxWaterRun(detour) <= MAX_BRIDGE_CELLS) {
|
||||
out.push(...detour.slice(1));
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const repaired = compactOutputPath(out);
|
||||
return repaired.length >= 3 ? repaired : [];
|
||||
}
|
||||
|
||||
function enforceBridgeLimitList(paths) {
|
||||
return (paths || [])
|
||||
.map((path) => pathMaxWaterRun(path) > MAX_BRIDGE_CELLS ? repairLongBridgeSegments(path) : compactOutputPath(path))
|
||||
.filter((path) => path && path.length >= 3 && pathMaxWaterRun(path) <= MAX_BRIDGE_CELLS);
|
||||
}
|
||||
|
||||
function shoreContactScore(x, y) {
|
||||
let contacts = 0;
|
||||
for (let dy = -2; dy <= 2; dy++) {
|
||||
for (let dx = -2; dx <= 2; dx++) {
|
||||
if (!dx && !dy) continue;
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
if (!sea[indexOf(nx, ny)]) contacts++;
|
||||
}
|
||||
}
|
||||
return contacts;
|
||||
}
|
||||
|
||||
function labelCandidateScore(x, y, avoidPoints, fallback, capital, seedOffset, preferSea = false) {
|
||||
const i = indexOf(x, y);
|
||||
const nearest = labelCollisionScore(x, y, avoidPoints);
|
||||
const capD = capital ? Math.hypot(x - capital.x, y - capital.y) : 24;
|
||||
const fallbackD = fallback ? Math.hypot(x - fallback.x, y - fallback.y) : 0;
|
||||
const terrainBias = sea[i]
|
||||
? 8 + Math.min(18, shoreContactScore(x, y)) * 0.45
|
||||
: plain[i] * 0.18 + basinField[i] * 0.10 + coastalLowland[i] * 0.10 - slope[i] * 0.08;
|
||||
const clearance = Math.min(nearest, 24) * 1.55 - Math.max(0, 8 - nearest) * 5.0;
|
||||
const capitalBias = capital ? -Math.max(0, capD - (preferSea ? 26 : 45)) * 0.09 : 0;
|
||||
return clearance + terrainBias + capitalBias - fallbackD * 0.012 + rand(seed, seedOffset + x * 17 + y * 19) * 0.42;
|
||||
}
|
||||
|
||||
function pickPrefectureLabelPosition(avoidPoints, fallback, options = {}) {
|
||||
const {
|
||||
capital = null,
|
||||
landPredicate = (x, y, i) => prefectureMask[i] && !sea[i],
|
||||
areaPredicate = null,
|
||||
seedOffset = 7461,
|
||||
} = options;
|
||||
let best = null;
|
||||
let bestScore = -INF;
|
||||
|
||||
// Prefer open water near the prefectural capital when it reads like a coastal prefecture label.
|
||||
if (capital) {
|
||||
const rMax = 30;
|
||||
for (let dy = -rMax; dy <= rMax; dy += 2) {
|
||||
for (let dx = -rMax; dx <= rMax; dx += 2) {
|
||||
const x = clamp(Math.round(capital.x + dx), 0, MAP_W - 1);
|
||||
const y = clamp(Math.round(capital.y + dy), 0, MAP_H - 1);
|
||||
if (x < 6 || y < 6 || x > MAP_W - 7 || y > MAP_H - 7) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (!sea[i]) continue;
|
||||
if (areaPredicate && !areaPredicate(x, y)) continue;
|
||||
const capD = Math.hypot(x - capital.x, y - capital.y);
|
||||
if (capD < 5 || capD > rMax) continue;
|
||||
const coastTouch = shoreContactScore(x, y);
|
||||
if (coastTouch < 3 || coastTouch > 20) continue;
|
||||
const score = labelCandidateScore(x, y, avoidPoints, fallback, capital, seedOffset + 3000, true) + coastTouch * 0.35;
|
||||
if (score > bestScore) { bestScore = score; best = { x, y, placement: "sea" }; }
|
||||
}
|
||||
}
|
||||
if (best && bestScore >= 18) return best;
|
||||
}
|
||||
|
||||
best = null;
|
||||
bestScore = -INF;
|
||||
for (let y = 6; y < MAP_H - 6; y += 3) {
|
||||
for (let x = 6; x < MAP_W - 6; x += 3) {
|
||||
const i = indexOf(x, y);
|
||||
if (!landPredicate(x, y, i)) continue;
|
||||
const score = labelCandidateScore(x, y, avoidPoints, fallback, capital, seedOffset, false);
|
||||
if (score > bestScore) { bestScore = score; best = { x, y, placement: "land" }; }
|
||||
}
|
||||
}
|
||||
if (!best || bestScore < 10) return fallback;
|
||||
return best;
|
||||
}
|
||||
|
||||
function looseOutsidePath(a, b, salt, keepOutside = true) {
|
||||
if (!a || !b) return [];
|
||||
const steps = Math.max(4, Math.ceil(Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y))));
|
||||
const path = [];
|
||||
let lastKey = "";
|
||||
const bend = (rand(seed, 7600 + salt) - 0.5) * 5.5;
|
||||
const sideways = Math.abs(a.x - b.x) > Math.abs(a.y - b.y) ? "y" : "x";
|
||||
for (let k = 0; k <= steps; k++) {
|
||||
const t = k / steps;
|
||||
let x = Math.round(a.x + (b.x - a.x) * t);
|
||||
let y = Math.round(a.y + (b.y - a.y) * t);
|
||||
const wave = Math.sin(t * Math.PI) * bend;
|
||||
if (sideways === "y") y = Math.round(y + wave);
|
||||
else x = Math.round(x + wave);
|
||||
x = clamp(x, 0, MAP_W - 1);
|
||||
y = clamp(y, 0, MAP_H - 1);
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
if (keepOutside && prefectureMask[i]) continue;
|
||||
const key = `${x},${y}`;
|
||||
if (key === lastKey) continue;
|
||||
lastKey = key;
|
||||
path.push([x, y]);
|
||||
}
|
||||
return path.length >= 3 ? repairLongBridgeSegments(path) : [];
|
||||
}
|
||||
|
||||
function generatePrefectureIdentity(usedNamesForIdentity, avoidPoints) {
|
||||
const stemsA = ["青", "白", "黒", "高", "奥", "新", "東", "西", "南", "北", "中", "美", "豊", "若", "真", "清", "瑞", "長", "久", "安", "阿", "葛", "榛", "碓", "那", "鹿", "宇", "志", "遠", "羽"];
|
||||
const stemsB = ["森", "川", "野", "原", "沢", "島", "浦", "浜", "海", "山", "岳", "谷", "津", "崎", "里", "畑", "橋", "瀬", "井", "沼", "丘", "郷", "城", "坂", "泊", "戸", "湊", "庄"];
|
||||
const suffixes = ["県", "県", "県", "県", "県", "府"];
|
||||
const localUsed = new Set(usedNamesForIdentity || []);
|
||||
function pickGeneratedName(offset) {
|
||||
const a = stemsA[Math.floor(rand(seed, 7410 + offset * 11) * stemsA.length) % stemsA.length];
|
||||
const b = stemsB[Math.floor(rand(seed, 7420 + offset * 13) * stemsB.length) % stemsB.length];
|
||||
const c = suffixes[Math.floor(rand(seed, 7430 + offset * 17) * suffixes.length) % suffixes.length];
|
||||
return `${a}${b}${c}`;
|
||||
}
|
||||
function pickUniqueName(offset, preferred = null) {
|
||||
let candidate = preferred || pickGeneratedName(offset);
|
||||
let guard = 0;
|
||||
while (localUsed.has(candidate) && guard++ < 24) candidate = pickGeneratedName(offset + guard + 3);
|
||||
localUsed.add(candidate);
|
||||
if (usedNamesForIdentity) usedNamesForIdentity.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
const capital = modernCities.find((city) => city.isPrefecturalCapital && prefectureMask[indexOf(city.x, city.y)]);
|
||||
const capStem = generatedNameStem(capital?.name);
|
||||
const prefectureSuffix = suffixes[Math.floor(rand(seed, 7433) * suffixes.length) % suffixes.length];
|
||||
const capitalBased = capStem && rand(seed, 7440) < 0.80 ? `${capStem}${prefectureSuffix}` : null;
|
||||
const name = pickUniqueName(0, capitalBased);
|
||||
|
||||
let sx = 0, sy = 0, n = 0;
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (!prefectureMask[i] || sea[i]) continue;
|
||||
sx += x; sy += y; n++;
|
||||
}
|
||||
}
|
||||
const fallback = { x: n ? sx / n : MAP_W / 2, y: n ? sy / n : MAP_H / 2 };
|
||||
const labelPoint = pickPrefectureLabelPosition(avoidPoints, fallback, {
|
||||
capital,
|
||||
landPredicate: (x, y, i) => prefectureMask[i] && !sea[i],
|
||||
seedOffset: 7461,
|
||||
});
|
||||
const label = { name, x: labelPoint.x, y: labelPoint.y, kind: "Prefecture Label", placement: labelPoint.placement || "land" };
|
||||
|
||||
const sideSamples = { north: [], south: [], west: [], east: [] };
|
||||
for (let x = 2; x < MAP_W - 2; x += 4) {
|
||||
for (let y = 0; y < Math.min(13, MAP_H); y++) if (inside(x, y) && !prefectureMask[indexOf(x, y)] && !sea[indexOf(x, y)]) { sideSamples.north.push({ x, y: 3 }); break; }
|
||||
for (let y = MAP_H - 1; y >= Math.max(0, MAP_H - 13); y--) if (inside(x, y) && !prefectureMask[indexOf(x, y)] && !sea[indexOf(x, y)]) { sideSamples.south.push({ x, y: MAP_H - 4 }); break; }
|
||||
}
|
||||
for (let y = 2; y < MAP_H - 2; y += 4) {
|
||||
for (let x = 0; x < Math.min(13, MAP_W); x++) if (inside(x, y) && !prefectureMask[indexOf(x, y)] && !sea[indexOf(x, y)]) { sideSamples.west.push({ x: 3, y }); break; }
|
||||
for (let x = MAP_W - 1; x >= Math.max(0, MAP_W - 13); x--) if (inside(x, y) && !prefectureMask[indexOf(x, y)] && !sea[indexOf(x, y)]) { sideSamples.east.push({ x: MAP_W - 4, y }); break; }
|
||||
}
|
||||
|
||||
const neighbors = [];
|
||||
const sideOrder = ["north", "east", "south", "west"];
|
||||
for (let si = 0; si < sideOrder.length; si++) {
|
||||
const side = sideOrder[si];
|
||||
const samples = sideSamples[side];
|
||||
if (!samples.length && rand(seed, 7500 + si) < 0.45) continue;
|
||||
const nm = pickUniqueName(si + 1);
|
||||
const p = samples.length ? samples[Math.floor(rand(seed, 7520 + si) * samples.length) % samples.length] : (
|
||||
side === "north" ? { x: MAP_W * 0.5, y: 3 } : side === "south" ? { x: MAP_W * 0.5, y: MAP_H - 4 } : side === "west" ? { x: 3, y: MAP_H * 0.5 } : { x: MAP_W - 4, y: MAP_H * 0.5 }
|
||||
);
|
||||
neighbors.push({ name: nm, side, x: p.x, y: p.y, kind: "Neighbor Prefecture Label" });
|
||||
}
|
||||
return { name, label, neighbors };
|
||||
}
|
||||
|
||||
function generateNeighborPrefectureDetails(neighborLabels, usedNamesForNeighbor, nameDebugForNeighbor) {
|
||||
const allCities = [];
|
||||
const allAdmins = [];
|
||||
const allCbds = [];
|
||||
const allRoads = [];
|
||||
const allRailways = [];
|
||||
const details = [];
|
||||
const sideBand = {
|
||||
north: (x, y) => y <= Math.floor(MAP_H * 0.30),
|
||||
south: (x, y) => y >= Math.ceil(MAP_H * 0.70),
|
||||
west: (x, y) => x <= Math.floor(MAP_W * 0.34),
|
||||
east: (x, y) => x >= Math.ceil(MAP_W * 0.66),
|
||||
};
|
||||
for (let ni = 0; ni < neighborLabels.length; ni++) {
|
||||
const label = neighborLabels[ni];
|
||||
const predicate = sideBand[label.side] || (() => true);
|
||||
const candidates = [];
|
||||
for (let y = 2; y < MAP_H - 2; y += 2) {
|
||||
for (let x = 2; x < MAP_W - 2; x += 2) {
|
||||
const i = indexOf(x, y);
|
||||
if (prefectureMask[i] || sea[i] || !predicate(x, y)) continue;
|
||||
const dToLabel = Math.hypot(x - label.x, y - label.y);
|
||||
const borderAway = label.side === "north" ? y : label.side === "south" ? MAP_H - 1 - y : label.side === "west" ? x : MAP_W - 1 - x;
|
||||
const score = terrainSettlementScore(x, y, label.side) - dToLabel * 0.006 + Math.min(16, borderAway) * 0.006;
|
||||
if (score > 0.06) candidates.push({ x, y, score, neighborIndex: ni, neighborName: label.name, side: label.side });
|
||||
}
|
||||
}
|
||||
const rawCities = chooseSpacedPoints(candidates, 2 + Math.floor(rand(seed, 7700 + ni) * 3), 12, 7710 + ni * 100)
|
||||
.map((p, n) => {
|
||||
const rank = n === 0 ? "Neighbor Prefectural Capital" : n === 1 ? "Neighbor Regional Center" : "Neighbor City";
|
||||
const popBase = n === 0 ? 240000 : n === 1 ? 90000 : 36000;
|
||||
const popSpread = n === 0 ? 620000 : n === 1 ? 220000 : 90000;
|
||||
const population = Math.round((popBase + popSpread * Math.pow(clamp(p.score + rand(seed, 7730 + ni * 31 + n), 0, 1), 1.8)) / 1000) * 1000;
|
||||
return { ...p, kind: rank, rank, population, urbanRadius: clamp(7 + Math.sqrt(population) / 105, 7, 21), coreRadius: clamp(2.4 + Math.sqrt(population) / 420, 2.4, 6.2), urbanWeight: 1.0 + Math.log10(Math.max(10000, population)) * 0.23, insidePrefecture: false };
|
||||
});
|
||||
const namedCities = attachIdsAndNames(rawCities, `neighborCity${ni}`, seed + ni * 100, null, nameFields, usedNamesForNeighbor, nameDebugForNeighbor);
|
||||
const adminCandidates = chooseSpacedPoints(candidates.filter((p) => !namedCities.some((c) => Math.hypot(c.x - p.x, c.y - p.y) < 7)), 4 + Math.floor(rand(seed, 7760 + ni) * 5), 8, 7770 + ni * 100)
|
||||
.map((p) => ({ ...p, kind: "Neighbor Municipal Center", insidePrefecture: false }));
|
||||
const namedAdmins = attachIdsAndNames(adminCandidates, `neighborAdmin${ni}`, seed + ni * 131, "Neighbor Municipal Center", nameFields, usedNamesForNeighbor, nameDebugForNeighbor);
|
||||
const cbds = namedCities.map((city, ci) => ({ x: city.x, y: city.y, parentId: city.id, parentName: city.name, name: `${city.name}CBD`, kind: ci === 0 ? "Neighbor Central Business District" : "Neighbor Urban Center", neighborIndex: ni, neighborName: label.name, insidePrefecture: false }));
|
||||
const nodes = [...namedCities, ...namedAdmins].sort((a, b) => (b.population || 0) - (a.population || 0) || (b.score || 0) - (a.score || 0));
|
||||
const roads = [];
|
||||
const rails = [];
|
||||
for (let i = 1; i < nodes.length; i++) {
|
||||
const target = nodes[i];
|
||||
const anchor = nodes.slice(0, i).sort((a, b) => Math.hypot(a.x - target.x, a.y - target.y) - Math.hypot(b.x - target.x, b.y - target.y))[0];
|
||||
const road = looseOutsidePath(anchor, target, ni * 400 + i * 17, true);
|
||||
if (road.length >= 3) roads.push(road);
|
||||
if (i <= 2 && rand(seed, 7790 + ni * 19 + i) > 0.30) {
|
||||
const rail = looseOutsidePath(anchor, target, ni * 500 + i * 23, true);
|
||||
if (rail.length >= 4) rails.push(rail);
|
||||
}
|
||||
}
|
||||
if (nodes[0]) {
|
||||
const gate = { x: label.x, y: label.y };
|
||||
const gatewayRoad = looseOutsidePath(nodes[0], gate, ni * 600 + 7, true);
|
||||
if (gatewayRoad.length >= 3) roads.push(gatewayRoad);
|
||||
}
|
||||
const labelAvoid = [...namedCities, ...namedAdmins, ...allCities, ...allAdmins];
|
||||
const labelCandidates = candidates.length ? candidates : [{ x: label.x, y: label.y, score: 0.1 }];
|
||||
const fallbackLabel = chooseSpacedPoints(labelCandidates, 1, 1, 7810 + ni)[0] || label;
|
||||
const capitalPoint = namedCities[0] || fallbackLabel;
|
||||
const betterLabel = pickPrefectureLabelPosition(labelAvoid, fallbackLabel, {
|
||||
capital: capitalPoint,
|
||||
landPredicate: (x, y, i) => !prefectureMask[i] && !sea[i] && predicate(x, y),
|
||||
areaPredicate: predicate,
|
||||
seedOffset: 7810 + ni * 97,
|
||||
}) || fallbackLabel;
|
||||
label.x = betterLabel.x;
|
||||
label.y = betterLabel.y;
|
||||
label.placement = betterLabel.placement || "land";
|
||||
allCities.push(...namedCities);
|
||||
allAdmins.push(...namedAdmins);
|
||||
allCbds.push(...cbds);
|
||||
allRoads.push(...roads);
|
||||
allRailways.push(...rails);
|
||||
details.push({ ...label, cities: namedCities, adminCenters: namedAdmins, centralBusinessDistricts: cbds, roads, railways: rails });
|
||||
}
|
||||
return { prefectures: details, cities: allCities, adminCenters: allAdmins, centralBusinessDistricts: allCbds, roads: allRoads, railways: allRailways };
|
||||
}
|
||||
|
||||
// Bridge and tunnel icon systems were removed from the visual model.
|
||||
// Arrays remain empty for backward-compatible tests and downstream code.
|
||||
const bridges = [];
|
||||
const tunnels = [];
|
||||
const harborWorks = makeHarborWorks(ports);
|
||||
let abandonedRailways = branchRailways.filter((_, i) => i % 3 === 0);
|
||||
const abandonedRailways = branchRailways.filter((_, i) => i % 3 === 0);
|
||||
let castleRuins = castles.filter((_, i) => i % 2 === 1).map((c) => ({ ...c, kind: "Castle Ruins" }));
|
||||
let preservedOldRoads = premodernRoads.filter((_, i) => i % 2 === 0);
|
||||
const preservedOldRoads = premodernRoads.filter((_, i) => i % 2 === 0);
|
||||
const nameFields = { elevation, slope, sea, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity };
|
||||
const usedNames = new Set();
|
||||
const nameDebug = createNameDebug();
|
||||
|
|
@ -585,96 +205,10 @@ export function finishMapOutput({
|
|||
...externalGateways,
|
||||
].filter((p) => p.insidePrefecture || p.kind === "External Gateway");
|
||||
|
||||
const avoidForPrefectureLabel = [
|
||||
...modernCities,
|
||||
...ports,
|
||||
...markets,
|
||||
...castles,
|
||||
...castleTowns,
|
||||
...adminCenters,
|
||||
...stations,
|
||||
...satelliteCities,
|
||||
...newTowns,
|
||||
].filter((p) => p.insidePrefecture && p.name);
|
||||
const prefectureIdentity = generatePrefectureIdentity(usedNames, avoidForPrefectureLabel);
|
||||
const neighborPrefectureDetails = generateNeighborPrefectureDetails(prefectureIdentity.neighbors, usedNames, nameDebug);
|
||||
|
||||
const bridgeLimitedPathGroups = [
|
||||
"premodernRoads",
|
||||
"minorRoads",
|
||||
"nationalRoads",
|
||||
"ringRoads",
|
||||
"expressways",
|
||||
"ringExpressways",
|
||||
"icAccessRoads",
|
||||
"externalRoads",
|
||||
"externalExpressways",
|
||||
"railways",
|
||||
"branchRailways",
|
||||
"ringRailways",
|
||||
"externalRailways",
|
||||
"abandonedRailways",
|
||||
"preservedOldRoads",
|
||||
];
|
||||
premodernRoads = enforceBridgeLimitList(premodernRoads);
|
||||
minorRoads = enforceBridgeLimitList(minorRoads);
|
||||
nationalRoads = enforceBridgeLimitList(nationalRoads);
|
||||
ringRoads = enforceBridgeLimitList(ringRoads);
|
||||
expressways = enforceBridgeLimitList(expressways);
|
||||
ringExpressways = enforceBridgeLimitList(ringExpressways);
|
||||
icAccessRoads = enforceBridgeLimitList(icAccessRoads);
|
||||
externalRoads = enforceBridgeLimitList(externalRoads);
|
||||
externalExpressways = enforceBridgeLimitList(externalExpressways);
|
||||
railways = enforceBridgeLimitList(railways);
|
||||
branchRailways = enforceBridgeLimitList(branchRailways);
|
||||
ringRailways = enforceBridgeLimitList(ringRailways);
|
||||
externalRailways = enforceBridgeLimitList(externalRailways);
|
||||
abandonedRailways = enforceBridgeLimitList(abandonedRailways);
|
||||
preservedOldRoads = enforceBridgeLimitList(preservedOldRoads);
|
||||
for (const pref of neighborPrefectureDetails.prefectures || []) {
|
||||
pref.roads = enforceBridgeLimitList(pref.roads);
|
||||
pref.railways = enforceBridgeLimitList(pref.railways);
|
||||
}
|
||||
neighborPrefectureDetails.roads = enforceBridgeLimitList(neighborPrefectureDetails.roads);
|
||||
neighborPrefectureDetails.railways = enforceBridgeLimitList(neighborPrefectureDetails.railways);
|
||||
const bridgeLimitDebug = {
|
||||
maxBridgeCells: MAX_BRIDGE_CELLS,
|
||||
maxWaterRun: Math.max(
|
||||
0,
|
||||
...[
|
||||
...premodernRoads,
|
||||
...minorRoads,
|
||||
...nationalRoads,
|
||||
...ringRoads,
|
||||
...expressways,
|
||||
...ringExpressways,
|
||||
...icAccessRoads,
|
||||
...externalRoads,
|
||||
...externalExpressways,
|
||||
...railways,
|
||||
...branchRailways,
|
||||
...ringRailways,
|
||||
...externalRailways,
|
||||
...abandonedRailways,
|
||||
...preservedOldRoads,
|
||||
...(neighborPrefectureDetails.roads || []),
|
||||
...(neighborPrefectureDetails.railways || []),
|
||||
].map(pathMaxWaterRun)
|
||||
),
|
||||
enforcedGroups: bridgeLimitedPathGroups,
|
||||
};
|
||||
|
||||
return applyOutputOptions({
|
||||
width: MAP_W,
|
||||
height: MAP_H,
|
||||
cellSize: CELL_SIZE,
|
||||
// Keep the apparent map scale close to the original 172-cell-wide version
|
||||
// after increasing logical terrain resolution.
|
||||
scaleKmPerCell: 172 / MAP_W,
|
||||
prefectureName: prefectureIdentity.name,
|
||||
prefectureLabel: prefectureIdentity.label,
|
||||
neighborPrefectures: prefectureIdentity.neighbors,
|
||||
neighborPrefectureDetails,
|
||||
terrainTemplate,
|
||||
seaLevel,
|
||||
prefectureMask,
|
||||
|
|
@ -696,6 +230,8 @@ export function finishMapOutput({
|
|||
settlementCluster,
|
||||
ridgeField,
|
||||
valleyField,
|
||||
visibleRavineField,
|
||||
surfaceTextureField,
|
||||
basinField,
|
||||
coastalLowland,
|
||||
flowAccum,
|
||||
|
|
@ -754,7 +290,6 @@ export function finishMapOutput({
|
|||
smallStreams,
|
||||
externalGateways,
|
||||
transportDebug,
|
||||
bridgeLimitDebug,
|
||||
entitiesForNames,
|
||||
nameDebug,
|
||||
}, options);
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ export function generateMap(seedInput = 114514, options = {}) {
|
|||
agriculture,
|
||||
ridgeField,
|
||||
valleyField,
|
||||
visibleRavineField,
|
||||
surfaceTextureField,
|
||||
basinField,
|
||||
coastalLowland,
|
||||
flowAccum,
|
||||
|
|
@ -65,7 +67,7 @@ export function generateMap(seedInput = 114514, options = {}) {
|
|||
|
||||
return finishMapOutput({
|
||||
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,
|
||||
elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, agriculture, settlementCluster, ridgeField, valleyField, visibleRavineField, surfaceTextureField, basinField, coastalLowland, flowAccum, erosionField, depositionField,
|
||||
arcSpineField, branchRidgeField, depositionalLowland, alluvialFanField, deltaField, naturalBarrierScore,
|
||||
villages, ports, crossings, passes, markets, castles, castleTowns, premodernRoads, minorRoads, modernCities, populationDensity,
|
||||
railways, branchRailways, ringRailways, externalRailways, stations, industrialZones, nationalRoads, ringRoads, expressways, ringExpressways, icAccessRoads, externalRoads, externalExpressways,
|
||||
|
|
|
|||
1978
mapTerrain.js
1978
mapTerrain.js
File diff suppressed because it is too large
Load diff
1804
mapTerrain.v4.bak.js
Normal file
1804
mapTerrain.v4.bak.js
Normal file
File diff suppressed because it is too large
Load diff
4
names.js
4
names.js
|
|
@ -30,11 +30,11 @@ export const NAME_KANJI_POOLS = {
|
|||
"池", "沼", "泉", "井",
|
||||
"滝", "梅", "沢", "澤", "谷", "津",
|
||||
"水", "清", "渡", "橋", "堀",
|
||||
"溝", "浦", "洲"
|
||||
"溝", "浦"
|
||||
],
|
||||
|
||||
coastalTerrain: [
|
||||
"津", "浦", "ヶ浦", "津", "崎",
|
||||
"津", "浦", "津", "崎",
|
||||
"島", "磯", "潟", "湊", "津",
|
||||
"州", "洲", "瀬", "砂", "潮", "塩", "汐",
|
||||
"泊", "江", "浦", "灘", "入",
|
||||
|
|
|
|||
356
renderer.js
356
renderer.js
|
|
@ -1,4 +1,4 @@
|
|||
import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf } from "./mapUtils.js";
|
||||
import { CELL_SIZE, MAP_H, MAP_W, clamp, fbm, indexOf, valueNoise } from "./mapUtils.js";
|
||||
|
||||
|
||||
const segmentVectorCache = new WeakMap();
|
||||
|
|
@ -261,10 +261,12 @@ function distToNearest(points, x, y, fallback = 999) {
|
|||
}
|
||||
|
||||
function blendOutside(color, isInside) {
|
||||
// Seamless-map mode: do not dim cells outside the currently named prefecture.
|
||||
// The prefecture mask is still kept for statistics/admin debug, but the base
|
||||
// terrain should read as one continuous region when the map is later panned.
|
||||
return color;
|
||||
if (isInside) return color;
|
||||
return [
|
||||
Math.round(color[0] * 0.8 + 50),
|
||||
Math.round(color[1] * 0.8 + 50),
|
||||
Math.round(color[2] * 0.8 + 50),
|
||||
];
|
||||
}
|
||||
|
||||
function fieldSample(field, fx, fy) {
|
||||
|
|
@ -285,6 +287,23 @@ function fieldSample(field, fx, fy) {
|
|||
return ((a * (1 - tx) + b * tx) * (1 - ty)) + ((c * (1 - tx) + d * tx) * ty);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function terrainColorContinuous(map, fx, fy, mode) {
|
||||
const i = sampleCellIndex(fx, fy);
|
||||
const isInside = Boolean(map.prefectureMask[i]);
|
||||
|
|
@ -314,39 +333,75 @@ function terrainColorContinuous(map, fx, fy, mode) {
|
|||
Math.round(230 + density * 10),
|
||||
];
|
||||
} else {
|
||||
// 地形色は「細い山脈線」を直接塗らず、標高+広域山地性で読む。
|
||||
// 狭い arcSpineField を強く発色させると人工的な波線になるため、寄与を抑える。
|
||||
// 地形の基底色は標高のみに従わせる。
|
||||
// 谷や微地形の見え方は陰影側で制御し、谷底だけが不自然に茶色化しないようにする。
|
||||
const e = fieldSample(map.elevation, fx, fy);
|
||||
const spine = map.arcSpineField ? fieldSample(map.arcSpineField, fx, fy) : 0;
|
||||
const ridge = map.ridgeField ? fieldSample(map.ridgeField, fx, fy) : 0;
|
||||
const rel = clamp((e - (map.seaLevel ?? 0.285) + 0.035) / 0.46);
|
||||
const mountainRead = clamp(rel * 0.92 + ridge * 0.16 + spine * 0.045);
|
||||
if (mountainRead > 0.86) color = [176, 166, 150];
|
||||
else if (mountainRead > 0.76) color = [192, 183, 166];
|
||||
else if (mountainRead > 0.62) color = [207, 199, 181];
|
||||
else if (mountainRead > 0.46) color = [221, 225, 204];
|
||||
else if (mountainRead > 0.29) color = [232, 238, 222];
|
||||
else color = [240, 244, 230];
|
||||
|
||||
const highRugged = clamp((ridge - 0.42) * 0.36 + (rel - 0.66) * 0.22);
|
||||
if (highRugged > 0) {
|
||||
color = [
|
||||
Math.round(color[0] - highRugged * 18),
|
||||
Math.round(color[1] - highRugged * 15),
|
||||
Math.round(color[2] - highRugged * 12),
|
||||
];
|
||||
}
|
||||
color = interpolateColorStops(clamp(e), [
|
||||
[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]],
|
||||
]);
|
||||
}
|
||||
|
||||
return blendOutside(color, isInside);
|
||||
}
|
||||
|
||||
function terrainShadeContinuous(map, fx, fy) {
|
||||
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);
|
||||
|
||||
// x は東向き, y は南向き。法線は (-dz/dx, -dz/dy, 1)。
|
||||
// 光源は北西上空(日本の地形表現で一般的な見え方)。
|
||||
const dzdx = (eR - eL) / 1.2;
|
||||
const dzdy = (eD - eU) / 1.2;
|
||||
const nx = -dzdx * 2.5;
|
||||
const ny = -dzdy * 2.5;
|
||||
const nz = 1.0;
|
||||
const nLen = Math.hypot(nx, ny, nz) || 1;
|
||||
|
||||
const lx = -0.5;
|
||||
const ly = -0.5;
|
||||
const lz = 0.7071067811865476;
|
||||
const hill = clamp((nx * lx + ny * ly + nz * lz) / nLen * 0.5 + 0.5);
|
||||
|
||||
const slope = map.slope ? fieldSample(map.slope, fx, fy) : 0;
|
||||
const valley = map.valleyField ? fieldSample(map.valleyField, fx, fy) : 0;
|
||||
const ravine = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy) : 0;
|
||||
const tex = map.surfaceTextureField ? fieldSample(map.surfaceTextureField, fx, fy) : 0;
|
||||
const rvL = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx - 0.75, fy) : 0;
|
||||
const rvR = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx + 0.75, fy) : 0;
|
||||
const rvU = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy - 0.75) : 0;
|
||||
const rvD = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy + 0.75) : 0;
|
||||
const ravineRelief = (rvL - rvR) * 0.16 + (rvU - rvD) * 0.12;
|
||||
|
||||
// 谷底の低傾斜面では陰影を少し圧縮し、標高色がそのまま見えるようにする。
|
||||
const valleyFloor = clamp((valley - 0.16) * 1.8) * clamp((0.28 - slope) * 4.5);
|
||||
let shade = 0.76 + hill * 0.32 + ravineRelief - ravine * 0.08 - tex * 0.028;
|
||||
if (shade < 1) shade = 1 - (1 - shade) * (1 - valleyFloor * 0.52);
|
||||
else shade = 1 + (shade - 1) * (1 - valleyFloor * 0.20);
|
||||
|
||||
return clamp(shade, 0.66, 1.13);
|
||||
}
|
||||
|
||||
function discreteColor(map, x, y, mode) {
|
||||
const i = indexOf(x, y);
|
||||
let color;
|
||||
|
||||
if (map.sea[i]) {
|
||||
color = [170, 218, 255];
|
||||
color = [160, 205, 239];
|
||||
} else if (mode === "landuse") {
|
||||
const colors = {
|
||||
0: [242, 248, 238],
|
||||
|
|
@ -386,12 +441,7 @@ function drawBase(ctx, map, mode, continuousTerrain) {
|
|||
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.965 + (eR - eL) * 1.24 + (eD - eU) * 0.86, 0.78, 1.17);
|
||||
const shade = terrainShadeContinuous(map, fx, fy);
|
||||
|
||||
const ii = (py * width + px) * 4;
|
||||
img.data[ii] = Math.round(r * shade);
|
||||
|
|
@ -419,6 +469,28 @@ function drawBase(ctx, map, mode, continuousTerrain) {
|
|||
ctx.putImageData(img, 0, 0);
|
||||
}
|
||||
|
||||
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];
|
||||
const i1 = indexOf(x1, y1);
|
||||
const i2 = indexOf(x2, y2);
|
||||
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();
|
||||
}
|
||||
|
||||
function drawPath(ctx, path, color, width, dashed = false) {
|
||||
const points = vectorPath(path);
|
||||
if (points.length < 2) return;
|
||||
|
|
@ -602,136 +674,6 @@ function drawLabels(ctx, points, limit = Infinity) {
|
|||
for (const p of prioritized.slice(0, limit)) labelWithCollision(ctx, p, occupied);
|
||||
}
|
||||
|
||||
function niceScaleKm(maxKm) {
|
||||
const candidates = [1, 2, 5, 10, 20, 25, 50, 100, 200];
|
||||
let best = candidates[0];
|
||||
for (const c of candidates) {
|
||||
if (c <= maxKm) best = c;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function drawScaleBar(ctx, map) {
|
||||
const kmPerCell = map.scaleKmPerCell || 1;
|
||||
const width = MAP_W * CELL_SIZE;
|
||||
const height = MAP_H * CELL_SIZE;
|
||||
const maxPx = Math.min(150, width * 0.18);
|
||||
const scaleKm = niceScaleKm(Math.max(1, maxPx / CELL_SIZE * kmPerCell));
|
||||
const barPx = Math.max(24, (scaleKm / kmPerCell) * CELL_SIZE);
|
||||
const x = 18;
|
||||
const y = height - 22;
|
||||
const h = 6;
|
||||
|
||||
ctx.save();
|
||||
ctx.font = '11px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||
ctx.textBaseline = "bottom";
|
||||
ctx.fillStyle = "rgba(255, 255, 255, 0.72)";
|
||||
ctx.strokeStyle = "rgba(80, 80, 80, 0.34)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
if (ctx.roundRect) ctx.roundRect(x - 8, y - 22, barPx + 42, 29, 5);
|
||||
else ctx.rect(x - 8, y - 22, barPx + 42, 29);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = "rgba(60, 60, 60, 0.95)";
|
||||
ctx.fillRect(x, y - h, barPx / 2, h);
|
||||
ctx.fillStyle = "rgba(245, 245, 245, 0.95)";
|
||||
ctx.fillRect(x + barPx / 2, y - h, barPx / 2, h);
|
||||
ctx.strokeStyle = "rgba(55, 55, 55, 0.95)";
|
||||
ctx.lineWidth = 1.2;
|
||||
ctx.strokeRect(x, y - h, barPx, h);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y - h - 3);
|
||||
ctx.lineTo(x, y + 3);
|
||||
ctx.moveTo(x + barPx / 2, y - h);
|
||||
ctx.lineTo(x + barPx / 2, y + 2);
|
||||
ctx.moveTo(x + barPx, y - h - 3);
|
||||
ctx.lineTo(x + barPx, y + 3);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = "rgba(45, 45, 45, 0.95)";
|
||||
ctx.fillText(`${scaleKm} km`, x + barPx + 8, y + 2);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
|
||||
|
||||
function drawNeighborPrefectureDetails(ctx, map, mode = "all") {
|
||||
const details = map?.neighborPrefectureDetails;
|
||||
if (!details || !details.prefectures?.length) return;
|
||||
const showRoads = ["roads", "all", "development"].includes(mode);
|
||||
const showModern = ["modern", "all", "development", "landuse", "roads", "admin", "admin-debug", "borders-debug"].includes(mode);
|
||||
const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode);
|
||||
if (!showRoads && !showModern && !showAdmin) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 1.0;
|
||||
if (showModern) {
|
||||
for (const city of details.cities || []) {
|
||||
const r = Math.max(6, Math.min(22, (city.urbanRadius || 8) * CELL_SIZE * 0.48));
|
||||
ctx.beginPath();
|
||||
ctx.arc(city.x * CELL_SIZE + CELL_SIZE / 2, city.y * CELL_SIZE + CELL_SIZE / 2, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "rgba(226, 216, 202, 0.52)";
|
||||
ctx.fill();
|
||||
}
|
||||
for (const cbd of details.centralBusinessDistricts || []) {
|
||||
ctx.fillStyle = "rgba(222, 168, 145, 0.68)";
|
||||
ctx.fillRect(cbd.x * CELL_SIZE - 1.5, cbd.y * CELL_SIZE - 1.5, CELL_SIZE + 3, CELL_SIZE + 3);
|
||||
}
|
||||
}
|
||||
if (showRoads) {
|
||||
for (const path of details.roads || []) drawPath(ctx, path, "rgba(190, 174, 118, 0.92)", 3.2);
|
||||
for (const path of details.roads || []) drawPath(ctx, path, "rgba(248, 226, 122, 0.96)", 1.65);
|
||||
}
|
||||
if (showModern || showRoads) {
|
||||
for (const path of details.railways || []) drawPath(ctx, path, "rgba(255,255,255,0.74)", 2.8);
|
||||
for (const path of details.railways || []) drawRailway(ctx, path, "rgba(76, 76, 76, 0.86)", 1.15, 4.0, 6.0);
|
||||
}
|
||||
if (showModern || showAdmin) {
|
||||
for (const p of details.adminCenters || []) dot(ctx, p, 2.0, "rgba(250,250,245,0.82)", "rgba(90,90,90,0.66)");
|
||||
for (const p of details.cities || []) {
|
||||
const r = p.rank === "Neighbor Prefectural Capital" ? 5.4 : 3.8;
|
||||
dot(ctx, p, r, "rgba(226, 104, 104, 0.86)", "rgba(255,255,255,0.78)");
|
||||
if (p.rank === "Neighbor Prefectural Capital") dot(ctx, p, r + 4.6, "rgba(255,255,255,0.05)", "rgba(225,95,95,0.55)");
|
||||
}
|
||||
}
|
||||
if (["all", "admin", "modern", "roads", "admin-debug", "borders-debug"].includes(mode)) {
|
||||
ctx.globalAlpha = 0.72;
|
||||
drawLabels(ctx, [...(details.cities || []), ...(showAdmin ? (details.adminCenters || []) : [])], showAdmin ? 64 : 36);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawPrefectureLabels(ctx, map, mode = "all") {
|
||||
if (!map?.prefectureLabel && !(map?.neighborPrefectures || []).length) return;
|
||||
const width = MAP_W * CELL_SIZE;
|
||||
const height = MAP_H * CELL_SIZE;
|
||||
const shouldShow = ["all", "modern", "roads", "development", "landuse", "admin", "admin-debug", "borders-debug"].includes(mode);
|
||||
if (!shouldShow) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.lineJoin = "round";
|
||||
|
||||
const drawPrefLabel = (label, isMain = false) => {
|
||||
if (!label?.name) return;
|
||||
const x = clamp(label.x * CELL_SIZE, 44, width - 44);
|
||||
const y = clamp(label.y * CELL_SIZE - (isMain ? 14 : 8), 28, height - 34);
|
||||
ctx.font = `${isMain ? 700 : 700} ${isMain ? 22 : 20}px "Hiragino Sans", "Yu Gothic", system-ui, sans-serif`;
|
||||
ctx.lineWidth = isMain ? 5.2 : 4.8;
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.72)";
|
||||
ctx.fillStyle = "rgba(75, 62, 90, 0.64)";
|
||||
ctx.strokeText(label.name, x, y);
|
||||
ctx.fillText(label.name, x, y);
|
||||
};
|
||||
|
||||
drawPrefLabel(map.prefectureLabel, true);
|
||||
for (const label of map.neighborPrefectures || []) drawPrefLabel(label, false);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export function drawMap(canvas, map, options) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
|
@ -749,13 +691,58 @@ export function drawMap(canvas, map, options) {
|
|||
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 });
|
||||
drawVectorSegments(ctx, coastSegments, "rgba(248, 250, 242, 0.68)", 1.1, false, { iterations: 2, tolerance: 0.06 });
|
||||
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)";
|
||||
for (const path of map.tributaryRivers || map.riverPaths || []) drawPath(ctx, path, "rgba(160, 205, 240, 0.8)", 1.5);
|
||||
for (const path of map.mainRivers) drawPath(ctx, path, waterBlue, 3.5);
|
||||
const mediumBlue = "rgba(160, 205, 240, 0.88)";
|
||||
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];
|
||||
const i = indexOf(x, y);
|
||||
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;
|
||||
drawRiverPath(ctx, map, path, "rgba(150, 198, 235, 1)", (s) => s > 0.45 ? 0.58 : s > 0.22 ? 0.48 : 0.36, 0.34);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
const showHistory = ["history", "all", "terrain"].includes(mode);
|
||||
const showModern = ["modern", "all", "development", "landuse", "roads", "admin-debug", "borders-debug"].includes(mode);
|
||||
|
|
@ -763,36 +750,22 @@ export function drawMap(canvas, map, options) {
|
|||
const showMinorRoads = ["roads", "all", "modern", "development"].includes(mode);
|
||||
const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode);
|
||||
|
||||
drawNeighborPrefectureDetails(ctx, map, mode);
|
||||
|
||||
// 3. Borders
|
||||
if (showAdmin && map.adminBorders) {
|
||||
drawVectorSegments(ctx, map.adminBorders, "rgba(145, 145, 145, 0.82)", 1.15, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
|
||||
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) drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(70, 55, 95, 0.95)", 2.4, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
}
|
||||
|
||||
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 });
|
||||
|
||||
// Draw all generated prefecture borders with the same weight. The old main
|
||||
// prefecture border is shown only in debug modes so the normal view no longer
|
||||
// reads as a single highlighted prefecture surrounded by context.
|
||||
const prefectureBorderSegments = map.regionalPrefectureBorders?.length ? map.regionalPrefectureBorders : map.prefectureBorder;
|
||||
if (prefectureBorderSegments?.length) {
|
||||
drawVectorSegments(ctx, prefectureBorderSegments, "rgba(255, 255, 255, 0.82)", 4.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
drawVectorSegments(ctx, prefectureBorderSegments, "rgba(110, 90, 120, 0.86)", 1.7, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
}
|
||||
if ((mode === "admin-debug" || mode === "borders-debug") && map.prefectureBorder?.length) {
|
||||
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 (showLabels) drawPrefectureLabels(ctx, map, mode);
|
||||
|
||||
if (!showFeatures) {
|
||||
drawScaleBar(ctx, map);
|
||||
return;
|
||||
}
|
||||
if (!showFeatures) return;
|
||||
|
||||
// 4. Transport casings. Layer order: local roads, trunk roads, railways, expressways.
|
||||
if (showHistory) {
|
||||
|
|
@ -802,8 +775,8 @@ export function drawMap(canvas, map, options) {
|
|||
for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(205, 205, 205, 0.60)", 2.35);
|
||||
}
|
||||
if (showRoads) {
|
||||
for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(205, 205, 205, 0.62)", 2.8);
|
||||
for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8);
|
||||
for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8);
|
||||
for (const path of map.externalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8);
|
||||
}
|
||||
if (showModern || showRoads) {
|
||||
|
|
@ -824,8 +797,8 @@ export function drawMap(canvas, map, options) {
|
|||
for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(255, 255, 255, 0.94)", 1.1, false);
|
||||
}
|
||||
if (showRoads) {
|
||||
for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(255, 255, 255, 0.96)", 1.25, false);
|
||||
for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0);
|
||||
for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0);
|
||||
for (const path of map.externalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0);
|
||||
}
|
||||
if (showModern || showRoads) {
|
||||
|
|
@ -856,12 +829,10 @@ export function drawMap(canvas, map, options) {
|
|||
if (showLabels) {
|
||||
if (mode === "admin") {
|
||||
drawLabels(ctx, map.adminCenters || [], Infinity);
|
||||
drawScaleBar(ctx, map);
|
||||
return;
|
||||
}
|
||||
if (mode === "admin-debug" || mode === "borders-debug") {
|
||||
drawLabels(ctx, [...(map.adminCenters || []), ...(map.externalGateways || [])], Infinity);
|
||||
drawScaleBar(ctx, map);
|
||||
return;
|
||||
}
|
||||
const important = [
|
||||
|
|
@ -871,5 +842,4 @@ export function drawMap(canvas, map, options) {
|
|||
].filter((p) => p.insidePrefecture || p.kind === "External Gateway");
|
||||
drawLabels(ctx, important, 60);
|
||||
}
|
||||
drawScaleBar(ctx, map);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue