CoolFace
Apppublic

zakhtar/Global_Oceans_ACLED

sourceHugging Faceupdated 4mo agoView on Hugging Face
0likes
index.html912 linesDownload Raw Back to root
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,'&amp;').replace(/</g,'&lt;'); }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: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> contributors &copy; <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: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> contributors &copy; <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: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> contributors &copy; <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 &copy; <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