295 lines
10 KiB
JavaScript
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,
|
|
});
|
|
}
|