improoooved
This commit is contained in:
parent
440dba2f09
commit
0990b98436
5 changed files with 358 additions and 2795 deletions
28
app.js
28
app.js
|
|
@ -957,6 +957,7 @@ function updateRenderDiagnostics(options = {}, timings = {}) {
|
||||||
hoverMs: timings.hoverMs || 0,
|
hoverMs: timings.hoverMs || 0,
|
||||||
drawMs: timings.drawMs || 0,
|
drawMs: timings.drawMs || 0,
|
||||||
totalRenderMs: timings.totalRenderMs || 0,
|
totalRenderMs: timings.totalRenderMs || 0,
|
||||||
|
renderBreakdown: timings.renderBreakdown || null,
|
||||||
};
|
};
|
||||||
state.diagnostics.lastFeatureCounts = collectFeatureCounts(map);
|
state.diagnostics.lastFeatureCounts = collectFeatureCounts(map);
|
||||||
}
|
}
|
||||||
|
|
@ -981,6 +982,15 @@ function selectionWriteDiagnostics() {
|
||||||
|
|
||||||
function renderViewportDiagnostics() {
|
function renderViewportDiagnostics() {
|
||||||
const viewport = state.diagnostics.lastViewport;
|
const viewport = state.diagnostics.lastViewport;
|
||||||
|
const renderRows = viewport?.renderBreakdown ? [
|
||||||
|
{ label: "Draw / base", value: formatMs(viewport.renderBreakdown.baseTerrain || 0), sub: "terrain raster" },
|
||||||
|
{ label: "Draw / urban", value: formatMs(viewport.renderBreakdown.urbanFill || 0), sub: "land-use overlay" },
|
||||||
|
{ label: "Draw / coast", value: formatMs(viewport.renderBreakdown.coastline || 0), sub: "coastline vectors" },
|
||||||
|
{ label: "Draw / rivers", value: formatMs(viewport.renderBreakdown.rivers || 0), sub: "river paths" },
|
||||||
|
{ label: "Draw / admin", value: formatMs(viewport.renderBreakdown.adminBorders || 0), sub: "fills and borders" },
|
||||||
|
{ label: "Draw / transport", value: formatMs(viewport.renderBreakdown.transport || 0), sub: "roads and rail" },
|
||||||
|
{ label: "Draw / labels", value: formatMs((viewport.renderBreakdown.icons || 0) + (viewport.renderBreakdown.labels || 0)), sub: "icons and text" },
|
||||||
|
] : [];
|
||||||
renderDiagnosticGrid(advancedViewportDiagnosticsEl, viewport ? [
|
renderDiagnosticGrid(advancedViewportDiagnosticsEl, viewport ? [
|
||||||
{ label: "Viewport", value: `${viewport.viewWidth}×${viewport.viewHeight}`, sub: `${formatAreaCells(viewport.cells)} drawn` },
|
{ label: "Viewport", value: `${viewport.viewWidth}×${viewport.viewHeight}`, sub: `${formatAreaCells(viewport.cells)} drawn` },
|
||||||
{ label: "Canvas CSS", value: `${Math.round(viewport.cssWidth)}×${Math.round(viewport.cssHeight)}`, sub: "display pixels" },
|
{ label: "Canvas CSS", value: `${Math.round(viewport.cssWidth)}×${Math.round(viewport.cssHeight)}`, sub: "display pixels" },
|
||||||
|
|
@ -990,6 +1000,7 @@ function renderViewportDiagnostics() {
|
||||||
{ label: "Canvas draw", value: formatMs(viewport.drawMs), sub: "drawMap" },
|
{ label: "Canvas draw", value: formatMs(viewport.drawMs), sub: "drawMap" },
|
||||||
{ label: "Hover index", value: formatMs(viewport.hoverMs), sub: "labels / hit targets" },
|
{ label: "Hover index", value: formatMs(viewport.hoverMs), sub: "labels / hit targets" },
|
||||||
{ label: "Render total", value: formatMs(viewport.totalRenderMs), sub: `${viewport.mode} mode` },
|
{ label: "Render total", value: formatMs(viewport.totalRenderMs), sub: `${viewport.mode} mode` },
|
||||||
|
...renderRows,
|
||||||
] : [], "No viewport render recorded yet.");
|
] : [], "No viewport render recorded yet.");
|
||||||
|
|
||||||
const counts = state.diagnostics.lastFeatureCounts;
|
const counts = state.diagnostics.lastFeatureCounts;
|
||||||
|
|
@ -1056,13 +1067,15 @@ function performanceMetricsForRuns(runs) {
|
||||||
const totals = summarizeValues(numericValues(runs, (run) => run.totalMs));
|
const totals = summarizeValues(numericValues(runs, (run) => run.totalMs));
|
||||||
const perArea = summarizeValues(numericValues(runs, (run) => run.secondsPerThousand));
|
const perArea = summarizeValues(numericValues(runs, (run) => run.secondsPerThousand));
|
||||||
if (!totals) return [];
|
if (!totals) return [];
|
||||||
|
const hasSmallAreaRuns = (runs || []).some((run) => Number(run?.areaCells) > 0 && Number(run.areaCells) < 1000);
|
||||||
|
const perAreaSub = hasSmallAreaRuns ? "area-normalized; small runs include fixed overhead" : "seconds / 1k cells";
|
||||||
return [
|
return [
|
||||||
{ label: "Samples", value: String(totals.count), sub: "last 10" },
|
{ label: "Samples", value: String(totals.count), sub: "last 10" },
|
||||||
{ label: "Avg total", value: formatMs(totals.avg), sub: "generation time" },
|
{ label: "Avg total", value: formatMs(totals.avg), sub: "generation time" },
|
||||||
{ label: "Max total", value: formatMs(totals.max), sub: "slowest run" },
|
{ label: "Max total", value: formatMs(totals.max), sub: "slowest run" },
|
||||||
{ label: "P95 total", value: formatMs(totals.p95), sub: "tail latency" },
|
{ label: "P95 total", value: formatMs(totals.p95), sub: "tail latency" },
|
||||||
perArea ? { label: "Avg / 1k", value: formatSeconds(perArea.avg), sub: "seconds / 1k cells" } : null,
|
perArea ? { label: "Avg / 1k", value: formatSeconds(perArea.avg), sub: perAreaSub } : null,
|
||||||
perArea ? { label: "P95 / 1k", value: formatSeconds(perArea.p95), sub: "area-normalized" } : null,
|
perArea ? { label: "P95 / 1k", value: formatSeconds(perArea.p95), sub: hasSmallAreaRuns ? "fixed overhead dominated below 1k cells" : "area-normalized" } : null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1072,6 +1085,10 @@ function recordGenerationRun(map, terrainType) {
|
||||||
const totalMs = Number.isFinite(map.generationTotalMs)
|
const totalMs = Number.isFinite(map.generationTotalMs)
|
||||||
? map.generationTotalMs
|
? map.generationTotalMs
|
||||||
: (map.generationTimings || []).reduce((sum, row) => sum + (Number(row?.ms) || 0), 0);
|
: (map.generationTimings || []).reduce((sum, row) => sum + (Number(row?.ms) || 0), 0);
|
||||||
|
const featureTimings = (map.transportDebug?.featureTimings || []).map((row) => ({
|
||||||
|
label: `Settlements / ${row.key || "substage"}`,
|
||||||
|
ms: Number(row.ms) || 0,
|
||||||
|
}));
|
||||||
pushCapped(state.generationRuns, {
|
pushCapped(state.generationRuns, {
|
||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
|
|
@ -1082,7 +1099,7 @@ function recordGenerationRun(map, terrainType) {
|
||||||
timings: (map.generationTimings || []).map((row) => ({
|
timings: (map.generationTimings || []).map((row) => ({
|
||||||
label: row.label || row.key || "Stage",
|
label: row.label || row.key || "Stage",
|
||||||
ms: Number(row.ms) || 0,
|
ms: Number(row.ms) || 0,
|
||||||
})),
|
})).concat(featureTimings),
|
||||||
});
|
});
|
||||||
renderAdvancedData();
|
renderAdvancedData();
|
||||||
}
|
}
|
||||||
|
|
@ -1187,7 +1204,7 @@ function appendRunHistory(container, runs, options = {}) {
|
||||||
<span>${name}</span>
|
<span>${name}</span>
|
||||||
<strong>${formatMs(run.totalMs)}</strong>
|
<strong>${formatMs(run.totalMs)}</strong>
|
||||||
<span>${formatAreaCells(run.areaCells)}${options.patch && run.writeAreaCells ? ` / write ${formatAreaCells(run.writeAreaCells)}` : ""}</span>
|
<span>${formatAreaCells(run.areaCells)}${options.patch && run.writeAreaCells ? ` / write ${formatAreaCells(run.writeAreaCells)}` : ""}</span>
|
||||||
<span>${formatSeconds(run.secondsPerThousand)} / 1k cells</span>
|
<span>${formatSeconds(run.secondsPerThousand)} / 1k cells${run.areaCells && run.areaCells < 1000 ? " (fixed overhead)" : ""}</span>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const body = document.createElement("div");
|
const body = document.createElement("div");
|
||||||
|
|
@ -2235,7 +2252,7 @@ function redraw(options = {}) {
|
||||||
state.hoverEntities = options.fastTerrain ? [] : buildHoverEntities(state.viewportMap);
|
state.hoverEntities = options.fastTerrain ? [] : buildHoverEntities(state.viewportMap);
|
||||||
const hoverMs = performance.now() - hoverStartedAt;
|
const hoverMs = performance.now() - hoverStartedAt;
|
||||||
const drawStartedAt = performance.now();
|
const drawStartedAt = performance.now();
|
||||||
drawMap(canvas, state.viewportMap, {
|
const renderBreakdown = drawMap(canvas, state.viewportMap, {
|
||||||
mode: state.mode,
|
mode: state.mode,
|
||||||
showFeatures: state.showFeatures && !options.fastTerrain,
|
showFeatures: state.showFeatures && !options.fastTerrain,
|
||||||
showLabels: state.showLabels && !options.fastTerrain,
|
showLabels: state.showLabels && !options.fastTerrain,
|
||||||
|
|
@ -2251,6 +2268,7 @@ function redraw(options = {}) {
|
||||||
hoverMs,
|
hoverMs,
|
||||||
drawMs,
|
drawMs,
|
||||||
totalRenderMs: performance.now() - redrawStartedAt,
|
totalRenderMs: performance.now() - redrawStartedAt,
|
||||||
|
renderBreakdown,
|
||||||
});
|
});
|
||||||
renderAdvancedData();
|
renderAdvancedData();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
258
mapPatch.js
258
mapPatch.js
|
|
@ -3,6 +3,7 @@ import { generateMap } from "./mapPipeline.js";
|
||||||
import { generateTerrainRect } from "./mapTerrain.js";
|
import { generateTerrainRect } from "./mapTerrain.js";
|
||||||
import { LANDUSE } from "./landuseCodes.js";
|
import { LANDUSE } from "./landuseCodes.js";
|
||||||
import { reconcileMunicipalMetadata, refreshPrefectureRegionsMetadata } from "./mapMunicipalCoherence.js";
|
import { reconcileMunicipalMetadata, refreshPrefectureRegionsMetadata } from "./mapMunicipalCoherence.js";
|
||||||
|
import { createPatchContext } from "./mapPatchContext.js";
|
||||||
|
|
||||||
export const PATCH_MIN_WIDTH = 48;
|
export const PATCH_MIN_WIDTH = 48;
|
||||||
export const PATCH_MIN_HEIGHT = 48;
|
export const PATCH_MIN_HEIGHT = 48;
|
||||||
|
|
@ -1358,8 +1359,8 @@ function repairDiscreteSeamOwnership(world, rects, oldFields, seed = 0) {
|
||||||
return { adminSeamCellsResolved, prefectureSeamCellsResolved };
|
return { adminSeamCellsResolved, prefectureSeamCellsResolved };
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyFullPipelineFields(world, candidate, rects, seed, sourceMap = null, seaLevel = 0.30) {
|
function copyFullPipelineFields(world, candidate, rects, seed, sourceMap = null, seaLevel = 0.30, patchContext = null) {
|
||||||
const window = sourceWindowForRects(rects);
|
const window = patchContext?.candidateWindow || sourceWindowForRects(rects);
|
||||||
const elevationAdjustment = computeElevationCandidateAdjustment(world, candidate, rects, window, seed);
|
const elevationAdjustment = computeElevationCandidateAdjustment(world, candidate, rects, window, seed);
|
||||||
const oldSea = world.fields.sea ? new Uint8Array(world.fields.sea) : null;
|
const oldSea = world.fields.sea ? new Uint8Array(world.fields.sea) : null;
|
||||||
const oldLanduse = world.fields.landuse ? new world.fields.landuse.constructor(world.fields.landuse) : null;
|
const oldLanduse = world.fields.landuse ? new world.fields.landuse.constructor(world.fields.landuse) : null;
|
||||||
|
|
@ -1381,6 +1382,7 @@ function copyFullPipelineFields(world, candidate, rects, seed, sourceMap = null,
|
||||||
let adminCellsReassigned = 0;
|
let adminCellsReassigned = 0;
|
||||||
let landUseCellsUpdated = 0;
|
let landUseCellsUpdated = 0;
|
||||||
|
|
||||||
|
const fieldEntries = [];
|
||||||
for (const [name, source] of Object.entries(candidate || {})) {
|
for (const [name, source] of Object.entries(candidate || {})) {
|
||||||
if (SKIP_CELL_FIELDS.has(name) || !isCandidateCellField(candidate, source, window)) continue;
|
if (SKIP_CELL_FIELDS.has(name) || !isCandidateCellField(candidate, source, window)) continue;
|
||||||
const dest = ensureWorldField(world, name, source);
|
const dest = ensureWorldField(world, name, source);
|
||||||
|
|
@ -1388,7 +1390,11 @@ function copyFullPipelineFields(world, candidate, rects, seed, sourceMap = null,
|
||||||
const isFloat = source.constructor === Float32Array || source.constructor === Float64Array;
|
const isFloat = source.constructor === Float32Array || source.constructor === Float64Array;
|
||||||
const isDiscrete = DISCRETE_FIELD_NAMES.has(name) || !isFloat;
|
const isDiscrete = DISCRETE_FIELD_NAMES.has(name) || !isFloat;
|
||||||
const idOffset = fieldIdOffset(name, seed);
|
const idOffset = fieldIdOffset(name, seed);
|
||||||
|
fieldEntries.push({ name, source, dest, isDiscrete, idOffset });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cells = patchContext?.writeCells?.length ? patchContext.writeCells : (() => {
|
||||||
|
const out = [];
|
||||||
for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
|
for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
|
||||||
for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
|
for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
|
||||||
const wi = worldIndex(world, x, y);
|
const wi = worldIndex(world, x, y);
|
||||||
|
|
@ -1396,33 +1402,39 @@ function copyFullPipelineFields(world, candidate, rects, seed, sourceMap = null,
|
||||||
const si = sourceIndexForWorld(rects, window, x, y);
|
const si = sourceIndexForWorld(rects, window, x, y);
|
||||||
if (si < 0) continue;
|
if (si < 0) continue;
|
||||||
const alpha = patchAlpha(x, y, rects, seed);
|
const alpha = patchAlpha(x, y, rects, seed);
|
||||||
if (alpha <= 0.005) continue;
|
if (alpha > 0.005) out.push({ x, y, wi, si, alpha });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
})();
|
||||||
|
|
||||||
if (isDiscrete) {
|
for (const cell of cells) {
|
||||||
const threshold = continuityReplaceThreshold(name, x, y, rects, seed);
|
const { x, y, wi, si, alpha } = cell;
|
||||||
if (alpha >= threshold) {
|
for (const entry of fieldEntries) {
|
||||||
const raw = source[si];
|
const { name, source, dest, isDiscrete, idOffset } = entry;
|
||||||
const mapped = remapAdminCandidateValue(name, raw, adminIdMapping);
|
if (isDiscrete) {
|
||||||
const value = (name === "adminId" || name === "municipalityId" || name === "prefectureRegionId")
|
const threshold = continuityReplaceThreshold(name, x, y, rects, seed);
|
||||||
? mapped
|
if (alpha < threshold) continue;
|
||||||
: idOffset && raw >= 0 ? raw + idOffset : raw;
|
const raw = source[si];
|
||||||
if (name === "sea" && oldSea && dest[wi] !== value) coastCellsChanged++;
|
const mapped = remapAdminCandidateValue(name, raw, adminIdMapping);
|
||||||
if ((name === "naturalCompartmentId" || name === "watershedId" || name === "regionId") && dest[wi] !== value) naturalRegionsUpdated++;
|
const value = (name === "adminId" || name === "municipalityId" || name === "prefectureRegionId")
|
||||||
if ((name === "adminId" || name === "municipalityId") && dest[wi] !== value) adminCellsReassigned++;
|
? mapped
|
||||||
if (name === "landuse" && dest[wi] !== value) landUseCellsUpdated++;
|
: idOffset && raw >= 0 ? raw + idOffset : raw;
|
||||||
dest[wi] = value;
|
if (name === "sea" && oldSea && dest[wi] !== value) coastCellsChanged++;
|
||||||
}
|
if ((name === "naturalCompartmentId" || name === "watershedId" || name === "regionId") && dest[wi] !== value) naturalRegionsUpdated++;
|
||||||
} else {
|
if ((name === "adminId" || name === "municipalityId") && dest[wi] !== value) adminCellsReassigned++;
|
||||||
const before = dest[wi] || 0;
|
if (name === "landuse" && dest[wi] !== value) landUseCellsUpdated++;
|
||||||
let candidateValue = source[si] || 0;
|
dest[wi] = value;
|
||||||
if (name === "elevation") candidateValue = adjustedCandidateElevation(candidateValue, x, y, elevationAdjustment);
|
} else {
|
||||||
dest[wi] = lerp(before, candidateValue, alpha);
|
const before = dest[wi] || 0;
|
||||||
}
|
let candidateValue = source[si] || 0;
|
||||||
|
if (name === "elevation") candidateValue = adjustedCandidateElevation(candidateValue, x, y, elevationAdjustment);
|
||||||
|
dest[wi] = lerp(before, candidateValue, alpha);
|
||||||
|
}
|
||||||
|
|
||||||
if (name === "elevation") {
|
if (name === "elevation") {
|
||||||
updatedCells++;
|
updatedCells++;
|
||||||
if (alpha > 0.94) terrainCellsFullyReplaced++;
|
if (alpha > 0.94) terrainCellsFullyReplaced++;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3212,7 +3224,6 @@ function reconnectTransportGraph(world, sourceMap, rects, seed, mode, graphRect,
|
||||||
const rejectedPairs = new Set();
|
const rejectedPairs = new Set();
|
||||||
let attemptsRemaining = options.maxAttempts ?? (mode === "rail" ? 4 : 8);
|
let attemptsRemaining = options.maxAttempts ?? (mode === "rail" ? 4 : 8);
|
||||||
for (let pass = 0; pass < maxAdds && attemptsRemaining > 0; pass++) {
|
for (let pass = 0; pass < maxAdds && attemptsRemaining > 0; pass++) {
|
||||||
snapshot = buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, seed);
|
|
||||||
const dirtyCount = snapshot.comps.filter(eligible).length;
|
const dirtyCount = snapshot.comps.filter(eligible).length;
|
||||||
if (dirtyCount <= 1) break;
|
if (dirtyCount <= 1) break;
|
||||||
const comps = snapshot.comps.filter(contextual);
|
const comps = snapshot.comps.filter(contextual);
|
||||||
|
|
@ -3285,13 +3296,13 @@ function reconnectTransportGraph(world, sourceMap, rects, seed, mode, graphRect,
|
||||||
rejectedPairs.add(transportPairSignature(b, a, mode));
|
rejectedPairs.add(transportPairSignature(b, a, mode));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
snapshot = checkSnapshot;
|
||||||
debug[`${mode}GraphConnectorsAdded`] += writeResult.wrote;
|
debug[`${mode}GraphConnectorsAdded`] += writeResult.wrote;
|
||||||
accepted = true;
|
accepted = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (!accepted) break;
|
if (!accepted) break;
|
||||||
}
|
}
|
||||||
snapshot = buildTransportGraphSnapshot(world, sourceMap, mode, graphRect, rects, seed);
|
|
||||||
debug[`${mode}GraphAfterComponents`] = snapshot.comps.filter(eligible).length;
|
debug[`${mode}GraphAfterComponents`] = snapshot.comps.filter(eligible).length;
|
||||||
return debug;
|
return debug;
|
||||||
}
|
}
|
||||||
|
|
@ -3438,7 +3449,6 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) {
|
||||||
// repair is intentionally allowed to operate in a much broader neighborhood,
|
// repair is intentionally allowed to operate in a much broader neighborhood,
|
||||||
// because clipping graph repairs to the selected polygon leaves implausible
|
// because clipping graph repairs to the selected polygon leaves implausible
|
||||||
// dangling regional networks just outside the patch.
|
// dangling regional networks just outside the patch.
|
||||||
const connectorPatchOptions = null;
|
|
||||||
const externalRoadAnchors = collectExternalNetworkAnchors(world, sourceMap, ["nationalRoads", "minorRoads", "premodernRoads", "externalRoads"], rects.writeRect, transportRect, "road");
|
const externalRoadAnchors = collectExternalNetworkAnchors(world, sourceMap, ["nationalRoads", "minorRoads", "premodernRoads", "externalRoads"], rects.writeRect, transportRect, "road");
|
||||||
const externalRailAnchors = collectExternalNetworkAnchors(world, sourceMap, ["railways", "branchRailways", "externalRailways"], rects.writeRect, transportRect, "rail");
|
const externalRailAnchors = collectExternalNetworkAnchors(world, sourceMap, ["railways", "branchRailways", "externalRailways"], rects.writeRect, transportRect, "rail");
|
||||||
roadAnchors = roadAnchors.concat(externalRoadAnchors);
|
roadAnchors = roadAnchors.concat(externalRoadAnchors);
|
||||||
|
|
@ -3447,9 +3457,6 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) {
|
||||||
// visible fragments that did not reduce disconnected components. Keep the
|
// visible fragments that did not reduce disconnected components. Keep the
|
||||||
// anchor collection for diagnostics, but route all actual repair through the
|
// anchor collection for diagnostics, but route all actual repair through the
|
||||||
// graph reconnection pass below, which rolls back failed candidates.
|
// graph reconnection pass below, which rolls back failed candidates.
|
||||||
const roadConn = { connectors: 0, disconnected: roadAnchors.length, skippedConnectorAnchors: 0, connectorAttempts: 0 };
|
|
||||||
const railConn = { connectors: 0, disconnected: railAnchors.length, skippedConnectorAnchors: 0, connectorAttempts: 0 };
|
|
||||||
const settlementRoadConnectors = { connectors: 0, skippedServedSettlements: 0, checkedSettlementCoverage: 0 };
|
|
||||||
const graphRect = strictMask
|
const graphRect = strictMask
|
||||||
? transportRect
|
? transportRect
|
||||||
: transportRect;
|
: transportRect;
|
||||||
|
|
@ -3459,14 +3466,14 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) {
|
||||||
roadsClipped,
|
roadsClipped,
|
||||||
railsClipped,
|
railsClipped,
|
||||||
regeneratedPaths,
|
regeneratedPaths,
|
||||||
roadConnectorsCreated: roadConn.connectors + settlementRoadConnectors.connectors + (roadGraph.roadGraphConnectorsAdded || 0),
|
roadConnectorsCreated: roadGraph.roadGraphConnectorsAdded || 0,
|
||||||
railwayConnectorsCreated: railConn.connectors + (railGraph.railGraphConnectorsAdded || 0),
|
railwayConnectorsCreated: railGraph.railGraphConnectorsAdded || 0,
|
||||||
disconnectedRoadComponents: roadConn.disconnected,
|
disconnectedRoadComponents: roadAnchors.length,
|
||||||
disconnectedRailComponents: railConn.disconnected,
|
disconnectedRailComponents: railAnchors.length,
|
||||||
skippedConnectorAnchors: (roadConn.skippedConnectorAnchors || 0) + (railConn.skippedConnectorAnchors || 0),
|
skippedConnectorAnchors: 0,
|
||||||
connectorAttempts: (roadConn.connectorAttempts || 0) + (railConn.connectorAttempts || 0),
|
connectorAttempts: (roadGraph.roadGraphCandidatesConsidered || 0) + (railGraph.railGraphCandidatesConsidered || 0),
|
||||||
skippedServedSettlements: settlementRoadConnectors.skippedServedSettlements || 0,
|
skippedServedSettlements: 0,
|
||||||
checkedSettlementCoverage: settlementRoadConnectors.checkedSettlementCoverage || 0,
|
checkedSettlementCoverage: 0,
|
||||||
externalRoadAnchors: externalRoadAnchors.length,
|
externalRoadAnchors: externalRoadAnchors.length,
|
||||||
externalRailAnchors: externalRailAnchors.length,
|
externalRailAnchors: externalRailAnchors.length,
|
||||||
...roadGraph,
|
...roadGraph,
|
||||||
|
|
@ -4208,6 +4215,82 @@ function addInvalidatedRect(world, rect) {
|
||||||
if (!list.some((r) => rectKey(r) === key)) list.push({ ...normalized });
|
if (!list.some((r) => rectKey(r) === key)) list.push({ ...normalized });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function repairPatchTerrain(world, rects, seed, seaLevel) {
|
||||||
|
const terrainSeamDebug = featherTerrainSeam(world, rects, seed);
|
||||||
|
const elevationCliffDebug = smoothExtremeElevationSeams(world, rects, seed, seaLevel);
|
||||||
|
const waterDebug = smoothWaterTopology(world, rects.writeRect, seaLevel, rects, seed);
|
||||||
|
const waterComponentDebug = repairWaterComponentTopology(world, rects, seaLevel, seed);
|
||||||
|
const residualSeaDebug = fillTinyResidualSeas(world, rects, seaLevel, seed, { aggressive: true });
|
||||||
|
const waterElevationDebug = smoothPatchedWaterElevation(world, rects, seaLevel, seed);
|
||||||
|
const maskDebug = repairDisplayMasks(world, rects, seed);
|
||||||
|
recomputeSlopeAndWaterDependentFields(world, rects.repairRect, seaLevel);
|
||||||
|
return {
|
||||||
|
terrainSeamDebug,
|
||||||
|
elevationCliffDebug,
|
||||||
|
waterDebug,
|
||||||
|
waterComponentDebug,
|
||||||
|
residualSeaDebug,
|
||||||
|
waterElevationDebug,
|
||||||
|
maskDebug,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function repairPatchAdministration(world, sourceMap, rects, seed, adminIdMapping, strictFieldSnapshot) {
|
||||||
|
const landDebug = repairLanduseAndPopulation(world, rects);
|
||||||
|
const finalAdminCoverageDebug = repairAdminCoverage(world, sourceMap, rects, adminIdMapping, seed);
|
||||||
|
const adminTopologyDebug = repairPatchAdministrativeTopology(world, rects);
|
||||||
|
const strictMaskDebugPreCoherence = restoreOutsideStrictSelectionFields(world, rects, strictFieldSnapshot, seed);
|
||||||
|
const sourceAdminMetadataUpdated = updateSourceAdminMetadata(sourceMap, adminIdMapping);
|
||||||
|
const municipalCoherence = reconcileMunicipalMetadata({
|
||||||
|
adminId: world.fields.adminId,
|
||||||
|
municipalityId: world.fields.municipalityId,
|
||||||
|
prefectureRegionId: world.fields.prefectureRegionId,
|
||||||
|
sea: world.fields.sea,
|
||||||
|
adminCenters: sourceMap.adminCenters || [],
|
||||||
|
municipalityToPrefectureId: sourceMap.municipalityToPrefectureId,
|
||||||
|
fields: world.fields,
|
||||||
|
width: world.width,
|
||||||
|
height: world.height,
|
||||||
|
pointOffsetX: world.originX || 0,
|
||||||
|
pointOffsetY: world.originY || 0,
|
||||||
|
seed,
|
||||||
|
});
|
||||||
|
sourceMap.adminCenters = municipalCoherence.adminCenters;
|
||||||
|
sourceMap.municipalityToPrefectureId = municipalCoherence.municipalityToPrefectureId;
|
||||||
|
const prefectureCoherence = refreshPrefectureRegionsMetadata({
|
||||||
|
prefectureRegionId: world.fields.prefectureRegionId,
|
||||||
|
sea: world.fields.sea,
|
||||||
|
existing: sourceMap.prefectureRegions || [],
|
||||||
|
adminCenters: sourceMap.adminCenters || [],
|
||||||
|
fields: world.fields,
|
||||||
|
width: world.width,
|
||||||
|
height: world.height,
|
||||||
|
pointOffsetX: world.originX || 0,
|
||||||
|
pointOffsetY: world.originY || 0,
|
||||||
|
});
|
||||||
|
sourceMap.prefectureRegions = prefectureCoherence.prefectureRegions;
|
||||||
|
const strictMaskDebugPostCoherence = restoreOutsideStrictSelectionFields(world, rects, strictFieldSnapshot, seed);
|
||||||
|
sourceMap.adminDebug = {
|
||||||
|
...(sourceMap.adminDebug || {}),
|
||||||
|
municipalCoherence: municipalCoherence.debug,
|
||||||
|
prefectureMetadataCoherence: prefectureCoherence.debug,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
landDebug,
|
||||||
|
finalAdminCoverageDebug,
|
||||||
|
adminTopologyDebug,
|
||||||
|
strictMaskDebugPreCoherence,
|
||||||
|
strictMaskDebugPostCoherence,
|
||||||
|
sourceAdminMetadataUpdated,
|
||||||
|
municipalCoherence,
|
||||||
|
prefectureCoherence,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function repairPatchTransport(world, sourceMap, candidate, rects, window, seed) {
|
||||||
|
return mergePathLayers(world, sourceMap, candidate, rects, window, seed);
|
||||||
|
}
|
||||||
|
|
||||||
export function generatePatch(world, userRectInput, options = {}) {
|
export function generatePatch(world, userRectInput, options = {}) {
|
||||||
const validation = validatePatchRect(userRectInput, world);
|
const validation = validatePatchRect(userRectInput, world);
|
||||||
if (!validation.ok) return { ok: false, ...validation };
|
if (!validation.ok) return { ok: false, ...validation };
|
||||||
|
|
@ -4258,85 +4341,80 @@ export function generatePatch(world, userRectInput, options = {}) {
|
||||||
: (candidateWindow.variable ? "Variable candidate generation" : "Full candidate generation"));
|
: (candidateWindow.variable ? "Variable candidate generation" : "Full candidate generation"));
|
||||||
getPatchAlphaCache(rects, seed);
|
getPatchAlphaCache(rects, seed);
|
||||||
getPatchSourceIndexCache(rects, candidateWindow);
|
getPatchSourceIndexCache(rects, candidateWindow);
|
||||||
|
const patchContext = createPatchContext({
|
||||||
|
world,
|
||||||
|
rects,
|
||||||
|
candidateWindow,
|
||||||
|
seed,
|
||||||
|
patchAlpha,
|
||||||
|
sourceIndexForWorld,
|
||||||
|
worldIndex,
|
||||||
|
});
|
||||||
const sourceMap = world.sourceMap || (world.sourceMap = {});
|
const sourceMap = world.sourceMap || (world.sourceMap = {});
|
||||||
const strictMetadataSnapshot = captureStrictMetadataSnapshot(world, sourceMap, rects, seed);
|
const strictMetadataSnapshot = captureStrictMetadataSnapshot(world, sourceMap, rects, seed);
|
||||||
const logisticsLabelsMigrated = sanitizeExistingLogistics(sourceMap);
|
const logisticsLabelsMigrated = sanitizeExistingLogistics(sourceMap);
|
||||||
|
|
||||||
const seaLevel = candidate.seaLevel || world.sourceMap?.seaLevel || 0.30;
|
const seaLevel = candidate.seaLevel || world.sourceMap?.seaLevel || 0.30;
|
||||||
const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed, sourceMap, seaLevel);
|
const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed, sourceMap, seaLevel, patchContext);
|
||||||
patchTimer.mark("fields", "Field copy and alpha blend");
|
patchTimer.mark("fields", "Field copy and alpha blend");
|
||||||
const terrainSeamDebug = featherTerrainSeam(world, rects, seed);
|
const terrainDebug = repairPatchTerrain(world, rects, seed, seaLevel);
|
||||||
const elevationCliffDebug = smoothExtremeElevationSeams(world, rects, seed, seaLevel);
|
|
||||||
const waterDebug = smoothWaterTopology(world, rects.writeRect, seaLevel, rects, seed);
|
|
||||||
const waterComponentDebug = repairWaterComponentTopology(world, rects, seaLevel, seed);
|
|
||||||
const residualSeaDebug = fillTinyResidualSeas(world, rects, seaLevel, seed, { aggressive: true });
|
|
||||||
const waterElevationDebug = smoothPatchedWaterElevation(world, rects, seaLevel, seed);
|
|
||||||
const maskDebug = repairDisplayMasks(world, rects, seed);
|
|
||||||
recomputeSlopeAndWaterDependentFields(world, rects.repairRect, seaLevel);
|
|
||||||
patchTimer.mark("terrainRepair", "Water, masks, and terrain repair");
|
patchTimer.mark("terrainRepair", "Water, masks, and terrain repair");
|
||||||
const pointDebug = mergePointLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed, fieldDebug.adminIdMapping);
|
const pointDebug = mergePointLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed, fieldDebug.adminIdMapping);
|
||||||
patchTimer.mark("points", "Point merge");
|
patchTimer.mark("points", "Point merge");
|
||||||
const pathDebug = mergePathLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed);
|
const pathDebug = repairPatchTransport(world, sourceMap, candidate, rects, fieldDebug.window, seed);
|
||||||
patchTimer.mark("paths", "Path merge and connector repair");
|
patchTimer.mark("paths", "Path merge and connector repair");
|
||||||
const influenceDebug = refreshPatchInfluenceFields(world, sourceMap, rects);
|
const influenceDebug = refreshPatchInfluenceFields(world, sourceMap, rects);
|
||||||
patchTimer.mark("influence", "Influence refresh");
|
patchTimer.mark("influence", "Influence refresh");
|
||||||
const landDebug = repairLanduseAndPopulation(world, rects);
|
const adminDebug = repairPatchAdministration(world, sourceMap, rects, seed, fieldDebug.adminIdMapping, strictFieldSnapshot);
|
||||||
const finalAdminCoverageDebug = repairAdminCoverage(world, sourceMap, rects, fieldDebug.adminIdMapping, seed);
|
|
||||||
const adminTopologyDebug = repairPatchAdministrativeTopology(world, rects);
|
|
||||||
const strictMaskDebugPreCoherence = restoreOutsideStrictSelectionFields(world, rects, strictFieldSnapshot, seed);
|
|
||||||
const sourceAdminMetadataUpdated = updateSourceAdminMetadata(sourceMap, fieldDebug.adminIdMapping);
|
|
||||||
const municipalCoherence = reconcileMunicipalMetadata({
|
|
||||||
adminId: world.fields.adminId,
|
|
||||||
municipalityId: world.fields.municipalityId,
|
|
||||||
prefectureRegionId: world.fields.prefectureRegionId,
|
|
||||||
sea: world.fields.sea,
|
|
||||||
adminCenters: sourceMap.adminCenters || [],
|
|
||||||
municipalityToPrefectureId: sourceMap.municipalityToPrefectureId,
|
|
||||||
fields: world.fields,
|
|
||||||
width: world.width,
|
|
||||||
height: world.height,
|
|
||||||
pointOffsetX: world.originX || 0,
|
|
||||||
pointOffsetY: world.originY || 0,
|
|
||||||
seed,
|
|
||||||
});
|
|
||||||
sourceMap.adminCenters = municipalCoherence.adminCenters;
|
|
||||||
sourceMap.municipalityToPrefectureId = municipalCoherence.municipalityToPrefectureId;
|
|
||||||
const prefectureCoherence = refreshPrefectureRegionsMetadata({
|
|
||||||
prefectureRegionId: world.fields.prefectureRegionId,
|
|
||||||
sea: world.fields.sea,
|
|
||||||
existing: sourceMap.prefectureRegions || [],
|
|
||||||
adminCenters: sourceMap.adminCenters || [],
|
|
||||||
fields: world.fields,
|
|
||||||
width: world.width,
|
|
||||||
height: world.height,
|
|
||||||
pointOffsetX: world.originX || 0,
|
|
||||||
pointOffsetY: world.originY || 0,
|
|
||||||
});
|
|
||||||
sourceMap.prefectureRegions = prefectureCoherence.prefectureRegions;
|
|
||||||
const strictMaskDebugPostCoherence = restoreOutsideStrictSelectionFields(world, rects, strictFieldSnapshot, seed);
|
|
||||||
const residualSeaStrictDebug = fillTinyResidualSeas(world, rects, seaLevel, seed, { aggressive: true, preservePockets: true });
|
const residualSeaStrictDebug = fillTinyResidualSeas(world, rects, seaLevel, seed, { aggressive: true, preservePockets: true });
|
||||||
if ((residualSeaStrictDebug.residualSeaCellsFilled || 0) > 0) {
|
if ((residualSeaStrictDebug.residualSeaCellsFilled || 0) > 0) {
|
||||||
repairDisplayMasks(world, rects, seed);
|
repairDisplayMasks(world, rects, seed);
|
||||||
recomputeSlopeAndWaterDependentFields(world, rects.repairRect, seaLevel);
|
recomputeSlopeAndWaterDependentFields(world, rects.repairRect, seaLevel);
|
||||||
}
|
}
|
||||||
const strictMetadataDebug = restoreOutsideStrictMetadata(world, sourceMap, rects, strictMetadataSnapshot, seed);
|
const strictMetadataDebug = restoreOutsideStrictMetadata(world, sourceMap, rects, strictMetadataSnapshot, seed);
|
||||||
sourceMap.adminDebug = {
|
|
||||||
...(sourceMap.adminDebug || {}),
|
|
||||||
municipalCoherence: municipalCoherence.debug,
|
|
||||||
prefectureMetadataCoherence: prefectureCoherence.debug,
|
|
||||||
};
|
|
||||||
const segmentDebug = mergeSegmentLayers(world, sourceMap, rects, seed, candidate, fieldDebug.window);
|
const segmentDebug = mergeSegmentLayers(world, sourceMap, rects, seed, candidate, fieldDebug.window);
|
||||||
const candidateCompartmentSegmentsAdded = mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rects, fieldDebug.window, seed);
|
const candidateCompartmentSegmentsAdded = mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rects, fieldDebug.window, seed);
|
||||||
patchTimer.mark("segments", "Boundary and debug segment merge");
|
patchTimer.mark("segments", "Boundary and debug segment merge");
|
||||||
sanitizeExistingLogistics(sourceMap);
|
sanitizeExistingLogistics(sourceMap);
|
||||||
patchTimer.mark("cleanup", "Land-use, admin, and label cleanup");
|
patchTimer.mark("cleanup", "Land-use, admin, and label cleanup");
|
||||||
const patchTimings = patchTimer.timings;
|
const patchTimings = patchTimer.timings;
|
||||||
|
const {
|
||||||
|
terrainSeamDebug,
|
||||||
|
elevationCliffDebug,
|
||||||
|
waterDebug,
|
||||||
|
waterComponentDebug,
|
||||||
|
residualSeaDebug,
|
||||||
|
waterElevationDebug,
|
||||||
|
maskDebug,
|
||||||
|
} = terrainDebug;
|
||||||
|
const {
|
||||||
|
landDebug,
|
||||||
|
finalAdminCoverageDebug,
|
||||||
|
adminTopologyDebug,
|
||||||
|
strictMaskDebugPreCoherence,
|
||||||
|
strictMaskDebugPostCoherence,
|
||||||
|
sourceAdminMetadataUpdated,
|
||||||
|
municipalCoherence,
|
||||||
|
prefectureCoherence,
|
||||||
|
} = adminDebug;
|
||||||
|
|
||||||
const seaStats = countSea(world, rects.coreRect, rects, seed);
|
const seaStats = countSea(world, rects.coreRect, rects, seed);
|
||||||
const label = terrainLabel(candidate, terrainType);
|
const label = terrainLabel(candidate, terrainType);
|
||||||
const id = terrainId(candidate, terrainType);
|
const id = terrainId(candidate, terrainType);
|
||||||
|
const patchStageDebug = {
|
||||||
|
candidate: { cacheHit, cacheSize, patchGenerationMode, candidateAreaRatio: candidateWindow.areaRatio ?? 1 },
|
||||||
|
fields: fieldDebug,
|
||||||
|
terrain: terrainDebug,
|
||||||
|
points: pointDebug,
|
||||||
|
transport: pathDebug,
|
||||||
|
influence: influenceDebug,
|
||||||
|
admin: adminDebug,
|
||||||
|
segments: { ...segmentDebug, candidateCompartmentSegmentsAdded },
|
||||||
|
cleanup: { logisticsLabelsMigrated, strictMetadataDebug, residualSeaStrictDebug },
|
||||||
|
};
|
||||||
const humanGeography = {
|
const humanGeography = {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
patchStages: patchStageDebug,
|
||||||
modernCities: (sourceMap.modernCities || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length,
|
modernCities: (sourceMap.modernCities || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length,
|
||||||
ports: (sourceMap.ports || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length,
|
ports: (sourceMap.ports || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length,
|
||||||
villages: (sourceMap.villages || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length,
|
villages: (sourceMap.villages || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length,
|
||||||
|
|
|
||||||
2660
mapPatch.js.bak
2660
mapPatch.js.bak
File diff suppressed because it is too large
Load diff
24
mapPatchContext.js
Normal file
24
mapPatchContext.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
export function createPatchContext({ world, rects, candidateWindow, seed, patchAlpha, sourceIndexForWorld, worldIndex }) {
|
||||||
|
const writeRect = rects?.writeRect;
|
||||||
|
const writeCells = [];
|
||||||
|
if (world && writeRect && typeof patchAlpha === "function" && typeof sourceIndexForWorld === "function" && typeof worldIndex === "function") {
|
||||||
|
for (let y = writeRect.y0; y < writeRect.y1; y++) {
|
||||||
|
for (let x = writeRect.x0; x < writeRect.x1; x++) {
|
||||||
|
const wi = worldIndex(world, x, y);
|
||||||
|
if (wi < 0) continue;
|
||||||
|
const si = sourceIndexForWorld(rects, candidateWindow, x, y);
|
||||||
|
if (si < 0) continue;
|
||||||
|
const alpha = patchAlpha(x, y, rects, seed);
|
||||||
|
if (alpha <= 0.005) continue;
|
||||||
|
writeCells.push({ x, y, wi, si, alpha });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
world,
|
||||||
|
rects,
|
||||||
|
candidateWindow,
|
||||||
|
seed,
|
||||||
|
writeCells,
|
||||||
|
};
|
||||||
|
}
|
||||||
181
renderer.js
181
renderer.js
|
|
@ -2,11 +2,74 @@ import { CELL_SIZE, MAP_H, MAP_W, clamp, valueNoise } from "./mapUtils.js";
|
||||||
|
|
||||||
|
|
||||||
const segmentVectorCache = new WeakMap();
|
const segmentVectorCache = new WeakMap();
|
||||||
|
const segmentVectorStableCache = new Map();
|
||||||
const pathVectorCache = new WeakMap();
|
const pathVectorCache = new WeakMap();
|
||||||
const coastlineCache = new WeakMap();
|
const coastlineCache = new WeakMap();
|
||||||
const rasterBorderCache = new WeakMap();
|
const rasterBorderCache = new WeakMap();
|
||||||
const baseImageCache = new WeakMap();
|
const baseImageCache = new Map();
|
||||||
const MAX_BASE_CACHE_IMAGES = 4;
|
const urbanOverlayCache = new Map();
|
||||||
|
const prefectureFillCache = new Map();
|
||||||
|
const MAX_BASE_CACHE_IMAGES = 18;
|
||||||
|
const MAX_OVERLAY_CACHE_IMAGES = 18;
|
||||||
|
const MAX_SEGMENT_VECTOR_CACHE = 48;
|
||||||
|
const CONTINUOUS_BASE_MODES = ["terrain", "development", "all"];
|
||||||
|
|
||||||
|
function nowMs() {
|
||||||
|
return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldRefSignature(map) {
|
||||||
|
const refs = [
|
||||||
|
map?.elevation, map?.slope, map?.sea, map?.prefectureMask, map?.humanRegionMask,
|
||||||
|
map?.landuse, map?.populationDensity, map?.prefectureRegionId, map?.adminId,
|
||||||
|
];
|
||||||
|
return refs.map((field) => ArrayBuffer.isView(field) ? `${field.constructor.name}:${field.length}` : "-").join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatedRectSignature(map) {
|
||||||
|
const rects = Array.isArray(map?.generatedRects) ? map.generatedRects : [];
|
||||||
|
const last = rects[rects.length - 1] || null;
|
||||||
|
if (!last) return "0";
|
||||||
|
return `${rects.length}:${last.x0 || 0},${last.y0 || 0},${last.x1 || 0},${last.y1 || 0}:${last.terrainType || ""}:${last.variant || ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableViewportCachePrefix(map) {
|
||||||
|
const camera = map?.worldCamera || {};
|
||||||
|
const origin = map?.worldOrigin || {};
|
||||||
|
return [
|
||||||
|
map?.baseSeed ?? map?.seed ?? 0,
|
||||||
|
map?.effectiveSeed ?? "",
|
||||||
|
map?.generationContext?.variant ?? "",
|
||||||
|
mapWidth(map),
|
||||||
|
mapHeight(map),
|
||||||
|
Math.round(camera.x || 0),
|
||||||
|
Math.round(camera.y || 0),
|
||||||
|
Math.round(origin.x || 0),
|
||||||
|
Math.round(origin.y || 0),
|
||||||
|
generatedRectSignature(map),
|
||||||
|
fieldRefSignature(map),
|
||||||
|
].join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cappedSet(cache, key, value, maxSize) {
|
||||||
|
cache.set(key, value);
|
||||||
|
while (cache.size > maxSize) cache.delete(cache.keys().next().value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentContentSignature(segments) {
|
||||||
|
const count = segments?.length || 0;
|
||||||
|
if (!count) return "0";
|
||||||
|
const step = Math.max(1, Math.floor(count / 24));
|
||||||
|
const parts = [String(count)];
|
||||||
|
for (let i = 0; i < count; i += step) {
|
||||||
|
const seg = segments[i];
|
||||||
|
parts.push(`${seg?.[0]?.[0] || 0},${seg?.[0]?.[1] || 0},${seg?.[1]?.[0] || 0},${seg?.[1]?.[1] || 0}`);
|
||||||
|
}
|
||||||
|
const last = segments[count - 1];
|
||||||
|
parts.push(`${last?.[0]?.[0] || 0},${last?.[0]?.[1] || 0},${last?.[1]?.[0] || 0},${last?.[1]?.[1] || 0}`);
|
||||||
|
return parts.join("|");
|
||||||
|
}
|
||||||
|
|
||||||
function mapWidth(map) {
|
function mapWidth(map) {
|
||||||
return Math.max(1, Math.floor(Number.isFinite(map?.width) ? map.width : MAP_W));
|
return Math.max(1, Math.floor(Number.isFinite(map?.width) ? map.width : MAP_W));
|
||||||
|
|
@ -189,6 +252,12 @@ function vectorizeSegments(segments, { iterations = 2, tolerance = 0.08 } = {})
|
||||||
segmentVectorCache.set(segments, cachedByOption);
|
segmentVectorCache.set(segments, cachedByOption);
|
||||||
}
|
}
|
||||||
if (cachedByOption.has(cacheKey)) return cachedByOption.get(cacheKey);
|
if (cachedByOption.has(cacheKey)) return cachedByOption.get(cacheKey);
|
||||||
|
const stableKey = `${cacheKey}:${segmentContentSignature(segments)}`;
|
||||||
|
const stableCached = segmentVectorStableCache.get(stableKey);
|
||||||
|
if (stableCached) {
|
||||||
|
cachedByOption.set(cacheKey, stableCached);
|
||||||
|
return stableCached;
|
||||||
|
}
|
||||||
|
|
||||||
const polylines = segmentChains(segments).map((line) => {
|
const polylines = segmentChains(segments).map((line) => {
|
||||||
const cleaned = removeCollinear(line);
|
const cleaned = removeCollinear(line);
|
||||||
|
|
@ -199,6 +268,7 @@ function vectorizeSegments(segments, { iterations = 2, tolerance = 0.08 } = {})
|
||||||
}).filter((line) => line.length >= 2);
|
}).filter((line) => line.length >= 2);
|
||||||
|
|
||||||
cachedByOption.set(cacheKey, polylines);
|
cachedByOption.set(cacheKey, polylines);
|
||||||
|
cappedSet(segmentVectorStableCache, stableKey, polylines, MAX_SEGMENT_VECTOR_CACHE);
|
||||||
return polylines;
|
return polylines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -527,35 +597,29 @@ function discreteColor(map, x, y, mode) {
|
||||||
return blendOutside(color, Boolean(map.prefectureMask[i]));
|
return blendOutside(color, Boolean(map.prefectureMask[i]));
|
||||||
}
|
}
|
||||||
|
|
||||||
function baseCacheKey(mode, continuousTerrain, renderScale = 1) {
|
function baseCacheKey(map, mode, continuousTerrain, renderScale = 1) {
|
||||||
const continuousModes = ["terrain", "development", "all"];
|
const prefix = stableViewportCachePrefix(map);
|
||||||
const scaleKey = Math.round((renderScale || 1) * 20) / 20;
|
if (continuousTerrain && CONTINUOUS_BASE_MODES.includes(mode)) {
|
||||||
if (continuousTerrain && continuousModes.includes(mode)) {
|
const scaleKey = Math.round((renderScale || 1) * 20) / 20;
|
||||||
return `continuous:${mode === "all" ? "terrain" : mode}:${scaleKey}`;
|
return `${prefix}:continuous:${mode === "all" ? "terrain" : mode}:${scaleKey}`;
|
||||||
}
|
}
|
||||||
return `discrete:${mode}:${scaleKey}`;
|
return `${prefix}:discrete:${mode}:1`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCachedBaseCanvas(ctx, map, mode, continuousTerrain, renderScale = 1) {
|
function getCachedBaseCanvas(ctx, map, mode, continuousTerrain, renderScale = 1) {
|
||||||
let cache = baseImageCache.get(map);
|
const key = baseCacheKey(map, mode, continuousTerrain, renderScale);
|
||||||
if (!cache) {
|
let canvas = baseImageCache.get(key);
|
||||||
cache = new Map();
|
|
||||||
baseImageCache.set(map, cache);
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = baseCacheKey(mode, continuousTerrain, renderScale);
|
|
||||||
let canvas = cache.get(key);
|
|
||||||
if (canvas) return canvas;
|
if (canvas) return canvas;
|
||||||
|
|
||||||
const sourceWidth = mapPixelWidth(map);
|
const sourceWidth = mapPixelWidth(map);
|
||||||
const sourceHeight = mapPixelHeight(map);
|
const sourceHeight = mapPixelHeight(map);
|
||||||
const targetScale = Math.max(0.35, Math.min(1, renderScale || 1));
|
const continuous = continuousTerrain && CONTINUOUS_BASE_MODES.includes(mode);
|
||||||
|
const targetScale = continuous ? Math.max(0.35, Math.min(1, renderScale || 1)) : 1;
|
||||||
const width = Math.max(1, Math.round(sourceWidth * targetScale));
|
const width = Math.max(1, Math.round(sourceWidth * targetScale));
|
||||||
const height = Math.max(1, Math.round(sourceHeight * targetScale));
|
const height = Math.max(1, Math.round(sourceHeight * targetScale));
|
||||||
const img = ctx.createImageData(width, height);
|
const img = ctx.createImageData(width, height);
|
||||||
const continuousModes = ["terrain", "development", "all"];
|
|
||||||
|
|
||||||
if (continuousTerrain && continuousModes.includes(mode)) {
|
if (continuous) {
|
||||||
for (let py = 0; py < height; py++) {
|
for (let py = 0; py < height; py++) {
|
||||||
const fy = (py / Math.max(1, height)) * mapHeight(map);
|
const fy = (py / Math.max(1, height)) * mapHeight(map);
|
||||||
for (let px = 0; px < width; px++) {
|
for (let px = 0; px < width; px++) {
|
||||||
|
|
@ -594,9 +658,7 @@ function getCachedBaseCanvas(ctx, map, mode, continuousTerrain, renderScale = 1)
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
const bctx = canvas.getContext("2d");
|
const bctx = canvas.getContext("2d");
|
||||||
bctx.putImageData(img, 0, 0);
|
bctx.putImageData(img, 0, 0);
|
||||||
cache.set(key, canvas);
|
return cappedSet(baseImageCache, key, canvas, MAX_BASE_CACHE_IMAGES);
|
||||||
if (cache.size > MAX_BASE_CACHE_IMAGES) cache.delete(cache.keys().next().value);
|
|
||||||
return canvas;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawBase(ctx, map, mode, continuousTerrain, renderScale = 1) {
|
function drawBase(ctx, map, mode, continuousTerrain, renderScale = 1) {
|
||||||
|
|
@ -842,6 +904,13 @@ function drawUrbanAreas(ctx, map, mode) {
|
||||||
const visibleModes = ["all", "modern", "development", "landuse", "admin"];
|
const visibleModes = ["all", "modern", "development", "landuse", "admin"];
|
||||||
if (!visibleModes.includes(mode)) return;
|
if (!visibleModes.includes(mode)) return;
|
||||||
|
|
||||||
|
const key = `${stableViewportCachePrefix(map)}:urban:${mode}`;
|
||||||
|
let canvas = urbanOverlayCache.get(key);
|
||||||
|
if (canvas) {
|
||||||
|
ctx.drawImage(canvas, 0, 0, mapPixelWidth(map), mapPixelHeight(map));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const detailedColors = {
|
const detailedColors = {
|
||||||
2: "rgba(223, 214, 206, 0.72)",
|
2: "rgba(223, 214, 206, 0.72)",
|
||||||
3: "rgba(215, 175, 172, 0.88)",
|
3: "rgba(215, 175, 172, 0.88)",
|
||||||
|
|
@ -854,7 +923,11 @@ function drawUrbanAreas(ctx, map, mode) {
|
||||||
const cityColor = "rgba(232, 222, 228, 0.60)";
|
const cityColor = "rgba(232, 222, 228, 0.60)";
|
||||||
const cbdColor = "rgba(215, 175, 172, 0.84)";
|
const cbdColor = "rgba(215, 175, 172, 0.84)";
|
||||||
|
|
||||||
ctx.save();
|
canvas = document.createElement("canvas");
|
||||||
|
canvas.width = mapPixelWidth(map);
|
||||||
|
canvas.height = mapPixelHeight(map);
|
||||||
|
const bctx = canvas.getContext("2d");
|
||||||
|
if (!bctx) return;
|
||||||
const w = mapWidth(map);
|
const w = mapWidth(map);
|
||||||
const h = mapHeight(map);
|
const h = mapHeight(map);
|
||||||
for (let y = 0; y < h; y++) {
|
for (let y = 0; y < h; y++) {
|
||||||
|
|
@ -871,11 +944,12 @@ function drawUrbanAreas(ctx, map, mode) {
|
||||||
|
|
||||||
const px = x * CELL_SIZE;
|
const px = x * CELL_SIZE;
|
||||||
const py = y * CELL_SIZE;
|
const py = y * CELL_SIZE;
|
||||||
ctx.fillStyle = fill;
|
bctx.fillStyle = fill;
|
||||||
ctx.fillRect(px, py, CELL_SIZE, CELL_SIZE);
|
bctx.fillRect(px, py, CELL_SIZE, CELL_SIZE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.restore();
|
cappedSet(urbanOverlayCache, key, canvas, MAX_OVERLAY_CACHE_IMAGES);
|
||||||
|
ctx.drawImage(canvas, 0, 0, mapPixelWidth(map), mapPixelHeight(map));
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawDebugCells(ctx, map, field, color) {
|
function drawDebugCells(ctx, map, field, color) {
|
||||||
|
|
@ -940,9 +1014,19 @@ function drawPrefectureRegionFill(ctx, map, mode) {
|
||||||
if (!["all", "admin", "borders-debug"].includes(mode)) return;
|
if (!["all", "admin", "borders-debug"].includes(mode)) return;
|
||||||
const ids = map.prefectureRegionId;
|
const ids = map.prefectureRegionId;
|
||||||
if (!ids) return;
|
if (!ids) return;
|
||||||
|
const key = `${stableViewportCachePrefix(map)}:prefecture-fill:${mode}`;
|
||||||
|
let canvas = prefectureFillCache.get(key);
|
||||||
|
if (canvas) {
|
||||||
|
ctx.drawImage(canvas, 0, 0, mapPixelWidth(map), mapPixelHeight(map));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const alpha = mode === "borders-debug" ? 0.34 : 0.18;
|
const alpha = mode === "borders-debug" ? 0.34 : 0.18;
|
||||||
ctx.save();
|
canvas = document.createElement("canvas");
|
||||||
ctx.globalAlpha = alpha;
|
canvas.width = mapPixelWidth(map);
|
||||||
|
canvas.height = mapPixelHeight(map);
|
||||||
|
const bctx = canvas.getContext("2d");
|
||||||
|
if (!bctx) return;
|
||||||
|
bctx.globalAlpha = alpha;
|
||||||
const w = mapWidth(map);
|
const w = mapWidth(map);
|
||||||
const h = mapHeight(map);
|
const h = mapHeight(map);
|
||||||
for (let y = 0; y < h; y++) {
|
for (let y = 0; y < h; y++) {
|
||||||
|
|
@ -951,11 +1035,12 @@ function drawPrefectureRegionFill(ctx, map, mode) {
|
||||||
const id = ids[i];
|
const id = ids[i];
|
||||||
if (map.sea[i] || id < 0) continue;
|
if (map.sea[i] || id < 0) continue;
|
||||||
const [r, g, b] = prefectureRegionColor(id);
|
const [r, g, b] = prefectureRegionColor(id);
|
||||||
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
|
bctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
|
||||||
ctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE);
|
bctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.restore();
|
cappedSet(prefectureFillCache, key, canvas, MAX_OVERLAY_CACHE_IMAGES);
|
||||||
|
ctx.drawImage(canvas, 0, 0, mapPixelWidth(map), mapPixelHeight(map));
|
||||||
}
|
}
|
||||||
|
|
||||||
function dot(ctx, p, radius, fill, stroke = "white") {
|
function dot(ctx, p, radius, fill, stroke = "white") {
|
||||||
|
|
@ -1090,6 +1175,14 @@ export function drawMap(canvas, map, options) {
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const timings = {};
|
||||||
|
let timingMark = nowMs();
|
||||||
|
const markTiming = (key) => {
|
||||||
|
const t = nowMs();
|
||||||
|
timings[key] = Math.round((t - timingMark) * 10) / 10;
|
||||||
|
timingMark = t;
|
||||||
|
};
|
||||||
|
|
||||||
const mode = options.mode || "all";
|
const mode = options.mode || "all";
|
||||||
const showFeatures = options.showFeatures !== false;
|
const showFeatures = options.showFeatures !== false;
|
||||||
const showLabels = options.showLabels !== false;
|
const showLabels = options.showLabels !== false;
|
||||||
|
|
@ -1102,7 +1195,8 @@ export function drawMap(canvas, map, options) {
|
||||||
const drawOffsetX = (outputWidth - sourceWidth * drawScale) * 0.5;
|
const drawOffsetX = (outputWidth - sourceWidth * drawScale) * 0.5;
|
||||||
const drawOffsetY = (outputHeight - sourceHeight * drawScale) * 0.5;
|
const drawOffsetY = (outputHeight - sourceHeight * drawScale) * 0.5;
|
||||||
const cellScreenSize = CELL_SIZE * drawScale;
|
const cellScreenSize = CELL_SIZE * drawScale;
|
||||||
const terrainRenderScale = continuousTerrain ? clamp(drawScale * (options.fastTerrain ? 0.48 : 1.35), 0.30, 1) : 1;
|
const terrainQualityScale = options.fastTerrain ? 0.48 : 0.62;
|
||||||
|
const terrainRenderScale = continuousTerrain ? clamp(drawScale * terrainQualityScale, 0.30, options.fastTerrain ? 0.55 : 0.78) : 1;
|
||||||
|
|
||||||
if (canvas.width !== outputWidth) canvas.width = outputWidth;
|
if (canvas.width !== outputWidth) canvas.width = outputWidth;
|
||||||
if (canvas.height !== outputHeight) canvas.height = outputHeight;
|
if (canvas.height !== outputHeight) canvas.height = outputHeight;
|
||||||
|
|
@ -1117,14 +1211,19 @@ export function drawMap(canvas, map, options) {
|
||||||
drawScaleBar(ctx, cellScreenSize);
|
drawScaleBar(ctx, cellScreenSize);
|
||||||
delete ctx.__mapPixelWidth;
|
delete ctx.__mapPixelWidth;
|
||||||
delete ctx.__mapPixelHeight;
|
delete ctx.__mapPixelHeight;
|
||||||
|
timings.scale = Math.round((nowMs() - timingMark) * 10) / 10;
|
||||||
|
return timings;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. Base Terrain & Urban
|
// 1. Base Terrain & Urban
|
||||||
drawBase(ctx, map, mode, continuousTerrain, terrainRenderScale);
|
drawBase(ctx, map, mode, continuousTerrain, terrainRenderScale);
|
||||||
|
markTiming("baseTerrain");
|
||||||
drawUrbanAreas(ctx, map, mode);
|
drawUrbanAreas(ctx, map, mode);
|
||||||
|
markTiming("urbanFill");
|
||||||
const coastSegments = getCoastlineSegments(map);
|
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(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 });
|
drawVectorSegments(ctx, coastSegments, "rgba(248, 250, 242, 0.68)", 1.1, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||||
|
markTiming("coastline");
|
||||||
|
|
||||||
// 2. Rivers
|
// 2. Rivers
|
||||||
const waterBlue = "rgba(116, 165, 202, 0.92)";
|
const waterBlue = "rgba(116, 165, 202, 0.92)";
|
||||||
|
|
@ -1175,6 +1274,7 @@ export function drawMap(canvas, map, options) {
|
||||||
return 1.55 * downstreamBoost;
|
return 1.55 * downstreamBoost;
|
||||||
}, 1.0);
|
}, 1.0);
|
||||||
}
|
}
|
||||||
|
markTiming("rivers");
|
||||||
|
|
||||||
const showHistory = mode === "history";
|
const showHistory = mode === "history";
|
||||||
const showTransportDebug = mode === "transport-debug";
|
const showTransportDebug = mode === "transport-debug";
|
||||||
|
|
@ -1214,10 +1314,10 @@ export function drawMap(canvas, map, options) {
|
||||||
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(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 });
|
drawVectorSegments(ctx, finalPrefectureBorders, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||||
}
|
}
|
||||||
|
markTiming("adminBorders");
|
||||||
|
|
||||||
if (!showFeatures) {
|
if (!showFeatures) {
|
||||||
finish();
|
return finish();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Transport casings. Layer order: local roads, trunk roads, railways, expressways.
|
// 4. Transport casings. Layer order: local roads, trunk roads, railways, expressways.
|
||||||
|
|
@ -1267,6 +1367,7 @@ export function drawMap(canvas, map, options) {
|
||||||
for (const path of map.expressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4);
|
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);
|
for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4);
|
||||||
}
|
}
|
||||||
|
markTiming("transport");
|
||||||
|
|
||||||
// 6. Icons & Labels
|
// 6. Icons & Labels
|
||||||
if (["admin", "borders-debug"].includes(mode)) {
|
if (["admin", "borders-debug"].includes(mode)) {
|
||||||
|
|
@ -1307,6 +1408,7 @@ export function drawMap(canvas, map, options) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
markTiming("icons");
|
||||||
|
|
||||||
if (showLabels) {
|
if (showLabels) {
|
||||||
const prefectureLabels = (map.prefectureRegions || []).map((p) => ({ ...p, isPrefectureLabel: true, forceLabel: true, labelPriorityBase: p.labelPriorityBase || 1900 }));
|
const prefectureLabels = (map.prefectureRegions || []).map((p) => ({ ...p, isPrefectureLabel: true, forceLabel: true, labelPriorityBase: p.labelPriorityBase || 1900 }));
|
||||||
|
|
@ -1315,13 +1417,13 @@ export function drawMap(canvas, map, options) {
|
||||||
.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))
|
.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))
|
||||||
.map((p) => ({ ...p, labelStyle: "municipality", forceLabel: true, labelPriorityBase: 1200 }));
|
.map((p) => ({ ...p, labelStyle: "municipality", forceLabel: true, labelPriorityBase: 1200 }));
|
||||||
drawLabels(ctx, [...prefectureLabels, ...municipalLabels], Infinity);
|
drawLabels(ctx, [...prefectureLabels, ...municipalLabels], Infinity);
|
||||||
finish();
|
markTiming("labels");
|
||||||
return;
|
return finish();
|
||||||
}
|
}
|
||||||
if (mode === "borders-debug") {
|
if (mode === "borders-debug") {
|
||||||
drawLabels(ctx, prefectureLabels, Infinity);
|
drawLabels(ctx, prefectureLabels, Infinity);
|
||||||
finish();
|
markTiming("labels");
|
||||||
return;
|
return finish();
|
||||||
}
|
}
|
||||||
const important = [
|
const important = [
|
||||||
...prefectureLabels,
|
...prefectureLabels,
|
||||||
|
|
@ -1332,5 +1434,6 @@ export function drawMap(canvas, map, options) {
|
||||||
].filter((p) => !p.suppressSettlementLabel && (p.isPrefectureLabel || p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000));
|
].filter((p) => !p.suppressSettlementLabel && (p.isPrefectureLabel || p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000));
|
||||||
drawLabels(ctx, important, mode === "all" || mode === "history" ? 78 : 60);
|
drawLabels(ctx, important, mode === "all" || mode === "history" ? 78 : 60);
|
||||||
}
|
}
|
||||||
finish();
|
markTiming("labels");
|
||||||
|
return finish();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue