diff --git a/admin/app/services/map_service.ts b/admin/app/services/map_service.ts index 0e1c902..9de8c49 100644 --- a/admin/app/services/map_service.ts +++ b/admin/app/services/map_service.ts @@ -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() 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 }