Offline Tile
Runnable Leaflet example: Offline Tile. Open the live demo, read the complete source, and copy the file into your project. No build step or API key.
single HTML file · CDN dependencies onlyinterface may contain Chinese labels
Source
<!--
示例:离线瓦片
要点:GridLayer 生成离线瓦片、Map 内存缓存模拟离线包、离线模式下缺失瓦片的占位处理
依赖:Leaflet 1.9.4 · jsDelivr CDN
-->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta name="robots" content="noindex,follow">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>离线瓦片</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css">
<style>
html,body{height:100%;margin:0}
body{display:flex;flex-direction:column;overflow:hidden;
font:13px/1.6 -apple-system,"Segoe UI","PingFang SC",sans-serif}
.bar{flex:0 0 auto;padding:8px 12px;background:#f6f7f9;border-bottom:1px solid #e3e6ea;
display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-size:12px;color:#58606e}
.bar button{font:inherit;font-size:12px;padding:4px 10px;border-radius:4px;cursor:pointer;
border:1px solid #ccd1d9;background:#fff;color:#16181d}
.bar button:hover{background:#eef0f3}
.bar button.on{background:#1f5fd0;border-color:#1f5fd0;color:#fff}
.bar b{color:#16181d;font-weight:600}
#map{flex:1 1 auto;min-height:0;background:#edf0f4}
</style>
</head>
<body>
<div class="bar">
<button id="prefetch">下载当前视野</button>
<button id="offline">离线模式:关</button>
<button id="clear">清空离线包</button>
<span>缓存 <b id="count">0</b> 块 · <b id="size">0.0 MB</b></span>
<span id="hint">在线模式下浏览过的区域会自动进入缓存</span>
</div>
<div id="map"></div>
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
(function () {
'use strict';
var map = L.map('map', { center: [39.907359, 116.391263], zoom: 12, zoomSnap: 1, minZoom: 9, maxZoom: 16 });
var offline = false;
var store = new Map(); /* key: z/x/y,value: 已绘制的 canvas */
var hits = 0;
var misses = 0;
var countEl = document.getElementById('count');
var sizeEl = document.getElementById('size');
var hintEl = document.getElementById('hint');
/* 模拟的离线瓦片:本地生成,内容只由 x/y/z 决定,可与真实瓦片一一对应 */
function paintTile(coords, size) {
var c = document.createElement('canvas');
c.width = size.x;
c.height = size.y;
var ctx = c.getContext('2d');
ctx.fillStyle = '#f3f5f2';
ctx.fillRect(0, 0, size.x, size.y);
/* 街区 */
ctx.fillStyle = '#e1e5de';
var cell = 64;
for (var bx = 0; bx < size.x; bx += cell) {
for (var by = 0; by < size.y; by += cell) {
if ((bx / cell + by / cell + coords.x + coords.y) % 2 === 0) {
ctx.fillRect(bx + 8, by + 8, cell - 16, cell - 16);
}
}
}
/* 主路:白色路面加浅灰描边,比纯白更容易分辨 */
function road(x1, y1, x2, y2) {
ctx.strokeStyle = '#d6dbd5';
ctx.lineWidth = 12;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 9;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
road(0, size.y * 0.55, size.x, size.y * 0.55);
road(size.x * 0.32, 0, size.x * 0.32, size.y);
/* 水面:只出现在部分瓦片,位置随瓦片变化,避免满屏重复 */
if ((coords.x * 3 + coords.y) % 4 === 0) {
var wx = size.x * (0.2 + ((coords.x + coords.y) % 5) * 0.13);
var wy = size.y * (0.18 + ((coords.x * 2 + coords.y) % 4) * 0.16);
ctx.fillStyle = '#dbeaf5';
ctx.beginPath();
ctx.ellipse(wx, wy, 34, 19, 0.35, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = '#9aa3b0';
ctx.font = '10px Consolas, monospace';
ctx.fillText(coords.z + '/' + coords.x + '/' + coords.y, 6, size.y - 6);
return c;
}
function paintMissing(ctx, size) {
ctx.fillStyle = '#eef1f5';
ctx.fillRect(0, 0, size.x, size.y);
ctx.strokeStyle = '#d5dae1';
ctx.lineWidth = 1;
for (var i = 0; i < size.x; i += 16) {
ctx.beginPath();
ctx.moveTo(i, 0);
ctx.lineTo(i, size.y);
ctx.stroke();
}
ctx.fillStyle = '#9aa3b0';
ctx.font = '11px sans-serif';
ctx.fillText('离线瓦片缺失', 12, size.y / 2);
}
var OfflineLayer = L.GridLayer.extend({
createTile: function (coords, done) {
var tile = L.DomUtil.create('canvas', 'leaflet-tile');
var size = this.getTileSize();
tile.width = size.x;
tile.height = size.y;
var ctx = tile.getContext('2d');
var key = coords.z + '/' + coords.x + '/' + coords.y;
/* 用 setTimeout 模拟磁盘读取延迟,真实项目里是 IndexedDB 异步查询 */
setTimeout(function () {
var cached = store.get(key);
if (offline) {
if (cached) {
hits++;
ctx.drawImage(cached, 0, 0);
} else {
misses++;
paintMissing(ctx, size);
}
} else {
if (cached) {
hits++;
ctx.drawImage(cached, 0, 0);
} else {
misses++;
var made = paintTile(coords, size);
store.set(key, made);
ctx.drawImage(made, 0, 0);
}
}
if (done) done(null, tile);
refresh();
}, 24);
return tile;
}
});
var layer = new OfflineLayer({ minZoom: 9, maxZoom: 16, attribution: '本地模拟瓦片' }).addTo(map);
function refresh() {
countEl.textContent = store.size;
/* 每块 256×256、4 字节/像素,估算内存占用 */
var mb = (store.size * 256 * 256 * 4) / 1024 / 1024;
sizeEl.textContent = mb.toFixed(1) + ' MB';
var ratio = hits + misses ? Math.round(hits / (hits + misses) * 100) : 0;
if (offline) {
hintEl.textContent = '离线模式:命中 ' + ratio + '%,缺失 ' + misses + ' 块';
}
}
document.getElementById('prefetch').addEventListener('click', function () {
var size = 256;
var z = map.getZoom();
var pb = map.getPixelBounds();
var min = pb.min.divideBy(size).floor();
var max = pb.max.divideBy(size).floor();
var added = 0;
for (var x = min.x; x <= max.x; x++) {
for (var y = min.y; y <= max.y; y++) {
var key = z + '/' + x + '/' + y;
if (store.has(key)) continue;
store.set(key, paintTile({ x: x, y: y, z: z }, L.point(size, size)));
added++;
}
}
hintEl.textContent = '本次写入 ' + added + ' 块到离线包';
refresh();
layer.redraw();
});
document.getElementById('offline').addEventListener('click', function () {
offline = !offline;
this.textContent = '离线模式:' + (offline ? '开' : '关');
this.classList.toggle('on', offline);
hits = 0;
misses = 0;
/* 切换后必须重绘,旧瓦片还留在 DOM 里 */
layer.redraw();
if (!offline) hintEl.textContent = '在线模式下浏览过的区域会自动进入缓存';
});
document.getElementById('clear').addEventListener('click', function () {
store.clear();
hits = 0;
misses = 0;
layer.redraw();
hintEl.textContent = '离线包已清空';
refresh();
});
refresh();
})();
</script>
</body>
</html>
Language: the code and this page are in English; the full tutorial and API notes for this example are in Chinese. The demo itself is the authoritative version — it runs exactly as shown.