From dec6babe1d19220dec07bdc5feefa31411b2ea02 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 6 Nov 2026 13:17:25 +0100 Subject: [PATCH] feat(native): an iOS map tile store, so viewed maps keep working offline WKWebView runs no service worker for a self-hosted server, so on iOS nothing kept a map tile once it scrolled away. Binaries with the tileCache feature answer a trek-tile:// scheme from a store on disk: a tile viewed once is served from there for up to 30 days (Mapbox's limit for a device cache, applied to every provider), then fetched anew; the store keeps under 250 MB, dropping the least recently used tiles first, sends a unique app User-Agent, and never prefetches. Mapbox's own resources (style, tiles, glyphs, sprites) and Leaflet's raster tiles go through it, telemetry never does. Offline, a Mapbox map in such an app now stays Mapbox where it was viewed, and the pill offers the stored trip region instead. The offline settings show what the store takes against its limit, with a way to empty it. --- .../App/App/TrekBridgeViewController.swift | 228 ++++++++++++++++++ client/ios/App/App/TrekShellPlugin.swift | 17 +- .../src/components/Journey/JourneyMapGL.tsx | 4 + client/src/components/Map/MapView.tsx | 3 +- .../Map/MapViewAuto.offline.test.tsx | 32 ++- client/src/components/Map/MapViewAuto.tsx | 53 +++- client/src/components/Map/MapViewGL.tsx | 4 + .../screens/settings/MSettingsOffline.tsx | 3 + .../screens/settings/MTileCacheUsage.test.tsx | 37 +++ .../screens/settings/MTileCacheUsage.tsx | 55 +++++ client/src/native/tileCache.test.ts | 58 +++++ client/src/native/tileCache.ts | 57 +++++ client/src/native/trekShell.ts | 4 + shared/src/i18n/ar/map.ts | 1 + shared/src/i18n/ar/settings.ts | 3 + shared/src/i18n/az/map.ts | 1 + shared/src/i18n/az/settings.ts | 3 + shared/src/i18n/br/map.ts | 1 + shared/src/i18n/br/settings.ts | 3 + shared/src/i18n/ca/map.ts | 1 + shared/src/i18n/ca/settings.ts | 3 + shared/src/i18n/cs/map.ts | 1 + shared/src/i18n/cs/settings.ts | 3 + shared/src/i18n/de/map.ts | 1 + shared/src/i18n/de/settings.ts | 3 + shared/src/i18n/en/map.ts | 1 + shared/src/i18n/en/settings.ts | 3 + shared/src/i18n/es/map.ts | 1 + shared/src/i18n/es/settings.ts | 3 + shared/src/i18n/et/map.ts | 1 + shared/src/i18n/et/settings.ts | 3 + shared/src/i18n/fr/map.ts | 1 + shared/src/i18n/fr/settings.ts | 3 + shared/src/i18n/gr/map.ts | 1 + shared/src/i18n/gr/settings.ts | 3 + shared/src/i18n/he/map.ts | 1 + shared/src/i18n/he/settings.ts | 3 + shared/src/i18n/hu/map.ts | 1 + shared/src/i18n/hu/settings.ts | 3 + shared/src/i18n/id/map.ts | 1 + shared/src/i18n/id/settings.ts | 3 + shared/src/i18n/it/map.ts | 1 + shared/src/i18n/it/settings.ts | 3 + shared/src/i18n/ja/map.ts | 1 + shared/src/i18n/ja/settings.ts | 3 + shared/src/i18n/ko/map.ts | 1 + shared/src/i18n/ko/settings.ts | 3 + shared/src/i18n/nl/map.ts | 1 + shared/src/i18n/nl/settings.ts | 3 + shared/src/i18n/pl/map.ts | 1 + shared/src/i18n/pl/settings.ts | 3 + shared/src/i18n/ru/map.ts | 1 + shared/src/i18n/ru/settings.ts | 3 + shared/src/i18n/sk/map.ts | 1 + shared/src/i18n/sk/settings.ts | 3 + shared/src/i18n/sv/map.ts | 1 + shared/src/i18n/sv/settings.ts | 3 + shared/src/i18n/th/map.ts | 1 + shared/src/i18n/th/settings.ts | 3 + shared/src/i18n/tr/map.ts | 1 + shared/src/i18n/tr/settings.ts | 3 + shared/src/i18n/uk/map.ts | 1 + shared/src/i18n/uk/settings.ts | 3 + shared/src/i18n/vi/map.ts | 1 + shared/src/i18n/vi/settings.ts | 3 + shared/src/i18n/zh-TW/map.ts | 1 + shared/src/i18n/zh-TW/settings.ts | 3 + shared/src/i18n/zh/map.ts | 1 + shared/src/i18n/zh/settings.ts | 3 + 69 files changed, 652 insertions(+), 15 deletions(-) create mode 100644 client/src/mobile/screens/settings/MTileCacheUsage.test.tsx create mode 100644 client/src/mobile/screens/settings/MTileCacheUsage.tsx create mode 100644 client/src/native/tileCache.test.ts create mode 100644 client/src/native/tileCache.ts 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;