fix(maps): warn when world basemap missing instead of silent grey map

An install that never went online while visiting /maps never provisions
world.pmtiles (it is lazily extracted from the internet), leaving the map
blank/grey offline outside any downloaded regional extracts.

Detect the world basemap's presence on disk and surface it to the UI so:
- /maps shows a clear notice explaining the base map isn't downloaded and
  how to fix it, instead of a silent grey screen.
- /settings/maps offers an explicit "Download Base Map" action (~15 MB) to
  provision it deliberately while online.

Adds MapService.checkWorldBasemapExists() / provisionWorldBasemap(), a
POST /maps/setup-world-basemap endpoint, and threads worldBasemapExists
through the maps page props.

Refs #1030

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
not-knope 2026-07-14 22:23:06 +02:00
parent 6a4f02dd46
commit d13679f961
7 changed files with 102 additions and 4 deletions

View File

@ -19,10 +19,14 @@ export default class MapsController {
async index({ inertia }: HttpContext) {
const baseAssetsCheck = await this.mapService.ensureBaseAssets()
const regionFiles = await this.mapService.listRegions()
const [regionFiles, worldBasemapExists] = await Promise.all([
this.mapService.listRegions(),
this.mapService.checkWorldBasemapExists(),
])
return inertia.render('maps', {
maps: {
baseAssetsExist: baseAssetsCheck,
worldBasemapExists,
regionFiles: regionFiles.files,
},
})
@ -35,6 +39,24 @@ export default class MapsController {
return { success: true }
}
async setupWorldBasemap({ response }: HttpContext) {
try {
const ready = await this.mapService.provisionWorldBasemap()
if (!ready) {
return response.status(500).send({
message:
'Could not download the base map. Please connect this NOMAD to the internet and try again.',
})
}
return { success: true }
} catch {
return response.status(500).send({
message:
'Could not download the base map. Please connect this NOMAD to the internet and try again.',
})
}
}
async downloadRemote({ request }: HttpContext) {
const payload = await request.validateUsing(remoteDownloadValidator)
assertNotPrivateUrl(payload.url)

View File

@ -45,10 +45,14 @@ export default class SettingsController {
async maps({ inertia }: HttpContext) {
const baseAssetsCheck = await this.mapService.ensureBaseAssets()
const regionFiles = await this.mapService.listRegions()
const [regionFiles, worldBasemapExists] = await Promise.all([
this.mapService.listRegions(),
this.mapService.checkWorldBasemapExists(),
])
return inertia.render('settings/maps', {
maps: {
baseAssetsExist: baseAssetsCheck,
worldBasemapExists,
regionFiles: regionFiles.files,
},
})

View File

@ -404,6 +404,34 @@ export class MapService implements IMapService {
return true
}
/**
* Whether the low-zoom world basemap is present on disk. Checked directly (rather than trusting
* the in-process `worldBasemapReady` flag) so callers get an accurate answer regardless of whether
* `ensureWorldBasemap()` has run yet this process. Used to warn the user instead of showing a
* silent grey map when the basemap was never provisioned (e.g. installed straight offline, #1030).
*/
async checkWorldBasemapExists(): Promise<boolean> {
const basePath = resolve(join(this.baseDirPath, 'pmtiles'))
const filepath = resolve(join(basePath, WORLD_BASEMAP_FILENAME))
if (!filepath.startsWith(basePath + sep)) return false
const stats = await getFileStatsIfExists(filepath)
const exists = !!stats && Number(stats.size) > 0
if (exists) this.worldBasemapReady = true
return exists
}
/**
* Explicitly (re)provision the world basemap on demand. Ensures base assets exist, then extracts
* the basemap if it isn't present yet. Requires internet surfaced to the user as a "download the
* base map" action so the one network dependency can be satisfied deliberately while online (#1030).
*/
async provisionWorldBasemap(): Promise<boolean> {
const baseAssetsExist = await this.ensureBaseAssets()
if (!baseAssetsExist) return false
await this.ensureWorldBasemap()
return this.checkWorldBasemapExists()
}
/**
* Extract a low-zoom global basemap once so the map isn't grey outside a
* regional extract's polygon. Cheap (~15 MB, a handful of HTTP range

View File

@ -82,6 +82,13 @@ class API {
})()
}
async setupWorldBasemap() {
return catchInternal(async () => {
const response = await this.client.post<{ success: boolean }>('/maps/setup-world-basemap')
return response.data
})()
}
async downloadMapCollection(slug: string): Promise<{
message: string
slug: string

View File

@ -10,13 +10,15 @@ import Alert from '~/components/Alert'
import { FileEntry } from '../../types/files'
export default function Maps(props: {
maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] }
maps: { baseAssetsExist: boolean; worldBasemapExists: boolean; regionFiles: FileEntry[] }
}) {
const [isHoveringUI, setIsHoveringUI] = useState(false)
const [showMapCoordinates, setShowMapCoordinates] = useState(true)
const alertMessage = !props.maps.baseAssetsExist
? 'The base map assets have not been installed. Please download them first to enable map functionality.'
: !props.maps.worldBasemapExists
? 'The world base map has not been downloaded yet, so the map may appear blank outside downloaded regions. Connect this NOMAD to the internet and download it (~15 MB) from Map Settings.'
: props.maps.regionFiles.length === 0
? 'No map regions have been downloaded yet. Please download some regions to enable map functionality.'
: null

View File

@ -24,7 +24,7 @@ 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[] }
maps: { baseAssetsExist: boolean; worldBasemapExists: boolean; regionFiles: FileEntry[] }
}) {
const queryClient = useQueryClient()
const { openModal, closeAllModals } = useModals()
@ -61,6 +61,24 @@ export default function MapsManager(props: {
})
const globalMapAlreadyDownloaded = hasDownloadedGlobalMap(globalMapInfo?.key, props.maps.regionFiles)
const setupWorldBasemap = useMutation({
mutationFn: () => api.setupWorldBasemap(),
onSuccess: () => {
addNotification({
type: 'success',
message: 'Base map downloaded successfully.',
})
router.reload({ only: ['maps'] })
},
onError: () => {
addNotification({
type: 'error',
message:
'Could not download the base map. Please connect this NOMAD to the internet and try again.',
})
},
})
const downloadGlobalMap = useMutation({
mutationFn: () => api.downloadGlobalMap(),
onSuccess: () => {
@ -306,6 +324,22 @@ export default function MapsManager(props: {
}}
/>
)}
{props.maps.baseAssetsExist && !props.maps.worldBasemapExists && (
<Alert
title="World base map not downloaded"
message="The low-zoom world base map (~15 MB) hasn't been downloaded yet, so the map appears blank outside any regions you've downloaded. Connect this NOMAD to the internet and download it once for offline use."
type="warning"
variant="solid"
className="my-4"
buttonProps={{
variant: 'secondary',
children: 'Download Base Map',
icon: 'IconCloudDownload',
loading: setupWorldBasemap.isPending,
onClick: () => setupWorldBasemap.mutate(),
}}
/>
)}
{globalMapInfo && globalMapAlreadyDownloaded && (
<Alert
title="Global Map Installed"

View File

@ -78,6 +78,7 @@ router
router.get('/curated-collections', [MapsController, 'listCuratedCollections'])
router.post('/fetch-latest-collections', [MapsController, 'fetchLatestCollections'])
router.post('/download-base-assets', [MapsController, 'downloadBaseAssets'])
router.post('/setup-world-basemap', [MapsController, 'setupWorldBasemap'])
router.post('/download-remote', [MapsController, 'downloadRemote'])
router.post('/download-remote-preflight', [MapsController, 'downloadRemotePreflight'])
router.post('/download-collection', [MapsController, 'downloadCollection'])