This commit is contained in:
0xGlitch 2026-03-26 07:30:27 -06:00 committed by GitHub
commit 2060127b4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 184 additions and 0 deletions

View File

@ -73,6 +73,18 @@ export default class MapsController {
return await this.mapService.listRegions()
}
async globalMapInfo({}: HttpContext) {
return await this.mapService.getGlobalMapInfo()
}
async downloadGlobalMap({}: HttpContext) {
const result = await this.mapService.downloadGlobalMap()
return {
message: 'Download started successfully',
...result,
}
}
async styles({ request, response }: HttpContext) {
// Automatically ensure base assets are present before generating styles
const baseAssetsExist = await this.mapService.ensureBaseAssets()

View File

@ -21,6 +21,16 @@ import InstalledResource from '#models/installed_resource'
import { CollectionManifestService } from './collection_manifest_service.js'
import type { CollectionWithStatus, MapsSpec } from '../../types/collections.js'
const PROTOMAPS_BUILDS_METADATA_URL = 'https://build-metadata.protomaps.dev/builds.json'
const PROTOMAPS_BUILD_BASE_URL = 'https://build.protomaps.com'
export interface ProtomapsBuildInfo {
url: string
date: string
size: number
key: string
}
const BASE_ASSETS_MIME_TYPES = [
'application/gzip',
'application/x-gzip',
@ -398,6 +408,76 @@ export class MapService implements IMapService {
return template
}
async getGlobalMapInfo(): Promise<ProtomapsBuildInfo> {
const { default: axios } = await import('axios')
const response = await axios.get(PROTOMAPS_BUILDS_METADATA_URL, { timeout: 15000 })
const builds = response.data as Array<{ key: string; size: number }>
if (!builds || builds.length === 0) {
throw new Error('No protomaps builds found')
}
// Latest build first
const sorted = builds.sort((a, b) => b.key.localeCompare(a.key))
const latest = sorted[0]
const dateStr = latest.key.replace('.pmtiles', '')
const date = `${dateStr.slice(0, 4)}-${dateStr.slice(4, 6)}-${dateStr.slice(6, 8)}`
return {
url: `${PROTOMAPS_BUILD_BASE_URL}/${latest.key}`,
date,
size: latest.size,
key: latest.key,
}
}
async downloadGlobalMap(): Promise<{ filename: string; jobId?: string }> {
const info = await this.getGlobalMapInfo()
const existing = await RunDownloadJob.getByUrl(info.url)
if (existing) {
throw new Error(`Download already in progress for URL ${info.url}`)
}
const basePath = resolve(join(this.baseDirPath, 'pmtiles'))
const filepath = resolve(join(basePath, info.key))
// Prevent path traversal — resolved path must stay within the storage directory
if (!filepath.startsWith(basePath + sep)) {
throw new Error('Invalid filename')
}
// First, ensure base assets are present - the global map depends on them
const baseAssetsExist = await this.ensureBaseAssets()
if (!baseAssetsExist) {
throw new Error(
'Base map assets are missing and could not be downloaded. Please check your connection and try again.'
)
}
// forceNew: false so retries resume partial downloads
const result = await RunDownloadJob.dispatch({
url: info.url,
filepath,
timeout: 30000,
allowedMimeTypes: PMTILES_MIME_TYPES,
forceNew: false,
filetype: 'map',
})
if (!result.job) {
throw new Error('Failed to dispatch download job')
}
logger.info(`[MapService] Dispatched global map download job ${result.job.id}`)
return {
filename: info.key,
jobId: result.job?.id,
}
}
async delete(file: string): Promise<void> {
let fileName = file
if (!fileName.endsWith('.pmtiles')) {

View File

@ -486,6 +486,29 @@ class API {
}
}
async getGlobalMapInfo() {
return catchInternal(async () => {
const response = await this.client.get<{
url: string
date: string
size: number
key: string
}>('/maps/global-map-info')
return response.data
})()
}
async downloadGlobalMap() {
return catchInternal(async () => {
const response = await this.client.post<{
message: string
filename: string
jobId?: string
}>('/maps/download-global-map')
return response.data
})()
}
async listCuratedMapCollections() {
return catchInternal(async () => {
const response = await this.client.get<CollectionWithStatus[]>(

View File

@ -16,8 +16,10 @@ import CuratedCollectionCard from '~/components/CuratedCollectionCard'
import type { CollectionWithStatus } from '../../../types/collections'
import ActiveDownloads from '~/components/ActiveDownloads'
import Alert from '~/components/Alert'
import { formatBytes } from '~/lib/util'
const CURATED_COLLECTIONS_KEY = 'curated-map-collections'
const GLOBAL_MAP_INFO_KEY = 'global-map-info'
export default function MapsManager(props: {
maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] }
@ -38,6 +40,31 @@ export default function MapsManager(props: {
enabled: true,
})
const { data: globalMapInfo } = useQuery({
queryKey: [GLOBAL_MAP_INFO_KEY],
queryFn: () => api.getGlobalMapInfo(),
refetchOnWindowFocus: false,
})
const downloadGlobalMap = useMutation({
mutationFn: () => api.downloadGlobalMap(),
onSuccess: () => {
invalidateDownloads()
addNotification({
type: 'success',
message: 'Global map download has been queued. This is a large file (~125 GB) and may take a while.',
})
closeAllModals()
},
onError: (error) => {
console.error('Error downloading global map:', error)
addNotification({
type: 'error',
message: 'Failed to start the global map download. Please try again.',
})
},
})
async function downloadBaseAssets() {
try {
setDownloading(true)
@ -146,6 +173,29 @@ export default function MapsManager(props: {
)
}
async function confirmGlobalMapDownload() {
if (!globalMapInfo) return
openModal(
<StyledModal
title="Download Global Map?"
onConfirm={() => downloadGlobalMap.mutate()}
onCancel={closeAllModals}
open={true}
confirmText="Download"
cancelText="Cancel"
confirmVariant="primary"
confirmLoading={downloadGlobalMap.isPending}
>
<p className="text-text-secondary">
This will download the full Protomaps global map ({formatBytes(globalMapInfo.size, 1)}, build {globalMapInfo.date}).
Covers the entire planet so you won't need individual region files.
Make sure you have enough disk space.
</p>
</StyledModal>,
'confirm-global-map-download-modal'
)
}
async function openDownloadModal() {
openModal(
<DownloadURLModal
@ -201,6 +251,23 @@ export default function MapsManager(props: {
}}
/>
)}
{globalMapInfo && (
<Alert
title="Global Map Coverage Available"
message={`Download a complete worldwide map from Protomaps (${formatBytes(globalMapInfo.size, 1)}, build ${globalMapInfo.date}). This is a large file but covers the entire planet — no individual region downloads needed.`}
type="info-inverted"
variant="bordered"
className="mt-8"
icon="IconWorld"
buttonProps={{
variant: 'primary',
children: 'Download Global Map',
icon: 'IconCloudDownload',
loading: downloadGlobalMap.isPending,
onClick: () => confirmGlobalMapDownload(),
}}
/>
)}
<div className="mt-8 mb-6 flex items-center justify-between">
<StyledSectionHeader title="Curated Map Regions" className="!mb-0" />
<StyledButton

View File

@ -78,6 +78,8 @@ router
router.post('/download-remote', [MapsController, 'downloadRemote'])
router.post('/download-remote-preflight', [MapsController, 'downloadRemotePreflight'])
router.post('/download-collection', [MapsController, 'downloadCollection'])
router.get('/global-map-info', [MapsController, 'globalMapInfo'])
router.post('/download-global-map', [MapsController, 'downloadGlobalMap'])
router.delete('/:filename', [MapsController, 'delete'])
})
.prefix('/api/maps')