map/mapMunicipalCoherence.js
2026-05-29 22:00:42 +09:00

295 lines
10 KiB
JavaScript

import { INF, MAP_H, MAP_W } from "./mapUtils.js";
const ADMIN_ID_KEYS = ["adminId", "municipalityId", "adminNumericId"];
const PREFECTURE_NAME_KEYS = ["prefectureName", "prefectureRegionName", "regionName", "name", "labelName"];
function usableName(value) {
const text = value == null ? "" : String(value).trim();
if (!text) return "";
if (/^県域\d*$/u.test(text)) return "";
if (/^Unnamed prefecture$/i.test(text)) return "";
if (/^Prefecture\s*-?\d+$/i.test(text)) return "";
return text;
}
function firstUsableName(obj, keys = PREFECTURE_NAME_KEYS) {
for (const key of keys) {
const text = usableName(obj?.[key]);
if (text) return text;
}
return "";
}
function coordIndex(width, height, x, y) {
if (x < 0 || y < 0 || x >= width || y >= height) return -1;
return y * width + x;
}
function numericAdminId(point) {
for (const key of ADMIN_ID_KEYS) {
const value = point?.[key];
if (Number.isFinite(value) && value >= 0) return Math.floor(value);
}
return -1;
}
function fieldValue(fields, name, i) {
return fields?.[name]?.[i] || 0;
}
function bestCellScore(fields, i) {
return fieldValue(fields, "populationDensity", i) * 3.0
+ fieldValue(fields, "plain", i) * 0.32
+ fieldValue(fields, "agriculture", i) * 0.16
- fieldValue(fields, "slope", i) * 0.30
- fieldValue(fields, "ridgeField", i) * 0.18;
}
function buildMunicipalStats({ adminId, prefectureRegionId, sea, fields = {}, width = MAP_W, height = MAP_H }) {
const stats = new Map();
for (let i = 0; i < adminId.length; i++) {
const id = adminId[i];
if (!Number.isFinite(id) || id < 0 || sea?.[i]) continue;
const row = stats.get(id) || { id, area: 0, sx: 0, sy: 0, bestI: -1, bestScore: -INF, prefVotes: new Map() };
const x = i % width;
const y = Math.floor(i / width);
row.area++;
row.sx += x;
row.sy += y;
const pref = prefectureRegionId?.[i] ?? -1;
if (pref >= 0) row.prefVotes.set(pref, (row.prefVotes.get(pref) || 0) + 1);
const score = bestCellScore(fields, i);
if (score > row.bestScore) {
row.bestScore = score;
row.bestI = i;
}
stats.set(id, row);
}
for (const row of stats.values()) {
let bestPref = -1;
let bestVotes = -1;
for (const [pref, count] of row.prefVotes) {
if (count > bestVotes || (count === bestVotes && pref < bestPref)) {
bestPref = pref;
bestVotes = count;
}
}
row.prefectureRegionId = bestPref;
row.x = row.bestI >= 0 ? row.bestI % width : Math.round(row.sx / Math.max(1, row.area));
row.y = row.bestI >= 0 ? Math.floor(row.bestI / width) : Math.round(row.sy / Math.max(1, row.area));
}
return stats;
}
function pointFieldCoord(point, pointOffsetX, pointOffsetY) {
return {
x: Math.round((point?.x || 0) + pointOffsetX),
y: Math.round((point?.y || 0) + pointOffsetY),
};
}
function centerQuality(center, id, stat, context) {
if (!center) return -INF;
const { adminId, sea, width, height, pointOffsetX, pointOffsetY } = context;
const p = pointFieldCoord(center, pointOffsetX, pointOffsetY);
const i = coordIndex(width, height, p.x, p.y);
const ownsCell = i >= 0 && !sea?.[i] && adminId?.[i] === id;
return (ownsCell ? 100000 : 0)
+ (center.name ? 5000 : 0)
+ (center.representativeFeatureName || center.canonicalSettlementName ? 1200 : 0)
+ (center.generatedOfficePoint ? -200 : 0)
- Math.hypot(p.x - stat.x, p.y - stat.y);
}
function normalizeCenter(center, id, stat, context, generated = false) {
const { pointOffsetX, pointOffsetY, fields = {}, seed = 0 } = context;
const out = {
...(center || {}),
x: stat.x - pointOffsetX,
y: stat.y - pointOffsetY,
adminId: id,
adminNumericId: id,
municipalityId: id,
prefectureRegionId: stat.prefectureRegionId,
municipalArea: stat.area,
insidePrefecture: true,
};
if (generated) {
out.generatedOfficePoint = true;
out.seedKind ||= "coherenceFallbackMunicipalityOffice";
out.kind ||= "Municipal Center";
out.generatedMunicipalityName ||= `自治${id + 1}`;
out.name ||= out.generatedMunicipalityName;
out.labelName ||= out.generatedMunicipalityName;
out.municipalityName ||= out.generatedMunicipalityName;
}
const i = coordIndex(context.width, context.height, stat.x, stat.y);
if (i >= 0) {
out.officePopulationDensity = fields.populationDensity?.[i] || 0;
out.officeLanduse = fields.landuse?.[i] ?? out.officeLanduse;
}
return out;
}
export function reconcileMunicipalMetadata({
adminId,
municipalityId = null,
prefectureRegionId = null,
sea = null,
adminCenters = [],
municipalityToPrefectureId = null,
fields = {},
width = MAP_W,
height = MAP_H,
pointOffsetX = 0,
pointOffsetY = 0,
seed = 0,
} = {}) {
if (!adminId) return { adminCenters: adminCenters || [], municipalityToPrefectureId, stats: new Map(), debug: { activeMunicipalities: 0 } };
const stats = buildMunicipalStats({ adminId, prefectureRegionId, sea, fields, width, height });
if (municipalityId) {
for (let i = 0; i < adminId.length; i++) municipalityId[i] = sea?.[i] ? -1 : (adminId[i] >= 0 ? adminId[i] : -1);
}
const byId = new Map();
let ghostCentersRemoved = 0;
let centersMovedToOwnedCells = 0;
for (const [index, center] of (adminCenters || []).entries()) {
if (!center) continue;
const explicitId = numericAdminId(center);
const id = explicitId >= 0 ? explicitId : (stats.has(index) ? index : -1);
const stat = stats.get(id);
if (!stat) {
ghostCentersRemoved++;
continue;
}
const current = byId.get(id);
if (!current || centerQuality(center, id, stat, { adminId, sea, width, height, pointOffsetX, pointOffsetY }) > centerQuality(current, id, stat, { adminId, sea, width, height, pointOffsetX, pointOffsetY })) {
byId.set(id, center);
}
}
let fallbackCentersAdded = 0;
const nextCenters = [];
for (const [id, stat] of [...stats.entries()].sort((a, b) => a[0] - b[0])) {
const existing = byId.get(id);
if (!existing) fallbackCentersAdded++;
else {
const p = pointFieldCoord(existing, pointOffsetX, pointOffsetY);
const pi = coordIndex(width, height, p.x, p.y);
if (pi < 0 || sea?.[pi] || adminId[pi] !== id) centersMovedToOwnedCells++;
}
nextCenters.push(normalizeCenter(existing, id, stat, { pointOffsetX, pointOffsetY, fields, width, height, seed }, !existing));
}
let maxId = Math.max(-1, ...stats.keys());
if (municipalityToPrefectureId?.length) maxId = Math.max(maxId, municipalityToPrefectureId.length - 1);
const nextMapping = new Int32Array(Math.max(0, maxId + 1));
nextMapping.fill(-1);
if (municipalityToPrefectureId) {
for (let i = 0; i < municipalityToPrefectureId.length && i < nextMapping.length; i++) nextMapping[i] = municipalityToPrefectureId[i] ?? -1;
}
for (const [id, stat] of stats) if (stat.prefectureRegionId >= 0) nextMapping[id] = stat.prefectureRegionId;
return {
adminCenters: nextCenters,
municipalityToPrefectureId: nextMapping,
stats,
debug: {
activeMunicipalities: stats.size,
ghostCentersRemoved,
fallbackCentersAdded,
centersMovedToOwnedCells,
},
};
}
export function refreshPrefectureRegionsMetadata({
prefectureRegionId,
sea,
existing = [],
adminCenters = [],
fields = {},
width = MAP_W,
height = MAP_H,
pointOffsetX = 0,
pointOffsetY = 0,
} = {}) {
if (!prefectureRegionId) return { prefectureRegions: existing || [], debug: { activePrefectureRegions: 0 } };
const byId = new Map();
for (let i = 0; i < prefectureRegionId.length; i++) {
const id = prefectureRegionId[i];
if (!Number.isFinite(id) || id < 0 || sea?.[i]) continue;
const row = byId.get(id) || { id, area: 0, sx: 0, sy: 0, bestI: -1, bestScore: -INF };
const x = i % width;
const y = Math.floor(i / width);
row.area++;
row.sx += x;
row.sy += y;
const score = bestCellScore(fields, i) - Math.hypot(x - row.sx / Math.max(1, row.area), y - row.sy / Math.max(1, row.area)) * 0.02;
if (score > row.bestScore) {
row.bestScore = score;
row.bestI = i;
}
byId.set(id, row);
}
const existingById = new Map();
for (const region of existing || []) {
const id = Number.isFinite(region?.prefectureRegionId) ? Math.floor(region.prefectureRegionId) : Number.isFinite(region?.id) ? Math.floor(region.id) : -1;
if (id >= 0 && !existingById.has(id)) existingById.set(id, region);
}
const nameByPref = new Map();
for (const center of adminCenters || []) {
const id = Number.isFinite(center?.prefectureRegionId) ? Math.floor(center.prefectureRegionId) : -1;
if (id < 0 || nameByPref.has(id)) continue;
const name = firstUsableName(center, ["prefectureName", "prefectureRegionName", "regionName"]);
if (name) nameByPref.set(id, name);
}
let fallbackRegionsAdded = 0;
const prefectureRegions = [];
for (const [id, row] of [...byId.entries()].sort((a, b) => a[0] - b[0])) {
const base = existingById.get(id);
if (!base) fallbackRegionsAdded++;
const x = row.bestI >= 0 ? row.bestI % width : Math.round(row.sx / Math.max(1, row.area));
const y = row.bestI >= 0 ? Math.floor(row.bestI / width) : Math.round(row.sy / Math.max(1, row.area));
const resolvedName = firstUsableName(base) || nameByPref.get(id) || `県域${id + 1}`;
prefectureRegions.push({
...(base || {}),
id,
prefectureRegionId: id,
featureId: id,
x: x - pointOffsetX,
y: y - pointOffsetY,
area: row.area,
kind: base?.kind || (id === 0 ? "Current Prefecture" : "Prefecture"),
name: resolvedName,
labelName: firstUsableName(base, ["labelName"]) || resolvedName,
prefectureName: firstUsableName(base, ["prefectureName"]) || resolvedName,
prefectureRegionName: firstUsableName(base, ["prefectureRegionName"]) || resolvedName,
regionName: firstUsableName(base, ["regionName"]) || resolvedName,
forceLabel: true,
labelPriorityBase: base?.labelPriorityBase || 950 + Math.sqrt(row.area),
});
}
return {
prefectureRegions,
debug: {
activePrefectureRegions: byId.size,
fallbackRegionsAdded,
},
};
}
export function municipalCoherenceForMap(map) {
return reconcileMunicipalMetadata({
adminId: map?.adminId,
municipalityId: map?.municipalityId,
prefectureRegionId: map?.prefectureRegionId,
sea: map?.sea,
adminCenters: map?.adminCenters,
municipalityToPrefectureId: map?.municipalityToPrefectureId,
fields: map,
width: map?.width || MAP_W,
height: map?.height || MAP_H,
});
}