zakhtar/Global_Oceans_ACLED
0
1<!doctype html>2<html lang="en">3<head>4 <meta charset="utf-8">5 <meta name="viewport" content="initial-scale=1,user-scalable=no,maximum-scale=1,width=device-width">6 <title>ACLED Maritime – April 2026</title>7 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ol@9.2.4/ol.css">8 <script src="https://cdn.jsdelivr.net/npm/ol@9.2.4/dist/ol.js"></script>9 <script src="https://cdn.jsdelivr.net/npm/topojson-client@3.1.0/dist/topojson-client.min.js"></script>10 <style>11 *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }12 html, body { height: 100%; width: 100%; font-family: system-ui, sans-serif; background: #f0eeea; overflow: hidden; }13 #app { display: flex; height: 100vh; width: 100vw; overflow: hidden; }14 #sidebar {15 width: 280px; min-width: 240px; flex-shrink: 0;16 background: #fff; border-right: 1px solid #ddd;17 display: flex; flex-direction: column; overflow: hidden;18 }19 #sidebar-header { padding: 12px 14px 8px; border-bottom: 1px solid #eee; }20 #sidebar-header h1 { font-size: 13px; font-weight: 700; color: #1a1a1a; }21 #sidebar-header p { font-size: 10px; color: #888; margin-top: 2px; }22 #filters { padding: 8px 12px; flex: 1; overflow-y: auto; }23 .filter-group { margin-bottom: 10px; }24 .filter-label {25 font-size: 10px; font-weight: 700; text-transform: uppercase;26 letter-spacing: .05em; color: #555; margin-bottom: 3px;27 display: flex; justify-content: space-between; align-items: center;28 }29 .filter-label span.clr { font-weight: 400; text-transform: none; letter-spacing: 0; color: #3b5bdb; cursor: pointer; }30 select.fsel {31 width: 100%; border: 1px solid #ddd; border-radius: 4px;32 font-size: 11px; padding: 2px; background: #fafafa; color: #222;33 }34 #basemap-switcher { display: flex; gap: 4px; margin: 0 12px 8px; }35 .bmbtn {36 flex: 1; padding: 4px 2px; font-size: 10px; font-weight: 600;37 border: 1px solid #ddd; background: #fff; border-radius: 4px;38 cursor: pointer; color: #444; white-space: nowrap;39 transition: background .12s, border-color .12s;40 }41 .bmbtn:hover { background: #f0f0f0; }42 .bmbtn.active { background: #3b5bdb; color: #fff; border-color: #3b5bdb; }43 #legend {44 margin: 0 12px 8px; padding: 7px 9px;45 background: #fafafa; border: 1px solid #eee; border-radius: 4px;46 font-size: 10px; color: #555;47 }48 #legend .legend-title { font-weight: 700; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 5px; color: #333; font-size: 9px; }49 .legend-row { display: flex; align-items: center; gap: 7px; margin-bottom: 3px; }50 .legend-swatch { width: 20px; height: 12px; border-radius: 2px; flex-shrink: 0; }51 .legend-circle { width: 13px; height: 13px; border-radius: 50%; flex-shrink: 0; }52 #reset-btn {53 margin: 0 12px 10px; width: calc(100% - 24px); padding: 7px;54 border: 1px solid #ddd; background: #fff; border-radius: 4px;55 font-size: 11px; font-weight: 600; cursor: pointer;56 }57 #reset-btn:hover { background: #f0f0f0; }58 #record-count { padding: 5px 12px; font-size: 10px; color: #888; border-top: 1px solid #eee; }59 #debug-log { padding: 4px 12px 8px; font-size: 9px; color: #aaa; border-top: 1px solid #eee; max-height: 60px; overflow-y: auto; line-height: 1.5; }60 #main { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }61 #map-pane { flex: 1; position: relative; min-height: 0; }62 #map { position: absolute; inset: 0; }63 #drag-handle {64 height: 6px; background: #e0ddd8; cursor: row-resize; flex-shrink: 0;65 display: flex; align-items: center; justify-content: center;66 border-top: 1px solid #ccc; border-bottom: 1px solid #ccc; user-select: none;67 }68 #drag-handle::after { content: '⋯'; font-size: 12px; color: #aaa; letter-spacing: 3px; }69 #table-pane {70 height: 220px; min-height: 80px; max-height: 60vh;71 display: flex; flex-direction: column; background: #fff;72 border-top: 1px solid #ccc; overflow: hidden; flex-shrink: 0;73 }74 #table-header {75 display: flex; align-items: center; justify-content: space-between;76 padding: 4px 10px; background: #f5f4f0; border-bottom: 1px solid #e0ddd8; flex-shrink: 0;77 }78 #table-header span { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: #555; }79 #table-scroll { flex: 1; overflow: auto; }80 #tbl { width: 100%; border-collapse: collapse; font-size: 11px; }81 #tbl thead { position: sticky; top: 0; z-index: 10; }82 #tbl th {83 background: #f2f2f2; border-bottom: 2px solid #ddd;84 padding: 6px 10px; text-align: left; font-weight: 700;85 font-size: 10px; text-transform: uppercase; letter-spacing: .04em;86 white-space: nowrap; cursor: pointer; user-select: none;87 }88 #tbl th:hover { background: #e8e8e8; }89 #tbl td {90 padding: 4px 10px; border-bottom: 1px solid #eee;91 vertical-align: top; max-width: 200px;92 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;93 }94 #tbl tr:hover td { background: #f0f4ff; }95 #tbl tr.highlighted td { background: #fff3cd !important; }96 .sort-asc::after { content: " ▲"; font-size: 8px; color: #3b5bdb; }97 .sort-desc::after { content: " ▼"; font-size: 8px; color: #3b5bdb; }98 #loading {99 position: fixed; inset: 0; background: rgba(248,247,244,.95);100 display: flex; flex-direction: column; align-items: center; justify-content: center;101 z-index: 9999; gap: 14px;102 }103 #loading.hidden { display: none; }104 .spinner {105 width: 36px; height: 36px; border-radius: 50%;106 border: 3px solid #e0ddd8; border-top-color: #3b5bdb;107 animation: spin .7s linear infinite;108 }109 @keyframes spin { to { transform: rotate(360deg); } }110 #loading-text { font-size: 13px; font-weight: 600; color: #333; }111 #loading-bar-wrap { width: 200px; height: 3px; background: #e0ddd8; border-radius: 2px; overflow: hidden; }112 #loading-bar { height: 100%; width: 0%; background: #3b5bdb; border-radius: 2px; transition: width .25s ease; }113 .ol-popup {114 position: absolute; background: #fff; border: 1px solid #ccc;115 border-radius: 6px; padding: 10px 28px 10px 12px;116 min-width: 160px; max-width: 280px; max-height: 320px; overflow-y: auto;117 box-shadow: 0 3px 14px rgba(0,0,0,.18); font-size: 11px;118 pointer-events: auto; transform: translate(-50%, calc(-100% - 14px));119 }120 .ol-popup::after {121 content: ''; position: absolute; bottom: -8px; left: 50%;122 transform: translateX(-50%);123 border: 8px solid transparent; border-top-color: #fff; border-bottom: 0;124 filter: drop-shadow(0 1px 1px rgba(0,0,0,.12));125 }126 .ol-popup table { border-collapse: collapse; width: 100%; }127 .ol-popup th { text-align: right; padding: 2px 7px 2px 0; color: #777; font-weight: 600; white-space: nowrap; vertical-align: top; font-size: 10px; }128 .ol-popup td { padding: 2px 0; color: #111; word-break: break-word; }129 .ol-popup-closer {130 position: absolute; top: 6px; right: 8px; cursor: pointer;131 font-size: 13px; color: #aaa; line-height: 1; background: none; border: none;132 }133 .ol-popup-closer:hover { color: #333; }134 .ol-attribution { font-size: 9px !important; }135 #edit-toolbar {136 display: flex; align-items: center; gap: 6px;137 margin: 0 12px 8px; padding: 7px 9px;138 background: #fff8e1; border: 1px solid #ffe082; border-radius: 4px;139 font-size: 10px;140 }141 #edit-toolbar.editing { background: #e8f5e9; border-color: #a5d6a7; }142 #btn-edit-toggle {143 padding: 4px 8px; font-size: 10px; font-weight: 700; border-radius: 4px;144 border: 1px solid #f9a825; background: #fff9c4; cursor: pointer; color: #5d4037;145 white-space: nowrap;146 }147 #btn-edit-toggle.editing { background: #c8e6c9; border-color: #66bb6a; color: #1b5e20; }148 #edit-status { flex: 1; color: #666; font-size: 9px; line-height: 1.3; }149 #btn-download-csv {150 padding: 4px 8px; font-size: 10px; font-weight: 700; border-radius: 4px;151 border: 1px solid #3b5bdb; background: #e8eeff; cursor: pointer; color: #1a237e;152 white-space: nowrap;153 }154 #btn-download-csv:hover { background: #3b5bdb; color: #fff; }155 156 /* CSV status banner */157 #csv-banner {158 margin: 0 12px 8px; padding: 7px 9px;159 background: #e3f2fd; border: 1px solid #90caf9; border-radius: 4px;160 font-size: 10px; color: #1565c0; display: flex; flex-direction: column; gap: 4px;161 }162 #csv-banner .csv-title { font-weight: 700; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 2px; }163 #csv-status { font-size: 9px; color: #555; }164 </style>165</head>166<body>167 168<div id="loading">169 <div class="spinner"></div>170 <div id="loading-text">Fetching data…</div>171 <div id="loading-bar-wrap"><div id="loading-bar"></div></div>172</div>173 174<div id="app">175 <div id="sidebar">176 <div id="sidebar-header">177 <h1>ACLED Maritime Events</h1>178 <p>April 2026 · Boundaries + Centroids</p>179 </div>180 181 <div id="filters">182 <div class="filter-group">183 <div class="filter-label">Country <span class="clr" onclick="clearFilter('country')">clear</span></div>184 <select id="sel_country" class="fsel" multiple size="4"></select>185 </div>186 <div class="filter-group">187 <div class="filter-label">Admin 1 <span class="clr" onclick="clearFilter('admin1')">clear</span></div>188 <select id="sel_admin1" class="fsel" multiple size="4"></select>189 </div>190 <div class="filter-group">191 <div class="filter-label">Admin 2 <span class="clr" onclick="clearFilter('admin2')">clear</span></div>192 <select id="sel_admin2" class="fsel" multiple size="5"></select>193 </div>194 <div class="filter-group">195 <div class="filter-label">Admin 3 <span class="clr" onclick="clearFilter('admin3')">clear</span></div>196 <select id="sel_admin3" class="fsel" multiple size="3"></select>197 </div>198 </div>199 200 <!-- Basemap switcher -->201 <div id="basemap-switcher">202 <button class="bmbtn active" onclick="setBasemap('positron')">☀ Light</button>203 <button class="bmbtn" onclick="setBasemap('voyager')">🌍 Colour</button>204 <button class="bmbtn" onclick="setBasemap('dark')">🌑 Dark</button>205 <button class="bmbtn" onclick="setBasemap('esri')">▭ Gray</button>206 </div>207 208 <div id="legend">209 <div class="legend-title">Legend</div>210 <div class="legend-row">211 <div class="legend-swatch" id="legend-poly" style="background:rgba(145,82,50,0.4);border:1.5px solid #5a3010;"></div>212 <span>Boundary polygon</span>213 </div>214 <div class="legend-row">215 <div class="legend-circle" id="legend-pt" style="background:#e63c2f;border:2px solid #8b1a10;"></div>216 <span>Centroid (from CSV)</span>217 </div>218 </div>219 220 <!-- CSV status -->221 <div id="csv-banner">222 <div class="csv-title">📍 Centroids CSV</div>223 <div id="csv-status">Loading centroids from HuggingFace…</div>224 </div>225 226 <!-- Edit toolbar -->227 <div id="edit-toolbar">228 <button id="btn-edit-toggle" onclick="toggleEditMode()">✏ Edit Centroids</button>229 <span id="edit-status">Click to enable drag-to-move</span>230 </div>231 <button id="btn-download-csv" onclick="downloadCSV()" style="margin:0 12px 6px;width:calc(100% - 24px);padding:7px;font-size:11px;font-weight:700;border:1px solid #3b5bdb;background:#e8eeff;border-radius:4px;cursor:pointer;color:#1a237e;">⬇ Download CSV</button>232 233 <button id="reset-btn" onclick="resetAll()">Reset All Filters</button>234 <div id="record-count">Loading…</div>235 <div id="debug-log"></div>236 </div>237 238 <div id="main">239 <div id="map-pane">240 <div id="map"></div>241 <div id="popup" class="ol-popup" style="display:none">242 <button class="ol-popup-closer" onclick="closePopup()">✕</button>243 <div id="popup-content"></div>244 </div>245 </div>246 247 <div id="drag-handle"></div>248 249 <div id="table-pane">250 <div id="table-header">251 <span id="table-title">Table</span>252 </div>253 <div id="table-scroll">254 <table id="tbl">255 <thead id="tbl-head"></thead>256 <tbody id="tbl-body"></tbody>257 </table>258 </div>259 </div>260 </div>261</div>262 263<script>264'use strict';265 266// ── State ─────────────────────────────────────────────────────────────────────267var ALL_FEATURES = [], ALL_OL_BOUND = [], ALL_OL_CENT = [];268var sortCol = null, sortDir = 1;269var highlightedBound = null, highlightedCent = null;270var editMode = false, editedIndices = {};271 272// CSV centroid lookup: key = "country,admin1,admin2,admin3" → {centroid_x, centroid_y}273// Falls back to properties from TopoJSON if key not found274var CSV_CENTROID_MAP = {};275var csvLoaded = false;276 277function dbg(msg) {278 var el = document.getElementById('debug-log');279 if (el) el.innerHTML += '<div>' + String(msg) + '</div>';280 console.log('[ACLED]', msg);281}282function setProgress(pct, text) {283 document.getElementById('loading-bar').style.width = pct + '%';284 if (text) document.getElementById('loading-text').textContent = text;285}286function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<'); }287 288// ── CSV centroid key builder ──────────────────────────────────────────────────289// Key uses country + admin1 + admin2 + admin3 joined by pipe290function centroidKey(country, admin1, admin2, admin3) {291 return [country, admin1, admin2, admin3]292 .map(function(v){ return (v == null) ? '' : String(v).trim(); })293 .join('|');294}295 296// ── Parse CSV text → array of objects ────────────────────────────────────────297function parseCSV(text) {298 var lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');299 if (!lines.length) return [];300 var headers = lines[0].split(',').map(function(h){ return h.trim(); });301 var rows = [];302 for (var i = 1; i < lines.length; i++) {303 var line = lines[i].trim();304 if (!line) continue;305 var vals = line.split(',');306 var obj = {};307 headers.forEach(function(h, hi){ obj[h] = (vals[hi] !== undefined) ? vals[hi].trim() : ''; });308 rows.push(obj);309 }310 return rows;311}312 313// ── Load CSV from file input ──────────────────────────────────────────────────314var CSV_URL = 'https://huggingface.co/spaces/zakhtar/Global_Oceans_ACLED/resolve/main/acled_maritime_centroids_danyaledits_v1.csv';315 316function fetchCSV() {317 document.getElementById('csv-status').textContent = 'Fetching centroids CSV…';318 fetch(CSV_URL)319 .then(function(r) {320 if (!r.ok) throw new Error('HTTP ' + r.status);321 return r.text();322 })323 .then(function(text) {324 ingestCSV(text, 'acled_maritime_centroids_danyaledits_v1.csv');325 })326 .catch(function(e) {327 document.getElementById('csv-status').textContent = '⚠ CSV fetch failed: ' + e + ' — using TopoJSON coords';328 document.getElementById('csv-banner').style.background = '#fff3e0';329 document.getElementById('csv-banner').style.borderColor = '#ffb74d';330 dbg('CSV fetch failed: ' + e);331 });332}333 334function ingestCSV(text, filename) {335 var rows = parseCSV(text);336 CSV_CENTROID_MAP = {};337 var matched = 0;338 rows.forEach(function(r) {339 var lon = parseFloat(r.centroid_x), lat = parseFloat(r.centroid_y);340 if (isNaN(lon) || isNaN(lat)) return;341 var key = centroidKey(r.country, r.admin1, r.admin2, r.admin3);342 CSV_CENTROID_MAP[key] = { centroid_x: lon, centroid_y: lat };343 matched++;344 });345 csvLoaded = true;346 document.getElementById('csv-status').textContent =347 '✓ ' + (filename || 'CSV') + ' — ' + matched + ' centroids loaded';348 dbg('CSV loaded: ' + matched + ' centroids from ' + (filename || 'file'));349 350 // Rebuild centroid OL features using new CSV coords, then re-apply filter351 if (ALL_FEATURES.length) {352 rebuildCentroidFeatures();353 applyFilter();354 }355}356 357// ── Resolve centroid coords for a feature ────────────────────────────────────358// Prefers CSV lookup, falls back to TopoJSON properties359function resolveCentroid(props) {360 if (csvLoaded) {361 var key = centroidKey(props.country, props.admin1, props.admin2, props.admin3);362 var csv = CSV_CENTROID_MAP[key];363 if (csv) return { lon: csv.centroid_x, lat: csv.centroid_y, fromCSV: true };364 }365 var lon = parseFloat(props.centroid_x), lat = parseFloat(props.centroid_y);366 if (!isNaN(lon) && !isNaN(lat)) return { lon: lon, lat: lat, fromCSV: false };367 return null;368}369 370// ── Basemaps ──────────────────────────────────────────────────────────────────371var BASEMAPS = {372 positron: {373 url: 'https://{a-d}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png',374 attr: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> contributors © <a href="https://carto.com/">CARTO</a>'375 },376 voyager: {377 url: 'https://{a-d}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png',378 attr: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> contributors © <a href="https://carto.com/">CARTO</a>'379 },380 dark: {381 url: 'https://{a-d}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',382 attr: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> contributors © <a href="https://carto.com/">CARTO</a>'383 },384 esri: {385 url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}',386 attr: 'Tiles © <a href="https://www.esri.com/">Esri</a>'387 }388};389var boundaryStyleLight = new ol.style.Style({390 stroke: new ol.style.Stroke({ color: '#5a3010', width: 1.5 }),391 fill: new ol.style.Fill({ color: 'rgba(145,82,50,0.30)' })392});393var centroidStyleLight = new ol.style.Style({394 image: new ol.style.Circle({ radius: 8, stroke: new ol.style.Stroke({ color: '#8b1a10', width: 2 }), fill: new ol.style.Fill({ color: '#e63c2f' }) })395});396var boundaryStyleDark = new ol.style.Style({397 stroke: new ol.style.Stroke({ color: '#f0c060', width: 1.5 }),398 fill: new ol.style.Fill({ color: 'rgba(240,180,60,0.18)' })399});400var centroidStyleDark = new ol.style.Style({401 image: new ol.style.Circle({ radius: 8, stroke: new ol.style.Stroke({ color: '#ffcc44', width: 2 }), fill: new ol.style.Fill({ color: '#ff8833' }) })402});403var boundaryHoverLight = new ol.style.Style({404 stroke: new ol.style.Stroke({ color: '#3b1a08', width: 2.5 }),405 fill: new ol.style.Fill({ color: 'rgba(145,82,50,0.55)' })406});407var centroidHoverLight = new ol.style.Style({408 image: new ol.style.Circle({ radius: 10, stroke: new ol.style.Stroke({ color: '#5a0a05', width: 2 }), fill: new ol.style.Fill({ color: '#ff6b5b' }) })409});410var boundaryHoverDark = new ol.style.Style({411 stroke: new ol.style.Stroke({ color: '#ffe066', width: 2.5 }),412 fill: new ol.style.Fill({ color: 'rgba(240,180,60,0.40)' })413});414var centroidHoverDark = new ol.style.Style({415 image: new ol.style.Circle({ radius: 10, stroke: new ol.style.Stroke({ color: '#fff', width: 2 }), fill: new ol.style.Fill({ color: '#ffaa44' }) })416});417var boundaryHighlight = new ol.style.Style({418 stroke: new ol.style.Stroke({ color: '#e6a800', width: 3 }),419 fill: new ol.style.Fill({ color: 'rgba(255,200,0,0.35)' })420});421var centroidHighlight = new ol.style.Style({422 image: new ol.style.Circle({ radius: 11, stroke: new ol.style.Stroke({ color: '#b37700', width: 2.5 }), fill: new ol.style.Fill({ color: '#ffe033' }) })423});424var centroidEditedStyle = new ol.style.Style({425 image: new ol.style.Circle({ radius: 9, stroke: new ol.style.Stroke({ color: '#e65100', width: 2.5 }), fill: new ol.style.Fill({ color: '#ff9800' }) })426});427var centroidDragStyle = new ol.style.Style({428 image: new ol.style.Circle({ radius: 11, stroke: new ol.style.Stroke({ color: '#bf360c', width: 2.5 }), fill: new ol.style.Fill({ color: '#ffcc02' }) })429});430var currentBasemap = 'positron';431 432// ── Map ───────────────────────────────────────────────────────────────────────433var baseTile = new ol.layer.Tile({434 source: new ol.source.XYZ({ url: BASEMAPS.positron.url, attributions: BASEMAPS.positron.attr, maxZoom: 19 })435});436var boundarySource = new ol.source.Vector();437var centroidSource = new ol.source.Vector();438var boundaryLayer = new ol.layer.Vector({ source: boundarySource, style: boundaryStyleLight });439var centroidLayer = new ol.layer.Vector({ source: centroidSource, style: centroidStyleLight });440var olMap = new ol.Map({441 target: 'map',442 controls: ol.control.defaults.defaults({ attribution: true, zoom: true }),443 view: new ol.View({ center: ol.proj.fromLonLat([0, 20]), zoom: 2, minZoom: 1, maxZoom: 18 }),444 layers: [ baseTile, boundaryLayer, centroidLayer ]445});446 447// ── Basemap switcher ──────────────────────────────────────────────────────────448function setBasemap(key) {449 currentBasemap = key;450 var bm = BASEMAPS[key];451 baseTile.setSource(new ol.source.XYZ({ url: bm.url, attributions: bm.attr, maxZoom: 19 }));452 var isDark = key === 'dark';453 boundaryLayer.setStyle(isDark ? boundaryStyleDark : boundaryStyleLight);454 centroidLayer.setStyle(isDark ? centroidStyleDark : centroidStyleLight);455 document.getElementById('legend-poly').style.cssText =456 isDark ? 'background:rgba(240,180,60,0.25);border:1.5px solid #f0c060;width:20px;height:12px;border-radius:2px;flex-shrink:0'457 : 'background:rgba(145,82,50,0.4);border:1.5px solid #5a3010;width:20px;height:12px;border-radius:2px;flex-shrink:0';458 document.getElementById('legend-pt').style.cssText =459 isDark ? 'background:#ff8833;border:2px solid #ffcc44;width:13px;height:13px;border-radius:50%;flex-shrink:0'460 : 'background:#e63c2f;border:2px solid #8b1a10;width:13px;height:13px;border-radius:50%;flex-shrink:0';461 document.querySelectorAll('.bmbtn').forEach(function(b, i) {462 b.classList.toggle('active', ['positron','voyager','dark','esri'][i] === key);463 });464}465 466// ── Popup ─────────────────────────────────────────────────────────────────────467var popupEl = document.getElementById('popup');468var popupContent = document.getElementById('popup-content');469var popupOverlay = new ol.Overlay({470 element: popupEl, positioning: 'bottom-center', stopEvent: true,471 autoPan: { animation: { duration: 150 } }472});473olMap.addOverlay(popupOverlay);474function closePopup() { popupOverlay.setPosition(undefined); popupEl.style.display = 'none'; }475function showPopup(coord, props) {476 var rows = Object.keys(props)477 .filter(function(k){ var v=props[k]; return v!=null && String(v).trim()!=='' && k!=='_idx'; })478 .map(function(k){ return '<tr><th>'+esc(k)+'</th><td>'+esc(String(props[k]))+'</td></tr>'; }).join('');479 popupContent.innerHTML = '<table>'+rows+'</table>';480 popupEl.style.display = 'block';481 popupOverlay.setPosition(coord);482}483 484// ── Hover ─────────────────────────────────────────────────────────────────────485var hoveredFeat = null;486olMap.on('pointermove', function(evt) {487 if (evt.dragging) return;488 var hit = null, hitLayer = null;489 olMap.forEachFeatureAtPixel(evt.pixel, function(f, l) {490 if (!hit) { hit = f; hitLayer = l; }491 }, { layerFilter: function(l){ return l===centroidLayer||l===boundaryLayer; }, hitTolerance: 10 });492 if (hoveredFeat && hoveredFeat !== hit) { hoveredFeat.setStyle(null); hoveredFeat = null; }493 if (hit && hit !== hoveredFeat) {494 var isDark = currentBasemap === 'dark';495 if (hitLayer === centroidLayer) hit.setStyle(isDark ? centroidHoverDark : centroidHoverLight);496 else hit.setStyle(isDark ? boundaryHoverDark : boundaryHoverLight);497 hoveredFeat = hit;498 }499 olMap.getTargetElement().style.cursor = hit ? 'pointer' : '';500});501 502// ── Click ─────────────────────────────────────────────────────────────────────503olMap.on('singleclick', function(evt) {504 var hit = false;505 olMap.forEachFeatureAtPixel(evt.pixel, function(f, l) {506 if (!hit && l===centroidLayer) {507 if (!editMode) { zoomToFeature(f.get('_idx')); }508 hit = true;509 }510 }, { hitTolerance: 10 });511 if (!hit) {512 olMap.forEachFeatureAtPixel(evt.pixel, function(f, l) {513 if (!hit && l===boundaryLayer) { showPopup(evt.coordinate, f.getProperties()); hit=true; highlightTableRow(f.get('_idx')); }514 }, { hitTolerance: 4 });515 }516 if (!hit) { closePopup(); clearMapHighlight(); }517});518 519// ── Feature builders ──────────────────────────────────────────────────────────520var geojsonFmt = new ol.format.GeoJSON();521 522function buildBoundaryFeatures() {523 ALL_OL_BOUND = [];524 ALL_FEATURES.forEach(function(gf, i) {525 var bf = null;526 try {527 bf = geojsonFmt.readFeature(gf, { dataProjection:'EPSG:4326', featureProjection:'EPSG:3857' });528 bf.set('_idx', i);529 } catch(e) {}530 ALL_OL_BOUND.push(bf);531 });532 dbg('Built ' + ALL_OL_BOUND.filter(Boolean).length + ' boundary features');533}534 535function rebuildCentroidFeatures() {536 ALL_OL_CENT = [];537 ALL_FEATURES.forEach(function(gf, i) {538 var p = gf.properties || {};539 var resolved = resolveCentroid(p);540 var cf = null;541 if (resolved) {542 cf = new ol.Feature({ geometry: new ol.geom.Point(ol.proj.fromLonLat([resolved.lon, resolved.lat])) });543 // Copy all props + add resolved coords so popup shows them544 var props = Object.assign({}, p);545 props.centroid_x = resolved.lon;546 props.centroid_y = resolved.lat;547 props._from_csv = resolved.fromCSV ? 'yes' : 'no';548 props._idx = i;549 cf.setProperties(props);550 }551 ALL_OL_CENT.push(cf);552 });553 var cnt = ALL_OL_CENT.filter(Boolean).length;554 dbg('Built ' + cnt + ' centroid features' + (csvLoaded ? ' (from CSV)' : ' (from TopoJSON)'));555}556 557function buildAllOLFeatures() {558 buildBoundaryFeatures();559 rebuildCentroidFeatures();560}561 562// ── Filters ───────────────────────────────────────────────────────────────────563function getSel(id) {564 var opts=document.getElementById(id).options, out=[];565 for (var i=0;i<opts.length;i++) if (opts[i].selected && opts[i].value!=='') out.push(opts[i].value);566 return out;567}568function uniqSorted(arr) {569 var seen={}, out=[];570 arr.forEach(function(v){571 var s=(v==null||(typeof v==='number'&&isNaN(v)))?'':String(v).trim();572 if (s&&!seen[s]){seen[s]=1;out.push(s);}573 });574 return out.sort(function(a,b){return a.localeCompare(b);});575}576function setOpts(id, vals, keepSel) {577 var sel=document.getElementById(id), prev=keepSel?getSel(id):[];578 sel.innerHTML='<option value=""></option>';579 vals.forEach(function(v){ var o=document.createElement('option'); o.value=o.text=v; if (prev.indexOf(v)>=0) o.selected=true; sel.appendChild(o); });580}581function clearFilter(key) {582 var opts=document.getElementById('sel_'+key).options;583 for (var i=0;i<opts.length;i++) opts[i].selected=false;584 rebuildCascades(); applyFilter();585}586function resetAll() { ['country','admin1','admin2','admin3'].forEach(function(k){clearFilter(k);}); }587function passFilter(p,cSel,a1Sel,a2Sel) {588 if (cSel.length && cSel.indexOf(String(p.country||''))<0) return false;589 if (a1Sel.length && a1Sel.indexOf(String(p.admin1||''))<0) return false;590 if (a2Sel.length && a2Sel.indexOf(String(p.admin2||''))<0) return false;591 return true;592}593function filteredIndices() {594 var cSel=getSel('sel_country'), a1Sel=getSel('sel_admin1'), a2Sel=getSel('sel_admin2'), a3Sel=getSel('sel_admin3');595 var out=[];596 for (var i=0;i<ALL_FEATURES.length;i++){597 var p=ALL_FEATURES[i].properties;598 if (!passFilter(p,cSel,a1Sel,a2Sel)) continue;599 if (a3Sel.length && a3Sel.indexOf(String(p.admin3||''))<0) continue;600 out.push(i);601 }602 return out;603}604function rebuildCascades() {605 var cSel=getSel('sel_country'), a1Sel, a2Sel;606 setOpts('sel_admin1', uniqSorted(ALL_FEATURES.filter(function(f){return passFilter(f.properties,cSel,[],[]);}).map(function(f){return f.properties.admin1;})), true);607 a1Sel=getSel('sel_admin1');608 setOpts('sel_admin2', uniqSorted(ALL_FEATURES.filter(function(f){return passFilter(f.properties,cSel,a1Sel,[]);}).map(function(f){return f.properties.admin2;})), true);609 a2Sel=getSel('sel_admin2');610 setOpts('sel_admin3', uniqSorted(ALL_FEATURES.filter(function(f){return passFilter(f.properties,cSel,a1Sel,a2Sel);}).map(function(f){return f.properties.admin3;})), true);611}612var currentIndices = [];613function applyFilter() {614 currentIndices = filteredIndices();615 document.getElementById('record-count').textContent = currentIndices.length + ' / ' + ALL_FEATURES.length + ' records';616 closePopup();617 clearMapHighlight();618 boundarySource.clear(true); centroidSource.clear(true);619 var bArr=[], cArr=[];620 for (var i=0;i<currentIndices.length;i++){621 var idx=currentIndices[i];622 if (ALL_OL_BOUND[idx]) bArr.push(ALL_OL_BOUND[idx]);623 if (ALL_OL_CENT[idx]) cArr.push(ALL_OL_CENT[idx]);624 }625 boundarySource.addFeatures(bArr);626 centroidSource.addFeatures(cArr);627 Object.keys(editedIndices).forEach(function(i) {628 var cf = ALL_OL_CENT[parseInt(i)];629 if (cf) cf.setStyle(centroidEditedStyle);630 });631 renderTable(currentIndices);632 document.getElementById('table-title').textContent = 'Table — ' + currentIndices.length + ' records';633}634['sel_country','sel_admin1','sel_admin2'].forEach(function(id){635 document.getElementById(id).onchange = function(){ rebuildCascades(); applyFilter(); };636});637document.getElementById('sel_admin3').onchange = function(){ applyFilter(); };638 639// ── Table ─────────────────────────────────────────────────────────────────────640var TABLE_COLS = null, lastHighlightedRow = null;641function highlightTableRow(featureIdx) {642 if (lastHighlightedRow) lastHighlightedRow.classList.remove('highlighted');643 var row = document.querySelector('#tbl-body tr[data-idx="'+featureIdx+'"]');644 if (row) { row.classList.add('highlighted'); row.scrollIntoView({block:'nearest',behavior:'smooth'}); lastHighlightedRow = row; }645}646function renderTable(indices) {647 if (!indices || !indices.length) {648 document.getElementById('tbl-body').innerHTML = '<tr><td colspan="20" style="padding:20px;text-align:center;color:#999">No records match.</td></tr>';649 return;650 }651 if (!TABLE_COLS) {652 TABLE_COLS = Object.keys(ALL_FEATURES[0].properties || {});653 var head=document.getElementById('tbl-head'), tr=document.createElement('tr');654 TABLE_COLS.forEach(function(c){655 var th=document.createElement('th'); th.textContent=c; th.dataset.col=c;656 th.onclick=function(){sortTable(c);}; tr.appendChild(th);657 });658 head.appendChild(tr);659 }660 var sorted = indices.slice();661 if (sortCol) {662 sorted.sort(function(a,b){663 var av=ALL_FEATURES[a].properties[sortCol], bv=ALL_FEATURES[b].properties[sortCol];664 if (av==null) av=''; if (bv==null) bv='';665 return String(av).localeCompare(String(bv),undefined,{numeric:true})*sortDir;666 });667 }668 document.querySelectorAll('#tbl-head th').forEach(function(th){669 th.classList.remove('sort-asc','sort-desc');670 if (th.dataset.col===sortCol) th.classList.add(sortDir===1?'sort-asc':'sort-desc');671 });672 var frag=document.createDocumentFragment();673 sorted.forEach(function(idx){674 var p=ALL_FEATURES[idx].properties, tr=document.createElement('tr');675 tr.dataset.idx=idx; tr.style.cursor='pointer';676 if (editedIndices[idx]) tr.style.background='#fff8e1';677 tr.onclick=(function(i){return function(){zoomToFeature(i);};})(idx);678 TABLE_COLS.forEach(function(c){679 var td=document.createElement('td');680 var v=p[c], s=(v==null||(typeof v==='number'&&isNaN(v)))?'':String(v);681 td.textContent=s; td.title=s; tr.appendChild(td);682 });683 frag.appendChild(tr);684 });685 var body=document.getElementById('tbl-body'); body.innerHTML=''; body.appendChild(frag);686}687function sortTable(col) {688 if (sortCol===col) sortDir*=-1; else {sortCol=col;sortDir=1;}689 renderTable(currentIndices);690}691 692// ── Highlight & zoom ──────────────────────────────────────────────────────────693function clearMapHighlight() {694 if (highlightedBound) { highlightedBound.setStyle(null); highlightedBound = null; }695 if (highlightedCent) { highlightedCent.setStyle(null); highlightedCent = null; }696}697 698function zoomToFeature(idx) {699 var bf = ALL_OL_BOUND[idx];700 var cf = ALL_OL_CENT[idx];701 clearMapHighlight();702 703 // Yellow highlight on boundary polygon704 if (bf) {705 bf.setStyle(boundaryHighlight);706 highlightedBound = bf;707 olMap.getView().fit(bf.getGeometry().getExtent(), { padding:[60,60,60,60], duration:400, maxZoom:8 });708 }709 710 // Yellow highlight on centroid point711 if (cf) {712 cf.setStyle(centroidHighlight);713 highlightedCent = cf;714 }715 716 // Show popup at centroid point (preferred) or polygon centre717 var coord = null;718 if (cf) {719 coord = cf.getGeometry().getCoordinates();720 } else if (bf) {721 coord = ol.extent.getCenter(bf.getGeometry().getExtent());722 }723 724 // Build popup props: merge TopoJSON props + resolved centroid coords725 var baseProps = ALL_FEATURES[idx].properties || {};726 var resolved = resolveCentroid(baseProps);727 var popupProps = Object.assign({}, baseProps);728 if (resolved) {729 popupProps.centroid_x = resolved.lon;730 popupProps.centroid_y = resolved.lat;731 popupProps._centroid_src = resolved.fromCSV ? 'CSV' : 'TopoJSON';732 }733 734 if (coord) showPopup(coord, popupProps);735 highlightTableRow(idx);736}737 738// ── Edit mode: drag centroids ────────────────────────────────────────────────739function toggleEditMode() {740 editMode = !editMode;741 var btn = document.getElementById('btn-edit-toggle');742 var bar = document.getElementById('edit-toolbar');743 var status = document.getElementById('edit-status');744 if (editMode) {745 btn.textContent = '✓ Done Editing';746 btn.classList.add('editing'); bar.classList.add('editing');747 status.textContent = 'Drag any centroid to move it';748 olMap.getTargetElement().style.cursor = 'crosshair';749 } else {750 btn.textContent = '✏ Edit Centroids';751 btn.classList.remove('editing'); bar.classList.remove('editing');752 var n = Object.keys(editedIndices).length;753 status.textContent = n > 0 ? n + ' centroid(s) modified — download CSV to save' : 'Click to enable drag-to-move';754 olMap.getTargetElement().style.cursor = '';755 if (draggingCentroid) stopDrag();756 }757}758var draggingCentroid = null, draggingIdx = -1;759olMap.on('pointerdown', function(evt) {760 if (!editMode) return;761 olMap.forEachFeatureAtPixel(evt.pixel, function(f, l) {762 if (!draggingCentroid && l === centroidLayer) {763 draggingCentroid = f; draggingIdx = f.get('_idx');764 f.setStyle(centroidDragStyle);765 olMap.getInteractions().forEach(function(i){ if (i instanceof ol.interaction.DragPan) i.setActive(false); });766 evt.preventDefault();767 }768 }, { hitTolerance: 12 });769});770olMap.on('pointermove', function(evt) {771 if (!editMode || !draggingCentroid) return;772 draggingCentroid.getGeometry().setCoordinates(olMap.getCoordinateFromPixel(evt.pixel));773 evt.preventDefault();774});775olMap.on('pointerup', function(evt) {776 if (!editMode || !draggingCentroid) return;777 stopDrag(evt.coordinate);778});779function stopDrag(coord) {780 if (!draggingCentroid) return;781 olMap.getInteractions().forEach(function(i){ if (i instanceof ol.interaction.DragPan) i.setActive(true); });782 if (coord) {783 var lonLat = ol.proj.toLonLat(coord);784 var lon = Math.round(lonLat[0]*1e6)/1e6, lat = Math.round(lonLat[1]*1e6)/1e6;785 // Update both the feature properties AND the CSV map786 var p = ALL_FEATURES[draggingIdx].properties;787 p.centroid_x = lon; p.centroid_y = lat;788 draggingCentroid.set('centroid_x', lon); draggingCentroid.set('centroid_y', lat);789 // Update CSV map so subsequent resolveCentroid calls return the dragged position790 var key = centroidKey(p.country, p.admin1, p.admin2, p.admin3);791 CSV_CENTROID_MAP[key] = { centroid_x: lon, centroid_y: lat };792 csvLoaded = true;793 editedIndices[draggingIdx] = { lon: lon, lat: lat };794 draggingCentroid.setStyle(centroidEditedStyle);795 var row = document.querySelector('#tbl-body tr[data-idx="'+draggingIdx+'"]');796 if (row && TABLE_COLS) {797 var cells = row.querySelectorAll('td');798 TABLE_COLS.forEach(function(c,ci){799 if (c==='centroid_x'){ cells[ci].textContent=String(lon); cells[ci].title=String(lon); }800 if (c==='centroid_y'){ cells[ci].textContent=String(lat); cells[ci].title=String(lat); }801 });802 row.style.background='#fff8e1';803 }804 document.getElementById('edit-status').textContent = Object.keys(editedIndices).length + ' centroid(s) modified';805 dbg('Moved idx='+draggingIdx+' to '+lon+','+lat);806 }807 draggingCentroid = null; draggingIdx = -1;808}809 810// ── CSV download ──────────────────────────────────────────────────────────────811function downloadCSV() {812 if (!ALL_FEATURES.length) { alert('No data loaded yet.'); return; }813 // Export: country, admin1, admin2, admin3, centroid_x, centroid_y814 // Using resolved centroid (CSV or TopoJSON) so edits are reflected815 var header = ['country','admin1','admin2','admin3','centroid_x','centroid_y'];816 var rows = [header.join(',')];817 ALL_FEATURES.forEach(function(f) {818 var p = f.properties || {};819 var resolved = resolveCentroid(p);820 rows.push([821 csvCell(p.country), csvCell(p.admin1), csvCell(p.admin2), csvCell(p.admin3),822 resolved ? resolved.lon : '', resolved ? resolved.lat : ''823 ].join(','));824 });825 var blob = new Blob([rows.join('\n')], { type: 'text/csv;charset=utf-8;' });826 var url = URL.createObjectURL(blob);827 var a = document.createElement('a');828 a.href = url; a.download = 'acled_maritime_april2026_centroids.csv';829 document.body.appendChild(a); a.click(); document.body.removeChild(a);830 URL.revokeObjectURL(url);831 dbg('CSV downloaded — ' + ALL_FEATURES.length + ' rows');832}833function csvCell(v) {834 if (v == null) return '';835 var s = String(v);836 if (s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0) return '"'+s.replace(/"/g,'""')+'"';837 return s;838}839 840// ── Drag resize ───────────────────────────────────────────────────────────────841(function(){842 var handle=document.getElementById('drag-handle'), tPane=document.getElementById('table-pane');843 var mainEl=document.getElementById('main'), dragging=false, startY=0, startH=0;844 handle.addEventListener('mousedown', function(e){ dragging=true; startY=e.clientY; startH=tPane.offsetHeight; e.preventDefault(); });845 document.addEventListener('mousemove', function(e){846 if (!dragging) return;847 var newH=Math.min(Math.max(startH+(startY-e.clientY),80),mainEl.offsetHeight*0.7);848 tPane.style.height=newH+'px'; olMap.updateSize();849 });850 document.addEventListener('mouseup', function(){ dragging=false; });851})();852 853// ── Load TopoJSON data ────────────────────────────────────────────────────────854//var TOPO_URL = 'https://huggingface.co/datasets/zakhtar/acled_maritime/resolve/main/acled_maritime_April2026_v4_centroid_simplifie.topojson.gz';855var TOPO_URL = 'https://huggingface.co/datasets/zakhtar/acled_maritime/resolve/main/acled_maritime_May20_2026_centroid.topojson.gz';856setProgress(10, 'Fetching data…');857dbg('GET ' + TOPO_URL);858fetch(TOPO_URL)859 .then(function(r) {860 if (!r.ok) throw new Error('HTTP ' + r.status + ' ' + r.statusText);861 var enc=r.headers.get('content-encoding')||'', type=r.headers.get('content-type')||'';862 dbg('enc:' + enc + ' type:' + type);863 setProgress(30, 'Downloading…');864 var getText = function(resp) {865 if (enc.includes('gzip') || type.includes('json')) return resp.text();866 if (!window.DecompressionStream) throw new Error('DecompressionStream not supported. Use Chrome 80+, Edge 80+, or Firefox 113+.');867 return new Response(resp.body.pipeThrough(new DecompressionStream('gzip'))).text();868 };869 return getText(r).then(function(txt) {870 setProgress(55, 'Parsing…');871 return JSON.parse(txt.replace(/:\s*NaN/g, ':null'));872 });873 })874 .then(function(topo) {875 setProgress(70, 'Decoding topology…');876 var key = Object.keys(topo.objects)[0];877 ALL_FEATURES = topojson.feature(topo, topo.objects[key]).features;878 dbg('Features: ' + ALL_FEATURES.length);879 ALL_FEATURES.forEach(function(f){880 var p=f.properties||{};881 Object.keys(p).forEach(function(k){ if (typeof p[k]==='number'&&isNaN(p[k])) p[k]=null; });882 });883 setProgress(80, 'Building layers…');884 buildAllOLFeatures();885 setProgress(90, 'Populating filters…');886 setOpts('sel_country', uniqSorted(ALL_FEATURES.map(function(f){return f.properties.country;})), false);887 setOpts('sel_admin1', uniqSorted(ALL_FEATURES.map(function(f){return f.properties.admin1; })), false);888 setOpts('sel_admin2', uniqSorted(ALL_FEATURES.map(function(f){return f.properties.admin2; })), false);889 setOpts('sel_admin3', uniqSorted(ALL_FEATURES.map(function(f){return f.properties.admin3; })), false);890 applyFilter();891 setProgress(100, 'Done');892 var ext=boundarySource.getExtent();893 if (ext && !ol.extent.isEmpty(ext)) olMap.getView().fit(ext,{padding:[40,40,40,40],duration:600});894 document.getElementById('loading').classList.add('hidden');895 olMap.updateSize();896 dbg('Ready — fetching CSV centroids from HuggingFace');897 fetchCSV();898 })899 .catch(function(e) {900 document.getElementById('loading-text').textContent = 'Failed to load data';901 document.getElementById('loading-bar').style.cssText = 'width:100%;background:#c0392b';902 document.querySelector('.spinner').style.borderTopColor = '#c0392b';903 var el=document.createElement('div');904 el.style.cssText='font-size:11px;color:#c0392b;max-width:300px;text-align:center;padding:0 20px;line-height:1.5';905 el.textContent=String(e);906 document.getElementById('loading').appendChild(el);907 dbg('FAILED: '+e);908 });909</script>910</body>911</html>912 