earthdiff 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- earthdiff/__init__.py +28 -0
- earthdiff/__main__.py +3 -0
- earthdiff/aoi.py +161 -0
- earthdiff/cause.py +144 -0
- earthdiff/change.py +337 -0
- earthdiff/cli.py +202 -0
- earthdiff/composite.py +169 -0
- earthdiff/geocode.py +186 -0
- earthdiff/history.py +86 -0
- earthdiff/i18n.py +201 -0
- earthdiff/indices.py +52 -0
- earthdiff/mcp_prompts.py +54 -0
- earthdiff/mcp_server.py +338 -0
- earthdiff/mcp_service.py +887 -0
- earthdiff/periods.py +87 -0
- earthdiff/pipeline.py +476 -0
- earthdiff/reader.py +59 -0
- earthdiff/render.py +146 -0
- earthdiff/report.py +113 -0
- earthdiff/safety.py +93 -0
- earthdiff/seasonal.py +155 -0
- earthdiff/stac.py +163 -0
- earthdiff/static/ui.html +239 -0
- earthdiff/templates/report.html +850 -0
- earthdiff/terrain.py +120 -0
- earthdiff/ui.py +227 -0
- earthdiff/vendor/LEAFLET-LICENSE.txt +26 -0
- earthdiff/vendor/leaflet.css +661 -0
- earthdiff/vendor/leaflet.js +6 -0
- earthdiff/watch.py +138 -0
- earthdiff/xlsx.py +183 -0
- earthdiff/yearly.py +109 -0
- earthdiff-0.2.0.dist-info/METADATA +368 -0
- earthdiff-0.2.0.dist-info/RECORD +37 -0
- earthdiff-0.2.0.dist-info/WHEEL +4 -0
- earthdiff-0.2.0.dist-info/entry_points.txt +3 -0
- earthdiff-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>{{TITLE}}</title>
|
|
7
|
+
<style>{{LEAFLET_CSS}}</style>
|
|
8
|
+
<style>
|
|
9
|
+
:root{--bg:#f6f7f9;--card:#fff;--ink:#16202a;--mute:#5b6773;--line:#dde2e8;--accent:#0b5cad;--warn-bg:#fff4e0;--warn-line:#e0a030}
|
|
10
|
+
@media (prefers-color-scheme:dark){:root{--bg:#0f1318;--card:#171d24;--ink:#e6ebf0;--mute:#9aa7b4;--line:#2a333d;--accent:#6aaeff;--warn-bg:#2b2412;--warn-line:#a67c1d}}
|
|
11
|
+
*{box-sizing:border-box}
|
|
12
|
+
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
|
13
|
+
.wrap{max-width:1200px;margin:0 auto;padding:16px}
|
|
14
|
+
.top{display:flex;gap:12px;align-items:flex-start;justify-content:space-between;flex-wrap:wrap}
|
|
15
|
+
h1{font-size:1.5rem;margin:.2rem 0}
|
|
16
|
+
h2{font-size:1.15rem;margin:1.6rem 0 .6rem}
|
|
17
|
+
.sub{color:var(--mute);margin:0 0 12px}
|
|
18
|
+
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;margin:12px 0}
|
|
19
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:10px 12px}
|
|
20
|
+
.card .k{font-size:.78rem;color:var(--mute);text-transform:uppercase;letter-spacing:.04em}
|
|
21
|
+
.card .v{font-size:1.35rem;font-weight:650}
|
|
22
|
+
.card .s{font-size:.8rem;color:var(--mute)}
|
|
23
|
+
.chip{display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;border:1px solid rgba(0,0,0,.35)}
|
|
24
|
+
.warn{background:var(--warn-bg);border:1px solid var(--warn-line);border-radius:10px;padding:10px 14px;margin:12px 0}
|
|
25
|
+
.warn ul{margin:.3rem 0 0 1.1rem;padding:0}
|
|
26
|
+
.toolbar{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center;margin:8px 0}
|
|
27
|
+
.seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden;flex-wrap:wrap}
|
|
28
|
+
.seg button{border:0;background:var(--card);color:var(--ink);padding:6px 12px;cursor:pointer;font:inherit}
|
|
29
|
+
.seg button[aria-pressed=true]{background:var(--accent);color:#fff}
|
|
30
|
+
.seg button:disabled{opacity:.45;cursor:default}
|
|
31
|
+
select{font:inherit;color:var(--ink);background:var(--card);border:1px solid var(--line);border-radius:8px;padding:5px 8px}
|
|
32
|
+
label.tg{display:inline-flex;gap:.35rem;align-items:center;color:var(--mute);font-size:.9rem}
|
|
33
|
+
#mapbox{position:relative}
|
|
34
|
+
#map,#map3d{height:68vh;min-height:380px;border:1px solid var(--line);border-radius:10px;background:#cfd6dd}
|
|
35
|
+
#map3d{display:none}
|
|
36
|
+
.leaflet-container{font:inherit}
|
|
37
|
+
#swipe{position:absolute;top:0;bottom:0;left:50%;width:0;z-index:900;pointer-events:none}
|
|
38
|
+
#swipe .bar{position:absolute;top:0;bottom:0;left:-1.5px;width:3px;background:#fff;box-shadow:0 0 4px rgba(0,0,0,.6)}
|
|
39
|
+
#swipe .grab{position:absolute;top:0;bottom:0;left:-14px;width:28px;pointer-events:auto;cursor:ew-resize;touch-action:none}
|
|
40
|
+
#swipe.dragging .bar{background:#ffd400}
|
|
41
|
+
#swipe .knob{position:absolute;top:50%;left:-18px;width:36px;height:36px;margin-top:-18px;border-radius:50%;background:#fff;box-shadow:0 0 6px rgba(0,0,0,.6);pointer-events:none;cursor:ew-resize;display:flex;align-items:center;justify-content:center;font-size:16px;color:#333;touch-action:none}
|
|
42
|
+
#knobdate{position:absolute;top:calc(50% + 24px);left:0;transform:translateX(-50%);display:flex;gap:0;white-space:nowrap;pointer-events:none;font-size:.8rem;font-weight:600;box-shadow:0 1px 5px rgba(0,0,0,.5);border-radius:6px;overflow:hidden}
|
|
43
|
+
#knobdate span{padding:3px 8px;color:#fff}#knobdate small{font-weight:500;opacity:.85;margin-left:4px}
|
|
44
|
+
#knobdate .b{background:rgba(20,20,20,.82)}#knobdate .a{background:rgba(11,92,173,.92)}
|
|
45
|
+
.lab{position:absolute;top:10px;z-index:901;background:rgba(0,0,0,.65);color:#fff;padding:2px 8px;border-radius:6px;font-size:.8rem;pointer-events:none}
|
|
46
|
+
.lab.l{left:56px}.lab.r{right:10px}
|
|
47
|
+
#tl{display:none;align-items:center;gap:10px;margin:8px 0;background:var(--card);border:1px solid var(--line);border-radius:10px;padding:8px 12px;flex-wrap:wrap}
|
|
48
|
+
#tl button{font:inherit;border:1px solid var(--line);background:var(--bg);color:var(--ink);border-radius:8px;padding:5px 12px;cursor:pointer;min-width:84px}
|
|
49
|
+
.rwrap{position:relative;flex:1;min-width:180px;padding-bottom:10px}
|
|
50
|
+
#tl input[type=range]{width:100%;margin:0;accent-color:var(--accent)}
|
|
51
|
+
#tl-date{font-weight:700;font-size:1.05rem;min-width:170px}
|
|
52
|
+
.badge{display:inline-block;border-radius:6px;padding:1px 8px;font-size:.78rem;font-weight:700;color:#fff;margin-left:6px;vertical-align:.1em}
|
|
53
|
+
.badge.before{background:#3a4450}.badge.after{background:var(--accent)}.badge.between{background:#7d5bd6}
|
|
54
|
+
#tl-meta{font-size:.82rem;color:var(--mute)}
|
|
55
|
+
#ticks{position:absolute;left:8px;right:8px;bottom:0;height:8px;pointer-events:none}
|
|
56
|
+
#ticks i{position:absolute;top:0;width:2px;height:8px;margin-left:-1px;background:var(--mute);border-radius:1px}
|
|
57
|
+
#ticks i.after{background:var(--accent)}#ticks i.between{background:#7d5bd6}
|
|
58
|
+
#banner{position:absolute;left:50%;bottom:22px;transform:translateX(-50%) translateY(20px);z-index:950;background:rgba(213,94,0,.95);color:#fff;padding:8px 14px;border-radius:9px;font-weight:600;font-size:.92rem;box-shadow:0 3px 12px rgba(0,0,0,.4);opacity:0;transition:opacity .4s,transform .4s;pointer-events:none;max-width:90%;text-align:center}
|
|
59
|
+
#banner.on{opacity:1;transform:translateX(-50%) translateY(0)}
|
|
60
|
+
#tlhint{position:absolute;left:56px;top:10px;z-index:901;background:rgba(0,0,0,.72);color:#fff;padding:3px 9px;border-radius:6px;font-size:.82rem;font-weight:600;pointer-events:none;display:none}
|
|
61
|
+
.legend{display:flex;flex-wrap:wrap;gap:6px 16px;margin:8px 0;font-size:.9rem}
|
|
62
|
+
.legend2{font-size:.85rem;color:var(--mute);margin:0 0 8px}
|
|
63
|
+
.dot{display:inline-block;width:.85em;height:.85em;border-radius:50%;border:2px solid #777;vertical-align:-.1em;margin:0 .25em 0 .6em}
|
|
64
|
+
.str-strong{font-weight:700}.str-moderate{font-weight:600}.str-weak{font-style:italic}
|
|
65
|
+
table{border-collapse:collapse;width:100%;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}
|
|
66
|
+
th,td{padding:6px 8px;border-bottom:1px solid var(--line);text-align:left;font-size:.88rem;white-space:nowrap}
|
|
67
|
+
th{background:rgba(127,127,127,.1);cursor:pointer;user-select:none}
|
|
68
|
+
tbody tr{cursor:pointer}tbody tr:hover{background:rgba(11,92,173,.1)}
|
|
69
|
+
td.n{text-align:right;font-variant-numeric:tabular-nums}
|
|
70
|
+
#tblbox{max-height:55vh;overflow:auto;border-radius:10px}#tblbox thead th{position:sticky;top:0;z-index:1;background:var(--card)}
|
|
71
|
+
tbody tr.sel{background:rgba(255,212,0,.22)}tbody tr.sel td:first-child{box-shadow:inset 4px 0 0 #ffd400}
|
|
72
|
+
@keyframes rowflash{0%,40%,80%{background:rgba(255,212,0,.65)}20%,60%,100%{background:rgba(255,212,0,.22)}}tbody tr.flash{animation:rowflash 1.6s ease}
|
|
73
|
+
#method table{display:block;overflow-x:auto}#method td,#method th{white-space:normal}
|
|
74
|
+
.tscroll{overflow-x:auto}
|
|
75
|
+
.tsearch{display:flex;gap:6px;align-items:center;margin:6px 0 8px;max-width:420px}
|
|
76
|
+
.tsearch input{flex:1;font:inherit;border:1px solid var(--line);border-radius:7px;background:var(--card);color:var(--ink);padding:5px 9px}
|
|
77
|
+
.tsearch input:focus{outline:2px solid var(--accent);outline-offset:0}
|
|
78
|
+
.tsearch button{font:inherit;border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:7px;padding:4px 9px;cursor:pointer}
|
|
79
|
+
.pager{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin:8px 0 4px;font-size:.88rem;color:var(--mute)}
|
|
80
|
+
.pager button{font:inherit;font-weight:600;min-width:34px;border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:7px;padding:3px 9px;cursor:pointer}
|
|
81
|
+
.pager button:disabled{opacity:.4;cursor:default}.pager button:not(:disabled):hover{border-color:var(--accent);color:var(--accent)}
|
|
82
|
+
.pager .pinfo{margin:0 4px;font-variant-numeric:tabular-nums}.pager label{margin-left:auto;display:inline-flex;gap:6px;align-items:center}
|
|
83
|
+
.pager select{font:inherit;border:1px solid var(--line);border-radius:7px;background:var(--card);color:var(--ink);padding:2px 6px}
|
|
84
|
+
.conf-high{color:#1a7f37;font-weight:600}.conf-medium{color:#9a6700;font-weight:600}.conf-low{color:#b42318;font-weight:600}
|
|
85
|
+
.pop{font-size:.85rem;min-width:230px;color:#222}
|
|
86
|
+
.pop h4{margin:0 0 4px;font-size:.95rem}
|
|
87
|
+
.pop .what{margin:4px 0 6px;font-size:.88rem}
|
|
88
|
+
.pop .pair{display:flex;gap:6px;margin:6px 0}
|
|
89
|
+
.pop canvas{width:112px;height:112px;border:1px solid #888;border-radius:4px;background:#ccc}
|
|
90
|
+
.pop .cap{font-size:.72rem;color:#555;text-align:center}
|
|
91
|
+
.leaflet-popup-content-wrapper,.leaflet-popup-tip{background:#fff;color:#222}
|
|
92
|
+
.pop table{border:0;font-size:.8rem;background:none;color:#222}.pop td,.pop th{padding:1px 6px;font-size:.8rem;border:0;background:none;cursor:default}
|
|
93
|
+
.acts{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:6px}
|
|
94
|
+
.act{font:inherit;font-weight:600;border:1px solid var(--accent);background:var(--card);color:var(--accent);border-radius:8px;padding:5px 12px;cursor:pointer}
|
|
95
|
+
.act:hover{background:var(--accent);color:#fff}
|
|
96
|
+
.lang{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
|
|
97
|
+
@media print{.tsearch,.pager,.acts,.toolbar,#tl,#swipe,#tlhint,#banner,.leaflet-control-zoom{display:none!important}#tblbox{max-height:none;overflow:visible}body{background:#fff;color:#000}.card,details,table{background:#fff}#map,#map3d{height:60vh;break-inside:avoid}h2{break-after:avoid}}
|
|
98
|
+
.lang button{border:0;background:var(--card);color:var(--ink);padding:5px 11px;cursor:pointer;font:inherit;font-weight:600}
|
|
99
|
+
.lang button[aria-pressed=true]{background:var(--accent);color:#fff}
|
|
100
|
+
details{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:8px 14px;margin:10px 0}
|
|
101
|
+
summary{cursor:pointer;font-weight:600}
|
|
102
|
+
code,pre{font-family:ui-monospace,Consolas,monospace;font-size:.85em}
|
|
103
|
+
pre{background:rgba(127,127,127,.12);padding:10px;border-radius:8px;overflow:auto}
|
|
104
|
+
footer{margin:24px 0 8px;color:var(--mute);font-size:.82rem}
|
|
105
|
+
.small{font-size:.85rem;color:var(--mute)}
|
|
106
|
+
@media (max-width:640px){#map,#map3d{height:56vh}}
|
|
107
|
+
</style>
|
|
108
|
+
</head>
|
|
109
|
+
<body>
|
|
110
|
+
<div class="wrap">
|
|
111
|
+
<div class="top">
|
|
112
|
+
<div><h1 id="h-title"></h1><p class="sub" id="h-sub"></p></div>
|
|
113
|
+
<div class="acts"><button id="b-xlsx" class="act"></button><button id="b-print" class="act"></button><div class="lang" role="group" aria-label="Language"><button data-l="tr">TR</button><button data-l="en">EN</button></div></div>
|
|
114
|
+
</div>
|
|
115
|
+
<div class="cards" id="cards"></div>
|
|
116
|
+
<div id="warnings"></div>
|
|
117
|
+
|
|
118
|
+
<div class="toolbar">
|
|
119
|
+
<div class="seg" id="modes" role="group">
|
|
120
|
+
<button data-m="swipe" aria-pressed="true"></button>
|
|
121
|
+
<button data-m="before" aria-pressed="false"></button>
|
|
122
|
+
<button data-m="after" aria-pressed="false"></button>
|
|
123
|
+
<button data-m="changes" aria-pressed="false"></button>
|
|
124
|
+
<button data-m="timeline" aria-pressed="false"></button>
|
|
125
|
+
</div>
|
|
126
|
+
<div class="seg" id="views" role="group"><button data-v="2d" aria-pressed="true">2D</button><button data-v="3d" aria-pressed="false">3D</button></div>
|
|
127
|
+
<label class="tg"><span id="t-base-l"></span> <select id="t-base"><option value="none"></option><option value="relief"></option><option value="osm"></option><option value="topo"></option></select></label>
|
|
128
|
+
<label class="tg"><span id="t-op-l"></span> <input type="range" id="t-op" min="0" max="100" value="100" style="width:90px"></label>
|
|
129
|
+
<label class="tg" id="exag-wrap" style="display:none"><span id="t-exag-l"></span> <select id="t-exag"><option value="1">×1</option><option value="1.5" selected>×1.5</option><option value="2">×2</option><option value="3">×3</option><option value="5">×5</option></select></label>
|
|
130
|
+
<label class="tg"><input type="checkbox" id="t-outline" checked> <span id="t-outline-l"></span></label>
|
|
131
|
+
<label class="tg"><input type="checkbox" id="t-pulse" checked> <span id="t-pulse-l"></span></label>
|
|
132
|
+
</div>
|
|
133
|
+
<div id="tl">
|
|
134
|
+
<button id="tl-play"></button>
|
|
135
|
+
<span id="tl-date"></span>
|
|
136
|
+
<div class="rwrap"><input type="range" id="tl-range" min="0" max="0" value="0" step="1"><div id="ticks"></div></div>
|
|
137
|
+
<span id="tl-meta"></span>
|
|
138
|
+
</div>
|
|
139
|
+
<div id="mapbox">
|
|
140
|
+
<div id="map"></div>
|
|
141
|
+
<div id="map3d"></div>
|
|
142
|
+
<div class="lab l" id="lab-l"></div><div class="lab r" id="lab-r"></div>
|
|
143
|
+
<div id="tlhint"></div>
|
|
144
|
+
<div id="swipe" role="slider" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50" tabindex="0"><div class="grab"></div><div class="bar"></div><div class="knob">↔</div><div id="knobdate"><span class="b" id="kd-b"></span><span class="a" id="kd-a"></span></div></div>
|
|
145
|
+
<div id="banner"></div>
|
|
146
|
+
</div>
|
|
147
|
+
<div class="legend" id="legend"></div>
|
|
148
|
+
<p class="legend2" id="legend2"></p>
|
|
149
|
+
|
|
150
|
+
<h2 id="h-changes"></h2>
|
|
151
|
+
<p class="small" id="tbl-note"></p>
|
|
152
|
+
<div class="tsearch"><input type="search" id="tbl-q" autocomplete="off"><button id="tbl-qx" type="button">✕</button></div>
|
|
153
|
+
<div class="tscroll" id="tblbox"><table id="tbl"><thead></thead><tbody></tbody></table></div>
|
|
154
|
+
<div class="pager" id="tbl-pager"></div>
|
|
155
|
+
|
|
156
|
+
<h2 id="h-method"></h2>
|
|
157
|
+
<div id="method"></div>
|
|
158
|
+
|
|
159
|
+
<details><summary><span id="s-scenes"></span> (<span id="nscenes"></span>)</summary><div class="tsearch"><input type="search" id="sc-q" autocomplete="off"><button id="sc-qx" type="button">✕</button></div><div class="tscroll"><table id="scenes"><thead></thead><tbody></tbody></table></div><div class="pager" id="sc-pager"></div></details>
|
|
160
|
+
<details><summary id="s-thr"></summary><div id="thr"></div></details>
|
|
161
|
+
|
|
162
|
+
<footer id="foot"></footer>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<script type="application/json" id="data">{{DATA_JSON}}</script>
|
|
166
|
+
<script>{{LEAFLET_JS}}</script>
|
|
167
|
+
<script>
|
|
168
|
+
(function(){
|
|
169
|
+
"use strict";
|
|
170
|
+
const D = JSON.parse(document.getElementById('data').textContent);
|
|
171
|
+
const $ = s => document.querySelector(s);
|
|
172
|
+
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
|
173
|
+
const cls = D.classes; // key -> {label,color}
|
|
174
|
+
const TR = (D.i18n && D.i18n.tr) || {};
|
|
175
|
+
const EX = D.extras || {};
|
|
176
|
+
const TL = (D.timeline && D.timeline.frames) || [];
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------- language
|
|
179
|
+
const T = {
|
|
180
|
+
en: {
|
|
181
|
+
title:'what changed?', before:'Before', after:'After', aoi:'AOI', at:'at', polygons:n=>n+' polygon'+(n===1?'':'s'),
|
|
182
|
+
causeCard:'Vegetation loss, likely cause', fire:'fire-like', clearing:'clearing-like', unclear:'unclear', hint:'a hint, not a verdict',
|
|
183
|
+
seasonalCard:'Likely seasonal', seasonalSub:n=>n+' polygons repeat in earlier years', comparable:'Comparable pixels', comparableSub:'cloud-free in both periods',
|
|
184
|
+
scenesUsed:'Scenes used', scenesSub:'before + after (Sentinel-2 L2A)', readFirst:'Read before trusting the numbers',
|
|
185
|
+
m_swipe:'Swipe before / after', m_before:'Before', m_after:'After', m_changes:'Change map', m_timeline:'▶ Scene by scene',
|
|
186
|
+
base:'Basemap', b_none:'Satellite only', b_relief:'Relief (offline)', sat:'Satellite', no3dBase:'This basemap could not be loaded into the 3D view (no internet or blocked); using the relief.', b_osm:'OpenStreetMap (internet)', b_topo:'Topographic, OpenTopoMap (internet)', exag:'Height', no3d:'This report has no terrain, so no 3D view (it was made without internet access to the elevation data, or with an older earthdiff).', help3d:'Drag: rotate · right-drag or Shift+drag: pan · wheel or pinch: zoom · click a pin: what changed', outlines:'Show changes', pulse:'Animate change spots',
|
|
187
|
+
play:'▶ Play', pause:'❚❚ Pause', frame:(i,n)=>'scene '+i+' of '+n, clear:p=>p+'% of the area seen clear (cloud, snow, shadow and gaps are not shown)', clearOnly:p=>p+'% of the area seen clear (one pass of an in-between year; for viewing only)',
|
|
188
|
+
noTimeline:'This report has no per-scene frames (made with an older earthdiff).',
|
|
189
|
+
bannerBefore:'Before period', bannerAfter:'After period: the changes were found here',
|
|
190
|
+
legend2:(mmu,px,res)=>'Hover or tap a change to read what happened. Small changes are also marked with a dot so none is missed when zoomed out: '+
|
|
191
|
+
'<span class="dot" style="background:#777"></span>strong <span class="dot" style="background:rgba(119,119,119,.45)"></span>moderate <span class="dot" style="border-style:dashed"></span>weak (possible). '+
|
|
192
|
+
'Smallest change mapped: '+mmu+' ha (about '+px+' pixels at '+res+' m). In “Scene by scene” the satellite passes play in date order; the changes stay marked on every frame.',
|
|
193
|
+
h_changes:'Detected changes', h_method:'How to read this report', s_scenes:'Scenes used', s_thr:'Thresholds used',
|
|
194
|
+
cols:['#','What changed','Area (ha)','Strength','Likely cause','NDVI b→a','MNDWI b→a','BSI b→a','Location'],
|
|
195
|
+
tblNote:all=>all+' change polygons. Click a row to zoom to it; click a polygon on the map to see before/after crops and index values; click a column heading to sort. All polygons are also in changes.geojson and in the Excel file.',
|
|
196
|
+
pageOf:(p,n,a,b,all)=>'Page '+p+' / '+n+' · '+a+'–'+b+' of '+all, perPage:'Rows per page', qTbl:'Search: number, change, cause, place …', qSc:'Search: period, scene, date …', qClear:'Clear the search', noMatch:'Nothing matches the search.', pFirst:'First page', pPrev:'Previous page', pNext:'Next page', pLast:'Last page',
|
|
197
|
+
tblNone:mmu=>'No change polygons above the minimum mapping unit ('+mmu+' ha) were found with the chosen thresholds.',
|
|
198
|
+
sceneCols:['Period','Scene','Date','Scene cloud %','Cloud-free AOI share'], mmuLine:(m,s)=>'MMU: '+m+' ha · sensitivity preset: '+s,
|
|
199
|
+
foot:(a,b,g,v)=>a+'. Imagery served by '+b+'. Generated '+g+' by earthdiff '+v+' (MIT). This is an automated screening tool: verify important findings with higher-resolution data or in the field.',
|
|
200
|
+
str:{strong:'Strong change',moderate:'Moderate change',weak:'Weak change (possible, check the images)'}, strShort:{strong:'strong',moderate:'moderate',weak:'weak'}, change:'change',
|
|
201
|
+
what:{vegetation_loss:'Green vegetation was lost here', vegetation_gain:'Vegetation grew or became greener here (regrowth, new crops, planting)',
|
|
202
|
+
new_water:'Water appeared here (flooding, a filling reservoir or a new pond)', water_loss:'Water disappeared here (a drying lake or reservoir, a retreating shore)',
|
|
203
|
+
new_bare_or_built:'The ground became bare or built-up here (quarry, construction, cleared or ploughed land)'},
|
|
204
|
+
cause:{'fire-like':'it looks like a burn: the surface is darker and burnt afterwards','clearing-like':'it looks like clearing: felling, harvest, ploughing or construction','unclear':'the cause is unclear'},
|
|
205
|
+
seasonalTxt:' The same drop happened at this time of year before, so it is probably harvest or a seasonal cycle.', brightTxt:' Very bright surface: change here is unreliable.', eventTxt:(a,b)=>' The largest drop happened between '+a+' and '+b+'.',
|
|
206
|
+
likely:'Likely cause', aHint:'(a hint)', seasonalPop:(r,c)=>'<b>likely seasonal</b>: NDVI also fell in '+r+' of '+c+' earlier year(s)', conf:'confidence', validObs:'valid obs.',
|
|
207
|
+
seasonal:'seasonal', between:'In between', xlsx:'⬇ Excel (.xlsx)', print:'🖨 Print / PDF'
|
|
208
|
+
},
|
|
209
|
+
tr: {
|
|
210
|
+
title:'ne değişti?', before:'Önce', after:'Sonra', aoi:'Alan', at:'', polygons:n=>n+' poligon',
|
|
211
|
+
causeCard:'Bitki kaybı, olası neden', fire:'yangına benzer', clearing:'açmaya benzer', unclear:'belirsiz', hint:'ipucudur, hüküm değildir',
|
|
212
|
+
seasonalCard:'Muhtemelen mevsimsel', seasonalSub:n=>n+' poligon önceki yıllarda da tekrarlıyor', comparable:'Karşılaştırılabilir pikseller', comparableSub:'iki dönemde de bulutsuz',
|
|
213
|
+
scenesUsed:'Kullanılan sahneler', scenesSub:'önce + sonra (Sentinel-2 L2A)', readFirst:'Sayılara güvenmeden önce okuyun',
|
|
214
|
+
m_swipe:'Önce / sonra kaydır', m_before:'Önce', m_after:'Sonra', m_changes:'Değişim haritası', m_timeline:'▶ Sahne sahne',
|
|
215
|
+
base:'Altlık', b_none:'Yalnız uydu', b_relief:'Kabartma (internetsiz)', sat:'Uydu', no3dBase:'Bu altlık 3B görünüme yüklenemedi (internet yok ya da engellendi); kabartma kullanılıyor.', b_osm:'OpenStreetMap (internet)', b_topo:'Topoğrafik, OpenTopoMap (internet)', exag:'Yükseklik', no3d:'Bu raporda yükseklik verisi yok, bu yüzden 3B görünüm yok (yükseklik verisine erişilemeden ya da eski bir earthdiff ile üretilmiş).', help3d:'Sürükle: döndür · sağ tık ya da Shift+sürükle: kaydır · tekerlek ya da iki parmak: yakınlaş · iğneye tıkla: ne değişti', outlines:'Değişimleri göster', pulse:'Değişim noktalarını canlandır',
|
|
216
|
+
play:'▶ Oynat', pause:'❚❚ Durdur', frame:(i,n)=>'sahne '+i+' / '+n, clear:p=>'alanın %'+p+' kadarı açık görülüyor (bulut, kar, gölge ve boşluklar gösterilmiyor)', clearOnly:p=>'alanın %'+p+' kadarı açık görülüyor (ara yılın tek geçişi; yalnız bakmak için)',
|
|
217
|
+
noTimeline:'Bu raporda sahne kareleri yok (eski bir earthdiff sürümüyle üretilmiş).',
|
|
218
|
+
bannerBefore:'Önce dönemi', bannerAfter:'Sonra dönemi: değişimler burada bulundu',
|
|
219
|
+
legend2:(mmu,px,res)=>'Ne olduğunu okumak için bir değişimin üstüne gelin ya da dokunun. Küçük değişimler de uzaktan kaybolmasın diye noktayla işaretlenir: '+
|
|
220
|
+
'<span class="dot" style="background:#777"></span>güçlü <span class="dot" style="background:rgba(119,119,119,.45)"></span>orta <span class="dot" style="border-style:dashed"></span>zayıf (olası). '+
|
|
221
|
+
'Haritalanan en küçük değişim: '+mmu+' ha ('+res+' m pikselde yaklaşık '+px+' piksel). “Sahne sahne” modunda uydu geçişleri tarih sırasıyla oynar; değişimler her karede işaretli kalır.',
|
|
222
|
+
h_changes:'Bulunan değişimler', h_method:'Bu rapor nasıl okunur', s_scenes:'Kullanılan sahneler', s_thr:'Kullanılan eşikler',
|
|
223
|
+
cols:['#','Ne değişti','Alan (ha)','Güç','Olası neden','NDVI ö→s','MNDWI ö→s','BSI ö→s','Konum'],
|
|
224
|
+
tblNote:all=>all+' değişim poligonu. Yakınlaşmak için bir satıra tıklayın; önce/sonra kesitlerini ve indis değerlerini görmek için haritada bir poligona tıklayın; sıralamak için sütun başlığına tıklayın. Tüm poligonlar changes.geojson ve Excel dosyasında da var.',
|
|
225
|
+
pageOf:(p,n,a,b,all)=>'Sayfa '+p+' / '+n+' · '+all+' satırın '+a+'–'+b+' arası', perPage:'Sayfa başına satır', qTbl:'Ara: numara, değişim, neden, konum …', qSc:'Ara: dönem, sahne, tarih …', qClear:'Aramayı temizle', noMatch:'Aramaya uyan satır yok.', pFirst:'İlk sayfa', pPrev:'Önceki sayfa', pNext:'Sonraki sayfa', pLast:'Son sayfa',
|
|
226
|
+
tblNone:mmu=>'Seçilen eşiklerle en küçük haritalama biriminin ('+mmu+' ha) üstünde değişim poligonu bulunamadı.',
|
|
227
|
+
sceneCols:['Dönem','Sahne','Tarih','Sahne bulut %','Alanın bulutsuz payı'], mmuLine:(m,s)=>'En küçük birim: '+m+' ha · duyarlılık: '+s,
|
|
228
|
+
foot:(a,b,g,v)=>a+'. Görüntüleri sağlayan: '+b+'. '+g+' tarihinde earthdiff '+v+' (MIT) ile üretildi. Bu otomatik bir tarama aracıdır: önemli bulguları daha yüksek çözünürlüklü veriyle ya da arazide doğrulayın.',
|
|
229
|
+
str:{strong:'Güçlü değişim',moderate:'Orta değişim',weak:'Zayıf değişim (olası, görüntülere bakın)'}, strShort:{strong:'güçlü',moderate:'orta',weak:'zayıf'}, change:'değişim',
|
|
230
|
+
what:{vegetation_loss:'Burada yeşil bitki örtüsü kaybolmuş', vegetation_gain:'Burada bitki örtüsü artmış ya da yeşermiş (yeniden büyüme, yeni ekin, dikim)',
|
|
231
|
+
new_water:'Burada su ortaya çıkmış (taşkın, dolan baraj gölü ya da yeni gölet)', water_loss:'Burada su çekilmiş (kuruyan göl ya da baraj, geri çekilen kıyı)',
|
|
232
|
+
new_bare_or_built:'Burada zemin çıplak toprağa ya da yapılaşmaya dönmüş (taş ocağı, inşaat, açılmış ya da sürülmüş arazi)'},
|
|
233
|
+
cause:{'fire-like':'yangın izine benziyor: sonrasında yüzey koyu ve yanık','clearing-like':'açmaya benziyor: ağaç kesimi, hasat, sürüm ya da inşaat','unclear':'nedeni belirsiz'},
|
|
234
|
+
seasonalTxt:' Aynı düşüş önceki yıllarda da yılın bu zamanında olmuş; muhtemelen hasat ya da mevsim döngüsü.', brightTxt:' Çok parlak yüzey: buradaki değişim güvenilir değil.', eventTxt:(a,b)=>' En büyük düşüş '+a+' ile '+b+' arasında olmuş.',
|
|
235
|
+
likely:'Olası neden', aHint:'(ipucu)', seasonalPop:(r,c)=>'<b>muhtemelen mevsimsel</b>: NDVI önceki '+c+' yılın '+r+' tanesinde de düşmüş', conf:'güven', validObs:'geçerli gözlem',
|
|
236
|
+
seasonal:'mevsimsel', between:'Ara yıl', xlsx:'⬇ Excel (.xlsx)', print:'🖨 Yazdır / PDF'
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
let lang = 'en';
|
|
240
|
+
try { lang = localStorage.getItem('earthdiff-lang') || ''; } catch (e) { lang = ''; }
|
|
241
|
+
if (!T[lang]) lang = (navigator.language || '').toLowerCase().startsWith('tr') ? 'tr' : 'en';
|
|
242
|
+
const L_ = () => T[lang];
|
|
243
|
+
const loc = () => lang === 'tr' ? 'tr-TR' : 'en-US';
|
|
244
|
+
const fmt = (x,n=1) => Number(x).toLocaleString(loc(),{minimumFractionDigits:n,maximumFractionDigits:n});
|
|
245
|
+
const clsLabel = k => (lang === 'tr' && TR.classes && TR.classes[k]) || cls[k].label;
|
|
246
|
+
const causeLabel = c => ({'fire-like':L_().fire,'clearing-like':L_().clearing,'unclear':L_().unclear})[c] || c;
|
|
247
|
+
function fmtDate(s){ // YYYY-MM-DD or YYYY-MM -> localized long date
|
|
248
|
+
const m = /^(\d{4})-(\d{2})(?:-(\d{2}))?$/.exec(String(s).trim()); if (!m) return s;
|
|
249
|
+
const d = new Date(Date.UTC(+m[1], +m[2]-1, m[3]?+m[3]:1));
|
|
250
|
+
return d.toLocaleDateString(lang==='tr'?'tr-TR':'en-GB', m[3]?{day:'numeric',month:'long',year:'numeric',timeZone:'UTC'}:{month:'long',year:'numeric',timeZone:'UTC'});
|
|
251
|
+
}
|
|
252
|
+
const periodLabel = p => p==='after' ? L_().after : p==='between' ? L_().between : L_().before;
|
|
253
|
+
const fmtWindow = s => String(s).split(/\s*\.\.\s*|\s*\/\s*/).map(fmtDate).join(' – ');
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------- reading of each change
|
|
256
|
+
const STRK = {high:'strong', medium:'moderate', low:'weak'};
|
|
257
|
+
const strengthKey = p => STRK[p.confidence_level] || 'weak';
|
|
258
|
+
function describe(p){
|
|
259
|
+
const t = L_();
|
|
260
|
+
let s = t.what[p.class] || clsLabel(p.class);
|
|
261
|
+
if (p.class==='vegetation_loss' && p.likely_cause && t.cause[p.likely_cause]) s += '; ' + t.cause[p.likely_cause];
|
|
262
|
+
s += '.';
|
|
263
|
+
if (p.likely_seasonal===true) s += t.seasonalTxt;
|
|
264
|
+
if (p.event_before && p.event_after) s += t.eventTxt(fmtWindow(p.event_before), fmtWindow(p.event_after));
|
|
265
|
+
if (p.bright_share>0.2) s += t.brightTxt;
|
|
266
|
+
return s;
|
|
267
|
+
}
|
|
268
|
+
const shortWhat = p => esc(clsLabel(p.class)) + (p.class==='vegetation_loss'&&p.likely_cause&&p.likely_cause!=='unclear' ? ' · '+esc(causeLabel(p.likely_cause)) : '') + (p.likely_seasonal===true?' · '+L_().seasonal:'');
|
|
269
|
+
const tip = p => '<b>'+shortWhat(p)+'</b><br>'+fmt(p.area_ha, p.area_ha<10?2:1)+' ha · '+L_().strShort[strengthKey(p)]+' '+L_().change;
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------- static texts (re-run on language switch)
|
|
272
|
+
function renderTexts(){
|
|
273
|
+
const t = L_(); document.documentElement.lang = lang;
|
|
274
|
+
document.querySelectorAll('.lang button').forEach(b=>b.setAttribute('aria-pressed', b.dataset.l===lang));
|
|
275
|
+
$('#h-title').textContent = D.meta.name + ' — ' + t.title;
|
|
276
|
+
$('#h-sub').textContent = t.before+': '+fmtWindow(D.meta.before_label)+' → '+t.after+': '+fmtWindow(D.meta.after_label)+' · '+t.aoi+' '+fmt(D.meta.aoi_km2,1)+' km² · '+D.meta.resolution_m+' m';
|
|
277
|
+
const cards = [];
|
|
278
|
+
for (const k of Object.keys(cls)) {
|
|
279
|
+
const tt = D.totals[k] || {area_ha:0,polygons:0};
|
|
280
|
+
cards.push('<div class="card"><div class="k"><span class="chip" style="background:'+cls[k].color+'"></span>'+esc(clsLabel(k))+'</div><div class="v">'+fmt(tt.area_ha,1)+' ha</div><div class="s">'+t.polygons(tt.polygons)+'</div></div>');
|
|
281
|
+
}
|
|
282
|
+
if (EX.cause) { const c = EX.cause.totals; cards.push('<div class="card"><div class="k">'+t.causeCard+'</div><div class="s">'+t.fire+' '+fmt(c['fire-like'].area_ha,1)+' ha<br>'+t.clearing+' '+fmt(c['clearing-like'].area_ha,1)+' ha<br>'+t.unclear+' '+fmt(c['unclear'].area_ha,1)+' ha<br><i>'+t.hint+'</i></div></div>'); }
|
|
283
|
+
if (EX.seasonal && EX.seasonal.enabled) { const s = EX.seasonal.likely_seasonal; cards.push('<div class="card"><div class="k">'+t.seasonalCard+'</div><div class="v">'+fmt(s.area_ha,1)+' ha</div><div class="s">'+t.seasonalSub(s.polygons)+'</div></div>'); }
|
|
284
|
+
cards.push('<div class="card"><div class="k">'+t.comparable+'</div><div class="v">'+fmt(D.meta.valid_fraction*100,0)+'%</div><div class="s">'+t.comparableSub+'</div></div>');
|
|
285
|
+
cards.push('<div class="card"><div class="k">'+t.scenesUsed+'</div><div class="v">'+D.meta.scenes_before+' + '+D.meta.scenes_after+'</div><div class="s">'+t.scenesSub+'</div></div>');
|
|
286
|
+
$('#cards').innerHTML = cards.join('');
|
|
287
|
+
const W = (lang==='tr' && TR.warnings && TR.warnings.length===D.warnings.length) ? TR.warnings : D.warnings;
|
|
288
|
+
$('#warnings').innerHTML = W.length ? '<div class="warn"><b>'+t.readFirst+'</b><ul>'+W.map(w=>'<li>'+esc(w)+'</li>').join('')+'</ul></div>' : '';
|
|
289
|
+
document.querySelectorAll('#modes button').forEach(b=>b.textContent=t['m_'+b.dataset.m]);
|
|
290
|
+
$('#t-base-l').textContent = t.base; const o = $('#t-base').options; o[0].textContent=t.b_none; o[1].textContent=t.b_relief; o[2].textContent=t.b_osm; o[3].textContent=t.b_topo; o[1].disabled=!D.terrain;
|
|
291
|
+
$('#b-xlsx').textContent = t.xlsx; $('#b-print').textContent = t.print; $('#b-xlsx').style.display = D.xlsx ? '' : 'none';
|
|
292
|
+
$('#t-op-l').textContent = t.sat; $('#t-outline-l').textContent = t.outlines; $('#t-pulse-l').textContent = t.pulse;
|
|
293
|
+
$('#lab-l').textContent = t.before+' · '+fmtWindow(D.meta.before_label);
|
|
294
|
+
$('#lab-r').textContent = t.after+' · '+fmtWindow(D.meta.after_label);
|
|
295
|
+
$('#kd-b').textContent = '◀ '+fmtWindow(D.meta.before_label); $('#kd-a').textContent = fmtWindow(D.meta.after_label)+' ▶';
|
|
296
|
+
$('#legend').innerHTML = Object.keys(cls).map(k=>'<span><span class="chip" style="background:'+cls[k].color+'"></span>'+esc(clsLabel(k))+'</span>').join('');
|
|
297
|
+
const px = Math.max(1,Math.round(D.meta.mmu_ha*10000/(D.meta.resolution_m*D.meta.resolution_m)));
|
|
298
|
+
$('#legend2').innerHTML = t.legend2(fmt(D.meta.mmu_ha, D.meta.mmu_ha<1?1:0), px, D.meta.resolution_m);
|
|
299
|
+
$('#h-changes').textContent = t.h_changes; $('#h-method').textContent = t.h_method; $('#s-scenes').textContent = t.s_scenes; $('#s-thr').textContent = t.s_thr;
|
|
300
|
+
$('#method').innerHTML = (lang==='tr' && TR.method_html) ? TR.method_html : D.method_html;
|
|
301
|
+
$('#nscenes').textContent = D.scenes.length;
|
|
302
|
+
$('#scenes thead').innerHTML = '<tr>'+t.sceneCols.map(c=>'<th>'+c+'</th>').join('')+'</tr>';
|
|
303
|
+
drawScenes();
|
|
304
|
+
$('#thr').innerHTML = '<table><tbody>'+Object.entries(D.thresholds).map(([k,v])=>'<tr><td><code>'+k+'</code></td><td class="n">'+v+'</td></tr>').join('')+'</tbody></table><p class="small">'+t.mmuLine(D.meta.mmu_ha, esc(D.meta.sensitivity))+'</p>';
|
|
305
|
+
$('#foot').innerHTML = esc(t.foot(D.attribution, D.meta.backend_attribution, D.meta.generated, D.meta.version));
|
|
306
|
+
$('#tl-play').textContent = playing ? t.pause : t.play;
|
|
307
|
+
if (polys) { polys.eachLayer(l=>l.setTooltipContent(tip(l.feature.properties))); for (const f of D.geojson.features) if (f.__dot) f.__dot.setTooltipContent(tip(f.properties)); }
|
|
308
|
+
$('#tbl-q').placeholder=t.qTbl; $('#sc-q').placeholder=t.qSc; $('#tbl-qx').title=$('#sc-qx').title=t.qClear;
|
|
309
|
+
drawTable(); showFrame(tlIndex, false); updateSwipe();
|
|
310
|
+
if (m3) m3.relabel();
|
|
311
|
+
$('#t-exag-l').textContent = t.exag;
|
|
312
|
+
}
|
|
313
|
+
// ---- downloads: the Excel workbook (in the language shown) and the browser's print / save as PDF
|
|
314
|
+
$('#b-xlsx').addEventListener('click',()=>{ const b64 = D.xlsx && (D.xlsx[lang] || D.xlsx.en); if (!b64) return;
|
|
315
|
+
const bin = atob(b64), u = new Uint8Array(bin.length); for (let i=0;i<bin.length;i++) u[i]=bin.charCodeAt(i);
|
|
316
|
+
const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([u],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}));
|
|
317
|
+
a.download = 'earthdiff_' + String(D.meta.name||'report').replace(/[^\p{L}\p{N}]+/gu,'_').replace(/^_|_$/g,'') + '_' + String(D.meta.after_label).slice(0,10) + (lang==='tr'?'_tr':'') + '.xlsx';
|
|
318
|
+
document.body.appendChild(a); a.click(); setTimeout(()=>{ URL.revokeObjectURL(a.href); a.remove(); }, 1000); });
|
|
319
|
+
$('#b-print').addEventListener('click',()=>{ document.querySelectorAll('details').forEach(d=>d.open=true); setTimeout(()=>window.print(), 100); });
|
|
320
|
+
document.querySelectorAll('.lang button').forEach(b=>b.addEventListener('click',()=>{ lang=b.dataset.l; try{localStorage.setItem('earthdiff-lang',lang);}catch(e){} renderTexts(); }));
|
|
321
|
+
|
|
322
|
+
// ---------------------------------------------------------------- 2D map
|
|
323
|
+
const map = L.map('map',{zoomSnap:0.25,preferCanvas:true,attributionControl:true});
|
|
324
|
+
map.attributionControl.setPrefix('earthdiff ' + D.meta.version);
|
|
325
|
+
map.attributionControl.addAttribution(esc(D.attribution));
|
|
326
|
+
const pane = (name,z)=>{const p=map.createPane(name);p.style.zIndex=z;return p;};
|
|
327
|
+
pane('osmPane',150); const pAfter=pane('afterPane',250), pBefore=pane('beforePane',251), pTl=pane('tlPane',252), pChg=pane('chgPane',300); pane('dotPane',650); pane('pulsePane',640).style.pointerEvents='none'; pane('hlPane',660).style.pointerEvents='none';
|
|
328
|
+
// polygons and dots share one canvas: a canvas on top swallows the clicks meant for one below, so both must be in the same one
|
|
329
|
+
const chgRenderer = L.canvas({pane:'dotPane', padding:0.5});
|
|
330
|
+
const B = D.bounds;
|
|
331
|
+
const bases = {
|
|
332
|
+
osm: L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{pane:'osmPane',maxZoom:19,attribution:'© OpenStreetMap contributors'}),
|
|
333
|
+
topo: L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',{pane:'osmPane',maxZoom:17,subdomains:'abc',attribution:'Map data © OpenStreetMap contributors, SRTM | Style © OpenTopoMap (CC-BY-SA)'}),
|
|
334
|
+
relief: D.terrain ? L.imageOverlay(D.terrain.relief, D.terrain.bounds, {pane:'osmPane', attribution: esc(D.terrain.attribution)}) : null
|
|
335
|
+
};
|
|
336
|
+
const imgAfter = L.imageOverlay(D.img.after,B,{pane:'afterPane'}).addTo(map);
|
|
337
|
+
const imgBefore = L.imageOverlay(D.img.before,B,{pane:'beforePane'}).addTo(map);
|
|
338
|
+
const imgChg = L.imageOverlay(D.img.changes,B,{pane:'chgPane'});
|
|
339
|
+
L.geoJSON(D.aoi,{style:{color:'#ffffff',weight:2,dashArray:'6 4',fill:false},interactive:false}).addTo(map);
|
|
340
|
+
map.fitBounds(B);
|
|
341
|
+
let fitted=false;
|
|
342
|
+
const refit=()=>{ map.invalidateSize(); const s=map.getSize(); if(s.x>0&&s.y>0&&!fitted){ map.fitBounds(B); fitted=true; } };
|
|
343
|
+
setTimeout(refit,60); window.addEventListener('load',refit);
|
|
344
|
+
if (window.ResizeObserver) new ResizeObserver(()=>{ const was=fitted; refit(); if(was) map.invalidateSize(); updateDotsSoon(); }).observe(document.getElementById('map'));
|
|
345
|
+
document.addEventListener('visibilitychange',()=>{ if(!document.hidden) refit(); });
|
|
346
|
+
function updateDotsSoon(){ setTimeout(()=>{ try{ updateDots(); clipBefore(); }catch(e){} },50); }
|
|
347
|
+
|
|
348
|
+
// satellite opacity: choosing a basemap fades the satellite image out, so the basemap shows inside the analysed square too
|
|
349
|
+
let satOpacity = 1;
|
|
350
|
+
function setSatOpacity(v){ satOpacity = Math.max(0, Math.min(1, v)); [pAfter,pBefore,pTl].forEach(p=>p.style.opacity=satOpacity); $('#t-op').value = Math.round(satOpacity*100); }
|
|
351
|
+
|
|
352
|
+
const POLY_W = {strong:3, moderate:2.2, weak:1.6};
|
|
353
|
+
const polyStyle = f=>{ const p=f.properties, k=strengthKey(p), c=cls[p.class].color;
|
|
354
|
+
return {color:c,weight:POLY_W[k],dashArray:k==='weak'?'4 3':null,fillOpacity:k==='strong'?0.16:k==='moderate'?0.1:0.04,fillColor:c}; };
|
|
355
|
+
let polys = null;
|
|
356
|
+
polys = L.geoJSON(D.geojson,{
|
|
357
|
+
style:polyStyle, renderer:chgRenderer,
|
|
358
|
+
onEachFeature:(f,l)=>{ l.bindPopup(()=>popup(f),{maxWidth:340}); l.bindTooltip(tip(f.properties),{sticky:true}); l.on('click',()=>selectRow(f.properties.id)); f.__layer=l; }
|
|
359
|
+
}).addTo(map);
|
|
360
|
+
const byId = new Map(D.geojson.features.map(f=>[f.properties.id, f]));
|
|
361
|
+
|
|
362
|
+
// dots: polygons under ~14 px on screen also get a dot (solid = strong, half = moderate, hollow = weak)
|
|
363
|
+
const DOT_FILL = {strong:0.95, moderate:0.5, weak:0.08};
|
|
364
|
+
const dots = L.layerGroup().addTo(map);
|
|
365
|
+
for (const f of D.geojson.features) {
|
|
366
|
+
const p=f.properties, k=strengthKey(p), c=cls[p.class].color;
|
|
367
|
+
const r = Math.max(3, Math.min(8, 3 + 1.4*Math.log10(1 + p.area_ha / Math.max(D.meta.mmu_ha,0.01))));
|
|
368
|
+
const m = L.circleMarker([p.centroid_lat,p.centroid_lon],{radius:r,color:k==='weak'?c:'#ffffff',weight:k==='weak'?1.5:1,dashArray:k==='weak'?'2 2':null,fillColor:c,fillOpacity:DOT_FILL[k],pane:'dotPane',renderer:chgRenderer});
|
|
369
|
+
m.bindTooltip(tip(p)); m.on('click',()=>{ goTo(f, false); selectRow(p.id); });
|
|
370
|
+
f.__dot=m;
|
|
371
|
+
}
|
|
372
|
+
let changesShown = true;
|
|
373
|
+
function updateDots(){
|
|
374
|
+
if (!changesShown || !map.hasLayer(polys)) { dots.clearLayers(); return; }
|
|
375
|
+
for (const f of D.geojson.features) {
|
|
376
|
+
const b=f.__layer.getBounds(), a=map.latLngToContainerPoint(b.getNorthWest()), z=map.latLngToContainerPoint(b.getSouthEast());
|
|
377
|
+
const small = Math.max(Math.abs(z.x-a.x), Math.abs(z.y-a.y)) < 14;
|
|
378
|
+
if (small && !dots.hasLayer(f.__dot)) dots.addLayer(f.__dot); else if (!small && dots.hasLayer(f.__dot)) dots.removeLayer(f.__dot);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
map.on('zoomend viewreset resize', updateDots); setTimeout(updateDots,80); window.addEventListener('load',updateDots);
|
|
382
|
+
function setChangesShown(on){
|
|
383
|
+
changesShown = on;
|
|
384
|
+
const want = on && $('#t-outline').checked;
|
|
385
|
+
if (want && !map.hasLayer(polys)) { polys.addTo(map); dots.eachLayer(d=>d.bringToFront()); } else if (!want && map.hasLayer(polys)) map.removeLayer(polys);
|
|
386
|
+
updateDots(); if (m3) set3dChangesVisible(want);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// pulse: expanding rings on the largest / strongest changes so they are easy to spot
|
|
390
|
+
const pulseFeats = D.geojson.features.slice().sort((a,b)=>(b.properties.confidence*Math.sqrt(b.properties.area_ha))-(a.properties.confidence*Math.sqrt(a.properties.area_ha))).slice(0,40);
|
|
391
|
+
const pulseLayer = L.layerGroup().addTo(map);
|
|
392
|
+
const rings = pulseFeats.map((f,i)=>{ const c=cls[f.properties.class].color;
|
|
393
|
+
return {f, phase:(i*0.137)%1, m:L.circleMarker([f.properties.centroid_lat,f.properties.centroid_lon],{radius:6,color:c,weight:3,fill:false,opacity:0,interactive:false,pane:'pulsePane'}).addTo(pulseLayer)}; });
|
|
394
|
+
let lastT = 0;
|
|
395
|
+
function pulseActive(){ return $('#t-pulse').checked && changesShown && $('#t-outline').checked; }
|
|
396
|
+
|
|
397
|
+
// highlight: the selected change blinks (outline + a ring) on the map, and its row lights up in the table
|
|
398
|
+
let selId = null, hlUntil = 0;
|
|
399
|
+
const hlRing = L.circleMarker([0,0],{radius:14,color:'#ffd400',weight:4,fill:false,opacity:0,interactive:false,pane:'hlPane'}).addTo(map);
|
|
400
|
+
function pulseLoop(ts){
|
|
401
|
+
const dt = Math.min(0.1,(ts-(lastT||ts))/1000); lastT = ts;
|
|
402
|
+
const on = pulseActive();
|
|
403
|
+
for (const r of rings) {
|
|
404
|
+
if (!on) { if (r.m.options.opacity) r.m.setStyle({opacity:0}); continue; }
|
|
405
|
+
r.phase = (r.phase + dt/1.6) % 1;
|
|
406
|
+
r.m.setRadius(5 + 22*r.phase); r.m.setStyle({opacity:0.9*(1-r.phase)});
|
|
407
|
+
}
|
|
408
|
+
const f = selId!=null && byId.get(selId);
|
|
409
|
+
if (f && ts < hlUntil && $('#t-outline').checked) {
|
|
410
|
+
const blink = 0.5 + 0.5*Math.sin(ts/120);
|
|
411
|
+
hlRing.setLatLng([f.properties.centroid_lat,f.properties.centroid_lon]); hlRing.setRadius(10 + 10*blink); hlRing.setStyle({opacity:0.95});
|
|
412
|
+
f.__layer.setStyle({color: blink>0.5 ? '#ffd400' : cls[f.properties.class].color, weight: 5});
|
|
413
|
+
} else if (f && hlRing.options.opacity) { hlRing.setStyle({opacity:0}); f.__layer.setStyle(polyStyle(f)); }
|
|
414
|
+
requestAnimationFrame(pulseLoop);
|
|
415
|
+
}
|
|
416
|
+
requestAnimationFrame(pulseLoop);
|
|
417
|
+
function goTo(f, openPop=true){
|
|
418
|
+
if (view==='3d') { fly3d(f); }
|
|
419
|
+
else { if(!$('#t-outline').checked)$('#t-outline').click(); map.fitBounds(f.__layer.getBounds().pad(2),{maxZoom:17}); if (openPop) f.__layer.openPopup(); }
|
|
420
|
+
const prev = selId!=null && byId.get(selId); if (prev && prev!==f) prev.__layer.setStyle(polyStyle(prev));
|
|
421
|
+
selId = f.properties.id; hlUntil = performance.now() + 4000; if (m3) m3.highlight(selId, hlUntil);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ---------------------------------------------------------------- modes: swipe / before / after / change map / timeline
|
|
425
|
+
let mode='swipe', ratio=0.5;
|
|
426
|
+
function clipBefore(){
|
|
427
|
+
if(mode!=='swipe'){pBefore.style.clip='';return;}
|
|
428
|
+
const nw=map.containerPointToLayerPoint([0,0]), se=map.containerPointToLayerPoint(map.getSize());
|
|
429
|
+
const x=nw.x+(se.x-nw.x)*ratio;
|
|
430
|
+
pBefore.style.clip='rect('+nw.y+'px,'+x+'px,'+se.y+'px,'+nw.x+'px)';
|
|
431
|
+
}
|
|
432
|
+
// swipe is a time cursor when the report has per-scene frames: the line sits between two consecutive satellite passes,
|
|
433
|
+
// the left side shows the earlier pass, the right side the later one, and the dates on the line follow it live
|
|
434
|
+
let swipePair = null;
|
|
435
|
+
function swipeFrames(){
|
|
436
|
+
if (!TL.length || TL.length<2) return null;
|
|
437
|
+
const g = Math.max(0, Math.min(TL.length-2, Math.floor(ratio*(TL.length-1))));
|
|
438
|
+
return [g, g+1];
|
|
439
|
+
}
|
|
440
|
+
function updateSwipe(){
|
|
441
|
+
const t=L_();
|
|
442
|
+
if (mode!=='swipe') return;
|
|
443
|
+
const pr = swipeFrames();
|
|
444
|
+
if (!pr) { $('#kd-b').textContent='◀ '+fmtWindow(D.meta.before_label); $('#kd-a').textContent=fmtWindow(D.meta.after_label)+' ▶'; return; }
|
|
445
|
+
const [a,b] = pr, fa=TL[a], fb=TL[b];
|
|
446
|
+
if (!swipePair || swipePair[0]!==a) { imgBefore.setUrl(fa.img); imgBefore.setBounds(L.latLngBounds(tlBounds)); }
|
|
447
|
+
if (!swipePair || swipePair[1]!==b) { imgAfter.setUrl(fb.img); imgAfter.setBounds(L.latLngBounds(tlBounds)); }
|
|
448
|
+
swipePair = pr;
|
|
449
|
+
$('#kd-b').innerHTML = '◀ '+esc(fmtDate(fa.date))+' <small>'+periodLabel(fa.period)+'</small>';
|
|
450
|
+
$('#kd-a').innerHTML = esc(fmtDate(fb.date))+' <small>'+periodLabel(fb.period)+'</small> ▶';
|
|
451
|
+
$('#lab-l').textContent = periodLabel(fa.period)+' · '+fmtDate(fa.date); $('#lab-r').textContent = periodLabel(fb.period)+' · '+fmtDate(fb.date);
|
|
452
|
+
setChangesShown(true);
|
|
453
|
+
if (m3 && view==='3d') sync3dImage();
|
|
454
|
+
}
|
|
455
|
+
function restoreComposites(){ if (!swipePair) return; imgBefore.setUrl(D.img.before); imgBefore.setBounds(L.latLngBounds(B)); imgAfter.setUrl(D.img.after); imgAfter.setBounds(L.latLngBounds(B)); swipePair=null;
|
|
456
|
+
$('#lab-l').textContent = L_().before+' · '+fmtWindow(D.meta.before_label); $('#lab-r').textContent = L_().after+' · '+fmtWindow(D.meta.after_label); }
|
|
457
|
+
function setMode(m){
|
|
458
|
+
if (m==='timeline' && !TL.length) { flash(L_().noTimeline); return; }
|
|
459
|
+
mode=m; if (m!=='timeline') stopPlay();
|
|
460
|
+
document.querySelectorAll('#modes button').forEach(b=>b.setAttribute('aria-pressed',b.dataset.m===m));
|
|
461
|
+
pBefore.style.display = (m==='swipe'||m==='before')?'':'none';
|
|
462
|
+
pAfter.style.display = (m==='swipe'||m==='after'||m==='changes')?'':'none';
|
|
463
|
+
pTl.style.display = (m==='timeline')?'':'none';
|
|
464
|
+
pChg.style.display = (m==='changes')?'':'none';
|
|
465
|
+
if(m==='changes'&&!map.hasLayer(imgChg)) imgChg.addTo(map);
|
|
466
|
+
const sw = (m==='swipe');
|
|
467
|
+
$('#swipe').style.display=$('#lab-l').style.display=$('#lab-r').style.display=sw?'':'none';
|
|
468
|
+
$('#tl').style.display = (m==='timeline')?'flex':'none';
|
|
469
|
+
$('#tlhint').style.display = (m==='timeline')?'block':'none';
|
|
470
|
+
if (m==='swipe') updateSwipe(); else restoreComposites();
|
|
471
|
+
if (m==='timeline') showFrame(tlIndex, false); else if (m!=='swipe') setChangesShown(true);
|
|
472
|
+
clipBefore(); if (m3) sync3dImage();
|
|
473
|
+
}
|
|
474
|
+
document.querySelectorAll('#modes button').forEach(b=>b.addEventListener('click',()=>setMode(b.dataset.m)));
|
|
475
|
+
map.on('move zoom viewreset resize',clipBefore);
|
|
476
|
+
const sw=$('#swipe'), mb=$('#mapbox');
|
|
477
|
+
function setRatio(r){ratio=Math.min(1,Math.max(0,r));sw.style.left=(ratio*100)+'%';sw.setAttribute('aria-valuenow',Math.round(ratio*100));clipBefore();updateSwipe();}
|
|
478
|
+
let drag=false;
|
|
479
|
+
// the whole line is a handle (a 28 px wide strip), not only the round knob
|
|
480
|
+
sw.querySelector('.grab').addEventListener('pointerdown',e=>{drag=true;e.preventDefault();e.stopPropagation();e.currentTarget.setPointerCapture(e.pointerId);map.dragging.disable();sw.classList.add('dragging');});
|
|
481
|
+
window.addEventListener('pointermove',e=>{if(!drag)return;const r=mb.getBoundingClientRect();setRatio((e.clientX-r.left)/r.width);});
|
|
482
|
+
window.addEventListener('pointerup',()=>{if(drag){drag=false;map.dragging.enable();sw.classList.remove('dragging');}});
|
|
483
|
+
sw.addEventListener('keydown',e=>{if(e.key==='ArrowLeft')setRatio(ratio-0.03);if(e.key==='ArrowRight')setRatio(ratio+0.03);});
|
|
484
|
+
|
|
485
|
+
$('#t-outline').addEventListener('change',()=>setChangesShown(changesShown));
|
|
486
|
+
function setBase(v){
|
|
487
|
+
for (const k in bases) if (bases[k] && map.hasLayer(bases[k])) map.removeLayer(bases[k]);
|
|
488
|
+
if (bases[v]) bases[v].addTo(map);
|
|
489
|
+
setSatOpacity(v==='none' ? 1 : 0);
|
|
490
|
+
}
|
|
491
|
+
$('#t-base').addEventListener('change',e=>setBase(e.target.value));
|
|
492
|
+
const pBase = map.getPane('osmPane');
|
|
493
|
+
function clipBase(){
|
|
494
|
+
const g = D.aoi && D.aoi.geometry; if (!g) return;
|
|
495
|
+
const polys = g.type==='Polygon' ? [g.coordinates] : g.type==='MultiPolygon' ? g.coordinates : [];
|
|
496
|
+
let d = '';
|
|
497
|
+
for (const poly of polys) for (const ring of poly) {
|
|
498
|
+
ring.forEach((c,i)=>{ const q = map.latLngToLayerPoint([c[1],c[0]]); d += (i?'L':'M') + q.x.toFixed(1) + ' ' + q.y.toFixed(1); });
|
|
499
|
+
d += 'Z';
|
|
500
|
+
}
|
|
501
|
+
pBase.style.clipPath = d ? 'path(evenodd, "' + d + '")' : '';
|
|
502
|
+
}
|
|
503
|
+
map.on('zoomend viewreset resize load', clipBase); setTimeout(clipBase, 80);
|
|
504
|
+
$('#t-op').addEventListener('input',e=>setSatOpacity(+e.target.value/100));
|
|
505
|
+
|
|
506
|
+
// ---------------------------------------------------------------- timeline: every satellite pass in date order
|
|
507
|
+
const tlBounds = (D.timeline && D.timeline.bounds) || B;
|
|
508
|
+
const tlA = L.imageOverlay(TL.length?TL[0].img:D.img.after, tlBounds, {pane:'tlPane', opacity:1}).addTo(map);
|
|
509
|
+
const tlB = L.imageOverlay(TL.length?TL[0].img:D.img.after, tlBounds, {pane:'tlPane', opacity:0}).addTo(map);
|
|
510
|
+
[tlA,tlB].forEach(o=>{ const el=o.getElement(); if (el) el.style.transition='opacity .45s ease'; });
|
|
511
|
+
let tlIndex = Math.max(0, TL.findIndex(f=>f.period==='after')-1), front = tlA, playing = false, timer = null, lastPeriod = null;
|
|
512
|
+
const rng = $('#tl-range'); rng.max = Math.max(0, TL.length-1); rng.value = tlIndex;
|
|
513
|
+
$('#ticks').innerHTML = TL.map((f,i)=>'<i class="'+f.period+'" style="left:'+(TL.length>1?(i/(TL.length-1)*100):0)+'%"></i>').join('');
|
|
514
|
+
function showFrame(i, animate=true){
|
|
515
|
+
if (!TL.length) return;
|
|
516
|
+
tlIndex = Math.max(0, Math.min(TL.length-1, i)); rng.value = tlIndex;
|
|
517
|
+
const f = TL[tlIndex], t = L_();
|
|
518
|
+
$('#tl-date').innerHTML = esc(fmtDate(f.date)) + '<span class="badge '+f.period+'">'+periodLabel(f.period)+'</span>';
|
|
519
|
+
$('#tl-meta').textContent = t.frame(tlIndex+1, TL.length) + ' · ' + (f.period==='between' ? t.clearOnly : t.clear)(Math.round(f.clear_fraction*100));
|
|
520
|
+
$('#tlhint').textContent = fmtDate(f.date) + ' · ' + periodLabel(f.period);
|
|
521
|
+
if (mode!=='timeline') return;
|
|
522
|
+
const back = front===tlA ? tlB : tlA;
|
|
523
|
+
if (animate) { back.setUrl(f.img); back.setOpacity(1); front.setOpacity(0); front = back; }
|
|
524
|
+
else { front.setUrl(f.img); front.setOpacity(1); back.setOpacity(0); }
|
|
525
|
+
setChangesShown(true);
|
|
526
|
+
if (lastPeriod && lastPeriod!==f.period) flash(f.period==='after' ? t.bannerAfter : t.bannerBefore);
|
|
527
|
+
lastPeriod = f.period;
|
|
528
|
+
if (m3) sync3dImage();
|
|
529
|
+
}
|
|
530
|
+
rng.addEventListener('input',()=>{ stopPlay(); showFrame(+rng.value, false); });
|
|
531
|
+
function stopPlay(){ playing=false; clearTimeout(timer); $('#tl-play').textContent = L_().play; }
|
|
532
|
+
function step(){ if(!playing) return; const last = tlIndex>=TL.length-1; showFrame(last?0:tlIndex+1, !last); timer=setTimeout(step, last?1500:(TL[tlIndex].period==='after'&&TL[Math.max(0,tlIndex-1)].period==='before'?2200:1000)); }
|
|
533
|
+
$('#tl-play').addEventListener('click',()=>{ if(playing){stopPlay();return;} playing=true; $('#tl-play').textContent=L_().pause; if (tlIndex>=TL.length-1) showFrame(0,false); timer=setTimeout(step,500); });
|
|
534
|
+
let flashT=null;
|
|
535
|
+
function flash(msg){ const b=$('#banner'); b.textContent=msg; b.classList.add('on'); clearTimeout(flashT); flashT=setTimeout(()=>b.classList.remove('on'),2600); }
|
|
536
|
+
document.addEventListener('keydown',e=>{ if(mode!=='timeline'||/INPUT|SELECT|TEXTAREA/.test(document.activeElement.tagName)) return;
|
|
537
|
+
if(e.key==='ArrowRight'){stopPlay();showFrame(tlIndex+1);} else if(e.key==='ArrowLeft'){stopPlay();showFrame(tlIndex-1);} else if(e.key===' '){e.preventDefault();$('#tl-play').click();} });
|
|
538
|
+
|
|
539
|
+
// ---------------------------------------------------------------- evidence popup: before/after crops with the polygon outline
|
|
540
|
+
const imgs={}; ['before','after'].forEach(k=>{const i=new Image();i.src=D.img[k];imgs[k]=i;});
|
|
541
|
+
const my=lat=>Math.log(Math.tan(Math.PI/4+lat*Math.PI/360));
|
|
542
|
+
function crop(canvas,which,f){
|
|
543
|
+
const im=imgs[which]; const ctx=canvas.getContext('2d'); const S=canvas.width; const R=Math.PI/180;
|
|
544
|
+
const coords=[]; (function walk(c){ if(typeof c[0]==='number')coords.push(c); else c.forEach(walk); })(f.geometry.coordinates);
|
|
545
|
+
let w=1e9,e=-1e9,s=1e9,n=-1e9; coords.forEach(c=>{const X=c[0]*R,Y=my(c[1]);w=Math.min(w,X);e=Math.max(e,X);s=Math.min(s,Y);n=Math.max(n,Y);});
|
|
546
|
+
const cx=(w+e)/2, cy=(s+n)/2, half=Math.max(e-w,n-s)*0.5*1.9+2e-6;
|
|
547
|
+
const x0=cx-half, x1=cx+half, y0=cy-half, y1=cy+half;
|
|
548
|
+
const bx0=B[0][1]*R, bx1=B[1][1]*R, by0=my(B[0][0]), by1=my(B[1][0]);
|
|
549
|
+
const px=X=>(X-bx0)/(bx1-bx0)*im.naturalWidth, py=Y=>(by1-Y)/(by1-by0)*im.naturalHeight;
|
|
550
|
+
ctx.imageSmoothingEnabled=false; ctx.fillStyle='#999'; ctx.fillRect(0,0,S,S);
|
|
551
|
+
ctx.drawImage(im,px(x0),py(y1),px(x1)-px(x0),py(y0)-py(y1),0,0,S,S);
|
|
552
|
+
ctx.strokeStyle='#ffff00'; ctx.lineWidth=1.5; ctx.beginPath();
|
|
553
|
+
const polysC=(f.geometry.type==='Polygon')?[f.geometry.coordinates]:f.geometry.coordinates;
|
|
554
|
+
polysC.forEach(poly=>poly.forEach(r=>{r.forEach((c,i)=>{const X=(c[0]*R-x0)/(x1-x0)*S, Y=(y1-my(c[1]))/(y1-y0)*S; i?ctx.lineTo(X,Y):ctx.moveTo(X,Y);});ctx.closePath();}));
|
|
555
|
+
ctx.stroke();
|
|
556
|
+
}
|
|
557
|
+
function popupHtml(p, withCrops){
|
|
558
|
+
const t=L_(), k=strengthKey(p);
|
|
559
|
+
const basis = lang==='tr' && p.likely_cause_basis_tr ? p.likely_cause_basis_tr : (p.likely_cause_basis||'');
|
|
560
|
+
return '<h4><span class="chip" style="background:'+cls[p.class].color+'"></span>#'+p.id+' '+esc(clsLabel(p.class))+'</h4>'+
|
|
561
|
+
'<div class="what">'+esc(describe(p))+'</div>'+
|
|
562
|
+
'<div><b>'+fmt(p.area_ha,2)+' ha</b> · <span class="str-'+k+'">'+esc(t.str[k])+'</span> · '+t.conf+' <span class="conf-'+p.confidence_level+'">'+fmt(p.confidence,2)+'</span></div>'+
|
|
563
|
+
(p.likely_cause?'<div>'+t.likely+': <b>'+esc(causeLabel(p.likely_cause))+'</b> <span class="small">'+t.aHint+' '+esc(basis)+'</span></div>':'')+
|
|
564
|
+
(p.likely_seasonal===true?'<div>'+t.seasonalPop(p.seasonal_years_repeating,p.seasonal_years_checked)+'</div>':'')+
|
|
565
|
+
(withCrops?'<div class="pair"><div><canvas width="224" height="224" data-w="before"></canvas><div class="cap">'+t.before+'</div></div><div><canvas width="224" height="224" data-w="after"></canvas><div class="cap">'+t.after+'</div></div></div>':'')+
|
|
566
|
+
'<table><tr><th></th><th>'+t.before+'</th><th>'+t.after+'</th></tr>'+
|
|
567
|
+
'<tr><td>NDVI</td><td>'+p.ndvi_before+'</td><td>'+p.ndvi_after+'</td></tr>'+
|
|
568
|
+
'<tr><td>MNDWI</td><td>'+p.mndwi_before+'</td><td>'+p.mndwi_after+'</td></tr>'+
|
|
569
|
+
'<tr><td>BSI</td><td>'+p.bsi_before+'</td><td>'+p.bsi_after+'</td></tr>'+
|
|
570
|
+
'<tr><td>'+t.validObs+'</td><td>'+p.valid_obs_before+'</td><td>'+p.valid_obs_after+'</td></tr></table>';
|
|
571
|
+
}
|
|
572
|
+
function popup(f){
|
|
573
|
+
const el=document.createElement('div'); el.className='pop'; el.innerHTML=popupHtml(f.properties, true);
|
|
574
|
+
setTimeout(()=>el.querySelectorAll('canvas').forEach(c=>{try{crop(c,c.dataset.w,f);}catch(e){}}),0);
|
|
575
|
+
return el;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ---------------------------------------------------------------- table (scrolls on its own, so selecting on the map does not move the page)
|
|
579
|
+
const rows=D.geojson.features.map(f=>f);
|
|
580
|
+
let sortKey='area_ha', sortDir=-1;
|
|
581
|
+
const colKeys=['id','class_label','area_ha','confidence','likely_cause','ndvi','mndwi','bsi','loc'];
|
|
582
|
+
const val=(f,k)=>{const p=f.properties; if(k==='ndvi')return p.ndvi_after-p.ndvi_before; if(k==='mndwi')return p.mndwi_after-p.mndwi_before; if(k==='bsi')return p.bsi_after-p.bsi_before; if(k==='loc')return p.centroid_lat; return p[k];};
|
|
583
|
+
// tables are shown page by page; a change picked on the map turns the table to its page
|
|
584
|
+
const PAGE_SIZES=[10,25,50,100];
|
|
585
|
+
const tblPage={page:0,size:25,pages:1}, scPage={page:0,size:10,pages:1};
|
|
586
|
+
let printAll=false;
|
|
587
|
+
// search: every word must appear in the row (number, what changed, strength, cause, coordinates; any letter case)
|
|
588
|
+
const norm=s=>String(s).toLocaleLowerCase(lang==='tr'?'tr-TR':'en-US');
|
|
589
|
+
function matches(text, q){ const h=norm(text); return norm(q).split(/\s+/).filter(Boolean).every(w=>h.includes(w)); }
|
|
590
|
+
function rowText(f){ const p=f.properties, t=L_(); return [p.id, clsLabel(p.class), t.strShort[strengthKey(p)], p.likely_cause?causeLabel(p.likely_cause):'', p.likely_seasonal===true?t.seasonal:'', fmt(p.area_ha,2), p.centroid_lat.toFixed(4)+', '+p.centroid_lon.toFixed(4)].join(' '); }
|
|
591
|
+
const sceneText=s=>[s.period, s.id, fmtDate(String(s.date).slice(0,10)), String(s.date).slice(0,10)].join(' ');
|
|
592
|
+
const sortedRows=()=>rows.filter(f=>matches(rowText(f), $('#tbl-q').value)).sort((a,b)=>{const x=val(a,sortKey),y=val(b,sortKey);return (x>y?1:x<y?-1:0)*sortDir;});
|
|
593
|
+
function pageSlice(list, st, box){
|
|
594
|
+
const t=L_(), all=list.length; st.pages=Math.max(1,Math.ceil(all/st.size)); st.page=Math.min(Math.max(0,st.page),st.pages-1);
|
|
595
|
+
box.style.display = all>PAGE_SIZES[0] ? '' : 'none';
|
|
596
|
+
if (printAll) return list;
|
|
597
|
+
const a=st.page*st.size, b=Math.min(all,a+st.size), first=st.page===0, last=st.page>=st.pages-1;
|
|
598
|
+
box.innerHTML='<button data-p="first" title="'+t.pFirst+'"'+(first?' disabled':'')+'>«</button><button data-p="prev" title="'+t.pPrev+'"'+(first?' disabled':'')+'>‹</button>'+
|
|
599
|
+
'<span class="pinfo">'+t.pageOf(st.page+1,st.pages,all?a+1:0,b,all)+'</span>'+
|
|
600
|
+
'<button data-p="next" title="'+t.pNext+'"'+(last?' disabled':'')+'>›</button><button data-p="last" title="'+t.pLast+'"'+(last?' disabled':'')+'>»</button>'+
|
|
601
|
+
'<label>'+t.perPage+' <select>'+PAGE_SIZES.map(n=>'<option'+(n===st.size?' selected':'')+'>'+n+'</option>').join('')+'</select></label>';
|
|
602
|
+
return list.slice(a,b);
|
|
603
|
+
}
|
|
604
|
+
function pagerEvents(box, st, redraw){
|
|
605
|
+
box.addEventListener('click',e=>{ const b=e.target.closest('button[data-p]'); if(!b||b.disabled) return; const k=b.dataset.p;
|
|
606
|
+
st.page = k==='first'?0 : k==='prev'?st.page-1 : k==='next'?st.page+1 : st.pages-1; redraw(); });
|
|
607
|
+
box.addEventListener('change',e=>{ if(e.target.tagName!=='SELECT') return; st.size=+e.target.value; st.page=0; redraw(); });
|
|
608
|
+
}
|
|
609
|
+
function drawTable(){
|
|
610
|
+
const t=L_();
|
|
611
|
+
const shown=pageSlice(sortedRows(), tblPage, $('#tbl-pager'));
|
|
612
|
+
$('#tbl thead').innerHTML='<tr>'+colKeys.map((k,i)=>'<th data-k="'+k+'">'+t.cols[i]+(sortKey===k?(sortDir>0?' ▲':' ▼'):'')+'</th>').join('')+'</tr>';
|
|
613
|
+
$('#tbl tbody').innerHTML=shown.map(f=>{const p=f.properties, k=strengthKey(p);
|
|
614
|
+
return '<tr data-id="'+p.id+'"'+(p.id===selId?' class="sel"':'')+'><td class="n">'+p.id+'</td><td title="'+esc(describe(p))+'"><span class="chip" style="background:'+cls[p.class].color+'"></span>'+esc(clsLabel(p.class))+'</td><td class="n">'+fmt(p.area_ha,2)+'</td><td><span class="conf-'+p.confidence_level+'">'+t.strShort[k]+'</span> '+fmt(p.confidence,2)+'</td><td>'+esc((p.likely_cause?causeLabel(p.likely_cause):'')+(p.likely_seasonal===true?' · '+t.seasonal:''))+'</td><td>'+p.ndvi_before+' → '+p.ndvi_after+'</td><td>'+p.mndwi_before+' → '+p.mndwi_after+'</td><td>'+p.bsi_before+' → '+p.bsi_after+'</td><td>'+p.centroid_lat.toFixed(4)+', '+p.centroid_lon.toFixed(4)+'</td></tr>';}).join('');
|
|
615
|
+
if (rows.length && !shown.length) $('#tbl tbody').innerHTML='<tr><td colspan="'+colKeys.length+'" class="small">'+t.noMatch+'</td></tr>';
|
|
616
|
+
document.querySelector('#tbl-q').closest('.tsearch').style.display = rows.length ? '' : 'none';
|
|
617
|
+
$('#tbl-note').textContent = rows.length ? t.tblNote(rows.length) : t.tblNone(D.meta.mmu_ha);
|
|
618
|
+
}
|
|
619
|
+
function drawScenes(){
|
|
620
|
+
const list=D.scenes.filter(s=>matches(sceneText(s), $('#sc-q').value));
|
|
621
|
+
const pg=pageSlice(list, scPage, $('#sc-pager'));
|
|
622
|
+
$('#scenes tbody').innerHTML = !list.length ? '<tr><td colspan="5" class="small">'+L_().noMatch+'</td></tr>' : pg.map(s=>'<tr><td>'+esc(s.period)+'</td><td>'+esc(s.id)+'</td><td>'+esc(fmtDate(String(s.date).slice(0,10)))+'</td><td class="n">'+fmt(s.cloud_cover,1)+'</td><td class="n">'+fmt(s.valid_fraction*100,0)+'%</td></tr>').join('');
|
|
623
|
+
}
|
|
624
|
+
for (const [q, x, st, redraw] of [['#tbl-q','#tbl-qx',tblPage,()=>drawTable()], ['#sc-q','#sc-qx',scPage,()=>drawScenes()]]) {
|
|
625
|
+
$(q).addEventListener('input',()=>{ st.page=0; redraw(); });
|
|
626
|
+
$(x).addEventListener('click',()=>{ $(q).value=''; st.page=0; redraw(); $(q).focus(); });
|
|
627
|
+
}
|
|
628
|
+
pagerEvents($('#tbl-pager'), tblPage, ()=>drawTable()); pagerEvents($('#sc-pager'), scPage, ()=>drawScenes());
|
|
629
|
+
// printing lists every row, not only the current page
|
|
630
|
+
addEventListener('beforeprint',()=>{ printAll=true; drawTable(); drawScenes(); }); addEventListener('afterprint',()=>{ printAll=false; drawTable(); drawScenes(); });
|
|
631
|
+
function selectRow(id){
|
|
632
|
+
selId = id; hlUntil = performance.now() + 4000; if (m3) m3.highlight(id, hlUntil);
|
|
633
|
+
let tr = document.querySelector('#tbl tbody tr[data-id="'+id+'"]');
|
|
634
|
+
if (!tr && $('#tbl-q').value && !sortedRows().some(f=>f.properties.id===id)) $('#tbl-q').value='';
|
|
635
|
+
if (!tr) { const i=sortedRows().findIndex(f=>f.properties.id===id); if (i>=0) tblPage.page=Math.floor(i/tblPage.size); drawTable(); tr = document.querySelector('#tbl tbody tr[data-id="'+id+'"]'); }
|
|
636
|
+
document.querySelectorAll('#tbl tbody tr.sel').forEach(r=>{ if(r!==tr) r.classList.remove('sel','flash'); });
|
|
637
|
+
if (!tr) return;
|
|
638
|
+
tr.classList.add('sel'); tr.classList.remove('flash'); void tr.offsetWidth; tr.classList.add('flash');
|
|
639
|
+
const box=$('#tblbox'); box.scrollTop = tr.offsetTop - box.clientHeight/2 + tr.offsetHeight/2;
|
|
640
|
+
}
|
|
641
|
+
$('#tbl thead').addEventListener('click',e=>{const k=e.target.closest('th')?.dataset.k; if(!k)return; if(k===sortKey)sortDir*=-1; else {sortKey=k;sortDir=-1;} tblPage.page=0; drawTable();});
|
|
642
|
+
$('#tbl tbody').addEventListener('click',e=>{const tr=e.target.closest('tr'); if(!tr)return; const f=byId.get(+tr.dataset.id); if(!f||!f.__layer)return;
|
|
643
|
+
$('#mapbox').scrollIntoView({behavior:'smooth',block:'center'}); goTo(f, true); selectRow(f.properties.id);});
|
|
644
|
+
|
|
645
|
+
// ---------------------------------------------------------------- 3D view (offline: WebGL on the embedded Copernicus DEM, nothing is downloaded)
|
|
646
|
+
const TER = D.terrain;
|
|
647
|
+
let view='2d', m3=null;
|
|
648
|
+
const RAD=Math.PI/180, RE=6378137;
|
|
649
|
+
const mxOf=lon=>RE*lon*RAD, myOf=lat=>RE*Math.log(Math.tan(Math.PI/4+lat*RAD/2));
|
|
650
|
+
function mat4mul(a,b){const o=new Float32Array(16);for(let i=0;i<4;i++)for(let j=0;j<4;j++){let s=0;for(let k=0;k<4;k++)s+=a[k*4+j]*b[i*4+k];o[i*4+j]=s;}return o;}
|
|
651
|
+
function persp(fov,asp,n,f){const t=1/Math.tan(fov/2),o=new Float32Array(16);o[0]=t/asp;o[5]=t;o[10]=(f+n)/(n-f);o[11]=-1;o[14]=2*f*n/(n-f);return o;}
|
|
652
|
+
function lookAt(e,c,u){const z=norm3([e[0]-c[0],e[1]-c[1],e[2]-c[2]]),x=norm3(cross(u,z)),y=cross(z,x);
|
|
653
|
+
return new Float32Array([x[0],y[0],z[0],0,x[1],y[1],z[1],0,x[2],y[2],z[2],0,-dot3(x,e),-dot3(y,e),-dot3(z,e),1]);}
|
|
654
|
+
const cross=(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]], dot3=(a,b)=>a[0]*b[0]+a[1]*b[1]+a[2]*b[2];
|
|
655
|
+
const norm3=a=>{const l=Math.hypot(a[0],a[1],a[2])||1;return [a[0]/l,a[1]/l,a[2]/l];};
|
|
656
|
+
|
|
657
|
+
function build3d(){
|
|
658
|
+
const box=$('#map3d'); box.innerHTML='';
|
|
659
|
+
const cv=document.createElement('canvas'), ov=document.createElement('canvas'), tipEl=document.createElement('div'), pop=document.createElement('div'), att=document.createElement('div'), help=document.createElement('div');
|
|
660
|
+
[cv,ov].forEach(c=>{c.style.cssText='position:absolute;inset:0;width:100%;height:100%;border-radius:10px';});
|
|
661
|
+
ov.style.pointerEvents='none';
|
|
662
|
+
tipEl.className='leaflet-tooltip'; tipEl.style.cssText='position:absolute;display:none;pointer-events:none;z-index:5';
|
|
663
|
+
pop.className='pop'; pop.style.cssText='position:absolute;display:none;z-index:6;background:#fff;border-radius:10px;padding:10px 12px;box-shadow:0 3px 14px rgba(0,0,0,.4);max-width:340px;max-height:70%;overflow:auto';
|
|
664
|
+
att.style.cssText='position:absolute;right:6px;bottom:4px;font-size:.62rem;color:#fff;background:rgba(0,0,0,.5);padding:1px 6px;border-radius:4px;max-width:50%;text-align:right;pointer-events:none';
|
|
665
|
+
att.textContent=D.attribution+' · '+TER.attribution;
|
|
666
|
+
help.style.cssText='position:absolute;left:8px;bottom:6px;max-width:44%;font-size:.75rem;color:#fff;background:rgba(0,0,0,.55);padding:2px 8px;border-radius:5px;pointer-events:none';
|
|
667
|
+
box.style.position='relative'; box.append(cv,ov,tipEl,pop,att,help);
|
|
668
|
+
const gl=cv.getContext('webgl',{antialias:true});
|
|
669
|
+
if(!gl){ box.textContent=L_().no3d; return null; }
|
|
670
|
+
|
|
671
|
+
// terrain grid -> model coordinates (x east, y north, z up; the longest side spans 2 units)
|
|
672
|
+
const w=TER.w,h=TER.h, raw=atob(TER.z), bytes=new Uint8Array(raw.length); for(let i=0;i<raw.length;i++)bytes[i]=raw.charCodeAt(i);
|
|
673
|
+
const q=new Uint16Array(bytes.buffer), elev=new Float32Array(w*h), span=TER.max-TER.min;
|
|
674
|
+
for(let i=0;i<w*h;i++) elev[i]=TER.min+q[i]/65535*span;
|
|
675
|
+
const X0=mxOf(TER.bounds[0][1]),X1=mxOf(TER.bounds[1][1]),Y0=myOf(TER.bounds[0][0]),Y1=myOf(TER.bounds[1][0]);
|
|
676
|
+
const cx=(X0+X1)/2, cy=(Y0+Y1)/2, S=2/Math.max(X1-X0,Y1-Y0), coslat=Math.cos((TER.bounds[0][0]+TER.bounds[1][0])/2*RAD);
|
|
677
|
+
const toModel=(X,Y)=>[(X-cx)*S,(Y-cy)*S];
|
|
678
|
+
const pos=new Float32Array(w*h*3), nor=new Float32Array(w*h*3);
|
|
679
|
+
let exag=+($('#t-exag').value||1.5);
|
|
680
|
+
function elevAt(X,Y){ // bilinear elevation at mercator X,Y
|
|
681
|
+
const fx=Math.min(w-1,Math.max(0,(X-X0)/(X1-X0)*(w-1))), fy=Math.min(h-1,Math.max(0,(Y1-Y)/(Y1-Y0)*(h-1)));
|
|
682
|
+
const i=Math.floor(fx),j=Math.floor(fy),i2=Math.min(w-1,i+1),j2=Math.min(h-1,j+1),a=fx-i,b=fy-j;
|
|
683
|
+
return (elev[j*w+i]*(1-a)+elev[j*w+i2]*a)*(1-b)+(elev[j2*w+i]*(1-a)+elev[j2*w+i2]*a)*b;
|
|
684
|
+
}
|
|
685
|
+
const zOf=e=>e/coslat*S*exag;
|
|
686
|
+
function fillMesh(){
|
|
687
|
+
for(let j=0;j<h;j++)for(let i=0;i<w;i++){const k=j*w+i,X=X0+i/(w-1)*(X1-X0),Y=Y1-j/(h-1)*(Y1-Y0),m=toModel(X,Y);pos[k*3]=m[0];pos[k*3+1]=m[1];pos[k*3+2]=zOf(elev[k]);}
|
|
688
|
+
for(let j=0;j<h;j++)for(let i=0;i<w;i++){const k=j*w+i,l=j*w+Math.max(0,i-1),r=j*w+Math.min(w-1,i+1),u=Math.max(0,j-1)*w+i,d=Math.min(h-1,j+1)*w+i;
|
|
689
|
+
const n=norm3(cross([pos[r*3]-pos[l*3],pos[r*3+1]-pos[l*3+1],pos[r*3+2]-pos[l*3+2]],[pos[u*3]-pos[d*3],pos[u*3+1]-pos[d*3+1],pos[u*3+2]-pos[d*3+2]]));
|
|
690
|
+
nor[k*3]=n[0];nor[k*3+1]=n[1];nor[k*3+2]=n[2];}
|
|
691
|
+
}
|
|
692
|
+
fillMesh();
|
|
693
|
+
const idx=new Uint16Array((w-1)*(h-1)*6); let t=0;
|
|
694
|
+
for(let j=0;j<h-1;j++)for(let i=0;i<w-1;i++){const a=j*w+i,b=a+1,c=a+w,d=c+1;idx[t++]=a;idx[t++]=c;idx[t++]=b;idx[t++]=b;idx[t++]=c;idx[t++]=d;}
|
|
695
|
+
|
|
696
|
+
const vs=`attribute vec3 aP;attribute vec3 aN;uniform mat4 uM;varying vec2 vXY;varying vec3 vN;void main(){vXY=aP.xy;vN=aN;gl_Position=uM*vec4(aP,1.0);}`;
|
|
697
|
+
const fs=`precision mediump float;varying vec2 vXY;varying vec3 vN;uniform sampler2D tR,tI,tC,tB;uniform vec4 rR,rI,rC,rB;uniform float sI,sC,uIO,uSplit;uniform vec3 uL;
|
|
698
|
+
vec2 uv(vec4 r){return vec2((vXY.x-r.x)/(r.z-r.x),(r.w-vXY.y)/(r.w-r.y));}
|
|
699
|
+
bool inside(vec2 p){return p.x>=0.0&&p.x<=1.0&&p.y>=0.0&&p.y<=1.0;}
|
|
700
|
+
void main(){float lit=0.6+0.5*max(dot(normalize(vN),uL),0.0);vec2 r0=uv(rR);vec3 c=texture2D(tR,r0).rgb*(0.85+0.15*lit);
|
|
701
|
+
if(!inside(r0))c=vec3(0.78,0.80,0.78)*lit;
|
|
702
|
+
bool left=uSplit>=0.0&&gl_FragCoord.x<uSplit;
|
|
703
|
+
if(left){vec2 a=uv(rB);if(sI>0.5&&inside(a)){vec4 s=texture2D(tB,a);if(s.a>0.5){c=mix(c,s.rgb*lit,uIO);}}}
|
|
704
|
+
else{vec2 a=uv(rI);if(sI>0.5&&inside(a)){vec4 s=texture2D(tI,a);if(s.a>0.5){c=mix(c,s.rgb*lit,uIO);}}}
|
|
705
|
+
vec2 b=uv(rC);if(sC>0.5&&inside(b)){vec4 g=texture2D(tC,b);c=mix(c,g.rgb,g.a*0.8);}
|
|
706
|
+
gl_FragColor=vec4(c,1.0);}`;
|
|
707
|
+
const sh=(type,src)=>{const s=gl.createShader(type);gl.shaderSource(s,src);gl.compileShader(s);if(!gl.getShaderParameter(s,gl.COMPILE_STATUS))throw new Error(gl.getShaderInfoLog(s));return s;};
|
|
708
|
+
const pr=gl.createProgram();gl.attachShader(pr,sh(gl.VERTEX_SHADER,vs));gl.attachShader(pr,sh(gl.FRAGMENT_SHADER,fs));gl.linkProgram(pr);gl.useProgram(pr);
|
|
709
|
+
const U=n=>gl.getUniformLocation(pr,n);
|
|
710
|
+
const bP=gl.createBuffer(),bN=gl.createBuffer(),bI=gl.createBuffer();
|
|
711
|
+
function upload(){gl.bindBuffer(gl.ARRAY_BUFFER,bP);gl.bufferData(gl.ARRAY_BUFFER,pos,gl.STATIC_DRAW);gl.bindBuffer(gl.ARRAY_BUFFER,bN);gl.bufferData(gl.ARRAY_BUFFER,nor,gl.STATIC_DRAW);}
|
|
712
|
+
upload(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,bI); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,idx,gl.STATIC_DRAW);
|
|
713
|
+
const aP=gl.getAttribLocation(pr,'aP'),aN=gl.getAttribLocation(pr,'aN');
|
|
714
|
+
gl.enableVertexAttribArray(aP);gl.enableVertexAttribArray(aN);
|
|
715
|
+
function tex(unit){const tx=gl.createTexture();gl.activeTexture(gl.TEXTURE0+unit);gl.bindTexture(gl.TEXTURE_2D,tx);
|
|
716
|
+
gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,1,1,0,gl.RGBA,gl.UNSIGNED_BYTE,new Uint8Array([0,0,0,0]));
|
|
717
|
+
[gl.TEXTURE_WRAP_S,gl.TEXTURE_WRAP_T].forEach(p=>gl.texParameteri(gl.TEXTURE_2D,p,gl.CLAMP_TO_EDGE));
|
|
718
|
+
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);return tx;}
|
|
719
|
+
function setTex(unit,tx,url){const im=new Image();im.onload=()=>{gl.activeTexture(gl.TEXTURE0+unit);gl.bindTexture(gl.TEXTURE_2D,tx);gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL,false);gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,gl.RGBA,gl.UNSIGNED_BYTE,im);dirty=true;};im.src=url;}
|
|
720
|
+
const txR=tex(0),txI=tex(1),txC=tex(2),txB=tex(3);
|
|
721
|
+
gl.uniform1i(U('tR'),0);gl.uniform1i(U('tI'),1);gl.uniform1i(U('tC'),2);gl.uniform1i(U('tB'),3);gl.uniform1f(U('uSplit'),-1);
|
|
722
|
+
const rectOf=bb=>{const a=toModel(mxOf(bb[0][1]),myOf(bb[0][0])),b=toModel(mxOf(bb[1][1]),myOf(bb[1][0]));return [a[0],a[1],b[0],b[1]];};
|
|
723
|
+
gl.uniform4fv(U('rR'),rectOf(TER.bounds)); gl.uniform4fv(U('rC'),rectOf(B));
|
|
724
|
+
setTex(0,txR,TER.relief); setTex(2,txC,D.img.changes);
|
|
725
|
+
gl.uniform3fv(U('uL'),norm3([-0.5,0.6,0.8])); gl.uniform1f(U('uIO'),1);
|
|
726
|
+
let imgUrl=null, sI=1, sC=1;
|
|
727
|
+
function setImage(url,bb,show){ sI=show?1:0; gl.uniform4fv(U('rI'),rectOf(bb)); if(url!==imgUrl){imgUrl=url;setTex(1,txI,url);} dirty=true; }
|
|
728
|
+
let leftUrl=null, split=-1; // split: fraction of the width (swipe line), -1 = off
|
|
729
|
+
function setLeft(url,bb){ gl.uniform4fv(U('rB'),rectOf(bb)); if(url!==leftUrl){leftUrl=url;setTex(3,txB,url);} dirty=true; }
|
|
730
|
+
function setSplit(f){ split=f; dirty=true; }
|
|
731
|
+
|
|
732
|
+
// camera: orbit around the centre; drag rotates, right-drag / shift-drag pans, wheel or pinch zooms
|
|
733
|
+
const zMid=zOf((TER.min+TER.max)/2);
|
|
734
|
+
const cam={yaw:-0.35,pitch:0.62,dist:2.3,tx:0,ty:0,tz:zMid,auto:true};
|
|
735
|
+
let M=null, dirty=true;
|
|
736
|
+
function matrices(){
|
|
737
|
+
const W=cv.clientWidth||1,H=cv.clientHeight||1,dpr=Math.min(2,window.devicePixelRatio||1);
|
|
738
|
+
if(cv.width!==Math.round(W*dpr)||cv.height!==Math.round(H*dpr)){cv.width=Math.round(W*dpr);cv.height=Math.round(H*dpr);ov.width=cv.width;ov.height=cv.height;}
|
|
739
|
+
const e=[cam.tx+cam.dist*Math.cos(cam.pitch)*Math.sin(cam.yaw), cam.ty-cam.dist*Math.cos(cam.pitch)*Math.cos(cam.yaw), cam.tz+cam.dist*Math.sin(cam.pitch)];
|
|
740
|
+
M=mat4mul(persp(45*RAD,W/H,0.01,50),lookAt(e,[cam.tx,cam.ty,cam.tz],[0,0,1]));
|
|
741
|
+
}
|
|
742
|
+
function project(X,Y,up=0){ const m=toModel(X,Y), z=zOf(elevAt(X,Y))+0.004+up, v=[m[0],m[1],z,1];
|
|
743
|
+
const c=[0,1,2,3].map(r=>M[r]*v[0]+M[4+r]*v[1]+M[8+r]*v[2]+M[12+r]); if(c[3]<=0.01) return null;
|
|
744
|
+
return [(c[0]/c[3]*0.5+0.5)*ov.width, (0.5-c[1]/c[3]*0.5)*ov.height, c[3]]; }
|
|
745
|
+
// a ring on the ground: a circle of growing radius (metres) around the change, following the terrain
|
|
746
|
+
function groundRing(c2,it,q,col,lw,alpha){ const R=Math.max(it.r*1.6, 250/coslat)*(0.25+0.9*q); c2.beginPath(); let ok=false;
|
|
747
|
+
for(let i=0;i<=24;i++){ const a=i/24*2*Math.PI, s=project(it.X+R*Math.cos(a), it.Y+R*Math.sin(a)); if(!s){ok=false;continue;} if(!ok){c2.moveTo(s[0],s[1]);ok=true;} else c2.lineTo(s[0],s[1]); }
|
|
748
|
+
c2.strokeStyle=col; c2.globalAlpha=alpha*(1-q*0.85); c2.lineWidth=lw; c2.stroke(); c2.globalAlpha=1; }
|
|
749
|
+
let hlId=null, hlTill=0;
|
|
750
|
+
const feats=D.geojson.features.map(f=>({f, X:mxOf(f.properties.centroid_lon), Y:myOf(f.properties.centroid_lat), r:Math.sqrt(f.properties.area_ha*1e4)/2/coslat}));
|
|
751
|
+
const pulseSet=new Set(pulseFeats.map(f=>f.properties.id));
|
|
752
|
+
let ph=0, last=0, chgOn=true, hover=null;
|
|
753
|
+
function draw(ts){
|
|
754
|
+
const dt=Math.min(0.1,(ts-(last||ts))/1000); last=ts;
|
|
755
|
+
if(cam.auto){cam.yaw+=dt*0.09;dirty=true;}
|
|
756
|
+
const pulsing=pulseActive();
|
|
757
|
+
if(dirty||pulsing||performance.now()<hlTill+300){
|
|
758
|
+
matrices(); gl.viewport(0,0,cv.width,cv.height); gl.clearColor(0.81,0.84,0.87,1); gl.enable(gl.DEPTH_TEST); gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
|
|
759
|
+
gl.uniformMatrix4fv(U('uM'),false,M); gl.uniform1f(U('sI'),sI); gl.uniform1f(U('sC'),chgOn?1:0); gl.uniform1f(U('uSplit'),split>=0?split*cv.width:-1);
|
|
760
|
+
gl.bindBuffer(gl.ARRAY_BUFFER,bP); gl.vertexAttribPointer(aP,3,gl.FLOAT,false,0,0); gl.bindBuffer(gl.ARRAY_BUFFER,bN); gl.vertexAttribPointer(aN,3,gl.FLOAT,false,0,0);
|
|
761
|
+
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,bI); gl.drawElements(gl.TRIANGLES,idx.length,gl.UNSIGNED_SHORT,0);
|
|
762
|
+
// overlay: pins standing on the terrain (small changes, like the 2D dots), rings lying on the ground (main changes), the selected change blinking
|
|
763
|
+
const c2=ov.getContext('2d'), dpr=ov.width/(ov.clientWidth||1); c2.clearRect(0,0,ov.width,ov.height);
|
|
764
|
+
ph=(ph+dt/1.6)%1;
|
|
765
|
+
const pins=[], now=performance.now(), blink=0.5+0.5*Math.sin(now/120);
|
|
766
|
+
if(chgOn) for(const it of feats){ const p=it.f.properties, s=project(it.X,it.Y); it.s=s; it.t=null; if(!s) continue; const col=cls[p.class].color;
|
|
767
|
+
const e=project(it.X+it.r,it.Y), small=!e||Math.hypot(e[0]-s[0],e[1]-s[1])<7*dpr, sel=(p.id===hlId&&now<hlTill);
|
|
768
|
+
// pins only when zoomed in (far away the changes show as the coloured areas draped on the terrain, and the rings), and always for the selected one
|
|
769
|
+
if((small&&cam.dist<0.9)||sel){ const top=project(it.X,it.Y,(sel?0.11:strengthKey(p)==='strong'?0.06:0.045)*Math.min(1,cam.dist/0.6+0.3)); if(top){ it.t=top; pins.push(it); } }
|
|
770
|
+
if(pulsing && pulseSet.has(p.id)) groundRing(c2,it,(ph+(p.id*0.137)%1)%1,col,3*dpr,0.9);
|
|
771
|
+
if(sel) groundRing(c2,it,0.35+0.25*blink,'#ffd400',4*dpr,0.95); }
|
|
772
|
+
pins.sort((a,b)=>b.s[2]-a.s[2]);
|
|
773
|
+
for(const it of pins){ const p=it.f.properties, k=strengthKey(p), col=cls[p.class].color, sel=(p.id===hlId&&now<hlTill), s=it.s, t=it.t;
|
|
774
|
+
c2.beginPath(); c2.ellipse(s[0],s[1],4*dpr,2*dpr,0,0,7); c2.fillStyle='rgba(0,0,0,.35)'; c2.fill();
|
|
775
|
+
c2.beginPath(); c2.moveTo(s[0],s[1]); c2.lineTo(t[0],t[1]); c2.strokeStyle=sel&&blink>0.5?'#ffd400':col; c2.lineWidth=(sel?3:2)*dpr; c2.stroke();
|
|
776
|
+
c2.beginPath(); c2.arc(t[0],t[1],(sel?7:k==='strong'?5:4)*dpr,0,7); c2.fillStyle=sel&&blink>0.5?'#ffd400':col; c2.globalAlpha=Math.max(0.35,DOT_FILL[k]); c2.fill(); c2.globalAlpha=1;
|
|
777
|
+
c2.lineWidth=1.5*dpr; c2.strokeStyle='#fff'; c2.stroke(); }
|
|
778
|
+
dirty=false;
|
|
779
|
+
}
|
|
780
|
+
if(view==='3d') requestAnimationFrame(draw); else running=false;
|
|
781
|
+
}
|
|
782
|
+
let running=false;
|
|
783
|
+
function start(){ if(!running){ running=true; dirty=true; requestAnimationFrame(draw);} }
|
|
784
|
+
function nearest(ev,px){ const r=cv.getBoundingClientRect(), dpr=ov.width/(r.width||1), x=(ev.clientX-r.left)*dpr, y=(ev.clientY-r.top)*dpr; let best=null,bd=px*dpr;
|
|
785
|
+
if(chgOn) for(const it of feats){ for(const q of [it.t,it.s]){ if(!q) continue; const d=Math.hypot(q[0]-x,q[1]-y); if(d<bd){bd=d;best=it;} } } return best; }
|
|
786
|
+
const ptrs=new Map(); let moved=0, pinch0=0;
|
|
787
|
+
cv.addEventListener('contextmenu',e=>e.preventDefault());
|
|
788
|
+
cv.addEventListener('pointerdown',e=>{cv.setPointerCapture(e.pointerId);ptrs.set(e.pointerId,{x:e.clientX,y:e.clientY,b:e.button,s:e.shiftKey});moved=0;cam.auto=false;
|
|
789
|
+
if(ptrs.size===2){const [a,b]=[...ptrs.values()];pinch0=Math.hypot(a.x-b.x,a.y-b.y);}});
|
|
790
|
+
cv.addEventListener('pointermove',e=>{
|
|
791
|
+
const p=ptrs.get(e.pointerId);
|
|
792
|
+
if(!p){ const it=nearest(e,10); if(it!==hover){hover=it;} if(it){tipEl.innerHTML=tip(it.f.properties);tipEl.style.display='block';tipEl.style.left=(e.offsetX+14)+'px';tipEl.style.top=(e.offsetY+10)+'px';cv.style.cursor='pointer';} else {tipEl.style.display='none';cv.style.cursor='grab';} return; }
|
|
793
|
+
const dx=e.clientX-p.x, dy=e.clientY-p.y; p.x=e.clientX; p.y=e.clientY; moved+=Math.abs(dx)+Math.abs(dy);
|
|
794
|
+
if(ptrs.size===2){const [a,b]=[...ptrs.values()],d=Math.hypot(a.x-b.x,a.y-b.y); if(pinch0){cam.dist=Math.min(8,Math.max(0.15,cam.dist*pinch0/d));} pinch0=d;}
|
|
795
|
+
else if(p.b===2||p.s){ const k=cam.dist*0.0016; cam.tx-=(dx*Math.cos(cam.yaw)+dy*Math.sin(cam.yaw))*k; cam.ty-=(dx*Math.sin(cam.yaw)-dy*Math.cos(cam.yaw))*k; }
|
|
796
|
+
else { cam.yaw-=dx*0.008; cam.pitch=Math.min(1.5,Math.max(0.12,cam.pitch+dy*0.006)); }
|
|
797
|
+
dirty=true; tipEl.style.display='none';
|
|
798
|
+
});
|
|
799
|
+
const up=e=>{ const p=ptrs.get(e.pointerId); ptrs.delete(e.pointerId); if(ptrs.size<2) pinch0=0;
|
|
800
|
+
if(p&&moved<5){ const it=nearest(e,12); if(it){ showPop(it.f, e.offsetX, e.offsetY); selectRow(it.f.properties.id); } else pop.style.display='none'; } };
|
|
801
|
+
cv.addEventListener('pointerup',up); cv.addEventListener('pointercancel',up);
|
|
802
|
+
cv.addEventListener('wheel',e=>{e.preventDefault();cam.auto=false;cam.dist=Math.min(8,Math.max(0.15,cam.dist*Math.exp(e.deltaY*0.0012)));dirty=true;},{passive:false});
|
|
803
|
+
function showPop(f,x,y){ pop.innerHTML='<div style="text-align:right;margin:-4px -4px 0 0"><button aria-label="close" style="border:0;background:none;font-size:18px;cursor:pointer">×</button></div>'+popupHtml(f.properties,false);
|
|
804
|
+
pop.querySelector('button').onclick=()=>{pop.style.display='none';};
|
|
805
|
+
pop.style.display='block'; const bw=box.clientWidth, bh=box.clientHeight; pop.style.left=Math.min(Math.max(8,x+12),bw-350)+'px'; pop.style.top=Math.min(Math.max(8,y-20),Math.max(8,bh-pop.offsetHeight-8))+'px'; }
|
|
806
|
+
function focus(f){ const X=mxOf(f.properties.centroid_lon),Y=myOf(f.properties.centroid_lat),m=toModel(X,Y); cam.auto=false; cam.tx=m[0]; cam.ty=m[1]; cam.tz=zOf(elevAt(X,Y)); cam.dist=0.35; cam.pitch=0.75; dirty=true;
|
|
807
|
+
matrices(); const s=project(X,Y); showPop(f, s?s[0]/(ov.width/(ov.clientWidth||1)):40, s?s[1]/(ov.width/(ov.clientWidth||1)):40); }
|
|
808
|
+
function setBase(){ gl.uniform4fv(U('rR'),rectOf(TER.bounds)); setTex(0,txR,TER.relief); } // 3D always drapes the satellite image on the embedded relief
|
|
809
|
+
function setExag(v){ exag=v; fillMesh(); upload(); cam.tz=zOf((TER.min+TER.max)/2); dirty=true; }
|
|
810
|
+
function relabel(){ help.textContent=L_().help3d; att.textContent=D.attribution+' · '+TER.attribution; dirty=true; }
|
|
811
|
+
relabel();
|
|
812
|
+
return {start, setImage, setLeft, setSplit, setChanges:on=>{chgOn=on;dirty=true;if(!on)pop.style.display='none';}, focus, setExag, relabel, resize:()=>{dirty=true;},
|
|
813
|
+
setBase, setImageOpacity:v=>{gl.useProgram(pr);gl.uniform1f(U('uIO'),v);dirty=true;}, highlight:(id,till)=>{hlId=id;hlTill=till;dirty=true;}};
|
|
814
|
+
}
|
|
815
|
+
function current3dImage(){
|
|
816
|
+
if (mode==='timeline' && TL.length) return [TL[tlIndex].img, tlBounds, true];
|
|
817
|
+
if (mode==='swipe') { const pr=swipeFrames(); return pr ? [TL[pr[1]].img, tlBounds, true] : [D.img.after, B, true]; }
|
|
818
|
+
if (mode==='changes') return [D.img.after, B, true];
|
|
819
|
+
return [mode==='before' ? D.img.before : D.img.after, B, true];
|
|
820
|
+
}
|
|
821
|
+
function sync3dImage(){ if (!m3) return; const [u,bb,show]=current3dImage(); m3.setImage(u,bb,show);
|
|
822
|
+
if (mode==='swipe') { const pr=swipeFrames(); if (pr) m3.setLeft(TL[pr[0]].img, tlBounds); else m3.setLeft(D.img.before, B); m3.setSplit(ratio); } else m3.setSplit(-1); }
|
|
823
|
+
function set3dChangesVisible(on){ if (m3) m3.setChanges(on); }
|
|
824
|
+
function fly3d(f){ if (m3) m3.focus(f); }
|
|
825
|
+
function setView(v){
|
|
826
|
+
if (v==='3d' && !TER) { flash(L_().no3d); return; }
|
|
827
|
+
view=v; document.querySelectorAll('#views button').forEach(b=>b.setAttribute('aria-pressed',b.dataset.v===v));
|
|
828
|
+
$('#exag-wrap').style.display = v==='3d' ? '' : 'none';
|
|
829
|
+
if (v==='3d') {
|
|
830
|
+
$('#map').style.display='none'; $('#map3d').style.display='block';
|
|
831
|
+
if (!m3) { try { m3 = build3d(); } catch(e) { m3=null; $('#map3d').textContent=L_().no3d; } }
|
|
832
|
+
if (m3) { m3.setBase(); m3.setImageOpacity(1); m3.start(); setMode(mode); }
|
|
833
|
+
} else { $('#map3d').style.display='none'; $('#map').style.display='block'; map.invalidateSize(); setMode(mode); updateDotsSoon(); }
|
|
834
|
+
$('#t-base').closest('label').style.display = $('#t-op').closest('label').style.display = v==='3d' ? 'none' : '';
|
|
835
|
+
}
|
|
836
|
+
document.querySelectorAll('#views button').forEach(b=>b.addEventListener('click',()=>setView(b.dataset.v)));
|
|
837
|
+
$('#t-exag').addEventListener('change',e=>{ if (m3) m3.setExag(+e.target.value); });
|
|
838
|
+
if (!TER) { const b=document.querySelector('#views button[data-v="3d"]'); b.disabled=true; }
|
|
839
|
+
|
|
840
|
+
const HASH = new URLSearchParams(location.hash.slice(1));
|
|
841
|
+
if (['none','relief','osm','topo'].includes(HASH.get('base')) && !($('#t-base').querySelector('option[value="'+HASH.get('base')+'"]')||{}).disabled) { $('#t-base').value = HASH.get('base'); setBase(HASH.get('base')); }
|
|
842
|
+
if (TL.length>1) ratio = Math.max(0.02, Math.min(0.98, (tlIndex+0.5)/(TL.length-1))); // the swipe line starts between the last "before" and the first "after" pass
|
|
843
|
+
setRatio(ratio);
|
|
844
|
+
setMode('swipe');
|
|
845
|
+
renderTexts();
|
|
846
|
+
if (HASH.get('view')==='3d' && TER) setView('3d');
|
|
847
|
+
})();
|
|
848
|
+
</script>
|
|
849
|
+
</body>
|
|
850
|
+
</html>
|