Skip to content

Commit 8aaf046

Browse files
committed
update maplibre-comparison
1 parent 4dab4c9 commit 8aaf046

1 file changed

Lines changed: 175 additions & 82 deletions

File tree

‎public/tools-apps/comparison-maplibre-split.html‎

Lines changed: 175 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -13,80 +13,59 @@
1313
1414
1515
// ── 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]
4030
-->
4131
<meta charset="utf-8" />
4232
<title>MapLibre Compare</title>
4333
<meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no" />
4434
<link href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" rel="stylesheet" />
4535
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
4636
<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>
4739
<style>
4840
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
4941
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; }
5744

5845
#divider {
5946
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;
6549
}
6650
#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%);
7153
pointer-events: none;
7254
}
7355
#divider-pill {
7456
position: absolute; top: 50%; left: 50%;
7557
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%;
7859
box-shadow: 0 0 0 5px rgba(0,0,0,.18), 0 2px 10px rgba(0,0,0,.5);
7960
display: flex; align-items: center; justify-content: center;
8061
pointer-events: none;
8162
}
8263
#divider-pill svg { display: block; }
8364

8465
#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%;
8767
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;
9069
}
9170

9271
.label {
@@ -100,8 +79,7 @@
10079

10180
#toggle {
10281
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;
10583
background: rgba(0,0,0,.6); border-radius: 6px; overflow: hidden;
10684
}
10785
#toggle button {
@@ -134,9 +112,9 @@
134112
</div>
135113

136114
<script>
137-
// ── COG protocol ──────────────────────────────────────────────────────────────
138115
maplibregl.addProtocol('cog', MaplibreCOGProtocol.cogProtocol);
139116

117+
// ── URL params ────────────────────────────────────────────────────────────────
140118
const _p = new URLSearchParams(window.location.search);
141119

142120
const PRESETS = {
@@ -197,29 +175,108 @@
197175
}
198176
}
199177

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';
202187
const TYPE1 = inferType(SRC1, _p.get('type1'));
203188
const TYPE2 = inferType(SRC2, _p.get('type2'));
204189
const LABEL1 = _p.get('label1') || 'Before';
205190
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';
206195

207196
document.getElementById('label-before').textContent = LABEL1;
208197
document.getElementById('label-after').textContent = LABEL2;
209198

210199
const STYLE_BEFORE = buildStyle(SRC1, TYPE1);
211200
const STYLE_AFTER = buildStyle(SRC2, TYPE2);
212201

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+
}
217221

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;
220225

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))); });
223280

224281
// ── Sync ──────────────────────────────────────────────────────────────────────
225282
let syncing = false;
@@ -231,41 +288,77 @@
231288
mapBefore.on('move', () => sync(mapBefore, mapAfter));
232289
mapAfter.on('move', () => sync(mapAfter, mapBefore));
233290

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);
243299

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+
}
246307

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);
249333

250334
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; }
252336

253337
const union = available.reduce((acc, b) => [
254338
Math.min(acc[0], b[0]), Math.min(acc[1], b[1]),
255339
Math.max(acc[2], b[2]), Math.max(acc[3], b[3]),
256340
]);
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+
}
258344

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();
263353
}
264354

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);
269362

270363
// ── Layout helpers ────────────────────────────────────────────────────────────
271364
const stage = document.getElementById('stage');

0 commit comments

Comments
 (0)