diff --git a/client/ios/App/App/TrekBridgeViewController.swift b/client/ios/App/App/TrekBridgeViewController.swift index 65d6477b3..f5fe90324 100644 --- a/client/ios/App/App/TrekBridgeViewController.swift +++ b/client/ios/App/App/TrekBridgeViewController.swift @@ -1,5 +1,7 @@ import UIKit import Capacitor +import CryptoKit +import WebKit /// Points the WebView at the TREK server the user picked instead of the bundled /// address screen. The UI then comes from that server, in the version it runs, @@ -17,7 +19,18 @@ class TrekBridgeViewController: CAPBridgeViewController { return descriptor } + /// The map tile store answers trek-tile:// (TrekTileSchemeHandler below). A + /// scheme handler can only be set on the configuration the WebView is built from. + private let tileHandler = TrekTileSchemeHandler() + + override func webViewConfiguration(for instanceConfiguration: InstanceConfiguration) -> WKWebViewConfiguration { + let configuration = super.webViewConfiguration(for: instanceConfiguration) + configuration.setURLSchemeHandler(tileHandler, forURLScheme: TrekTileSchemeHandler.scheme) + return configuration + } + override func capacitorDidLoad() { + TrekTileStore.shared.prune() bridge?.registerPluginInstance(TrekShellPlugin()) // WebKit's own swipe back slides away a screenshot of the whole page, // closes no sheet and takes every drag near the edge from the UI. The UI @@ -44,3 +57,218 @@ enum TrekServerStore { return support.appendingPathComponent("offline-start-page.html") } } + +// MARK: - Tile store (trek-tile://) + +/// Map tiles the user looked at, on disk, for the WebView's maps +/// (client/src/native/tileCache.ts). WKWebView runs no service worker for a +/// self-hosted server, so without this nothing kept a tile once the app closed. +/// +/// Each tile lives as `.bin` with its content type in `.type`. +/// A tile older than 30 days is never served again (Mapbox's limit for a device +/// cache, applied to every provider) and is fetched anew. The store keeps under +/// 250 MB: past that, the tiles used least recently go first. It sits in Caches, +/// which iOS may empty under storage pressure and never backs up. +final class TrekTileStore { + static let shared = TrekTileStore() + static let maxAge: TimeInterval = 30 * 24 * 60 * 60 + static let maxBytes: Int64 = 250 * 1024 * 1024 + /// Pruning brings the store down to this share of the limit, so it does not + /// run again on the very next tile. + static let pruneTarget = 0.8 + + let directory: URL + let queue = DispatchQueue(label: "com.liketrek.trek.tiles", qos: .utility) + private var writesSincePrune = 0 + + private init() { + let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + directory = caches.appendingPathComponent("trek-tiles", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + /// The tile's identity without what changes per user or session: tokens and + /// keys come out, the rest of the query is sorted. + static func key(for url: URL) -> String { + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + let dropped: Set = ["access_token", "sku", "key", "api_key", "apikey"] + let kept = components?.queryItems? + .filter { !dropped.contains($0.name.lowercased()) } + .sorted { $0.name < $1.name } + components?.queryItems = (kept?.isEmpty ?? true) ? nil : kept + let identity = components?.string ?? url.absoluteString + return SHA256.hash(data: Data(identity.utf8)).map { String(format: "%02x", $0) }.joined() + } + + private func dataURL(_ key: String) -> URL { directory.appendingPathComponent(key + ".bin") } + private func typeURL(_ key: String) -> URL { directory.appendingPathComponent(key + ".type") } + + /// A stored tile younger than 30 days, marked as just used. Call on `queue`. + func read(_ key: String) -> (data: Data, type: String)? { + let file = dataURL(key) + guard let attributes = try? FileManager.default.attributesOfItem(atPath: file.path), + let created = attributes[.creationDate] as? Date else { return nil } + if Date().timeIntervalSince(created) > TrekTileStore.maxAge { + remove(key) + return nil + } + guard let data = try? Data(contentsOf: file) else { return nil } + let type = (try? String(contentsOf: typeURL(key), encoding: .utf8)) ?? "application/octet-stream" + try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: file.path) + return (data, type) + } + + func write(_ key: String, data: Data, type: String) { + queue.async { + try? data.write(to: self.dataURL(key), options: .atomic) + try? type.write(to: self.typeURL(key), atomically: true, encoding: .utf8) + self.writesSincePrune += 1 + if self.writesSincePrune >= 200 { self.pruneNow() } + } + } + + private func remove(_ key: String) { + try? FileManager.default.removeItem(at: dataURL(key)) + try? FileManager.default.removeItem(at: typeURL(key)) + } + + private struct Entry { + let key: String + let size: Int64 + let used: Date + let created: Date + } + + private func entries() -> [Entry] { + let keys: [URLResourceKey] = [.fileSizeKey, .contentModificationDateKey, .creationDateKey] + let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: keys)) ?? [] + return files.filter { $0.pathExtension == "bin" }.compactMap { file in + guard let values = try? file.resourceValues(forKeys: Set(keys)) else { return nil } + return Entry( + key: file.deletingPathExtension().lastPathComponent, + size: Int64(values.fileSize ?? 0), + used: values.contentModificationDate ?? .distantPast, + created: values.creationDate ?? .distantPast + ) + } + } + + /// Drops what is past 30 days, then the least recently used tiles while the + /// store is over its limit. + func prune() { queue.async { self.pruneNow() } } + + private func pruneNow() { + writesSincePrune = 0 + let now = Date() + var live: [Entry] = [] + for entry in entries() { + if now.timeIntervalSince(entry.created) > TrekTileStore.maxAge { remove(entry.key) } else { live.append(entry) } + } + var total = live.reduce(Int64(0)) { $0 + $1.size } + guard total > TrekTileStore.maxBytes else { return } + let target = Int64(Double(TrekTileStore.maxBytes) * TrekTileStore.pruneTarget) + for entry in live.sorted(by: { $0.used < $1.used }) { + if total <= target { break } + remove(entry.key) + total -= entry.size + } + } + + func usage(_ done: @escaping (Int64) -> Void) { + queue.async { done(self.entries().reduce(Int64(0)) { $0 + $1.size }) } + } + + func clear(_ done: @escaping () -> Void) { + queue.async { + try? FileManager.default.removeItem(at: self.directory) + try? FileManager.default.createDirectory(at: self.directory, withIntermediateDirectories: true) + done() + } + } +} + +/// Serves trek-tile://host/path from the store, or from https://host/path when +/// it has no fresh copy, storing what comes back. Offline and not stored, the +/// map gets an error and draws nothing there, as it would for a missing tile. +final class TrekTileSchemeHandler: NSObject, WKURLSchemeHandler { + static let scheme = "trek-tile" + + /// Tasks WebKit still wants an answer for. Touched on the main thread only: + /// start and stop arrive there, and every answer is posted back there. + private var active = Set() + + private let session: URLSession = { + let configuration = URLSessionConfiguration.default + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.timeoutIntervalForRequest = 20 + configuration.httpMaximumConnectionsPerHost = 4 + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0" + // A unique app User-Agent, as OpenFreeMap and OpenStreetMap ask of apps. + configuration.httpAdditionalHeaders = ["User-Agent": "TREK-App/\(version) (iOS; +https://liketrek.com)"] + return URLSession(configuration: configuration) + }() + + func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + let id = ObjectIdentifier(urlSchemeTask) + guard let url = urlSchemeTask.request.url, + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + urlSchemeTask.didFailWithError(URLError(.badURL)) + return + } + components.scheme = "https" + guard let remote = components.url else { + urlSchemeTask.didFailWithError(URLError(.badURL)) + return + } + active.insert(id) + let key = TrekTileStore.key(for: remote) + let store = TrekTileStore.shared + let accept = urlSchemeTask.request.value(forHTTPHeaderField: "Accept") + store.queue.async { + if let hit = store.read(key) { + DispatchQueue.main.async { + self.respond(urlSchemeTask, id: id, url: url, status: 200, type: hit.type, data: hit.data) + } + return + } + var request = URLRequest(url: remote) + if let accept { request.setValue(accept, forHTTPHeaderField: "Accept") } + self.session.dataTask(with: request) { data, response, _ in + guard let data, let http = response as? HTTPURLResponse else { + DispatchQueue.main.async { + self.respond(urlSchemeTask, id: id, url: url, status: 504, type: "text/plain", data: Data()) + } + return + } + let type = http.value(forHTTPHeaderField: "Content-Type") ?? "application/octet-stream" + if http.statusCode == 200, !data.isEmpty { store.write(key, data: data, type: type) } + DispatchQueue.main.async { + self.respond(urlSchemeTask, id: id, url: url, status: http.statusCode, type: type, data: data) + } + }.resume() + } + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) { + active.remove(ObjectIdentifier(urlSchemeTask)) + } + + /// Answers once, and never after WebKit stopped the task: that would throw. + private func respond(_ task: WKURLSchemeTask, id: ObjectIdentifier, url: URL, status: Int, type: String, data: Data) { + guard active.remove(id) != nil else { return } + let headers = [ + "Content-Type": type, + "Content-Length": String(data.count), + // The page's origin is the user's server; WebGL reads tiles cross-origin. + "Access-Control-Allow-Origin": "*", + "Cache-Control": "no-store", + ] + guard let response = HTTPURLResponse(url: url, statusCode: status, httpVersion: "HTTP/1.1", headerFields: headers) else { + task.didFailWithError(URLError(.badServerResponse)) + return + } + task.didReceive(response) + task.didReceive(data) + task.didFinish() + } +} diff --git a/client/ios/App/App/TrekShellPlugin.swift b/client/ios/App/App/TrekShellPlugin.swift index 2cae368b1..20690db8b 100644 --- a/client/ios/App/App/TrekShellPlugin.swift +++ b/client/ios/App/App/TrekShellPlugin.swift @@ -17,6 +17,8 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr CAPPluginMethod(name: "refreshOfflineCopy", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "openOfflineCopy", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getCapabilities", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getTileCacheUsage", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "clearTileCache", returnType: CAPPluginReturnPromise), ] /// What this binary offers the server's UI, raised with every native feature @@ -24,7 +26,20 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr /// and a server can require a minimum (minNativeShellApi in /api/health/features). static let shellApi = 2 /// The native features by name, so the UI can turn each on by itself. - static let features: [String] = ["capabilities", "edgeBack"] + static let features: [String] = ["capabilities", "edgeBack", "tileCache"] + + // MARK: - Map tile store + + /// How much the stored map tiles take on disk, for the offline settings. + @objc func getTileCacheUsage(_ call: CAPPluginCall) { + TrekTileStore.shared.usage { bytes in + call.resolve(["bytes": bytes, "limit": TrekTileStore.maxBytes]) + } + } + + @objc func clearTileCache(_ call: CAPPluginCall) { + TrekTileStore.shared.clear { call.resolve() } + } @objc func getCapabilities(_ call: CAPPluginCall) { let info = Bundle.main.infoDictionary diff --git a/client/src/components/Journey/JourneyMapGL.tsx b/client/src/components/Journey/JourneyMapGL.tsx index ccb39fe98..7d03ef8b7 100644 --- a/client/src/components/Journey/JourneyMapGL.tsx +++ b/client/src/components/Journey/JourneyMapGL.tsx @@ -4,6 +4,7 @@ import { useSettingsStore } from '../../store/settingsStore' import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup' import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders' import { buildGlMap, initialMapStyle } from '../Map/glSupport' +import { mapboxTileCacheRequest, tileCacheActive } from '../../native/tileCache' import type { JourneyTrack } from '@trek/shared' import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup' import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer' @@ -395,6 +396,9 @@ function JourneyMapGL( antialias: mapboxQuality, } if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator' + // In the iOS app, Mapbox's own resources go through the app's tile store, so + // what was viewed stays on the device for up to 30 days (native/tileCache.ts). + if (!isMapLibre && tileCacheActive()) mapOptions.transformRequest = mapboxTileCacheRequest // MapLibre's around-center mouse rotate ping-pongs near mid-screen (#1545) // — see MapViewGL. Keep the plain dx-based rotate everywhere. if (isMapLibre) mapOptions.aroundCenter = false diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 194fbd70c..4d7311268 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -68,6 +68,7 @@ import { tileHealthHandlers, tourRasterBaseOf } from './tourRasterBases' import { crsForBasemap } from './gcj02Crs' import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl' import VectorBasemap from './VectorBasemap' +import { nativeRasterTemplate } from '../../native/tileCache' import { useSettingsStore } from '../../store/settingsStore' import { TourMapLayersLeaflet } from './RasterOverlayLeaflet' import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type TourBaseLayer } from './MapLayerSwitcher' @@ -938,7 +939,7 @@ export const MapView = memo(function MapView({ ) : ( ({ offline: false, dark: false })); +const env = vi.hoisted(() => ({ offline: false, dark: false, tileStore: false })); const seen = vi.hoisted(() => ({ leaflet: null as null | { tileUrl?: string }, gl: null as null | { engine: string; glProvider?: string; styleOverride?: string }, @@ -40,6 +40,7 @@ vi.mock('../../hooks/useNetworkMode', () => ({ useNetworkMode: () => ({ offline: env.offline, forced: false, setForced: () => {} }), })); vi.mock('../../hooks/useIsDark', () => ({ useIsDark: () => env.dark })); +vi.mock('../../native/tileCache', () => ({ tileCacheActive: () => env.tileStore })); vi.mock('../../native/platform', async (importOriginal) => ({ ...(await importOriginal()), isNativeIos: () => false, @@ -52,6 +53,7 @@ beforeEach(() => { resetWebGLProbe(); env.offline = false; env.dark = false; + env.tileStore = false; seen.leaflet = null; seen.gl = null; const ctx = { getExtension: () => ({ loseContext: () => {} }) }; @@ -120,4 +122,30 @@ describe('MapViewAuto offline', () => { expect(seen.leaflet?.tileUrl).toBe('https://tiles.example.com/{z}/{x}/{y}.png'); expect(pill()).not.toBeInTheDocument(); }); + + it('FE-COMP-MAPAUTO-OFFLINE-006: in the iOS app with its tile store, Mapbox stays offline and the pill switches to the stored map', async () => { + seedStore(useSettingsStore, { settings: { map_provider: 'mapbox-gl', mapbox_access_token: 'pk.test' } }); + env.offline = true; + env.tileStore = true; + + render(); + + await screen.findByTestId('gl-map'); + expect(seen.gl).toMatchObject({ engine: 'mapbox', styleOverride: undefined }); + const toggle = screen.getByRole('button', { pressed: false }); + fireEvent.click(toggle); + await screen.findByTestId('gl-map'); + expect(seen.gl).toMatchObject({ engine: 'maplibre', styleOverride: OFM_POSITRON }); + expect(screen.getByRole('button', { pressed: true })).toBeInTheDocument(); + }); + + it('FE-COMP-MAPAUTO-OFFLINE-007: with the tile store, a raster template stays offline in the app too', () => { + seedStore(useSettingsStore, { settings: { map_provider: 'leaflet' } }); + env.offline = true; + env.tileStore = true; + + render(); + + expect(seen.leaflet?.tileUrl).toBe('https://tiles.example.com/{z}/{x}/{y}.png'); + }); }); diff --git a/client/src/components/Map/MapViewAuto.tsx b/client/src/components/Map/MapViewAuto.tsx index 878334835..37308e04c 100644 --- a/client/src/components/Map/MapViewAuto.tsx +++ b/client/src/components/Map/MapViewAuto.tsx @@ -1,4 +1,4 @@ -import { Suspense } from 'react' +import { Suspense, useState } from 'react' import { AlertTriangle, Clock, CloudOff, Info } from 'lucide-react' import { Tooltip } from '../shared/Tooltip' import { useSettingsStore } from '../../store/settingsStore' @@ -13,6 +13,7 @@ import { useNetworkMode } from '../../hooks/useNetworkMode' import { useIsDark } from '../../hooks/useIsDark' import { OFM_POSITRON } from '../../constants/mapDefaults' import { glWorksOffline, leafletWorksOffline, offlineStyleFor } from './offlineBasemap' +import { tileCacheActive } from '../../native/tileCache' // Auto-selects the map renderer based on user settings. Keeps the existing // Leaflet MapView untouched so the Mapbox GL variant can mature iteratively @@ -43,12 +44,26 @@ export function MapViewAuto(props: MapViewAutoProps) { const { offline } = useNetworkMode() const dark = useIsDark() const offlineStyle = offlineStyleFor(dark) - // Offline on a basemap that has nothing stored: draw the stored one with MapLibre. - const glFallback = !!configuredGl && offline && !glWorksOffline(configuredGl, maplibreStyle) + // In the iOS app with its tile store, a Mapbox map keeps what was viewed for 30 + // days, so offline it stays Mapbox, and the pill offers the stored trip region + // instead for the places never looked at. Everywhere else a basemap with + // nothing stored is swapped for the stored one straight away. + const nativeStore = tileCacheActive() + const [preferStored, setPreferStored] = useState(false) + const mapboxKept = configuredGl === 'mapbox-gl' && nativeStore + const glFallback = !!configuredGl && offline && !glWorksOffline(configuredGl, maplibreStyle) && (!mapboxKept || preferStored) const glProvider = glFallback ? 'maplibre-gl' : configuredGl - const leafletFallback = !configuredGl && offline && !leafletWorksOffline(mapProps.tileUrl ?? OFM_POSITRON) + const leafletFallback = !configuredGl && offline && !leafletWorksOffline(mapProps.tileUrl ?? OFM_POSITRON, nativeIosWithoutStore(nativeStore)) if (leafletFallback) mapProps.tileUrl = offlineStyle - const pill = (glFallback || leafletFallback) && + const pill = offline && mapboxKept + ? ( + setPreferStored(v => !v)} + /> + ) + : (glFallback || leafletFallback) && // One chunk per engine: picking the binding here is what keeps mapbox-gl and // maplibre-gl out of each other's downloads. const MapViewGL = glProvider === 'maplibre-gl' ? MapViewGLMaplibre : MapViewGLMapbox @@ -79,13 +94,29 @@ export function MapViewAuto(props: MapViewAutoProps) { ) } -/** Says the map on screen is the stored offline basemap, not the user's own. */ -function OfflineBasemapPill({ label }: { label: string }) { +/** A raster template cannot be served offline in the iOS app unless it has its tile store. */ +function nativeIosWithoutStore(nativeStore: boolean): boolean | undefined { + return nativeStore ? false : undefined +} + +const PILL = 'absolute left-1/2 z-[5] flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-edge bg-surface-card px-3 py-1 text-caption font-semibold text-content-secondary shadow-sm backdrop-blur-md bottom-[calc(var(--bottom-nav-h,0px)+12px)]' + +/** + * Says the map on screen is the stored offline basemap, not the user's own. With + * onToggle it is a switch between the user's map (as far as it was viewed and + * stored) and the stored trip region. + */ +function OfflineBasemapPill({ label, pressed, onToggle }: { label: string; pressed?: boolean; onToggle?: () => void }) { + if (onToggle) { + return ( + + ) + } return ( -
+
diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 95402c9ed..34a0b329c 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -27,6 +27,7 @@ import { visibleRouteReservations } from '../../utils/reservationRoutes' import { safeHexColor } from '../../utils/safeColor' import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders' import { buildGlMap, initialMapStyle } from './glSupport' +import { mapboxTileCacheRequest, tileCacheActive } from '../../native/tileCache' import LocationButton from './LocationButton' import { useIsPhone } from '../../mobile/useIsPhone' import { useGeolocation } from '../../hooks/useGeolocation' @@ -1018,6 +1019,9 @@ export function MapViewGL({ antialias: mapboxQuality, } if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator' + // In the iOS app, Mapbox's own resources go through the app's tile store, so + // what was viewed stays on the device for up to 30 days (native/tileCache.ts). + if (!isMapLibre && tileCacheActive()) mapOptions.transformRequest = mapboxTileCacheRequest // MapLibre's around-center rotate ping-pongs near mid-screen (#1545); false is mapbox-gl's dx rotate. if (isMapLibre) mapOptions.aroundCenter = false diff --git a/client/src/mobile/screens/settings/MSettingsOffline.tsx b/client/src/mobile/screens/settings/MSettingsOffline.tsx index 4daad0175..c615b1cde 100644 --- a/client/src/mobile/screens/settings/MSettingsOffline.tsx +++ b/client/src/mobile/screens/settings/MSettingsOffline.tsx @@ -16,6 +16,7 @@ import type { QueuedMutation } from '../../../db/offlineDb' import { MSetCard, MSetEyebrow, MSetRow, MSetSegments, MSetButton } from './MSettingsUi' import MToggle from '../../components/MToggle' import MConfirmSheet from './MConfirmSheet' +import MTileCacheUsage from './MTileCacheUsage' function conflictName(m: QueuedMutation): string { const body = (m.body ?? {}) as { name?: unknown } @@ -159,6 +160,8 @@ export default function MSettingsOffline() { sub={t('settings.offline.storage.tilesHint')} trailing={} /> + {/* The iOS app's own store of the map tiles looked at, beside the trip regions above. */} +
{allTrips.length > 0 && (
diff --git a/client/src/mobile/screens/settings/MTileCacheUsage.test.tsx b/client/src/mobile/screens/settings/MTileCacheUsage.test.tsx new file mode 100644 index 000000000..768d90746 --- /dev/null +++ b/client/src/mobile/screens/settings/MTileCacheUsage.test.tsx @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render'; +import { tileCacheActive } from '../../../native/tileCache'; +import { TrekShell } from '../../../native/trekShell'; +import MTileCacheUsage from './MTileCacheUsage'; + +vi.mock('../../../native/tileCache', () => ({ tileCacheActive: vi.fn() })); +vi.mock('../../../native/trekShell', () => ({ + TrekShell: { getTileCacheUsage: vi.fn(), clearTileCache: vi.fn() }, +})); + +beforeEach(() => { + vi.mocked(tileCacheActive).mockReturnValue(true); + vi.mocked(TrekShell.getTileCacheUsage) + .mockReset() + .mockResolvedValue({ bytes: 84 * 1024 * 1024, limit: 250 * 1024 * 1024 }); + vi.mocked(TrekShell.clearTileCache).mockReset().mockResolvedValue(); +}); + +describe('MTileCacheUsage', () => { + it('shows what the map tile store takes against its limit, and empties it', async () => { + render(); + expect(await screen.findByText(/84/)).toHaveTextContent(/250/); + + vi.mocked(TrekShell.getTileCacheUsage).mockResolvedValue({ bytes: 0, limit: 250 * 1024 * 1024 }); + fireEvent.click(screen.getByRole('button')); + await waitFor(() => expect(TrekShell.clearTileCache).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(screen.getByRole('button')).toBeDisabled()); + }); + + it('renders nothing outside a binary with the store', () => { + vi.mocked(tileCacheActive).mockReturnValue(false); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + expect(TrekShell.getTileCacheUsage).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/mobile/screens/settings/MTileCacheUsage.tsx b/client/src/mobile/screens/settings/MTileCacheUsage.tsx new file mode 100644 index 000000000..4c381f581 --- /dev/null +++ b/client/src/mobile/screens/settings/MTileCacheUsage.tsx @@ -0,0 +1,55 @@ +import { Trash2 } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from '../../../i18n'; +import { reportNativeFailure } from '../../../native/reportFailure'; +import { tileCacheActive } from '../../../native/tileCache'; +import { TrekShell } from '../../../native/trekShell'; +import { formatBytes } from '../../../utils/formatBytes'; +import { MSetButton, MSetRow } from './MSettingsUi'; + +/** + * How much room the iOS app's map tile store takes (native/tileCache.ts), with + * a way to empty it. The store keeps itself under its limit and drops tiles + * older than 30 days on its own; this is for seeing it and taking the room back + * by hand. Renders nothing outside a binary that has the store. + */ +export default function MTileCacheUsage() { + const { t } = useTranslation(); + const active = tileCacheActive(); + const [usage, setUsage] = useState<{ bytes: number; limit: number } | null>(null); + const [clearing, setClearing] = useState(false); + + const refresh = useCallback(() => { + TrekShell.getTileCacheUsage().then(setUsage, reportNativeFailure); + }, []); + + useEffect(() => { + if (active) refresh(); + }, [active, refresh]); + + if (!active || !usage) return null; + + const clear = async () => { + setClearing(true); + try { + await TrekShell.clearTileCache(); + } catch (err) { + reportNativeFailure(err); + } + setClearing(false); + refresh(); + }; + + return ( + void clear()}> + + {t('settings.offline.tileStore.clear')} + + } + /> + ); +} diff --git a/client/src/native/tileCache.test.ts b/client/src/native/tileCache.test.ts new file mode 100644 index 000000000..ba310056f --- /dev/null +++ b/client/src/native/tileCache.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { hasNativeFeature } from './capabilities'; +import { isNativeIos } from './platform'; +import { mapboxTileCacheRequest, nativeRasterTemplate, tileCacheActive, toTileCacheUrl } from './tileCache'; + +vi.mock('./platform', () => ({ isNativeIos: vi.fn() })); +vi.mock('./capabilities', () => ({ hasNativeFeature: vi.fn() })); + +beforeEach(() => { + vi.mocked(isNativeIos).mockReturnValue(true); + vi.mocked(hasNativeFeature).mockReturnValue(true); +}); + +describe('tileCache', () => { + it('is on only in an iOS binary that has the store', () => { + expect(tileCacheActive()).toBe(true); + vi.mocked(hasNativeFeature).mockReturnValue(false); + expect(tileCacheActive()).toBe(false); + vi.mocked(hasNativeFeature).mockReturnValue(true); + vi.mocked(isNativeIos).mockReturnValue(false); + expect(tileCacheActive()).toBe(false); + }); + + it('swaps only the scheme, so the store knows where the tile lives', () => { + expect(toTileCacheUrl('https://a.tile.example.com/1/2/3.png?k=v')).toBe( + 'trek-tile://a.tile.example.com/1/2/3.png?k=v' + ); + expect(toTileCacheUrl('http://insecure.example.com/x')).toBe('http://insecure.example.com/x'); + }); + + it('routes Leaflet raster templates through the store only where there is one', () => { + expect(nativeRasterTemplate('https://{s}.tiles.example.com/{z}/{x}/{y}.png')).toBe( + 'trek-tile://{s}.tiles.example.com/{z}/{x}/{y}.png' + ); + vi.mocked(hasNativeFeature).mockReturnValue(false); + expect(nativeRasterTemplate('https://{s}.tiles.example.com/{z}/{x}/{y}.png')).toBe( + 'https://{s}.tiles.example.com/{z}/{x}/{y}.png' + ); + }); + + it('sends what a Mapbox map draws through the store, never its telemetry', () => { + expect( + mapboxTileCacheRequest('https://api.mapbox.com/styles/v1/mapbox/streets-v12?access_token=pk', 'Style').url + ).toBe('trek-tile://api.mapbox.com/styles/v1/mapbox/streets-v12?access_token=pk'); + expect(mapboxTileCacheRequest('https://a.tiles.mapbox.com/v4/x/1/2/3.vector.pbf', 'Tile').url).toMatch( + /^trek-tile:/ + ); + expect(mapboxTileCacheRequest('https://events.mapbox.com/events/v2', 'Unknown').url).toBe( + 'https://events.mapbox.com/events/v2' + ); + expect(mapboxTileCacheRequest('https://api.mapbox.com/map-sessions/v1', 'Unknown').url).toBe( + 'https://api.mapbox.com/map-sessions/v1' + ); + expect(mapboxTileCacheRequest('https://tiles.openfreemap.org/planet/1/2/3.pbf', 'Tile').url).toBe( + 'https://tiles.openfreemap.org/planet/1/2/3.pbf' + ); + }); +}); diff --git a/client/src/native/tileCache.ts b/client/src/native/tileCache.ts new file mode 100644 index 000000000..d698376dc --- /dev/null +++ b/client/src/native/tileCache.ts @@ -0,0 +1,57 @@ +import { hasNativeFeature } from './capabilities'; +import { isNativeIos } from './platform'; + +/** + * The iOS app's own tile store (TrekTileSchemeHandler.swift), reached through + * the trek-tile:// scheme. + * + * WKWebView runs no service worker for a self-hosted server, so on iOS nothing + * kept the map tiles a user had looked at. Binaries that report the tileCache + * feature serve every trek-tile:// request from a store on disk, filled as the + * tiles are viewed and kept for at most 30 days (Mapbox's limit for a device + * cache, applied to every provider alike), and from the network otherwise. The + * URL keeps its host and path, only the scheme changes, so the handler knows + * where the tile really lives. + * + * Nothing is prefetched through here: Mapbox forbids bulk downloads, and the + * OpenFreeMap region a trip stores goes through Cache Storage (offlineTiles.ts). + */ +export const TILE_CACHE_SCHEME = 'trek-tile'; + +export function tileCacheActive(): boolean { + return isNativeIos() && hasNativeFeature('tileCache'); +} + +/** https://host/path?query -> trek-tile://host/path?query. Anything else stays. */ +export function toTileCacheUrl(url: string): string { + return url.startsWith('https://') ? `${TILE_CACHE_SCHEME}://${url.slice('https://'.length)}` : url; +} + +/** A raster tile template for Leaflet, through the store where the app has one. */ +export function nativeRasterTemplate(template: string): string { + return tileCacheActive() ? toTileCacheUrl(template) : template; +} + +const MAPBOX_HOST = /^https:\/\/(?:api\.mapbox\.com|[a-z0-9-]+\.tiles\.mapbox\.com)\//i; +/** What a map draws; telemetry and anything else keeps going straight out. */ +const CACHED_RESOURCES = new Set([ + 'Style', + 'Source', + 'Tile', + 'Glyphs', + 'SpriteImage', + 'SpriteJSON', + 'Image', + 'Iconset', + 'Model', +]); + +/** + * Mapbox GL's transformRequest for the iOS app: the map's own resources from + * Mapbox's servers go through the store, events and sessions never do. + */ +export function mapboxTileCacheRequest(url: string, resourceType?: string): { url: string } { + if (!MAPBOX_HOST.test(url)) return { url }; + if (resourceType && !CACHED_RESOURCES.has(resourceType)) return { url }; + return { url: toTileCacheUrl(url) }; +} diff --git a/client/src/native/trekShell.ts b/client/src/native/trekShell.ts index 90d645f0e..834ea9e85 100644 --- a/client/src/native/trekShell.ts +++ b/client/src/native/trekShell.ts @@ -32,6 +32,10 @@ export interface TrekShellPlugin { openOfflineCopy(): Promise<{ opened: boolean }>; /** What this binary offers the UI (native/capabilities.ts). Missing before shell API 2. */ getCapabilities(): Promise; + /** iOS, feature tileCache: what the map tile store takes on disk, and its limit. */ + getTileCacheUsage(): Promise<{ bytes: number; limit: number }>; + /** iOS, feature tileCache: empties the map tile store. */ + clearTileCache(): Promise; } export const TrekShell = registerPlugin('TrekShell'); diff --git a/shared/src/i18n/ar/map.ts b/shared/src/i18n/ar/map.ts index 572c62cf9..06218393b 100644 --- a/shared/src/i18n/ar/map.ts +++ b/shared/src/i18n/ar/map.ts @@ -53,5 +53,6 @@ const map: TranslationStrings = { 'لا يوفّر هذا المتصفح WebGL2 الذي تحتاجه خرائط GL، لذا تُعرض الخرائط باستخدام Leaflet بدلًا منها.', 'map.webgl2.terrain': 'يحتاج العرض ثلاثي الأبعاد إلى WebGL2، وهذا المتصفح لا يوفّره. تبقى الخريطة ثنائية الأبعاد.', 'map.offlineBasemap': 'خريطة دون اتصال', + 'map.offlineShowStored': 'عرض الخريطة دون اتصال', }; export default map; diff --git a/shared/src/i18n/ar/settings.ts b/shared/src/i18n/ar/settings.ts index b37685a2e..ab4e2ed52 100644 --- a/shared/src/i18n/ar/settings.ts +++ b/shared/src/i18n/ar/settings.ts @@ -686,6 +686,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': 'تم', 'settings.mcp.deprecated': 'مهجور', 'settings.offline.cache.dateRange': 'من {start} إلى {end}', + 'settings.offline.tileStore.title': 'مربعات الخريطة المعروضة', + 'settings.offline.tileStore.usage': '{used} من {limit} على هذا الجهاز، تُحفظ حتى 30 يومًا', + 'settings.offline.tileStore.clear': 'مسح', }; export default settings; diff --git a/shared/src/i18n/az/map.ts b/shared/src/i18n/az/map.ts index 6f00aa0ca..79b04eba4 100644 --- a/shared/src/i18n/az/map.ts +++ b/shared/src/i18n/az/map.ts @@ -51,6 +51,7 @@ const map: TranslationStrings = { 'Bu brauzer GL xəritələrinin ehtiyac duyduğu WebGL2-ni təqdim etmir, ona görə xəritələr Leaflet ilə göstərilir.', 'map.webgl2.terrain': '3D üçün WebGL2 lazımdır, bu brauzer isə onu təqdim etmir. Xəritə 2D olaraq qalır.', 'map.offlineBasemap': 'Oflayn xəritə', + 'map.offlineShowStored': 'Oflayn xəritəni göstər', }; export default map; diff --git a/shared/src/i18n/az/settings.ts b/shared/src/i18n/az/settings.ts index abbfdf920..ceda56b20 100644 --- a/shared/src/i18n/az/settings.ts +++ b/shared/src/i18n/az/settings.ts @@ -728,6 +728,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': 'Hazır', 'settings.mcp.deprecated': 'Köhnəlmiş', 'settings.offline.cache.dateRange': '{start} tarixindən {end} tarixinədək', + 'settings.offline.tileStore.title': 'Baxılmış xəritə kafelləri', + 'settings.offline.tileStore.usage': 'Bu cihazda {limit}-dən {used}, 30 günə qədər saxlanılır', + 'settings.offline.tileStore.clear': 'Təmizlə', }; export default settings; diff --git a/shared/src/i18n/br/map.ts b/shared/src/i18n/br/map.ts index bf433284d..bacb8299b 100644 --- a/shared/src/i18n/br/map.ts +++ b/shared/src/i18n/br/map.ts @@ -48,5 +48,6 @@ const map: TranslationStrings = { 'Este navegador não oferece WebGL2, necessário para os mapas GL, por isso os mapas são exibidos com o Leaflet.', 'map.webgl2.terrain': 'O 3D precisa de WebGL2, que este navegador não oferece. O mapa continua em 2D.', 'map.offlineBasemap': 'Mapa offline', + 'map.offlineShowStored': 'Mostrar mapa offline', }; export default map; diff --git a/shared/src/i18n/br/settings.ts b/shared/src/i18n/br/settings.ts index 284764810..0fd635625 100644 --- a/shared/src/i18n/br/settings.ts +++ b/shared/src/i18n/br/settings.ts @@ -675,6 +675,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': 'Concluído', 'settings.mcp.deprecated': 'Obsoleto', 'settings.offline.cache.dateRange': '{start} a {end}', + 'settings.offline.tileStore.title': 'Blocos de mapa vistos', + 'settings.offline.tileStore.usage': '{used} de {limit} neste aparelho, guardados por até 30 dias', + 'settings.offline.tileStore.clear': 'Limpar', }; export default settings; diff --git a/shared/src/i18n/ca/map.ts b/shared/src/i18n/ca/map.ts index 502acda19..e464df183 100644 --- a/shared/src/i18n/ca/map.ts +++ b/shared/src/i18n/ca/map.ts @@ -46,5 +46,6 @@ const map: TranslationStrings = { 'Aquest navegador no ofereix WebGL2, que necessiten els mapes GL, així que els mapes es mostren amb Leaflet.', 'map.webgl2.terrain': 'El 3D necessita WebGL2, que aquest navegador no ofereix. El mapa es queda en 2D.', 'map.offlineBasemap': 'Mapa fora de línia', + 'map.offlineShowStored': 'Mostra el mapa fora de línia', }; export default map; diff --git a/shared/src/i18n/ca/settings.ts b/shared/src/i18n/ca/settings.ts index 45241b6fa..e13ffa9a8 100644 --- a/shared/src/i18n/ca/settings.ts +++ b/shared/src/i18n/ca/settings.ts @@ -677,6 +677,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': 'Fet', 'settings.mcp.deprecated': 'Obsolet', 'settings.offline.cache.dateRange': 'del {start} al {end}', + 'settings.offline.tileStore.title': 'Tessel·les de mapa vistes', + 'settings.offline.tileStore.usage': '{used} de {limit} en aquest dispositiu, es guarden fins a 30 dies', + 'settings.offline.tileStore.clear': 'Buida', }; export default settings; diff --git a/shared/src/i18n/cs/map.ts b/shared/src/i18n/cs/map.ts index 407292af7..43c300a5d 100644 --- a/shared/src/i18n/cs/map.ts +++ b/shared/src/i18n/cs/map.ts @@ -47,5 +47,6 @@ const map: TranslationStrings = { 'Tento prohlížeč nenabízí WebGL2, které mapy GL potřebují, proto se mapy zobrazují pomocí Leafletu.', 'map.webgl2.terrain': '3D potřebuje WebGL2, které tento prohlížeč nenabízí. Mapa zůstává ve 2D.', 'map.offlineBasemap': 'Offline mapa', + 'map.offlineShowStored': 'Zobrazit offline mapu', }; export default map; diff --git a/shared/src/i18n/cs/settings.ts b/shared/src/i18n/cs/settings.ts index 5a291b6e4..09c9f69d5 100644 --- a/shared/src/i18n/cs/settings.ts +++ b/shared/src/i18n/cs/settings.ts @@ -671,6 +671,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': 'Hotovo', 'settings.mcp.deprecated': 'Zastaralé', 'settings.offline.cache.dateRange': '{start} až {end}', + 'settings.offline.tileStore.title': 'Zobrazené dlaždice mapy', + 'settings.offline.tileStore.usage': '{used} z {limit} v tomto zařízení, uchovává se až 30 dní', + 'settings.offline.tileStore.clear': 'Vymazat', }; export default settings; diff --git a/shared/src/i18n/de/map.ts b/shared/src/i18n/de/map.ts index 951536543..1e1a31a69 100644 --- a/shared/src/i18n/de/map.ts +++ b/shared/src/i18n/de/map.ts @@ -48,5 +48,6 @@ const map: TranslationStrings = { 'Dieser Browser bietet kein WebGL2, das GL-Karten brauchen. Karten werden deshalb mit Leaflet angezeigt.', 'map.webgl2.terrain': '3D braucht WebGL2, das dieser Browser nicht bietet. Die Karte bleibt in 2D.', 'map.offlineBasemap': 'Offline-Karte', + 'map.offlineShowStored': 'Offline-Karte zeigen', }; export default map; diff --git a/shared/src/i18n/de/settings.ts b/shared/src/i18n/de/settings.ts index fd86690c7..ba6e69654 100644 --- a/shared/src/i18n/de/settings.ts +++ b/shared/src/i18n/de/settings.ts @@ -682,6 +682,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': 'Fertig', 'settings.mcp.deprecated': 'Veraltet', 'settings.offline.cache.dateRange': '{start} bis {end}', + 'settings.offline.tileStore.title': 'Angesehene Kartenkacheln', + 'settings.offline.tileStore.usage': '{used} von {limit} auf diesem Gerät, höchstens 30 Tage gespeichert', + 'settings.offline.tileStore.clear': 'Leeren', }; export default settings; diff --git a/shared/src/i18n/en/map.ts b/shared/src/i18n/en/map.ts index 531d0252b..fafd383e7 100644 --- a/shared/src/i18n/en/map.ts +++ b/shared/src/i18n/en/map.ts @@ -46,5 +46,6 @@ const map: TranslationStrings = { 'This browser does not offer WebGL2, which GL maps need, so maps are shown with Leaflet instead.', 'map.webgl2.terrain': '3D needs WebGL2, which this browser does not offer. The map stays in 2D.', 'map.offlineBasemap': 'Offline map', + 'map.offlineShowStored': 'Show offline map', }; export default map; diff --git a/shared/src/i18n/en/settings.ts b/shared/src/i18n/en/settings.ts index f7c5cd17e..a28fa726d 100644 --- a/shared/src/i18n/en/settings.ts +++ b/shared/src/i18n/en/settings.ts @@ -662,6 +662,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': 'Done', 'settings.mcp.deprecated': 'Deprecated', 'settings.offline.cache.dateRange': '{start} to {end}', + 'settings.offline.tileStore.title': 'Viewed map tiles', + 'settings.offline.tileStore.usage': '{used} of {limit} on this device, kept for up to 30 days', + 'settings.offline.tileStore.clear': 'Clear', }; export default settings; diff --git a/shared/src/i18n/es/map.ts b/shared/src/i18n/es/map.ts index 974495e1c..126c8aac5 100644 --- a/shared/src/i18n/es/map.ts +++ b/shared/src/i18n/es/map.ts @@ -45,5 +45,6 @@ const map: TranslationStrings = { 'Este navegador no ofrece WebGL2, que necesitan los mapas GL, así que los mapas se muestran con Leaflet.', 'map.webgl2.terrain': 'El 3D necesita WebGL2, que este navegador no ofrece. El mapa se queda en 2D.', 'map.offlineBasemap': 'Mapa sin conexión', + 'map.offlineShowStored': 'Mostrar mapa sin conexión', }; export default map; diff --git a/shared/src/i18n/es/settings.ts b/shared/src/i18n/es/settings.ts index 9b3034354..548afc3d8 100644 --- a/shared/src/i18n/es/settings.ts +++ b/shared/src/i18n/es/settings.ts @@ -678,6 +678,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': 'Listo', 'settings.mcp.deprecated': 'Obsoleto', 'settings.offline.cache.dateRange': 'del {start} al {end}', + 'settings.offline.tileStore.title': 'Teselas de mapa vistas', + 'settings.offline.tileStore.usage': '{used} de {limit} en este dispositivo, se guardan hasta 30 días', + 'settings.offline.tileStore.clear': 'Vaciar', }; export default settings; diff --git a/shared/src/i18n/et/map.ts b/shared/src/i18n/et/map.ts index 8871e8086..24fe3f1ab 100644 --- a/shared/src/i18n/et/map.ts +++ b/shared/src/i18n/et/map.ts @@ -44,5 +44,6 @@ const map: TranslationStrings = { 'map.webgl2.fallback': 'See brauser ei paku WebGL2-te, mida GL-kaardid vajavad, seega kuvatakse kaardid Leafletiga.', 'map.webgl2.terrain': '3D vajab WebGL2-te, mida see brauser ei paku. Kaart jääb 2D-vaatesse.', 'map.offlineBasemap': 'Võrguühenduseta kaart', + 'map.offlineShowStored': 'Näita võrguühenduseta kaarti', }; export default map; diff --git a/shared/src/i18n/et/settings.ts b/shared/src/i18n/et/settings.ts index 2f4324684..e89e8d55d 100644 --- a/shared/src/i18n/et/settings.ts +++ b/shared/src/i18n/et/settings.ts @@ -669,6 +669,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': 'Valmis', 'settings.mcp.deprecated': 'Aegunud', 'settings.offline.cache.dateRange': '{start} kuni {end}', + 'settings.offline.tileStore.title': 'Vaadatud kaardiruudud', + 'settings.offline.tileStore.usage': '{used} / {limit} selles seadmes, hoitakse kuni 30 päeva', + 'settings.offline.tileStore.clear': 'Tühjenda', }; export default settings; diff --git a/shared/src/i18n/fr/map.ts b/shared/src/i18n/fr/map.ts index 316f4adbf..79736b9db 100644 --- a/shared/src/i18n/fr/map.ts +++ b/shared/src/i18n/fr/map.ts @@ -47,5 +47,6 @@ const map: TranslationStrings = { 'Ce navigateur ne propose pas WebGL2, nécessaire aux cartes GL : les cartes s’affichent donc avec Leaflet.', 'map.webgl2.terrain': 'La 3D nécessite WebGL2, que ce navigateur ne propose pas. La carte reste en 2D.', 'map.offlineBasemap': 'Carte hors ligne', + 'map.offlineShowStored': 'Afficher la carte hors ligne', }; export default map; diff --git a/shared/src/i18n/fr/settings.ts b/shared/src/i18n/fr/settings.ts index 5d178c14c..191d3fddb 100644 --- a/shared/src/i18n/fr/settings.ts +++ b/shared/src/i18n/fr/settings.ts @@ -686,6 +686,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': 'Terminé', 'settings.mcp.deprecated': 'Obsolète', 'settings.offline.cache.dateRange': 'du {start} au {end}', + 'settings.offline.tileStore.title': 'Tuiles de carte consultées', + 'settings.offline.tileStore.usage': '{used} sur {limit} sur cet appareil, conservées jusqu’à 30 jours', + 'settings.offline.tileStore.clear': 'Vider', }; export default settings; diff --git a/shared/src/i18n/gr/map.ts b/shared/src/i18n/gr/map.ts index 22b24e22b..7a43ca92e 100644 --- a/shared/src/i18n/gr/map.ts +++ b/shared/src/i18n/gr/map.ts @@ -47,5 +47,6 @@ const map: TranslationStrings = { 'map.webgl2.terrain': 'Το 3D χρειάζεται WebGL2, που αυτό το πρόγραμμα περιήγησης δεν προσφέρει. Ο χάρτης μένει σε 2D.', 'map.offlineBasemap': 'Χάρτης εκτός σύνδεσης', + 'map.offlineShowStored': 'Εμφάνιση χάρτη εκτός σύνδεσης', }; export default map; diff --git a/shared/src/i18n/gr/settings.ts b/shared/src/i18n/gr/settings.ts index 5e9dacfcf..68f36ed64 100644 --- a/shared/src/i18n/gr/settings.ts +++ b/shared/src/i18n/gr/settings.ts @@ -692,6 +692,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': 'Έτοιμο', 'settings.mcp.deprecated': 'Απαρχαιωμένο', 'settings.offline.cache.dateRange': 'από {start} έως {end}', + 'settings.offline.tileStore.title': 'Πλακίδια χάρτη που προβλήθηκαν', + 'settings.offline.tileStore.usage': '{used} από {limit} σε αυτή τη συσκευή, φυλάσσονται έως 30 ημέρες', + 'settings.offline.tileStore.clear': 'Εκκαθάριση', }; export default settings; diff --git a/shared/src/i18n/he/map.ts b/shared/src/i18n/he/map.ts index ec7c202a4..64a950374 100644 --- a/shared/src/i18n/he/map.ts +++ b/shared/src/i18n/he/map.ts @@ -46,6 +46,7 @@ const map: TranslationStrings = { 'map.webgl2.fallback': 'הדפדפן הזה לא תומך ב-WebGL2, שמפות GL צריכות, ולכן המפות מוצגות עם Leaflet.', 'map.webgl2.terrain': 'תלת-ממד דורש WebGL2, והדפדפן הזה לא תומך בו. המפה נשארת בדו-ממד.', 'map.offlineBasemap': 'מפה לא מקוונת', + 'map.offlineShowStored': 'הצגת מפה לא מקוונת', }; export default map; diff --git a/shared/src/i18n/he/settings.ts b/shared/src/i18n/he/settings.ts index f72977f12..7c6218138 100644 --- a/shared/src/i18n/he/settings.ts +++ b/shared/src/i18n/he/settings.ts @@ -644,6 +644,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': 'סיום', 'settings.mcp.deprecated': 'מיושן', 'settings.offline.cache.dateRange': '{start} עד {end}', + 'settings.offline.tileStore.title': 'אריחי מפה שנצפו', + 'settings.offline.tileStore.usage': '{used} מתוך {limit} במכשיר הזה, נשמרים עד 30 יום', + 'settings.offline.tileStore.clear': 'ניקוי', }; export default settings; diff --git a/shared/src/i18n/hu/map.ts b/shared/src/i18n/hu/map.ts index bfd29efff..c9d1c6ca0 100644 --- a/shared/src/i18n/hu/map.ts +++ b/shared/src/i18n/hu/map.ts @@ -45,5 +45,6 @@ const map: TranslationStrings = { 'Ez a böngésző nem kínál WebGL2-t, amelyre a GL-térképeknek szükségük van, ezért a térképek Leaflettel jelennek meg.', 'map.webgl2.terrain': 'A 3D-hez WebGL2 kell, amelyet ez a böngésző nem kínál. A térkép 2D-ben marad.', 'map.offlineBasemap': 'Offline térkép', + 'map.offlineShowStored': 'Offline térkép megjelenítése', }; export default map; diff --git a/shared/src/i18n/hu/settings.ts b/shared/src/i18n/hu/settings.ts index 47550ba27..da3481da7 100644 --- a/shared/src/i18n/hu/settings.ts +++ b/shared/src/i18n/hu/settings.ts @@ -677,6 +677,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': 'Kész', 'settings.mcp.deprecated': 'Elavult', 'settings.offline.cache.dateRange': '{start} és {end} között', + 'settings.offline.tileStore.title': 'Megtekintett térképcsempék', + 'settings.offline.tileStore.usage': '{used} / {limit} ezen az eszközön, legfeljebb 30 napig őrizve', + 'settings.offline.tileStore.clear': 'Ürítés', }; export default settings; diff --git a/shared/src/i18n/id/map.ts b/shared/src/i18n/id/map.ts index 46a4dd820..aa7fc0d42 100644 --- a/shared/src/i18n/id/map.ts +++ b/shared/src/i18n/id/map.ts @@ -44,5 +44,6 @@ const map: TranslationStrings = { 'Browser ini tidak menyediakan WebGL2 yang dibutuhkan peta GL, jadi peta ditampilkan dengan Leaflet.', 'map.webgl2.terrain': '3D membutuhkan WebGL2, yang tidak disediakan browser ini. Peta tetap dalam 2D.', 'map.offlineBasemap': 'Peta offline', + 'map.offlineShowStored': 'Tampilkan peta offline', }; export default map; diff --git a/shared/src/i18n/id/settings.ts b/shared/src/i18n/id/settings.ts index 76ae644b9..4c5ee6f8b 100644 --- a/shared/src/i18n/id/settings.ts +++ b/shared/src/i18n/id/settings.ts @@ -665,6 +665,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': 'Selesai', 'settings.mcp.deprecated': 'Usang', 'settings.offline.cache.dateRange': '{start} sampai {end}', + 'settings.offline.tileStore.title': 'Ubin peta yang dilihat', + 'settings.offline.tileStore.usage': '{used} dari {limit} di perangkat ini, disimpan hingga 30 hari', + 'settings.offline.tileStore.clear': 'Kosongkan', }; export default settings; diff --git a/shared/src/i18n/it/map.ts b/shared/src/i18n/it/map.ts index 91a345d17..0529319a2 100644 --- a/shared/src/i18n/it/map.ts +++ b/shared/src/i18n/it/map.ts @@ -48,5 +48,6 @@ const map: TranslationStrings = { 'Questo browser non offre WebGL2, necessario alle mappe GL, quindi le mappe vengono mostrate con Leaflet.', 'map.webgl2.terrain': 'Il 3D richiede WebGL2, che questo browser non offre. La mappa resta in 2D.', 'map.offlineBasemap': 'Mappa offline', + 'map.offlineShowStored': 'Mostra mappa offline', }; export default map; diff --git a/shared/src/i18n/it/settings.ts b/shared/src/i18n/it/settings.ts index a1a334352..968d3e272 100644 --- a/shared/src/i18n/it/settings.ts +++ b/shared/src/i18n/it/settings.ts @@ -675,6 +675,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': 'Fatto', 'settings.mcp.deprecated': 'Obsoleto', 'settings.offline.cache.dateRange': 'dal {start} al {end}', + 'settings.offline.tileStore.title': 'Tessere di mappa visualizzate', + 'settings.offline.tileStore.usage': '{used} di {limit} su questo dispositivo, conservate fino a 30 giorni', + 'settings.offline.tileStore.clear': 'Svuota', }; export default settings; diff --git a/shared/src/i18n/ja/map.ts b/shared/src/i18n/ja/map.ts index af4a2b1cb..501acbf7e 100644 --- a/shared/src/i18n/ja/map.ts +++ b/shared/src/i18n/ja/map.ts @@ -44,5 +44,6 @@ const map: TranslationStrings = { 'このブラウザーは GL マップに必要な WebGL2 に対応していないため、地図は Leaflet で表示されます。', 'map.webgl2.terrain': '3D には WebGL2 が必要ですが、このブラウザーは対応していません。地図は 2D のままです。', 'map.offlineBasemap': 'オフライン地図', + 'map.offlineShowStored': 'オフライン地図を表示', }; export default map; diff --git a/shared/src/i18n/ja/settings.ts b/shared/src/i18n/ja/settings.ts index b3cfa3afe..07f40ccac 100644 --- a/shared/src/i18n/ja/settings.ts +++ b/shared/src/i18n/ja/settings.ts @@ -636,6 +636,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': '完了', 'settings.mcp.deprecated': '非推奨', 'settings.offline.cache.dateRange': '{start}から{end}', + 'settings.offline.tileStore.title': '表示した地図タイル', + 'settings.offline.tileStore.usage': 'この端末で {limit} 中 {used}、最長30日間保存', + 'settings.offline.tileStore.clear': '消去', }; export default settings; diff --git a/shared/src/i18n/ko/map.ts b/shared/src/i18n/ko/map.ts index 544b39650..c6f19f231 100644 --- a/shared/src/i18n/ko/map.ts +++ b/shared/src/i18n/ko/map.ts @@ -43,5 +43,6 @@ const map: TranslationStrings = { 'map.webgl2.fallback': '이 브라우저는 GL 지도에 필요한 WebGL2를 지원하지 않아 지도를 Leaflet으로 표시합니다.', 'map.webgl2.terrain': '3D에는 WebGL2가 필요하지만 이 브라우저는 지원하지 않습니다. 지도는 2D로 유지됩니다.', 'map.offlineBasemap': '오프라인 지도', + 'map.offlineShowStored': '오프라인 지도 보기', }; export default map; diff --git a/shared/src/i18n/ko/settings.ts b/shared/src/i18n/ko/settings.ts index c67a4e024..bf41114ef 100644 --- a/shared/src/i18n/ko/settings.ts +++ b/shared/src/i18n/ko/settings.ts @@ -646,6 +646,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': '완료', 'settings.mcp.deprecated': '지원 중단 예정', 'settings.offline.cache.dateRange': '{start}부터 {end}까지', + 'settings.offline.tileStore.title': '본 지도 타일', + 'settings.offline.tileStore.usage': '이 기기에서 {limit} 중 {used}, 최대 30일간 보관', + 'settings.offline.tileStore.clear': '비우기', }; export default settings; diff --git a/shared/src/i18n/nl/map.ts b/shared/src/i18n/nl/map.ts index bca23f52b..fb5b906bd 100644 --- a/shared/src/i18n/nl/map.ts +++ b/shared/src/i18n/nl/map.ts @@ -47,5 +47,6 @@ const map: TranslationStrings = { 'Deze browser biedt geen WebGL2, dat GL-kaarten nodig hebben, dus kaarten worden met Leaflet getoond.', 'map.webgl2.terrain': '3D heeft WebGL2 nodig, dat deze browser niet biedt. De kaart blijft in 2D.', 'map.offlineBasemap': 'Offline kaart', + 'map.offlineShowStored': 'Offline kaart tonen', }; export default map; diff --git a/shared/src/i18n/nl/settings.ts b/shared/src/i18n/nl/settings.ts index 23c16bd51..1ab31febb 100644 --- a/shared/src/i18n/nl/settings.ts +++ b/shared/src/i18n/nl/settings.ts @@ -678,6 +678,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': 'Klaar', 'settings.mcp.deprecated': 'Verouderd', 'settings.offline.cache.dateRange': '{start} tot {end}', + 'settings.offline.tileStore.title': 'Bekeken kaarttegels', + 'settings.offline.tileStore.usage': '{used} van {limit} op dit apparaat, maximaal 30 dagen bewaard', + 'settings.offline.tileStore.clear': 'Wissen', }; export default settings; diff --git a/shared/src/i18n/pl/map.ts b/shared/src/i18n/pl/map.ts index 126d4d7c8..de5378232 100644 --- a/shared/src/i18n/pl/map.ts +++ b/shared/src/i18n/pl/map.ts @@ -50,5 +50,6 @@ const map: TranslationStrings = { 'Ta przeglądarka nie obsługuje WebGL2, którego potrzebują mapy GL, więc mapy są wyświetlane za pomocą Leaflet.', 'map.webgl2.terrain': 'Tryb 3D wymaga WebGL2, którego ta przeglądarka nie obsługuje. Mapa pozostaje w 2D.', 'map.offlineBasemap': 'Mapa offline', + 'map.offlineShowStored': 'Pokaż mapę offline', }; export default map; diff --git a/shared/src/i18n/pl/settings.ts b/shared/src/i18n/pl/settings.ts index f600860c8..5a3afd4e3 100644 --- a/shared/src/i18n/pl/settings.ts +++ b/shared/src/i18n/pl/settings.ts @@ -692,6 +692,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': 'Gotowe', 'settings.mcp.deprecated': 'Przestarzałe', 'settings.offline.cache.dateRange': '{start} do {end}', + 'settings.offline.tileStore.title': 'Obejrzane kafelki mapy', + 'settings.offline.tileStore.usage': '{used} z {limit} na tym urządzeniu, przechowywane do 30 dni', + 'settings.offline.tileStore.clear': 'Wyczyść', }; export default settings; diff --git a/shared/src/i18n/ru/map.ts b/shared/src/i18n/ru/map.ts index 6114a6b49..bac76e60f 100644 --- a/shared/src/i18n/ru/map.ts +++ b/shared/src/i18n/ru/map.ts @@ -51,5 +51,6 @@ const map: TranslationStrings = { 'Этот браузер не поддерживает WebGL2, нужный картам GL, поэтому карты показываются через Leaflet.', 'map.webgl2.terrain': 'Для 3D нужен WebGL2, а этот браузер его не поддерживает. Карта остаётся в 2D.', 'map.offlineBasemap': 'Офлайн-карта', + 'map.offlineShowStored': 'Показать офлайн-карту', }; export default map; diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts index 1a7510161..604c5f24b 100644 --- a/shared/src/i18n/ru/settings.ts +++ b/shared/src/i18n/ru/settings.ts @@ -694,6 +694,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': 'Готово', 'settings.mcp.deprecated': 'Устарело', 'settings.offline.cache.dateRange': 'с {start} по {end}', + 'settings.offline.tileStore.title': 'Просмотренные тайлы карты', + 'settings.offline.tileStore.usage': '{used} из {limit} на этом устройстве, хранятся до 30 дней', + 'settings.offline.tileStore.clear': 'Очистить', }; export default settings; diff --git a/shared/src/i18n/sk/map.ts b/shared/src/i18n/sk/map.ts index 231d5d0dc..da9835631 100644 --- a/shared/src/i18n/sk/map.ts +++ b/shared/src/i18n/sk/map.ts @@ -47,5 +47,6 @@ const map: TranslationStrings = { 'Tento prehliadač neponúka WebGL2, ktoré mapy GL potrebujú, preto sa mapy zobrazujú pomocou Leafletu.', 'map.webgl2.terrain': '3D potrebuje WebGL2, ktoré tento prehliadač neponúka. Mapa zostáva v 2D.', 'map.offlineBasemap': 'Offline mapa', + 'map.offlineShowStored': 'Zobraziť offline mapu', }; export default map; diff --git a/shared/src/i18n/sk/settings.ts b/shared/src/i18n/sk/settings.ts index c98d2859a..05f1c1cb6 100644 --- a/shared/src/i18n/sk/settings.ts +++ b/shared/src/i18n/sk/settings.ts @@ -678,6 +678,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': 'Hotovo', 'settings.mcp.deprecated': 'Zastarané', 'settings.offline.cache.dateRange': '{start} až {end}', + 'settings.offline.tileStore.title': 'Zobrazené dlaždice mapy', + 'settings.offline.tileStore.usage': '{used} z {limit} v tomto zariadení, uchováva sa až 30 dní', + 'settings.offline.tileStore.clear': 'Vymazať', }; export default settings; diff --git a/shared/src/i18n/sv/map.ts b/shared/src/i18n/sv/map.ts index 7cde3cfb1..bea606c3c 100644 --- a/shared/src/i18n/sv/map.ts +++ b/shared/src/i18n/sv/map.ts @@ -45,5 +45,6 @@ const map: TranslationStrings = { 'Den här webbläsaren erbjuder inte WebGL2, som GL-kartor behöver, så kartorna visas med Leaflet i stället.', 'map.webgl2.terrain': '3D kräver WebGL2, som den här webbläsaren inte erbjuder. Kartan stannar i 2D.', 'map.offlineBasemap': 'Offlinekarta', + 'map.offlineShowStored': 'Visa offlinekarta', }; export default map; diff --git a/shared/src/i18n/sv/settings.ts b/shared/src/i18n/sv/settings.ts index 1b50e4045..ac1cceca1 100644 --- a/shared/src/i18n/sv/settings.ts +++ b/shared/src/i18n/sv/settings.ts @@ -668,6 +668,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': 'Klar', 'settings.mcp.deprecated': 'Utfasad', 'settings.offline.cache.dateRange': '{start} till {end}', + 'settings.offline.tileStore.title': 'Visade kartrutor', + 'settings.offline.tileStore.usage': '{used} av {limit} på den här enheten, sparas i upp till 30 dagar', + 'settings.offline.tileStore.clear': 'Rensa', }; export default settings; diff --git a/shared/src/i18n/th/map.ts b/shared/src/i18n/th/map.ts index 288de9af7..c04a36f22 100644 --- a/shared/src/i18n/th/map.ts +++ b/shared/src/i18n/th/map.ts @@ -42,5 +42,6 @@ const map: TranslationStrings = { 'map.webgl2.fallback': 'เบราว์เซอร์นี้ไม่รองรับ WebGL2 ที่แผนที่ GL ต้องใช้ จึงแสดงแผนที่ด้วย Leaflet แทน', 'map.webgl2.terrain': 'โหมด 3D ต้องใช้ WebGL2 ซึ่งเบราว์เซอร์นี้ไม่รองรับ แผนที่จึงยังคงเป็น 2D', 'map.offlineBasemap': 'แผนที่ออฟไลน์', + 'map.offlineShowStored': 'แสดงแผนที่ออฟไลน์', }; export default map; diff --git a/shared/src/i18n/th/settings.ts b/shared/src/i18n/th/settings.ts index 2ec94c623..995172967 100644 --- a/shared/src/i18n/th/settings.ts +++ b/shared/src/i18n/th/settings.ts @@ -648,6 +648,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.copyFailed': 'ไม่สามารถคัดลอกได้ เลือกข้อความและคัดลอกด้วยตนเอง', 'settings.mcp.deprecated': 'เลิกใช้แล้ว', 'settings.offline.cache.dateRange': '{start} ถึง {end}', + 'settings.offline.tileStore.title': 'ไทล์แผนที่ที่ดูแล้ว', + 'settings.offline.tileStore.usage': '{used} จาก {limit} บนอุปกรณ์นี้ เก็บไว้สูงสุด 30 วัน', + 'settings.offline.tileStore.clear': 'ล้าง', }; export default settings; diff --git a/shared/src/i18n/tr/map.ts b/shared/src/i18n/tr/map.ts index 70b1df378..6e0372f24 100644 --- a/shared/src/i18n/tr/map.ts +++ b/shared/src/i18n/tr/map.ts @@ -45,5 +45,6 @@ const map: TranslationStrings = { "Bu tarayıcı GL haritalarının ihtiyaç duyduğu WebGL2'yi sunmuyor, bu yüzden haritalar Leaflet ile gösteriliyor.", 'map.webgl2.terrain': '3D için WebGL2 gerekir, bu tarayıcı ise onu sunmuyor. Harita 2D olarak kalır.', 'map.offlineBasemap': 'Çevrimdışı harita', + 'map.offlineShowStored': 'Çevrimdışı haritayı göster', }; export default map; diff --git a/shared/src/i18n/tr/settings.ts b/shared/src/i18n/tr/settings.ts index faee2cffc..4eaef29bb 100644 --- a/shared/src/i18n/tr/settings.ts +++ b/shared/src/i18n/tr/settings.ts @@ -671,6 +671,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': 'Tamam', 'settings.mcp.deprecated': 'Kullanımdan kalkıyor', 'settings.offline.cache.dateRange': '{start} ile {end} arası', + 'settings.offline.tileStore.title': 'Görüntülenen harita karoları', + 'settings.offline.tileStore.usage': 'Bu cihazda {limit} içinden {used}, 30 güne kadar saklanır', + 'settings.offline.tileStore.clear': 'Temizle', }; export default settings; diff --git a/shared/src/i18n/uk/map.ts b/shared/src/i18n/uk/map.ts index 4db23d21f..eab483232 100644 --- a/shared/src/i18n/uk/map.ts +++ b/shared/src/i18n/uk/map.ts @@ -49,5 +49,6 @@ const map: TranslationStrings = { 'map.webgl2.fallback': 'Цей браузер не підтримує WebGL2, потрібний картам GL, тому карти показуються через Leaflet.', 'map.webgl2.terrain': 'Для 3D потрібен WebGL2, а цей браузер його не підтримує. Карта залишається у 2D.', 'map.offlineBasemap': 'Офлайн-карта', + 'map.offlineShowStored': 'Показати офлайн-карту', }; export default map; diff --git a/shared/src/i18n/uk/settings.ts b/shared/src/i18n/uk/settings.ts index 96c359b37..d6ded955d 100644 --- a/shared/src/i18n/uk/settings.ts +++ b/shared/src/i18n/uk/settings.ts @@ -692,6 +692,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': 'Готово', 'settings.mcp.deprecated': 'Застаріло', 'settings.offline.cache.dateRange': 'з {start} по {end}', + 'settings.offline.tileStore.title': 'Переглянуті плитки карти', + 'settings.offline.tileStore.usage': '{used} з {limit} на цьому пристрої, зберігаються до 30 днів', + 'settings.offline.tileStore.clear': 'Очистити', }; export default settings; diff --git a/shared/src/i18n/vi/map.ts b/shared/src/i18n/vi/map.ts index 34ce24447..731a18c57 100644 --- a/shared/src/i18n/vi/map.ts +++ b/shared/src/i18n/vi/map.ts @@ -45,5 +45,6 @@ const map: TranslationStrings = { 'map.webgl2.fallback': 'Trình duyệt này không hỗ trợ WebGL2 mà bản đồ GL cần, nên bản đồ được hiển thị bằng Leaflet.', 'map.webgl2.terrain': 'Chế độ 3D cần WebGL2, nhưng trình duyệt này không hỗ trợ. Bản đồ vẫn ở dạng 2D.', 'map.offlineBasemap': 'Bản đồ ngoại tuyến', + 'map.offlineShowStored': 'Hiện bản đồ ngoại tuyến', }; export default map; diff --git a/shared/src/i18n/vi/settings.ts b/shared/src/i18n/vi/settings.ts index 0e802fccc..9cf6c62be 100644 --- a/shared/src/i18n/vi/settings.ts +++ b/shared/src/i18n/vi/settings.ts @@ -665,6 +665,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': 'Xong', 'settings.mcp.deprecated': 'Không còn được khuyến nghị', 'settings.offline.cache.dateRange': '{start} đến {end}', + 'settings.offline.tileStore.title': 'Ô bản đồ đã xem', + 'settings.offline.tileStore.usage': '{used} trên {limit} trên thiết bị này, lưu tối đa 30 ngày', + 'settings.offline.tileStore.clear': 'Xóa', }; export default settings; diff --git a/shared/src/i18n/zh-TW/map.ts b/shared/src/i18n/zh-TW/map.ts index 9c00df569..3d859e318 100644 --- a/shared/src/i18n/zh-TW/map.ts +++ b/shared/src/i18n/zh-TW/map.ts @@ -41,5 +41,6 @@ const map: TranslationStrings = { 'map.webgl2.fallback': '此瀏覽器不支援 GL 地圖所需的 WebGL2,因此地圖改用 Leaflet 顯示。', 'map.webgl2.terrain': '3D 需要 WebGL2,而此瀏覽器不支援。地圖維持 2D。', 'map.offlineBasemap': '離線地圖', + 'map.offlineShowStored': '顯示離線地圖', }; export default map; diff --git a/shared/src/i18n/zh-TW/settings.ts b/shared/src/i18n/zh-TW/settings.ts index 1f5d2f930..c37747090 100644 --- a/shared/src/i18n/zh-TW/settings.ts +++ b/shared/src/i18n/zh-TW/settings.ts @@ -616,6 +616,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': '完成', 'settings.mcp.deprecated': '已棄用', 'settings.offline.cache.dateRange': '{start} 至 {end}', + 'settings.offline.tileStore.title': '已檢視的地圖圖磚', + 'settings.offline.tileStore.usage': '本裝置已使用 {used},上限 {limit},最多保存 30 天', + 'settings.offline.tileStore.clear': '清除', }; export default settings; diff --git a/shared/src/i18n/zh/map.ts b/shared/src/i18n/zh/map.ts index bef81a9de..cb0be528e 100644 --- a/shared/src/i18n/zh/map.ts +++ b/shared/src/i18n/zh/map.ts @@ -41,5 +41,6 @@ const map: TranslationStrings = { 'map.webgl2.fallback': '此浏览器不支持 GL 地图所需的 WebGL2,因此地图改用 Leaflet 显示。', 'map.webgl2.terrain': '3D 需要 WebGL2,而此浏览器不支持。地图保持 2D。', 'map.offlineBasemap': '离线地图', + 'map.offlineShowStored': '显示离线地图', }; export default map; diff --git a/shared/src/i18n/zh/settings.ts b/shared/src/i18n/zh/settings.ts index 93516cc68..5d8aff183 100644 --- a/shared/src/i18n/zh/settings.ts +++ b/shared/src/i18n/zh/settings.ts @@ -614,6 +614,9 @@ const settings: TranslationStrings = { 'settings.apiKeys.modal.done': '完成', 'settings.mcp.deprecated': '已弃用', 'settings.offline.cache.dateRange': '{start} 至 {end}', + 'settings.offline.tileStore.title': '已查看的地图瓦片', + 'settings.offline.tileStore.usage': '本设备上已用 {used},上限 {limit},最多保存 30 天', + 'settings.offline.tileStore.clear': '清除', }; export default settings;