|
13 | 13 |
|
14 | 14 | |
15 | 15 | // ── Source resolution ───────────────────────────────────────────────────────── |
16 | | -// URL params: |
17 | | -// src1 / src2 — the source value (COG path, TMS url template, JSON style, preset id) |
18 | | -// type1 / type2 — optional explicit type: 'cog' | 'tms' | 'style' | 'preset' |
19 | | -// if omitted, type is inferred from the value |
20 | | -// label1 / label2 — display labels (default: Before / After) |
21 | | -// |
22 | | -// Presets (type=preset or auto-detected): |
23 | | -// osm, satellite, satellite-esri, satellite-bing, satellite-google, hybrid-google, satellite-here, topo |
24 | | -// |
25 | | -// Examples: |
26 | | -// comparison-maplibre-split.html?src1=./before.tif&src2=./after.tif |
27 | | -// ?src1=satellite&src2=./my.cog.tif&label1=Satellite&label2=My COG |
28 | | -// ?src1=osm&src2=satellite&type1=preset&type2=preset |
29 | | -// ?src1=https://example.com/tiles/{z}/{x}/{y}.png&type1=tms&src2=./my.tif |
30 | | -// ?src1={"version":8,"sources":{...},"layers":[...]}&type1=style |
31 | | -// |
32 | | -// iframe embed: |
33 | | -// <iframe |
34 | | -// src="compare-maplibre.html?cog1=./before.tif&cog2=./after.tif&label1=Before&label2=After" |
35 | | -// style="width:100%; height:600px; border:none;" |
36 | | -// allow="cross-origin-isolated"> |
37 | | -// </iframe> |
38 | | -
|
39 | | -
|
| 16 | + <!-- iframe embedding — no X-Frame-Options set, embed freely. |
| 17 | + URL params: |
| 18 | + src1 / src2 COG path, TMS template, preset id, or JSON style string |
| 19 | + type1 / type2 'cog' | 'tms' | 'style' | 'preset' (auto-inferred if omitted) |
| 20 | + label1 / label2 display labels (default: Before / After) |
| 21 | + bounds 'auto' (fit to COG extents) | 'none' | '[minLng,minLat,maxLng,maxLat]' |
| 22 | + default: 'auto' |
| 23 | + Presets: osm · satellite · satellite-esri · satellite-bing · satellite-google · satellite-here · hybrid-google · topo |
| 24 | + Examples: |
| 25 | + ?src1=./before.tif&src2=./after.tif |
| 26 | + ?src1=./before.tif&src2=satellite-google&label1=2019&label2=Satellite |
| 27 | + ?src1=https://sentinel-cogs.s3.us-west-2.amazonaws.com/.../TCI.tif&src2=satellite-google |
| 28 | + ?src1=osm&src2=satellite&bounds=none |
| 29 | + ?src1=./a.tif&src2=./b.tif&bounds=[35.18,33.26,35.22,33.28] |
40 | 30 | --> |
41 | 31 | <meta charset="utf-8" /> |
42 | 32 | <title>MapLibre Compare</title> |
43 | 33 | <meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no" /> |
44 | 34 | <link href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" rel="stylesheet" /> |
45 | 35 | <script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script> |
46 | 36 | <script src="https://unpkg.com/@geomatico/maplibre-cog-protocol@0.5.0/dist/index.js"></script> |
| 37 | +<!-- geotiff.js for reading COG extents directly from headers --> |
| 38 | +<script src="https://unpkg.com/geotiff@2.1.3/dist-browser/geotiff.js"></script> |
47 | 39 | <style> |
48 | 40 | *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
49 | 41 | body { background: #111; overflow: hidden; } |
50 | | - |
51 | | - #stage { |
52 | | - position: relative; |
53 | | - width: 100vw; height: 100vh; |
54 | | - } |
55 | | - |
56 | | - .map { position: absolute; inset: 0; } |
| 42 | + #stage { position: relative; width: 100vw; height: 100vh; } |
| 43 | + .map { position: absolute; inset: 0; background-color: black; } |
57 | 44 |
|
58 | 45 | #divider { |
59 | 46 | position: absolute; top: 0; bottom: 0; left: 50%; |
60 | | - width: 44px; |
61 | | - transform: translateX(-50%); |
62 | | - cursor: ew-resize; z-index: 20; |
63 | | - touch-action: none; |
64 | | - display: none; |
| 47 | + width: 44px; transform: translateX(-50%); |
| 48 | + cursor: ew-resize; z-index: 20; touch-action: none; display: none; |
65 | 49 | } |
66 | 50 | #divider-line { |
67 | | - position: absolute; top: 0; bottom: 0; |
68 | | - left: 50%; width: 2px; |
69 | | - background: rgba(255,255,255,0.85); |
70 | | - transform: translateX(-50%); |
| 51 | + position: absolute; top: 0; bottom: 0; left: 50%; width: 2px; |
| 52 | + background: rgba(255,255,255,0.85); transform: translateX(-50%); |
71 | 53 | pointer-events: none; |
72 | 54 | } |
73 | 55 | #divider-pill { |
74 | 56 | position: absolute; top: 50%; left: 50%; |
75 | 57 | transform: translate(-50%, -50%); |
76 | | - width: 40px; height: 40px; |
77 | | - background: #fff; border-radius: 50%; |
| 58 | + width: 40px; height: 40px; background: #fff; border-radius: 50%; |
78 | 59 | box-shadow: 0 0 0 5px rgba(0,0,0,.18), 0 2px 10px rgba(0,0,0,.5); |
79 | 60 | display: flex; align-items: center; justify-content: center; |
80 | 61 | pointer-events: none; |
81 | 62 | } |
82 | 63 | #divider-pill svg { display: block; } |
83 | 64 |
|
84 | 65 | #sbs-gutter { |
85 | | - display: none; |
86 | | - position: absolute; top: 0; bottom: 0; left: 50%; |
| 66 | + display: none; position: absolute; top: 0; bottom: 0; left: 50%; |
87 | 67 | width: 2px; transform: translateX(-50%); |
88 | | - background: rgba(255,255,255,.35); |
89 | | - z-index: 5; pointer-events: none; |
| 68 | + background: rgba(255,255,255,.35); z-index: 5; pointer-events: none; |
90 | 69 | } |
91 | 70 |
|
92 | 71 | .label { |
|
100 | 79 |
|
101 | 80 | #toggle { |
102 | 81 | position: absolute; bottom: 16px; left: 50%; |
103 | | - transform: translateX(-50%); |
104 | | - z-index: 30; display: flex; |
| 82 | + transform: translateX(-50%); z-index: 30; display: flex; |
105 | 83 | background: rgba(0,0,0,.6); border-radius: 6px; overflow: hidden; |
106 | 84 | } |
107 | 85 | #toggle button { |
|
134 | 112 | </div> |
135 | 113 |
|
136 | 114 | <script> |
137 | | -// ── COG protocol ────────────────────────────────────────────────────────────── |
138 | 115 | maplibregl.addProtocol('cog', MaplibreCOGProtocol.cogProtocol); |
139 | 116 |
|
| 117 | +// ── URL params ──────────────────────────────────────────────────────────────── |
140 | 118 | const _p = new URLSearchParams(window.location.search); |
141 | 119 |
|
142 | 120 | const PRESETS = { |
|
197 | 175 | } |
198 | 176 | } |
199 | 177 |
|
200 | | -const SRC1 = _p.get('src1') || './IMG_PHR1A_PMS_202603120816196-uint8-3857.cog.tif'; |
201 | | -const SRC2 = _p.get('src2') || './Tyre-before-ESRI-50cm-3857.cog.tif'; |
| 178 | +// Default sources: |
| 179 | +// src1 — Tyre (Lebanon) before imagery, 50cm ESRI, EPSG:3857, hosted on heritagewatch.ai |
| 180 | +// src2 — Google Hybrid basemap preset |
| 181 | +// Open-data COG examples in EPSG:3857 (for testing): |
| 182 | +// https://maplibre.org/maplibre-gl-js/docs/assets/cog.tif (Innsbruck aerial, 3857) |
| 183 | +// Note: Sentinel-2/Landsat public COGs on AWS are in UTM (not 3857) — reproject first with: |
| 184 | +// gdalwarp -t_srs EPSG:3857 -of COG -co TILING_SCHEME=GoogleMapsCompatible input.tif out.tif |
| 185 | +const SRC1 = _p.get('src1') || 'https://heritagewatch.ai/wp-content/uploads/2026/03/Tyre-before-ESRI-50cm-3857.cog_.tif'; |
| 186 | +const SRC2 = _p.get('src2') || 'hybrid-google'; |
202 | 187 | const TYPE1 = inferType(SRC1, _p.get('type1')); |
203 | 188 | const TYPE2 = inferType(SRC2, _p.get('type2')); |
204 | 189 | const LABEL1 = _p.get('label1') || 'Before'; |
205 | 190 | const LABEL2 = _p.get('label2') || 'After'; |
| 191 | +const BOUNDS_PARAM = _p.get('bounds') || 'auto'; // 'auto' | 'none' | 'w,s,e,n' or '[w,s,e,n]' |
| 192 | +// padding: pixels inset from viewport edges when fitting bounds. |
| 193 | +// Single number or top,right,bottom,left e.g. padding=40 or padding=60,40,60,40 |
| 194 | +const PADDING_PARAM = _p.get('padding') || '40'; |
206 | 195 |
|
207 | 196 | document.getElementById('label-before').textContent = LABEL1; |
208 | 197 | document.getElementById('label-after').textContent = LABEL2; |
209 | 198 |
|
210 | 199 | const STYLE_BEFORE = buildStyle(SRC1, TYPE1); |
211 | 200 | const STYLE_AFTER = buildStyle(SRC2, TYPE2); |
212 | 201 |
|
213 | | -// ── Maps ────────────────────────────────────────────────────────────────────── |
214 | | -// Init with black background so each panel shows only its own source. |
215 | | -const BG_STYLE = { version: 8, sources: {}, layers: [{ id: 'bg', type: 'background', paint: { 'background-color': '#000' }}]}; |
216 | | -const MAP_OPTS = { center: [0,0], zoom: 1, maxZoom: 24, pixelRatio: 1, attributionControl: false, style: BG_STYLE }; |
| 202 | +// ── Read COG extent via GeoTIFF.js ──────────────────────────────────────────── |
| 203 | +// Reads just the COG header (range request) to get the bounding box in WGS84. |
| 204 | +// Returns [minLng, minLat, maxLng, maxLat], or null on failure. |
| 205 | +// Supports EPSG:3857 and EPSG:4326 only — alerts + returns null for other CRS. |
| 206 | +async function cogBboxWgs84(url, label) { |
| 207 | + let tiff, img; |
| 208 | + try { |
| 209 | + tiff = await GeoTIFF.fromUrl(url, { allowFullFile: false }); |
| 210 | + } catch(e) { |
| 211 | + console.error(`COG unreachable [${label}]: ${url}`, e); |
| 212 | + return null; |
| 213 | + } |
| 214 | + |
| 215 | + try { |
| 216 | + img = await tiff.getImage(0); |
| 217 | + } catch(e) { |
| 218 | + console.error(`COG header read failed [${label}]: ${url}`, e); |
| 219 | + return null; |
| 220 | + } |
217 | 221 |
|
218 | | -const mapBefore = new maplibregl.Map({ container: 'map-before', ...MAP_OPTS }); |
219 | | -const mapAfter = new maplibregl.Map({ container: 'map-after', ...MAP_OPTS }); |
| 222 | + const bbox = img.getBoundingBox(); // [minX, minY, maxX, maxY] in image CRS |
| 223 | + const geoKeys = img.getGeoKeys() || {}; |
| 224 | + const epsg = geoKeys.ProjectedCSTypeGeoKey || geoKeys.GeographicTypeGeoKey; |
220 | 225 |
|
221 | | -mapBefore.once('load', () => mapBefore.setStyle(STYLE_BEFORE)); |
222 | | -mapAfter.once('load', () => mapAfter.setStyle(STYLE_AFTER)); |
| 226 | + console.log(`COG [${label}] epsg=${epsg} bbox=`, bbox); |
| 227 | + |
| 228 | + // EPSG:4326 — already in degrees |
| 229 | + if (epsg === 4326) { |
| 230 | + return [bbox[0], bbox[1], bbox[2], bbox[3]]; |
| 231 | + } |
| 232 | + |
| 233 | + // EPSG:3857 — convert Mercator meters → WGS84 degrees |
| 234 | + if (epsg === 3857 || epsg === 900913) { |
| 235 | + const toWgs84 = (mx, my) => [ |
| 236 | + mx / 20037508.34 * 180, |
| 237 | + Math.atan(Math.exp(my / 20037508.34 * Math.PI)) * 360 / Math.PI - 90, |
| 238 | + ]; |
| 239 | + const [minLng, minLat] = toWgs84(bbox[0], bbox[1]); |
| 240 | + const [maxLng, maxLat] = toWgs84(bbox[2], bbox[3]); |
| 241 | + return [minLng, minLat, maxLng, maxLat]; |
| 242 | + } |
| 243 | + |
| 244 | + // No EPSG tag — heuristic: if values look like degrees, treat as 4326 |
| 245 | + if (!epsg && Math.abs(bbox[0]) <= 180 && Math.abs(bbox[1]) <= 90) { |
| 246 | + console.warn(`COG [${label}] has no CRS tag, treating as EPSG:4326`); |
| 247 | + return [bbox[0], bbox[1], bbox[2], bbox[3]]; |
| 248 | + } |
| 249 | + |
| 250 | + // Unsupported CRS |
| 251 | + const msg = `COG [${label}] uses unsupported CRS (EPSG:${epsg || 'unknown'}). |
| 252 | +Only EPSG:3857 and EPSG:4326 are supported. |
| 253 | +Reproject with: gdalwarp -t_srs EPSG:3857 -of COG input.tif output.tif`; |
| 254 | + console.error(msg, 'url:', url, 'bbox:', bbox); |
| 255 | + window.alert(msg); |
| 256 | + return null; |
| 257 | +} |
| 258 | + |
| 259 | +// ── Maps ────────────────────────────────────────────────────────────────────── |
| 260 | +// Both maps start with a black background style. |
| 261 | +// We swap to the real style immediately after construction using setStyle. |
| 262 | +// We DON'T wait for 'load' — setStyle can be called at any time, even before |
| 263 | +// the map has fully initialised, and MapLibre will queue it correctly. |
| 264 | +// We listen for 'style.load' which fires once per setStyle call. |
| 265 | + |
| 266 | +const mapBefore = new maplibregl.Map({ |
| 267 | + container: 'map-before', center: [0,0], zoom: 1, maxZoom: 24, |
| 268 | + pixelRatio: 1, attributionControl: false, |
| 269 | + style: { version: 8, sources: {}, layers: [{ id: 'bg', type: 'background', paint: { 'background-color': '#000' }}]} |
| 270 | +}); |
| 271 | +const mapAfter = new maplibregl.Map({ |
| 272 | + container: 'map-after', center: [0,0], zoom: 1, maxZoom: 24, |
| 273 | + pixelRatio: 1, attributionControl: false, |
| 274 | + style: { version: 8, sources: {}, layers: [{ id: 'bg', type: 'background', paint: { 'background-color': '#000' }}]} |
| 275 | +}); |
| 276 | + |
| 277 | +// Error alerts |
| 278 | +mapBefore.on('error', e => { console.error('map-before error', e); window.alert('Error loading src1:\n' + (e.error?.message || e.message || JSON.stringify(e))); }); |
| 279 | +mapAfter.on('error', e => { console.error('map-after error', e); window.alert('Error loading src2:\n' + (e.error?.message || e.message || JSON.stringify(e))); }); |
223 | 280 |
|
224 | 281 | // ── Sync ────────────────────────────────────────────────────────────────────── |
225 | 282 | let syncing = false; |
|
231 | 288 | mapBefore.on('move', () => sync(mapBefore, mapAfter)); |
232 | 289 | mapAfter.on('move', () => sync(mapAfter, mapBefore)); |
233 | 290 |
|
234 | | -// ── Autozoom + bounds ───────────────────────────────────────────────────────── |
235 | | -// Only COG sources expose .bounds on the MapLibre source object. |
236 | | -// TMS/preset sources cover the whole world — ignore their bounds. |
237 | | -// If at least one source is COG: union the COG bounds, fitBounds + setMaxBounds. |
238 | | -// If no COG source: skip bounds, user navigates freely. |
239 | | -let styleLoaded = 0; |
240 | | -function onStyleReady(map) { |
241 | | - map.resize(); |
242 | | - if (++styleLoaded < 2) return; |
| 291 | +// ── Bounds ──────────────────────────────────────────────────────────────────── |
| 292 | +function parsePadding(str) { |
| 293 | + const parts = String(str).split(',').map(Number).filter(isFinite); |
| 294 | + if (parts.length === 1) return parts[0]; |
| 295 | + if (parts.length === 4) return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[3] }; |
| 296 | + return 40; // fallback |
| 297 | +} |
| 298 | +const PADDING = parsePadding(PADDING_PARAM); |
243 | 299 |
|
244 | | - const bB = TYPE1 === 'cog' ? mapBefore.getSource(Object.keys(STYLE_BEFORE.sources)[0])?.bounds : null; |
245 | | - const bA = TYPE2 === 'cog' ? mapAfter.getSource(Object.keys(STYLE_AFTER.sources)[0])?.bounds : null; |
| 300 | +function applyBounds(llb) { |
| 301 | + console.log('[bounds] fitBounds + setMaxBounds ->', llb.toArray(), 'padding=', PADDING); |
| 302 | + [mapBefore, mapAfter].forEach(m => { |
| 303 | + m.fitBounds(llb, { padding: PADDING, animate: false }); |
| 304 | + m.setMaxBounds(llb); |
| 305 | + }); |
| 306 | +} |
246 | 307 |
|
247 | | - console.log('cog bounds before:', bB); |
248 | | - console.log('cog bounds after: ', bA); |
| 308 | +async function computeAndApplyBounds() { |
| 309 | + console.log('[bounds] computeAndApplyBounds, BOUNDS_PARAM=', BOUNDS_PARAM); |
| 310 | + |
| 311 | + if (BOUNDS_PARAM === 'none') { |
| 312 | + console.log('[bounds] none - skipping'); |
| 313 | + return; |
| 314 | + } |
| 315 | + |
| 316 | + if (BOUNDS_PARAM !== 'auto') { |
| 317 | + const str = BOUNDS_PARAM.trim().replace(/^\[|\]$/g, ''); // strip optional brackets |
| 318 | + const arr = str.split(',').map(Number); |
| 319 | + console.log('[bounds] explicit parsed arr=', arr); |
| 320 | + if (arr.length === 4 && arr.every(isFinite)) { |
| 321 | + applyBounds(new maplibregl.LngLatBounds([arr[0], arr[1]], [arr[2], arr[3]])); |
| 322 | + return; |
| 323 | + } |
| 324 | + console.warn('[bounds] explicit parse failed, falling back to auto. Raw:', BOUNDS_PARAM); |
| 325 | + } |
| 326 | + |
| 327 | + console.log('[bounds] auto - TYPE1=', TYPE1, 'TYPE2=', TYPE2); |
| 328 | + const [bB, bA] = await Promise.all([ |
| 329 | + TYPE1 === 'cog' ? cogBboxWgs84(SRC1, 'src1') : Promise.resolve(null), |
| 330 | + TYPE2 === 'cog' ? cogBboxWgs84(SRC2, 'src2') : Promise.resolve(null), |
| 331 | + ]); |
| 332 | + console.log('[bounds] src1 bbox:', bB, '| src2 bbox:', bA); |
249 | 333 |
|
250 | 334 | const available = [bB, bA].filter(Boolean); |
251 | | - if (available.length === 0) return; |
| 335 | + if (available.length === 0) { console.warn('[bounds] no COG bbox available'); return; } |
252 | 336 |
|
253 | 337 | const union = available.reduce((acc, b) => [ |
254 | 338 | Math.min(acc[0], b[0]), Math.min(acc[1], b[1]), |
255 | 339 | Math.max(acc[2], b[2]), Math.max(acc[3], b[3]), |
256 | 340 | ]); |
257 | | - const unionBounds = new maplibregl.LngLatBounds([union[0], union[1]], [union[2], union[3]]); |
| 341 | + console.log('[bounds] union=', union); |
| 342 | + applyBounds(new maplibregl.LngLatBounds([union[0], union[1]], [union[2], union[3]])); |
| 343 | +} |
258 | 344 |
|
259 | | - [mapBefore, mapAfter].forEach(m => { |
260 | | - m.fitBounds(unionBounds, { padding: 32, animate: false }); |
261 | | - m.setMaxBounds(unionBounds); |
262 | | - }); |
| 345 | +// Wait for both maps to load their real styles, then apply bounds. |
| 346 | +// Use a counter — each map calls onRealStyleLoad once. |
| 347 | +let _readyCount = 0; |
| 348 | +function onRealStyleLoad(map) { |
| 349 | + map.resize(); |
| 350 | + console.log('[bounds] real style loaded:', map.getContainer().id, '| total ready:', _readyCount + 1); |
| 351 | + if (++_readyCount < 2) return; |
| 352 | + computeAndApplyBounds(); |
263 | 353 | } |
264 | 354 |
|
265 | | -// style.load fires twice per map (blank bg + real style) — skip the first |
266 | | -let _beforeStyleSet = false, _afterStyleSet = false; |
267 | | -mapBefore.on('style.load', () => { if (!_beforeStyleSet) { _beforeStyleSet = true; return; } onStyleReady(mapBefore); }); |
268 | | -mapAfter.on('style.load', () => { if (!_afterStyleSet) { _afterStyleSet = true; return; } onStyleReady(mapAfter); }); |
| 355 | +// Set real styles now. 'style.load' fires once per setStyle. |
| 356 | +// We register the listener first, then call setStyle. |
| 357 | +mapBefore.once('style.load', () => onRealStyleLoad(mapBefore)); |
| 358 | +mapBefore.setStyle(STYLE_BEFORE); |
| 359 | + |
| 360 | +mapAfter.once('style.load', () => onRealStyleLoad(mapAfter)); |
| 361 | +mapAfter.setStyle(STYLE_AFTER); |
269 | 362 |
|
270 | 363 | // ── Layout helpers ──────────────────────────────────────────────────────────── |
271 | 364 | const stage = document.getElementById('stage'); |
|
0 commit comments