mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-28 03:29:25 +01:00
feat(maps): add global map download from Protomaps
This commit is contained in:
parent
5c92c89813
commit
8e9131d2ff
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,70 @@ 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 filepath = join(process.cwd(), this.mapStoragePath, 'pmtiles', info.key)
|
||||
|
||||
// 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')) {
|
||||
|
|
|
|||
|
|
@ -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[]>(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user