Atlaslog
Atlaslog / Examples / Leaflet / Offline Tile
Leaflet · Runnable Example

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.

Open standalone → Chinese notes All Leaflet examples
/demos/leaflet-offline-tile.htmlLIVE
single HTML file · CDN dependencies onlyinterface may contain Chinese labels

Source

demos/leaflet-offline-tile.html
<!--
  示例:离线瓦片
  要点: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.

More Leaflet examples

Leaflet · EXAMPLEPane Order Leaflet · EXAMPLEPolygon Edit Leaflet · EXAMPLEPopup Position