human tweaks
This commit is contained in:
parent
1ea8ba1701
commit
f5e7a1df1d
9 changed files with 1201 additions and 45 deletions
182
mapAdminStage.js
182
mapAdminStage.js
|
|
@ -286,7 +286,22 @@ function restoreSurvivedSeedsByCompartment(adminId, prefectureMask, sea, compart
|
|||
return { changedCells, restoredSeeds };
|
||||
}
|
||||
|
||||
function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages }) {
|
||||
function municipalityCountBoundsForRegion(landCells, meta = {}) {
|
||||
const focused = meta.isFocusedRegion !== false;
|
||||
if (focused) return { min: 20, max: 50 };
|
||||
// Neighbor prefectures are often visible only as clipped map-edge slivers.
|
||||
// Avoid giving every tiny visible fragment the full 20-municipality floor.
|
||||
let min = 1;
|
||||
if (landCells >= 500) min = 2;
|
||||
if (landCells >= 950) min = 3;
|
||||
if (landCells >= 1700) min = 5;
|
||||
if (landCells >= 2800) min = 7;
|
||||
if (landCells >= 4300) min = 10;
|
||||
const max = clamp(Math.round(landCells / 260 + 2), Math.max(min, 2), 34);
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages, adminRegionMeta = {} }) {
|
||||
let landCells = 0;
|
||||
let habitableCells = 0;
|
||||
let lowlandCells = 0;
|
||||
|
|
@ -314,8 +329,9 @@ function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope,
|
|||
const basinBonus = Math.min(8, [...basinField].filter((v, i) => prefectureMask[i] && !sea[i] && v > 0.34).length / 520);
|
||||
const mountainRatio = landCells ? mountainCells / landCells : 0;
|
||||
const lowlandBonus = Math.min(7, lowlandCells / 430);
|
||||
const target = Math.round(habitableCells / 230 + settlementWeight + coastlineComplexity * 0.03 + basinBonus * 0.55 + lowlandBonus - mountainRatio * 2.2);
|
||||
return clamp(target, 20, 50);
|
||||
const rawTarget = Math.round(habitableCells / 230 + settlementWeight + coastlineComplexity * 0.03 + basinBonus * 0.55 + lowlandBonus - mountainRatio * 2.2);
|
||||
const { min, max } = municipalityCountBoundsForRegion(landCells, adminRegionMeta);
|
||||
return clamp(rawTarget, min, max);
|
||||
}
|
||||
|
||||
function lowlandAdminSeedScore(i, { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse }) {
|
||||
|
|
@ -620,14 +636,24 @@ function generateAdminLayoutForMask({
|
|||
stations,
|
||||
industrialZones,
|
||||
logisticsParks,
|
||||
adminRegionMeta = {},
|
||||
adminProgress = null,
|
||||
}) {
|
||||
const boundaryRidgeField = naturalBarrierScore
|
||||
? Float32Array.from(ridgeField, (value, i) => clamp(value * 0.72 + naturalBarrierScore[i] * 0.46))
|
||||
: ridgeField;
|
||||
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "classify satellites" });
|
||||
const satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, boundaryRidgeField, river, flowAccum);
|
||||
const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages });
|
||||
const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages, adminRegionMeta });
|
||||
const compartmentMultiplier = clamp(4.6 + rand(seed, 1320) * 2.4, 4.6, 7.0);
|
||||
let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), 120, 360);
|
||||
const regionLandArea = adminRegionMeta.landArea || maskLandArea(prefectureMask, sea);
|
||||
const minCompartmentTarget = adminRegionMeta.isFocusedRegion === false
|
||||
? clamp(Math.round(Math.max(targetMunicipalityCount * 3.2, regionLandArea / 75)), 18, 90)
|
||||
: 120;
|
||||
const maxCompartmentTarget = adminRegionMeta.isFocusedRegion === false
|
||||
? clamp(Math.round(Math.max(targetMunicipalityCount * 5.8, regionLandArea / 38)), minCompartmentTarget, 220)
|
||||
: 360;
|
||||
let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), minCompartmentTarget, maxCompartmentTarget);
|
||||
let adminCentersRaw = buildLowlandAdminSeeds({
|
||||
seed,
|
||||
targetMunicipalityCount,
|
||||
|
|
@ -652,12 +678,14 @@ function generateAdminLayoutForMask({
|
|||
newTowns,
|
||||
stations,
|
||||
});
|
||||
if (adminCentersRaw.length < 20) targetCompartmentCount = Math.max(targetCompartmentCount, 120);
|
||||
if (adminCentersRaw.length < 20) targetCompartmentCount = Math.max(targetCompartmentCount, minCompartmentTarget);
|
||||
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "natural compartments", targetMunicipalityCount, targetCompartmentCount, seedCount: adminCentersRaw.length });
|
||||
const compartmentAssignment = assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, {
|
||||
seed,
|
||||
targetMunicipalityCount,
|
||||
targetCompartmentCount,
|
||||
maxNaturalCompartmentArea: Math.max(32, Math.round(maskLandArea(prefectureMask, sea) / Math.max(1, targetCompartmentCount) * 1.65)),
|
||||
progress: (step) => adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step }),
|
||||
});
|
||||
const adminId = compartmentAssignment.adminId;
|
||||
let previousSnapshot = new Int16Array(adminId);
|
||||
|
|
@ -710,6 +738,7 @@ function generateAdminLayoutForMask({
|
|||
satelliteMunicipalityStats: satelliteClassificationDebug,
|
||||
...compartmentAssignment.debug,
|
||||
};
|
||||
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "seed lifecycle" });
|
||||
const seedLifecycle = buildSeedLifecycle(adminCentersRaw, adminId, prefectureMask, sea, 35);
|
||||
const pendingSplitDebug = splitOversizedLowlandsWithPendingSeeds(adminId, prefectureMask, sea, {
|
||||
plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation,
|
||||
|
|
@ -735,6 +764,7 @@ function generateAdminLayoutForMask({
|
|||
adminDebug[field] = changedCellsSince(previousSnapshot, adminId, prefectureMask, sea);
|
||||
previousSnapshot = new Int16Array(adminId);
|
||||
}
|
||||
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "smooth boundaries" });
|
||||
smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, populationDensity, landuse, 2);
|
||||
markChanged("changedAfterSmooth");
|
||||
|
||||
|
|
@ -797,19 +827,34 @@ function generateAdminLayoutForMask({
|
|||
const activeAdminCenters = () => adminCentersRaw.filter((_, id) => activeAdminIds.has(id));
|
||||
mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 120, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 100, protectedPoints: activeAdminCenters() });
|
||||
markChanged("changedAfterInitialMerge");
|
||||
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "topology cleanup" });
|
||||
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 180);
|
||||
markChanged("changedAfterInitialExclaveRemoval");
|
||||
// The initial compartment graph assignment is now the primary natural partition.
|
||||
// Re-running the older raw landscape-unit pass here collapses lowland seeds into a few broad owners.
|
||||
markChanged("changedAfterLandscapePartition");
|
||||
const oversizedSplitDebug = splitOversizedLowlandMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, [...(satelliteCities || []), ...newTowns, ...markets, ...villages]);
|
||||
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "split oversized municipalities" });
|
||||
// The older oversized-lowland pass rebuilds natural compartments a second time.
|
||||
// The current pipeline already performs pending-seed lowland splitting on the active
|
||||
// compartment graph above, so keep the full admin layout while avoiding the duplicate
|
||||
// high-cost recomputation.
|
||||
const oversizedSplitDebug = {
|
||||
changedCells: 0,
|
||||
splitMunicipalities: 0,
|
||||
rejectedMunicipalities: 0,
|
||||
skippedDuplicateCompartmentRebuild: true,
|
||||
skippedForVisibleFragment: adminRegionMeta.isFocusedRegion === false && regionLandArea < 6500,
|
||||
};
|
||||
adminDebug.changedAfterOversizedRuralSplit = oversizedSplitDebug.changedCells;
|
||||
adminDebug.oversizedRuralMunicipalitiesSplit = oversizedSplitDebug.splitMunicipalities;
|
||||
adminDebug.oversizedRuralSplits = oversizedSplitDebug.splitMunicipalities;
|
||||
adminDebug.oversizedLowlandSplits = oversizedSplitDebug.splitMunicipalities;
|
||||
adminDebug.ruralSplitsAccepted = oversizedSplitDebug.splitMunicipalities;
|
||||
adminDebug.ruralSplitsRejected = oversizedSplitDebug.rejectedMunicipalities || 0;
|
||||
adminDebug.oversizedSplitSkippedForVisibleFragment = Boolean(oversizedSplitDebug.skippedForVisibleFragment);
|
||||
adminDebug.oversizedSplitSkippedDuplicateCompartmentRebuild = Boolean(oversizedSplitDebug.skippedDuplicateCompartmentRebuild);
|
||||
previousSnapshot = new Int16Array(adminId);
|
||||
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "terrain snap" });
|
||||
snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 2);
|
||||
markChanged("changedAfterSnap");
|
||||
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 360);
|
||||
|
|
@ -881,6 +926,7 @@ function generateAdminLayoutForMask({
|
|||
adminDebug.seedLifecycle = seedLifecycle.map((seed) => ({ id: seed.id, state: seed.state, protected: seed.protected, area: seed.area }));
|
||||
adminDebug.borderNaturalBarrierAverage = adminDebug.finalBorderNaturalBarrierAverage ?? 0;
|
||||
adminDebug.voronoiLikeRate = adminDebug.voronoiLikeRateAfter ?? 0;
|
||||
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "extract borders" });
|
||||
const adminBorders = extractAdminBorderSegments(adminId, prefectureMask);
|
||||
|
||||
|
||||
|
|
@ -889,7 +935,9 @@ function generateAdminLayoutForMask({
|
|||
|
||||
|
||||
function filterPointsForMask(points = [], mask, sea) {
|
||||
return (points || []).filter((p) => p && inside(p.x, p.y) && mask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]);
|
||||
return (points || [])
|
||||
.filter((p) => p && inside(p.x, p.y) && mask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)])
|
||||
.map((p) => ({ ...p }));
|
||||
}
|
||||
|
||||
function buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId) {
|
||||
|
|
@ -907,6 +955,85 @@ function maskLandArea(mask, sea) {
|
|||
return area;
|
||||
}
|
||||
|
||||
|
||||
function compactAdminIdsAndCenters(adminId, humanMask, sea, centers = [], fields = {}) {
|
||||
const activeIds = [...new Set([...adminId].filter((id, i) => id >= 0 && humanMask[i] && !sea[i]))].sort((a, b) => a - b);
|
||||
const idMap = new Map(activeIds.map((oldId, newId) => [oldId, newId]));
|
||||
const newAdminId = new Int16Array(SIZE);
|
||||
newAdminId.fill(-1);
|
||||
const cellsByNewId = Array.from({ length: activeIds.length }, () => []);
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (!humanMask[i] || sea[i]) continue;
|
||||
const newId = idMap.get(adminId[i]);
|
||||
if (newId === undefined) continue;
|
||||
newAdminId[i] = newId;
|
||||
cellsByNewId[newId].push(i);
|
||||
}
|
||||
|
||||
const chooseOffice = (newId, oldId) => {
|
||||
const cells = cellsByNewId[newId] || [];
|
||||
const current = centers[oldId];
|
||||
if (current && inside(current.x, current.y)) {
|
||||
const ci = indexOf(current.x, current.y);
|
||||
if (newAdminId[ci] === newId && humanMask[ci] && !sea[ci]) {
|
||||
return { ...current, localAdminId: newId, oldAdminId: oldId, municipalityOffice: true };
|
||||
}
|
||||
}
|
||||
let sx = 0, sy = 0;
|
||||
for (const i of cells) {
|
||||
const [x, y] = xyOf(i);
|
||||
sx += x;
|
||||
sy += y;
|
||||
}
|
||||
const cx = cells.length ? sx / cells.length : current?.x || 0;
|
||||
const cy = cells.length ? sy / cells.length : current?.y || 0;
|
||||
let bestI = cells[0] ?? -1;
|
||||
let bestScore = -INF;
|
||||
for (const i of cells) {
|
||||
const [x, y] = xyOf(i);
|
||||
const land = fields.landuse?.[i] ?? 0;
|
||||
const urbanBonus = land === 3 ? 1.2 : land === 2 ? 1.0 : land === 4 || land === 7 || land === 8 ? 0.55 : land === 1 ? 0.24 : 0;
|
||||
const density = fields.populationDensity?.[i] || 0;
|
||||
const settlement = fields.settlementScore?.[i] || 0;
|
||||
const score =
|
||||
density * 2.25 +
|
||||
settlement * 0.75 +
|
||||
urbanBonus +
|
||||
(fields.plain?.[i] || 0) * 0.32 +
|
||||
(fields.basinField?.[i] || 0) * 0.24 +
|
||||
(fields.coastalLowland?.[i] || 0) * 0.18 +
|
||||
(fields.roadInfluence?.[i] || 0) * 0.34 +
|
||||
(fields.stationInfluence?.[i] || 0) * 0.45 -
|
||||
(fields.slope?.[i] || 0) * 0.52 -
|
||||
Math.hypot(x - cx, y - cy) * 0.018 +
|
||||
hash2(x, y, 91337 + newId) * 0.012;
|
||||
if (score > bestScore) { bestScore = score; bestI = i; }
|
||||
}
|
||||
const [bx, by] = bestI >= 0 ? xyOf(bestI) : [Math.round(cx), Math.round(cy)];
|
||||
return {
|
||||
...(current || {}),
|
||||
x: bx,
|
||||
y: by,
|
||||
score: bestScore > -INF ? bestScore : 0,
|
||||
seedKind: current?.seedKind || "generatedMunicipalOffice",
|
||||
invisibleLowlandAdminSeed: current?.invisibleLowlandAdminSeed ?? true,
|
||||
localAdminId: newId,
|
||||
oldAdminId: oldId,
|
||||
municipalityOffice: true,
|
||||
generatedOfficePoint: !current || !inside(current.x, current.y) || newAdminId[indexOf(current.x, current.y)] !== newId,
|
||||
};
|
||||
};
|
||||
|
||||
const adminCenters = activeIds.map((oldId, newId) => chooseOffice(newId, oldId));
|
||||
return {
|
||||
adminId: newAdminId,
|
||||
adminCenters,
|
||||
activeMunicipalityCount: activeIds.length,
|
||||
removedUnusedAdminCenterCount: Math.max(0, centers.length - activeIds.length),
|
||||
generatedOfficePointCount: adminCenters.filter((p) => p.generatedOfficePoint).length,
|
||||
};
|
||||
}
|
||||
|
||||
function discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea) {
|
||||
const ids = new Set();
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
|
|
@ -918,16 +1045,17 @@ function discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea) {
|
|||
}
|
||||
|
||||
export function generateAdminLayout(context) {
|
||||
const { prefectureMask, prefectureRegionId, sea, populationDensity, plain, slope } = context;
|
||||
const { prefectureMask, prefectureRegionId, sea, populationDensity, plain, slope, adminProgress } = context;
|
||||
const minFullAdminRegionArea = 1500;
|
||||
const regionIds = discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea)
|
||||
.filter((regionId) => maskLandArea(buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId), sea) >= 120);
|
||||
.filter((regionId) => regionId === 0 || (regionId !== OUTER_ANCHOR_REGION_ID && maskLandArea(buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId), sea) >= minFullAdminRegionArea));
|
||||
|
||||
if (!prefectureRegionId || regionIds.length <= 1) return generateAdminLayoutForMask(context);
|
||||
|
||||
const combinedAdminId = new Int16Array(SIZE);
|
||||
let combinedAdminId = new Int16Array(SIZE);
|
||||
combinedAdminId.fill(-1);
|
||||
const combinedHumanMask = new Uint8Array(SIZE);
|
||||
const combinedCenters = [];
|
||||
let combinedCenters = [];
|
||||
const combinedCompartmentBorders = [];
|
||||
const perRegion = [];
|
||||
let idOffset = 0;
|
||||
|
|
@ -935,7 +1063,7 @@ export function generateAdminLayout(context) {
|
|||
for (const regionId of regionIds) {
|
||||
const regionMask = buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId);
|
||||
const regionArea = maskLandArea(regionMask, sea);
|
||||
if (regionArea < 120) continue;
|
||||
if (regionId !== 0 && regionArea < minFullAdminRegionArea) continue;
|
||||
|
||||
const localContext = {
|
||||
...context,
|
||||
|
|
@ -950,9 +1078,18 @@ export function generateAdminLayout(context) {
|
|||
stations: filterPointsForMask(context.stations, regionMask, sea),
|
||||
industrialZones: filterPointsForMask(context.industrialZones, regionMask, sea),
|
||||
logisticsParks: filterPointsForMask(context.logisticsParks, regionMask, sea),
|
||||
adminRegionMeta: {
|
||||
regionId,
|
||||
landArea: regionArea,
|
||||
isFocusedRegion: regionId === 0,
|
||||
isOuterAnchorRegion: regionId === OUTER_ANCHOR_REGION_ID,
|
||||
},
|
||||
adminProgress,
|
||||
};
|
||||
|
||||
adminProgress?.({ status: "region-start", regionId, area: regionArea });
|
||||
const local = generateAdminLayoutForMask(localContext);
|
||||
adminProgress?.({ status: "region-done", regionId, area: regionArea, municipalities: local.adminDebug?.finalMunicipalityCount || local.adminDebug?.actualMunicipalityCount || 0 });
|
||||
if (local.adminDebug?.compartmentBorders?.length) combinedCompartmentBorders.push(...local.adminDebug.compartmentBorders);
|
||||
let localMaxAdminId = -1;
|
||||
for (let i = 0; i < SIZE; i++) if (regionMask[i] && !sea[i] && (local.adminId?.[i] ?? -1) > localMaxAdminId) localMaxAdminId = local.adminId[i];
|
||||
|
|
@ -1036,8 +1173,21 @@ export function generateAdminLayout(context) {
|
|||
perRegion.push({ regionId, area: cells.length, centerCount: 1, municipalityCount: 1, naturalCompartmentCount: 1, targetNaturalCompartmentCount: 1, averageCompartmentArea: cells.length, maxCompartmentArea: cells.length, maxCompartmentElongation: 1, singleCompartmentMunicipalityRatio: 1, tinyRegionFallback: true });
|
||||
}
|
||||
|
||||
const compactedAdmin = compactAdminIdsAndCenters(combinedAdminId, combinedHumanMask, sea, combinedCenters, {
|
||||
populationDensity,
|
||||
plain,
|
||||
slope,
|
||||
settlementScore: context.settlementScore,
|
||||
landuse: context.landuse,
|
||||
basinField: context.basinField,
|
||||
coastalLowland: context.coastalLowland,
|
||||
roadInfluence: context.roadInfluence,
|
||||
stationInfluence: context.stationInfluence,
|
||||
});
|
||||
combinedAdminId = compactedAdmin.adminId;
|
||||
combinedCenters = compactedAdmin.adminCenters;
|
||||
const adminBorders = extractAdminBorderSegments(combinedAdminId, combinedHumanMask);
|
||||
const totalMunicipalityCount = new Set([...combinedAdminId].filter((id, i) => id >= 0 && combinedHumanMask[i] && !sea[i])).size;
|
||||
const totalMunicipalityCount = compactedAdmin.activeMunicipalityCount;
|
||||
const totalNaturalCompartmentCount = perRegion.reduce((sum, row) => sum + (row.naturalCompartmentCount || 0), 0);
|
||||
const totalTargetNaturalCompartmentCount = perRegion.reduce((sum, row) => sum + (row.targetNaturalCompartmentCount || 0), 0);
|
||||
const weightedCompartmentArea = perRegion.reduce((sum, row) => sum + (row.averageCompartmentArea || 0) * (row.naturalCompartmentCount || 0), 0);
|
||||
|
|
@ -1045,10 +1195,14 @@ export function generateAdminLayout(context) {
|
|||
const adminDebug = {
|
||||
multiRegionAdmin: true,
|
||||
adminRegionCount: perRegion.length,
|
||||
minFullAdminRegionArea,
|
||||
perRegion,
|
||||
finalMunicipalityCount: totalMunicipalityCount,
|
||||
actualMunicipalityCount: totalMunicipalityCount,
|
||||
candidateSeedCount: combinedCenters.length,
|
||||
municipalOfficePointCount: combinedCenters.length,
|
||||
generatedOfficePointCount: compactedAdmin.generatedOfficePointCount,
|
||||
removedUnusedAdminCenterCount: compactedAdmin.removedUnusedAdminCenterCount,
|
||||
naturalCompartmentCount: totalNaturalCompartmentCount,
|
||||
compartmentCount: totalNaturalCompartmentCount,
|
||||
targetNaturalCompartmentCount: totalTargetNaturalCompartmentCount,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue