mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 23:31:02 +00:00
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.
This commit is contained in:
@@ -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 `<sha256>.bin` with its content type in `<sha256>.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<String> = ["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<ObjectIdentifier>()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
) : (
|
||||
<TileLayer
|
||||
key="raster"
|
||||
url={basemap.url}
|
||||
url={nativeRasterTemplate(basemap.url)}
|
||||
attribution={attributionForTile(basemap.url)}
|
||||
maxZoom={19}
|
||||
keepBuffer={8}
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
* markers and routes, and says so.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { resetWebGLProbe } from '../../utils/webgl';
|
||||
import { MapViewAuto } from './MapViewAuto';
|
||||
|
||||
const env = vi.hoisted(() => ({ 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<typeof import('../../native/platform')>()),
|
||||
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(<MapViewAuto places={[]} />);
|
||||
|
||||
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(<MapViewAuto places={[]} tileUrl="https://tiles.example.com/{z}/{x}/{y}.png" />);
|
||||
|
||||
expect(seen.leaflet?.tileUrl).toBe('https://tiles.example.com/{z}/{x}/{y}.png');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) && <OfflineBasemapPill label={t('map.offlineBasemap')} />
|
||||
const pill = offline && mapboxKept
|
||||
? (
|
||||
<OfflineBasemapPill
|
||||
label={preferStored ? t('map.offlineBasemap') : t('map.offlineShowStored')}
|
||||
pressed={preferStored}
|
||||
onToggle={() => setPreferStored(v => !v)}
|
||||
/>
|
||||
)
|
||||
: (glFallback || leafletFallback) && <OfflineBasemapPill label={t('map.offlineBasemap')} />
|
||||
// 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 (
|
||||
<button type="button" onClick={onToggle} aria-pressed={pressed} className={`${PILL} pointer-events-auto`}>
|
||||
<CloudOff size={13} strokeWidth={2.2} aria-hidden="true" />
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="pointer-events-none 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)]"
|
||||
>
|
||||
<div role="status" className={`${PILL} pointer-events-none`}>
|
||||
<CloudOff size={13} strokeWidth={2.2} aria-hidden="true" />
|
||||
{label}
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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={<MToggle checked={prefs.cacheTiles} onChange={handleToggleTiles} ariaLabel={t('settings.offline.storage.tiles')} />}
|
||||
/>
|
||||
{/* The iOS app's own store of the map tiles looked at, beside the trip regions above. */}
|
||||
<MTileCacheUsage />
|
||||
</div>
|
||||
{allTrips.length > 0 && (
|
||||
<div className="mt-3 border-t border-[color:var(--m-rowbr)] pt-3">
|
||||
|
||||
@@ -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(<MTileCacheUsage />);
|
||||
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(<MTileCacheUsage />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(TrekShell.getTileCacheUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<MSetRow
|
||||
label={t('settings.offline.tileStore.title')}
|
||||
sub={t('settings.offline.tileStore.usage', { used: formatBytes(usage.bytes), limit: formatBytes(usage.limit) })}
|
||||
trailing={
|
||||
<MSetButton variant="ghost" disabled={clearing || usage.bytes === 0} onClick={() => void clear()}>
|
||||
<Trash2 size={14} />
|
||||
{t('settings.offline.tileStore.clear')}
|
||||
</MSetButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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<NativeCapabilities>;
|
||||
/** 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<void>;
|
||||
}
|
||||
|
||||
export const TrekShell = registerPlugin<TrekShellPlugin>('TrekShell');
|
||||
|
||||
@@ -53,5 +53,6 @@ const map: TranslationStrings = {
|
||||
'لا يوفّر هذا المتصفح WebGL2 الذي تحتاجه خرائط GL، لذا تُعرض الخرائط باستخدام Leaflet بدلًا منها.',
|
||||
'map.webgl2.terrain': 'يحتاج العرض ثلاثي الأبعاد إلى WebGL2، وهذا المتصفح لا يوفّره. تبقى الخريطة ثنائية الأبعاد.',
|
||||
'map.offlineBasemap': 'خريطة دون اتصال',
|
||||
'map.offlineShowStored': 'عرض الخريطة دون اتصال',
|
||||
};
|
||||
export default map;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -47,5 +47,6 @@ const map: TranslationStrings = {
|
||||
'map.webgl2.terrain':
|
||||
'Το 3D χρειάζεται WebGL2, που αυτό το πρόγραμμα περιήγησης δεν προσφέρει. Ο χάρτης μένει σε 2D.',
|
||||
'map.offlineBasemap': 'Χάρτης εκτός σύνδεσης',
|
||||
'map.offlineShowStored': 'Εμφάνιση χάρτη εκτός σύνδεσης',
|
||||
};
|
||||
export default map;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -46,6 +46,7 @@ const map: TranslationStrings = {
|
||||
'map.webgl2.fallback': 'הדפדפן הזה לא תומך ב-WebGL2, שמפות GL צריכות, ולכן המפות מוצגות עם Leaflet.',
|
||||
'map.webgl2.terrain': 'תלת-ממד דורש WebGL2, והדפדפן הזה לא תומך בו. המפה נשארת בדו-ממד.',
|
||||
'map.offlineBasemap': 'מפה לא מקוונת',
|
||||
'map.offlineShowStored': 'הצגת מפה לא מקוונת',
|
||||
};
|
||||
|
||||
export default map;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -44,5 +44,6 @@ const map: TranslationStrings = {
|
||||
'このブラウザーは GL マップに必要な WebGL2 に対応していないため、地図は Leaflet で表示されます。',
|
||||
'map.webgl2.terrain': '3D には WebGL2 が必要ですが、このブラウザーは対応していません。地図は 2D のままです。',
|
||||
'map.offlineBasemap': 'オフライン地図',
|
||||
'map.offlineShowStored': 'オフライン地図を表示',
|
||||
};
|
||||
export default map;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -51,5 +51,6 @@ const map: TranslationStrings = {
|
||||
'Этот браузер не поддерживает WebGL2, нужный картам GL, поэтому карты показываются через Leaflet.',
|
||||
'map.webgl2.terrain': 'Для 3D нужен WebGL2, а этот браузер его не поддерживает. Карта остаётся в 2D.',
|
||||
'map.offlineBasemap': 'Офлайн-карта',
|
||||
'map.offlineShowStored': 'Показать офлайн-карту',
|
||||
};
|
||||
export default map;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user