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()
|
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) {
|
async styles({ request, response }: HttpContext) {
|
||||||
// Automatically ensure base assets are present before generating styles
|
// Automatically ensure base assets are present before generating styles
|
||||||
const baseAssetsExist = await this.mapService.ensureBaseAssets()
|
const baseAssetsExist = await this.mapService.ensureBaseAssets()
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,16 @@ import InstalledResource from '#models/installed_resource'
|
||||||
import { CollectionManifestService } from './collection_manifest_service.js'
|
import { CollectionManifestService } from './collection_manifest_service.js'
|
||||||
import type { CollectionWithStatus, MapsSpec } from '../../types/collections.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 = [
|
const BASE_ASSETS_MIME_TYPES = [
|
||||||
'application/gzip',
|
'application/gzip',
|
||||||
'application/x-gzip',
|
'application/x-gzip',
|
||||||
|
|
@ -398,6 +408,70 @@ export class MapService implements IMapService {
|
||||||
return template
|
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> {
|
async delete(file: string): Promise<void> {
|
||||||
let fileName = file
|
let fileName = file
|
||||||
if (!fileName.endsWith('.pmtiles')) {
|
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() {
|
async listCuratedMapCollections() {
|
||||||
return catchInternal(async () => {
|
return catchInternal(async () => {
|
||||||
const response = await this.client.get<CollectionWithStatus[]>(
|
const response = await this.client.get<CollectionWithStatus[]>(
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,10 @@ import CuratedCollectionCard from '~/components/CuratedCollectionCard'
|
||||||
import type { CollectionWithStatus } from '../../../types/collections'
|
import type { CollectionWithStatus } from '../../../types/collections'
|
||||||
import ActiveDownloads from '~/components/ActiveDownloads'
|
import ActiveDownloads from '~/components/ActiveDownloads'
|
||||||
import Alert from '~/components/Alert'
|
import Alert from '~/components/Alert'
|
||||||
|
import { formatBytes } from '~/lib/util'
|
||||||
|
|
||||||
const CURATED_COLLECTIONS_KEY = 'curated-map-collections'
|
const CURATED_COLLECTIONS_KEY = 'curated-map-collections'
|
||||||
|
const GLOBAL_MAP_INFO_KEY = 'global-map-info'
|
||||||
|
|
||||||
export default function MapsManager(props: {
|
export default function MapsManager(props: {
|
||||||
maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] }
|
maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] }
|
||||||
|
|
@ -38,6 +40,31 @@ export default function MapsManager(props: {
|
||||||
enabled: true,
|
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() {
|
async function downloadBaseAssets() {
|
||||||
try {
|
try {
|
||||||
setDownloading(true)
|
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() {
|
async function openDownloadModal() {
|
||||||
openModal(
|
openModal(
|
||||||
<DownloadURLModal
|
<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">
|
<div className="mt-8 mb-6 flex items-center justify-between">
|
||||||
<StyledSectionHeader title="Curated Map Regions" className="!mb-0" />
|
<StyledSectionHeader title="Curated Map Regions" className="!mb-0" />
|
||||||
<StyledButton
|
<StyledButton
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,8 @@ router
|
||||||
router.post('/download-remote', [MapsController, 'downloadRemote'])
|
router.post('/download-remote', [MapsController, 'downloadRemote'])
|
||||||
router.post('/download-remote-preflight', [MapsController, 'downloadRemotePreflight'])
|
router.post('/download-remote-preflight', [MapsController, 'downloadRemotePreflight'])
|
||||||
router.post('/download-collection', [MapsController, 'downloadCollection'])
|
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'])
|
router.delete('/:filename', [MapsController, 'delete'])
|
||||||
})
|
})
|
||||||
.prefix('/api/maps')
|
.prefix('/api/maps')
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user