fix(maps): dedupe map sources by region so duplicate files don't blank the map

The map style names each source by its date-stripped region (both
"washington.pmtiles" and "washington_2025-12.pmtiles" -> "washington").
When an old and new copy of the same region are both on disk, the style
emitted two sources with the same key and duplicate layer ids, which
MapLibre rejects outright -- blanking the ENTIRE map, not just that region.

Old copies linger when a newer curated version installs (#634), so a user
who updates maps can silently lose all map rendering until the stale file
is removed by hand.

generateSourcesArray() now keeps only the newest file per region: a dated
build beats an undated legacy file, and between two dated builds the later
YYYY-MM wins. The skipped duplicate is logged. The style stays valid even
when stale files are present.

Complements #981, which removes superseded curated files on install. This
is the runtime safety net that also recovers installs already in the broken
state (which a cleanup-on-install alone can't reach).

Refs #634
This commit is contained in:
Chris Sherwood 2026-06-08 10:24:28 -07:00 committed by jakeaturner
parent a315ce0f54
commit 9b84d3aa54
No known key found for this signature in database
GPG Key ID: B1072EBDEECE328D
1 changed files with 45 additions and 9 deletions

View File

@ -457,6 +457,18 @@ export class MapService implements IMapService {
return await listDirectoryContentsRecursive(this.baseDirPath)
}
/**
* Compare two map-file versions (YYYY-MM, or null for an undated legacy file). Returns >0 if
* `a` is newer than `b`, <0 if older, 0 if equal. A dated build is always newer than an undated
* legacy file; two dated builds compare lexicographically (correct for zero-padded YYYY-MM).
*/
private static compareMapVersions(a: string | null, b: string | null): number {
if (a === b) return 0
if (a === null) return -1
if (b === null) return 1
return a < b ? -1 : 1
}
private generateSourcesArray(host: string | null, regions: FileEntry[], protocol: string = 'http'): BaseStylesFile['sources'][] {
const sources: BaseStylesFile['sources'][] = []
const baseUrl = this.getPublicFileBaseUrl(host, 'pmtiles', protocol)
@ -474,23 +486,47 @@ export class MapService implements IMapService {
sources.push(worldSource)
}
// Dedupe by region name, keeping only the newest file per region. The source name is the
// date-stripped region (e.g. both "washington.pmtiles" and "washington_2025-12.pmtiles" map
// to "washington"). Emitting both produces duplicate source keys and duplicate layer ids,
// which MapLibre rejects outright — blanking the ENTIRE map, not just that region. Old copies
// linger when a newer curated version installs (#634), so guard against it here so the style
// stays valid even if cleanup hasn't run. A dated build beats an undated legacy file; between
// two dated builds the later YYYY-MM wins (lexicographic compare is correct for that format).
const bestByRegion = new Map<string, { region: FileEntry; version: string | null }>()
for (const region of regions) {
if (region.type === 'file' && region.name.endsWith('.pmtiles')) {
// Strip .pmtiles and date suffix (e.g. "alaska_2025-12" -> "alaska") for stable source names
const parsed = CollectionManifestService.parseMapFilename(region.name)
const regionName = parsed ? parsed.resource_id : region.name.replace('.pmtiles', '')
const source: BaseStylesFile['sources'] = {}
const sourceUrl = urlJoin(baseUrl, region.name)
source[regionName] = {
type: 'vector',
attribution: PMTILES_ATTRIBUTION,
url: `pmtiles://${sourceUrl}`,
const version = parsed?.version ?? null
const existing = bestByRegion.get(regionName)
if (!existing || MapService.compareMapVersions(version, existing.version) > 0) {
if (existing) {
logger.warn(
`[MapService] Duplicate map region "${regionName}": using "${region.name}" over "${existing.region.name}" (keeping newest)`
)
}
bestByRegion.set(regionName, { region, version })
} else {
logger.warn(
`[MapService] Duplicate map region "${regionName}": skipping "${region.name}" in favor of "${existing.region.name}" (keeping newest)`
)
}
sources.push(source)
}
}
for (const [regionName, { region }] of bestByRegion) {
const source: BaseStylesFile['sources'] = {}
const sourceUrl = urlJoin(baseUrl, region.name)
source[regionName] = {
type: 'vector',
attribution: PMTILES_ATTRIBUTION,
url: `pmtiles://${sourceUrl}`,
}
sources.push(source)
}
return sources
}