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, }); }