|
1 | 1 | (function() { |
2 | 2 | 'use strict'; |
3 | 3 |
|
| 4 | + // ============================================ |
| 5 | + // BatchTileLayer - Custom LeafletJS tile layer |
| 6 | + // Batches multiple tile requests into single HTTP requests |
| 7 | + // ============================================ |
| 8 | + L.TileLayer.Batch = L.TileLayer.extend({ |
| 9 | + options: { |
| 10 | + batchDelay: 300, |
| 11 | + maxBatchSize: 2000, |
| 12 | + batchEndpoint: '/api/tiles/batch' |
| 13 | + }, |
| 14 | + |
| 15 | + initialize: function(urlTemplate, options) { |
| 16 | + L.TileLayer.prototype.initialize.call(this, urlTemplate, options); |
| 17 | + this._pendingTiles = new Map(); |
| 18 | + this._batchTimer = null; |
| 19 | + this._emptyTileUrl = null; |
| 20 | + this._worldName = 'world'; |
| 21 | + this._isSending = false; |
| 22 | + this._queuedWhileSending = new Map(); |
| 23 | + }, |
| 24 | + |
| 25 | + setWorld: function(worldName) { |
| 26 | + this._worldName = worldName; |
| 27 | + }, |
| 28 | + |
| 29 | + createTile: function(coords, done) { |
| 30 | + const tile = document.createElement('img'); |
| 31 | + tile.alt = ''; |
| 32 | + tile.setAttribute('role', 'presentation'); |
| 33 | + |
| 34 | + const key = `0/${coords.x}/${coords.y}`; |
| 35 | + this._queueTileRequest(key, coords, tile, done); |
| 36 | + |
| 37 | + return tile; |
| 38 | + }, |
| 39 | + |
| 40 | + _queueTileRequest: function(key, coords, tile, done) { |
| 41 | + // If we're currently sending, queue for next batch |
| 42 | + const targetMap = this._isSending ? this._queuedWhileSending : this._pendingTiles; |
| 43 | + |
| 44 | + targetMap.set(key, { |
| 45 | + tile: tile, |
| 46 | + done: done, |
| 47 | + coords: coords |
| 48 | + }); |
| 49 | + |
| 50 | + if (this._batchTimer) { |
| 51 | + clearTimeout(this._batchTimer); |
| 52 | + } |
| 53 | + |
| 54 | + // Only auto-send if not currently sending and we hit a huge limit |
| 55 | + if (!this._isSending && this._pendingTiles.size >= this.options.maxBatchSize) { |
| 56 | + this._sendBatch(); |
| 57 | + } else if (!this._isSending) { |
| 58 | + this._batchTimer = setTimeout(() => this._sendBatch(), this.options.batchDelay); |
| 59 | + } |
| 60 | + }, |
| 61 | + |
| 62 | + _sendBatch: function() { |
| 63 | + if (this._pendingTiles.size === 0) return; |
| 64 | + |
| 65 | + this._isSending = true; |
| 66 | + const allTiles = new Map(this._pendingTiles); |
| 67 | + this._pendingTiles.clear(); |
| 68 | + this._batchTimer = null; |
| 69 | + |
| 70 | + // Split into chunks of 200 tiles max |
| 71 | + const CHUNK_SIZE = 200; |
| 72 | + const chunks = []; |
| 73 | + let currentChunk = new Map(); |
| 74 | + |
| 75 | + for (const [key, value] of allTiles) { |
| 76 | + currentChunk.set(key, value); |
| 77 | + if (currentChunk.size >= CHUNK_SIZE) { |
| 78 | + chunks.push(currentChunk); |
| 79 | + currentChunk = new Map(); |
| 80 | + } |
| 81 | + } |
| 82 | + if (currentChunk.size > 0) { |
| 83 | + chunks.push(currentChunk); |
| 84 | + } |
| 85 | + |
| 86 | + console.log(`Sending ${allTiles.size} tiles in ${chunks.length} batch(es)`); |
| 87 | + |
| 88 | + // Send all chunks in parallel |
| 89 | + const chunkPromises = chunks.map(chunk => this._sendChunk(chunk)); |
| 90 | + |
| 91 | + Promise.all(chunkPromises).finally(() => { |
| 92 | + this._isSending = false; |
| 93 | + // Process any tiles that were queued while we were sending |
| 94 | + if (this._queuedWhileSending.size > 0) { |
| 95 | + for (const [key, value] of this._queuedWhileSending) { |
| 96 | + this._pendingTiles.set(key, value); |
| 97 | + } |
| 98 | + this._queuedWhileSending.clear(); |
| 99 | + // Schedule next batch |
| 100 | + this._batchTimer = setTimeout(() => this._sendBatch(), this.options.batchDelay); |
| 101 | + } |
| 102 | + }); |
| 103 | + }, |
| 104 | + |
| 105 | + _sendChunk: function(batch) { |
| 106 | + const tiles = []; |
| 107 | + for (const [key, request] of batch) { |
| 108 | + const [z, x, y] = key.split('/').map(Number); |
| 109 | + tiles.push({ z, x, y }); |
| 110 | + } |
| 111 | + |
| 112 | + const requestBody = { |
| 113 | + world: this._worldName, |
| 114 | + tiles: tiles |
| 115 | + }; |
| 116 | + |
| 117 | + return fetch(this.options.batchEndpoint, { |
| 118 | + method: 'POST', |
| 119 | + headers: { 'Content-Type': 'application/json' }, |
| 120 | + body: JSON.stringify(requestBody) |
| 121 | + }) |
| 122 | + .then(response => { |
| 123 | + if (!response.ok) throw new Error(`HTTP ${response.status}`); |
| 124 | + return response.json(); |
| 125 | + }) |
| 126 | + .then(data => { |
| 127 | + for (const [key, tileData] of Object.entries(data.tiles)) { |
| 128 | + const request = batch.get(key); |
| 129 | + if (!request) continue; |
| 130 | + |
| 131 | + if (tileData.empty) { |
| 132 | + this._setEmptyTile(request.tile, request.done); |
| 133 | + } else if (tileData.data) { |
| 134 | + request.tile.src = 'data:image/png;base64,' + tileData.data; |
| 135 | + request.tile.onload = () => request.done(null, request.tile); |
| 136 | + request.tile.onerror = () => request.done(new Error('Image load failed'), request.tile); |
| 137 | + } else if (tileData.error) { |
| 138 | + request.done(new Error(tileData.error), request.tile); |
| 139 | + } |
| 140 | + } |
| 141 | + }) |
| 142 | + .catch(error => { |
| 143 | + console.error('Batch chunk failed:', error); |
| 144 | + for (const [key, request] of batch) { |
| 145 | + request.done(error, request.tile); |
| 146 | + } |
| 147 | + }); |
| 148 | + }, |
| 149 | + |
| 150 | + _setEmptyTile: function(tile, done) { |
| 151 | + if (!this._emptyTileUrl) { |
| 152 | + this._emptyTileUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; |
| 153 | + } |
| 154 | + tile.src = this._emptyTileUrl; |
| 155 | + done(null, tile); |
| 156 | + } |
| 157 | + }); |
| 158 | + |
| 159 | + L.tileLayer.batch = function(urlTemplate, options) { |
| 160 | + return new L.TileLayer.Batch(urlTemplate, options); |
| 161 | + }; |
| 162 | + |
4 | 163 | // Config - 1 tile = 1 chunk = 32 blocks |
5 | 164 | const CHUNK_SIZE = 32; |
6 | 165 | const TILE_SIZE = 256; |
| 166 | + const SCALE = TILE_SIZE / CHUNK_SIZE; // 8 - Leaflet units per block |
7 | 167 |
|
8 | 168 | // State |
9 | 169 | let map = null; |
|
26 | 186 |
|
27 | 187 | map = L.map('map', { |
28 | 188 | crs: L.CRS.Simple, |
29 | | - minZoom: -6, |
| 189 | + minZoom: -4, |
30 | 190 | maxZoom: 4, |
31 | 191 | zoomSnap: 0.5, |
32 | 192 | zoomDelta: 0.5, |
33 | 193 | maxBounds: worldBounds, |
34 | 194 | maxBoundsViscosity: 1.0 |
35 | 195 | }); |
36 | 196 |
|
37 | | - // Start at origin (zoomed out) |
38 | | - map.setView([0, 0], -3); |
| 197 | + // Start at origin |
| 198 | + map.setView([0, 0], 0); |
39 | 199 |
|
40 | 200 | updateTileLayer(); |
41 | 201 |
|
42 | 202 | map.on('mousemove', function(e) { |
43 | | - // In CRS.Simple with our setup: lat = -Z, lng = X |
44 | | - const x = Math.round(e.latlng.lng); |
45 | | - const z = Math.round(-e.latlng.lat); |
| 203 | + // Convert Leaflet coords to world coords (divide by scale factor) |
| 204 | + const x = Math.round(e.latlng.lng / SCALE); |
| 205 | + const z = Math.round(-e.latlng.lat / SCALE); |
46 | 206 | document.getElementById('coords-display').textContent = `X: ${x}, Z: ${z}`; |
47 | 207 | }); |
48 | 208 |
|
|
54 | 214 | map.removeLayer(tileLayer); |
55 | 215 | } |
56 | 216 |
|
57 | | - // Custom tile layer - use zoomOffset so tiles are always fetched at native zoom |
58 | | - tileLayer = L.tileLayer('/api/tiles/' + currentWorld + '/0/{x}/{y}.png', { |
| 217 | + // Batch tile layer - reduces HTTP requests by batching multiple tiles per request |
| 218 | + tileLayer = L.tileLayer.batch('/api/tiles/' + currentWorld + '/0/{x}/{y}.png', { |
59 | 219 | tileSize: TILE_SIZE, |
60 | 220 | minNativeZoom: 0, |
61 | 221 | maxNativeZoom: 0, |
62 | | - minZoom: -6, |
| 222 | + minZoom: -4, |
63 | 223 | maxZoom: 4, |
64 | 224 | noWrap: true, |
65 | 225 | bounds: [[-100000, -100000], [100000, 100000]], |
| 226 | + batchDelay: 300, |
| 227 | + maxBatchSize: 2000, |
| 228 | + batchEndpoint: '/api/tiles/batch' |
66 | 229 | }); |
67 | 230 |
|
| 231 | + tileLayer.setWorld(currentWorld); |
68 | 232 | tileLayer.addTo(map); |
69 | 233 | } |
70 | 234 |
|
71 | 235 | // Convert world coords to LatLng |
72 | 236 | function worldToLatLng(x, z) { |
73 | 237 | // X -> lng, Z -> -lat (north is up, Z increases south) |
74 | | - // Scale by chunk size since tiles represent chunks |
75 | | - return L.latLng(-z, x); |
| 238 | + // Multiply by SCALE since Leaflet uses tile-pixel coords (256px per 32-block chunk) |
| 239 | + return L.latLng(-z * SCALE, x * SCALE); |
76 | 240 | } |
77 | 241 |
|
78 | 242 | async function loadWorlds() { |
|
0 commit comments