<?php
declare(strict_types=1);
date_default_timezone_set('Europe/Amsterdam');

$LIVE='/home/weernieuws/data/lightning/live.json';
$STATS='/home/weernieuws/data/lightning/stats.json';
$ARCHIVE='/home/weernieuws/data/lightning/archive';
$WEB='/home/weernieuws/assets/geo/europe/web';
$LAND=$WEB.'/map_land_overview.geojson';
$LINES=$WEB.'/country_lines_overview.geojson';
$MUNICIPAL_LINES=$WEB.'/nederland_gemeente_lines.geojson';

$ADMIN_LINES=[
    $WEB.'/nederland_province_lines.geojson',
    $WEB.'/belgie_province_lines.geojson',
    $WEB.'/denemarken_region_lines.geojson',
    $WEB.'/duitsland_state_lines.geojson',
    $WEB.'/frankrijk_department_lines.geojson',
    $WEB.'/groot_brittannie_admin1_lines.geojson',
    $WEB.'/luxemburg_admin1_lines.geojson'
];

$RADAR_DIR='/home/weernieuws/public/radar_live';

$DELAY=600;
$LIFETIME=3600;
$HISTORY=3600;
$FLASH_WINDOW=300;
$LIGHTNING_24H=86400;
$READBACK=$DELAY+$HISTORY+$FLASH_WINDOW+120;

$manifestPath=$RADAR_DIR.'/manifest.json';

if(is_file($manifestPath)){
    $m=json_decode((string)@file_get_contents($manifestPath),true);
    $oldest=null;

    if(is_array($m)){
        foreach(($m['pairs']??[]) as $p){
            if(!is_array($p))continue;

            foreach(['a_timestamp','b_timestamp'] as $k){
                if(!is_numeric($p[$k]??null))continue;
                $ts=(float)$p[$k];
                if($oldest===null||$ts<$oldest)$oldest=$ts;
            }
        }

        if($oldest===null){
            foreach(($m['frames']??[]) as $f){
                if(!is_array($f)||!is_numeric($f['timestamp']??null))continue;
                $ts=(float)$f['timestamp'];
                if($oldest===null||$ts<$oldest)$oldest=$ts;
            }
        }
    }

    if($oldest!==null){
        $READBACK=max(
            $READBACK,
            (int)ceil(microtime(true)-$oldest+$FLASH_WINDOW+300)
        );
    }
}

$W=740.0;
$H=877.0;
$MIN_LAT=47.0;
$MAX_LAT=56.5;
$MIN_LON=-1.5;
$MAX_LON=12.5;


/* ---------- RADAR ENDPOINTS ---------- */

if(isset($_GET['radar_manifest'])){
    $p=$RADAR_DIR.'/manifest.json';
    if(!is_file($p)){http_response_code(404);exit;}

    header('Content-Type: application/json; charset=utf-8');
    header('Cache-Control: no-store,no-cache,must-revalidate,max-age=0');
    readfile($p);
    exit;
}

if(isset($_GET['radar_file'])){
    $n=basename((string)$_GET['radar_file']);

    if(!preg_match('/^(?:radar_\d{12}\.bin|flow_\d{12}_\d{12}\.bin)$/D',$n)){
        http_response_code(400);
        exit;
    }

    $p=$RADAR_DIR.'/'.$n;

    if(!is_file($p)){
        http_response_code(404);
        exit;
    }

    header('Content-Type: application/octet-stream');
    header('Content-Length: '.filesize($p));
    header('Cache-Control: public,max-age=31536000,immutable');
    readfile($p);
    exit;
}


/* ---------- HELPERS ---------- */

function load_json(string $p):array{
    if(!is_file($p))return[];

    $r=@file_get_contents($p);

    if(!is_string($r)||trim($r)==='')return[];

    $j=json_decode($r,true);

    return is_array($j)?$j:[];
}

function merc_y(float $lat):float{
    $lat=max(-85.05112878,min(85.05112878,$lat));
    $r=deg2rad($lat);
    return log(tan(M_PI/4+$r/2));
}

function project_point(float $lon,float $lat):array{
    global $W,$H,$MIN_LAT,$MAX_LAT,$MIN_LON,$MAX_LON;

    $x=(($lon-$MIN_LON)/($MAX_LON-$MIN_LON))*$W;
    $n=merc_y($MAX_LAT);
    $s=merc_y($MIN_LAT);
    $y=(($n-merc_y($lat))/($n-$s))*$H;

    return[$x,$y];
}

function polygon_path(array $g):string{
    $t=(string)($g['type']??'');
    $c=$g['coordinates']??[];

    if($t==='Polygon')$ps=[$c];
    elseif($t==='MultiPolygon')$ps=$c;
    elseif($t==='GeometryCollection'){
        $o=[];
        foreach(($g['geometries']??[]) as $x)
            if(is_array($x)&&($p=polygon_path($x))!=='')$o[]=$p;
        return implode(' ',$o);
    }else return'';

    $o=[];

    foreach($ps as $poly){
        foreach($poly as $ring){
            if(!is_array($ring)||count($ring)<3)continue;

            $first=true;

            foreach($ring as $p){
                if(
                    !is_array($p)||
                    !isset($p[0],$p[1])||
                    !is_numeric($p[0])||
                    !is_numeric($p[1])
                )continue;

                [$x,$y]=project_point((float)$p[0],(float)$p[1]);

                $o[]=
                    ($first?'M':'L').
                    round($x,3).' '.
                    round($y,3);

                $first=false;
            }

            if(!$first)$o[]='Z';
        }
    }

    return implode(' ',$o);
}

function line_path(array $g):string{
    $t=(string)($g['type']??'');
    $c=$g['coordinates']??[];

    if($t==='LineString')$ls=[$c];
    elseif($t==='MultiLineString')$ls=$c;
    elseif($t==='GeometryCollection'){
        $o=[];
        foreach(($g['geometries']??[]) as $x)
            if(is_array($x)&&($p=line_path($x))!=='')$o[]=$p;
        return implode(' ',$o);
    }else return'';

    $o=[];

    foreach($ls as $l){
        $first=true;

        foreach($l as $p){
            if(
                !is_array($p)||
                !isset($p[0],$p[1])||
                !is_numeric($p[0])||
                !is_numeric($p[1])
            )continue;

            [$x,$y]=project_point((float)$p[0],(float)$p[1]);

            $o[]=
                ($first?'M':'L').
                round($x,3).' '.
                round($y,3);

            $first=false;
        }
    }

    return implode(' ',$o);
}


/* ---------- BLIKSEM ---------- */

function flash_ts(array $f):?float{
    $v=trim((string)($f['time']??''));
    if($v==='')return null;

    try{$d=new DateTimeImmutable($v);}
    catch(Throwable){return null;}

    return(float)$d->format('U.u');
}

function nl_today(string $p):int{
    $n=0;

    foreach((load_json($p)['today']??[]) as $r){
        if(is_array($r)&&is_numeric($r['count']??null)){
            $n+=(int)$r['count'];
        }
    }

    return$n;
}

function normalize_flash(array $f,float $cut,float $now):?array{
    if(!is_numeric($f['lat']??null)||!is_numeric($f['lon']??null))return null;

    $ts=flash_ts($f);

    if($ts===null||$ts<$cut||$ts>$now+120)return null;

    $lat=(float)$f['lat'];
    $lon=(float)$f['lon'];

    if($lat<-90||$lat>90||$lon<-180||$lon>180)return null;

    return[
        'timestamp'=>$ts,
        'lat'=>$lat,
        'lon'=>$lon,
        'flash_id'=>(string)($f['flash_id']??'')
    ];
}

function flash_key(array $f):string{
    $id=trim((string)($f['flash_id']??''));
    $t=(float)($f['timestamp']??0);

    if($id!==''){
        return$id.'|'.number_format($t,3,'.','');
    }

    return
        number_format($t,3,'.','').'|'.
        number_format((float)$f['lat'],5,'.','').'|'.
        number_format((float)$f['lon'],5,'.','');
}

function lightning_data(
    string $live,
    string $archive,
    string $stats,
    int $readback,
    int $delay,
    int $lifetime
):array{
    $now=microtime(true);
    $cut=$now-$readback;
    $u=[];

    $consume=static function(array $rows)use(&$u,$cut,$now){
        foreach($rows as $f){
            if(!is_array($f))continue;

            $r=normalize_flash($f,$cut,$now);

            if($r!==null){
                $u[flash_key($r)]=$r;
            }
        }
    };

    $start=(int)(floor(($cut-300)/300)*300);
    $end=(int)(floor($now/300)*300);
    $utc=new DateTimeZone('UTC');

    for($t=$start;$t<=$end;$t+=300){
        $d=(new DateTimeImmutable('@'.$t))->setTimezone($utc);

        $j=load_json(
            rtrim($archive,'/').'/'.
            $d->format('Y-m-d').'/'.
            $d->format('H-i').'.json'
        );

        $consume(
            is_array($j['flashes']??null)
            ?$j['flashes']
            :[]
        );
    }

    $j=load_json($live);

    $consume(
        is_array($j['flashes']??null)
        ?$j['flashes']
        :[]
    );

    $rows=array_values($u);

    usort(
        $rows,
        static fn($a,$b)=>
            $a['timestamp']<=>$b['timestamp']
    );

    return[
        'server_now'=>$now,
        'live_delay_seconds'=>$delay,
        'flash_lifetime_seconds'=>$lifetime,
        'netherlands_today'=>nl_today($stats),
        'flashes'=>$rows
    ];
}

if(isset($_GET['json'])){
    $range=(string)$_GET['json'];

    $readback=
        $range==='24h'
        ?$LIGHTNING_24H+600
        :$READBACK;

    header('Content-Type: application/json; charset=utf-8');
    header('Cache-Control: no-store,no-cache,must-revalidate,max-age=0');

    echo json_encode(
        lightning_data(
            $LIVE,
            $ARCHIVE,
            $STATS,
            $readback,
            $DELAY,
            $LIFETIME
        ),
        JSON_UNESCAPED_UNICODE|
        JSON_UNESCAPED_SLASHES
    );

    exit;
}


/* ---------- KAARTDATA ---------- */

$land=load_json($LAND);
$lines=load_json($LINES);
$municipalLines=load_json($MUNICIPAL_LINES);

$adminLines=[];

foreach($ADMIN_LINES as $file){
    foreach((load_json($file)['features']??[]) as $feature){
        if(is_array($feature))$adminLines[]=$feature;
    }
}
?>
<!doctype html>
<html lang="nl">
<head>

<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="robots" content="noindex,nofollow">

<title>Neerslag en onweer</title>

<style>
*{box-sizing:border-box}

html,body{
width:100%;
height:100%;
margin:0;
overflow:hidden;
background:#cbdde7;
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
touch-action:none
}

.map-stage{
position:absolute;
inset:0 0 34px;
overflow:hidden;
background:#cbdde7;
touch-action:none;
cursor:grab;
user-select:none
}

.map-stage.dragging{cursor:grabbing}

.map-svg,.radar-canvas{
position:absolute;
inset:0;
width:100%;
height:100%;
display:block;
background:transparent
}

.map-svg{touch-action:none}
.map-svg-base{z-index:1}
.radar-canvas{z-index:2;pointer-events:none}
.map-svg-overlay{z-index:3;pointer-events:none}

.map-background{fill:#cbdde7}
.land{fill:#ADD7A2;stroke:none}

.municipality{
fill:none;
stroke:rgb(55 83 110);
stroke-opacity:.20;
stroke-width:.45;
vector-effect:non-scaling-stroke;
stroke-linejoin:round;
stroke-linecap:round
}

.admin-line{
fill:none;
stroke:rgb(55 83 110);
stroke-opacity:.40;
stroke-width:.72;
vector-effect:non-scaling-stroke;
stroke-linejoin:round;
stroke-linecap:round
}

.country{
fill:none;
stroke:rgb(55 83 110);
stroke-opacity:.706;
stroke-width:1.2;
vector-effect:non-scaling-stroke;
stroke-linejoin:round;
stroke-linecap:round
}

/* Rand en grootte worden dynamisch bepaald. */
.flash-marker{
vector-effect:non-scaling-stroke;
pointer-events:none
}

.flash-ripple{
fill:none;
stroke:#d99a00;
stroke-width:3;
vector-effect:non-scaling-stroke
}

.layer-control,.playback-control{
position:absolute;
left:50%;
z-index:25;
display:flex;
gap:4px;
padding:4px;
transform:translateX(-50%);
background:rgba(255,255,255,.96);
border:2px solid rgba(0,0,0,.18);
border-radius:7px;
box-shadow:0 1px 4px rgba(0,0,0,.16)
}

.layer-control{top:20px}
.playback-control{bottom:14px}

.layer-btn,.playback-btn{
height:40px;
padding:0 13px;
border:0;
border-radius:4px;
background:transparent;
color:#475569;
font:800 13px/1 system-ui;
white-space:nowrap;
cursor:pointer
}

.layer-btn[aria-pressed="true"],
.playback-btn[aria-pressed="true"]{
background:#e7eef5;
color:#172c46
}

.playback-btn:disabled{
opacity:.42;
cursor:default
}

.zoom-control{
position:absolute;
top:20px;
left:20px;
z-index:20;
overflow:hidden;
border:2px solid rgba(0,0,0,.24);
border-radius:5px;
background:#fff
}

.zoom-btn{
display:flex;
align-items:center;
justify-content:center;
width:58px;
height:58px;
border:0;
background:#fff;
color:#111827;
font:700 34px/1 system-ui
}

.zoom-btn+.zoom-btn{
border-top:1px solid rgba(0,0,0,.18)
}

.close-btn{
position:absolute;
top:20px;
right:20px;
z-index:20;
width:58px;
height:58px;
border:2px solid rgba(0,0,0,.24);
border-radius:5px;
background:#fff;
color:#111827;
display:flex;
align-items:center;
justify-content:center
}

.close-btn svg{
width:28px;
height:28px;
fill:none;
stroke:currentColor;
stroke-width:2.4;
stroke-linecap:round
}

.map-status{
position:absolute;
left:0;
right:0;
bottom:0;
z-index:30;
height:34px;
padding:0 10px;
border-top:1px solid #cfd8e3;
background:rgba(241,245,249,.72);
color:#334155;
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
font-size:13px;
font-weight:800;
white-space:nowrap
}

.map-status-left,.map-status-right{
display:flex;
align-items:center;
gap:7px
}

.map-status-right{justify-content:flex-end}
.map-status strong{color:#2563eb;font-weight:950}
.map-status-separator{color:#64748b}

@media(max-width:430px){
.zoom-control{top:10px;left:10px}
.zoom-btn{width:40px;height:40px;font-size:26px}

.close-btn{
top:10px;
right:10px;
width:40px;
height:40px
}

.close-btn svg{width:20px;height:20px}

.layer-control{top:10px}
.playback-control{bottom:10px}

.layer-control,.playback-control{
padding:3px;
gap:2px
}

.layer-btn,.playback-btn{
height:34px;
padding:0 9px;
font-size:11px
}

.map-status{
padding:0 7px;
font-size:11px
}

.map-status-left,.map-status-right{gap:4px}
}
</style>

</head>

<body>

<div class="map-stage" id="mapStage">

<svg
class="map-svg map-svg-base"
id="baseSvg"
viewBox="0 0 740 877"
preserveAspectRatio="xMidYMid slice"
>
<rect class="map-background" width="740" height="877"/>

<g id="baseViewportLayer">

<?php foreach(($land['features']??[]) as $f):
$p=polygon_path(is_array($f['geometry']??null)?$f['geometry']:[]);
if($p==='')continue; ?>

<path
class="land"
d="<?=htmlspecialchars($p,ENT_QUOTES,'UTF-8')?>"
fill-rule="evenodd"
/>

<?php endforeach; ?>

</g>
</svg>


<canvas class="radar-canvas" id="radarCanvas"></canvas>


<svg
class="map-svg map-svg-overlay"
id="overlaySvg"
viewBox="0 0 740 877"
preserveAspectRatio="xMidYMid slice"
>

<g id="overlayViewportLayer">

<g id="municipalityLayer">

<?php foreach(($municipalLines['features']??[]) as $f):
$p=line_path(is_array($f['geometry']??null)?$f['geometry']:[]);
if($p==='')continue; ?>

<path
class="municipality"
d="<?=htmlspecialchars($p,ENT_QUOTES,'UTF-8')?>"
/>

<?php endforeach; ?>

</g>


<g id="adminLayer">

<?php foreach($adminLines as $f):
$p=line_path(is_array($f['geometry']??null)?$f['geometry']:[]);
if($p==='')continue; ?>

<path
class="admin-line"
d="<?=htmlspecialchars($p,ENT_QUOTES,'UTF-8')?>"
/>

<?php endforeach; ?>

</g>


<g>

<?php foreach(($lines['features']??[]) as $f):
$p=line_path(is_array($f['geometry']??null)?$f['geometry']:[]);
if($p==='')continue; ?>

<path
class="country"
d="<?=htmlspecialchars($p,ENT_QUOTES,'UTF-8')?>"
/>

<?php endforeach; ?>

</g>


<g id="stormLayer">
<g id="flashLayer"></g>
<g id="rippleLayer"></g>
</g>

</g>
</svg>


<div class="zoom-control">
<button class="zoom-btn" id="zoomInBtn" type="button">+</button>
<button class="zoom-btn" id="zoomOutBtn" type="button">−</button>
</div>


<div class="layer-control">
<button class="layer-btn" id="rainToggle" type="button" aria-pressed="true">Neerslag</button>
<button class="layer-btn" id="stormToggle" type="button" aria-pressed="true">Onweer</button>
</div>


<div class="playback-control">
<button class="playback-btn" id="playBtn" type="button">▶ Afspelen</button>
<button class="playback-btn" id="stopBtn" type="button" disabled>■ Stop</button>
</div>


<button class="close-btn" id="closeBtn" type="button" aria-label="Sluiten">
<svg viewBox="0 0 24 24"><path d="M6 6 18 18M18 6 6 18"/></svg>
</button>

</div>


<div class="map-status" id="mapStatus">
<div class="map-status-left">Bliksemgegevens laden…</div>
<div class="map-status-right"></div>
</div>


<script>
'use strict';

const NS='http://www.w3.org/2000/svg';

const W=740,H=877;
const MIN_LAT=47,MAX_LAT=56.5,MIN_LON=-1.5,MAX_LON=12.5;

const HISTORY_SECONDS=3600;
const RADAR_FLASH_WINDOW_SECONDS=300;
const STORM_24H_SECONDS=86400;

/* Markerweergave */
const MARKER_RADIUS=4;
const MARKER_24H_RADIUS=3;
const MARKER_BORDER_SECONDS=600;
const MARKER_BORDER_WIDTH=1;

const PLAYBACK_RATE=360;

const PLAYBACK_END_HOLD_MS=700;

const RADAR_MANIFEST_REFRESH_MS=30000;
const DATA_REFRESH_MS=10000;
const DAY24_REFRESH_MS=300000;

const RADAR_MAX_FPS=30;
const RADAR_DPR_MAX=2;
const MIN_ZOOM=1;
const MAX_ZOOM=16;
const MUNICIPALITY_ZOOM=3.2;

const $=id=>document.getElementById(id);

const stage=$('mapStage');
const overlaySvg=$('overlaySvg');
const baseViewport=$('baseViewportLayer');
const overlayViewport=$('overlayViewportLayer');
const radarCanvas=$('radarCanvas');
const stormLayer=$('stormLayer');
const flashLayer=$('flashLayer');
const rippleLayer=$('rippleLayer');
const status=$('mapStatus');
const rainToggle=$('rainToggle');
const stormToggle=$('stormToggle');
const playBtn=$('playBtn');
const stopBtn=$('stopBtn');
const zoomIn=$('zoomInBtn');
const zoomOut=$('zoomOutBtn');
const closeBtn=$('closeBtn');
const municipalityLayer=$('municipalityLayer');
const adminLayer=$('adminLayer');

let rainEnabled=true;
let stormEnabled=true;
let stormRange='current';

let scale=1,tx=0,ty=0;
let dragStart=null,pinchStart=null;

const pointers=new Map();
const active=new Map();

let flashes=[];
let flashesCurrent=[];
let flashes24=[];

let serverNow=Date.now()/1000;
let receivedAt=performance.now();
let delay=600;
let lifetime=3600;
let nlToday=0;

let lastLightningTick=null;
let lastLightningDisplayTime=null;
let lastStatusPaint=0;

let playbackActive=false;
let playbackLoading=false;
let playbackTime=0;
let playbackLastPerf=0;
let playbackHoldUntil=0;

let lightningLoading=false;
let last24Load=0;


function stormOnly(){
    return stormEnabled&&!rainEnabled;
}


/* ---------- PROJECTIE ---------- */

function merc(lat){
    lat=Math.max(-85.05112878,Math.min(85.05112878,+lat));
    const r=lat*Math.PI/180;
    return Math.log(Math.tan(Math.PI/4+r/2));
}

const mercN=merc(MAX_LAT);
const mercS=merc(MIN_LAT);

function project(lat,lon){
    return{
        x:((+lon-MIN_LON)/(MAX_LON-MIN_LON))*W,
        y:((mercN-merc(+lat))/(mercN-mercS))*H
    };
}


/* ---------- VIEW ---------- */

function markerRadius(){
    return(
        stormOnly()&&stormRange==='24h'
        ?MARKER_24H_RADIUS
        :MARKER_RADIUS
    )/scale;
}

function updateAdminLayers(){
    if(adminLayer)adminLayer.style.display='';

    if(municipalityLayer){
        municipalityLayer.style.display=
            scale>=MUNICIPALITY_ZOOM
            ?''
            :'none';
    }
}

function updateMarkerSizes(){
    const r=markerRadius();

    for(const row of active.values()){
        row.el.setAttribute('r',r);
    }
}

function applyViewport(){
    const t=`translate(${tx} ${ty}) scale(${scale})`;

    baseViewport.setAttribute('transform',t);
    overlayViewport.setAttribute('transform',t);

    updateMarkerSizes();
    updateAdminLayers();

    radarDirty=true;
}

function clamp(){
    if(scale<=1){
        scale=1;
        tx=0;
        ty=0;
        return;
    }

    tx=Math.min(0,Math.max(W-W*scale,tx));
    ty=Math.min(0,Math.max(H-H*scale,ty));
}

function screenPoint(x,y){
    const r=overlaySvg.getBoundingClientRect();
    const fit=Math.max(r.width/W,r.height/H);
    const ox=(r.width-W*fit)/2;
    const oy=(r.height-H*fit)/2;

    return{
        x:(x-r.left-ox)/fit,
        y:(y-r.top-oy)/fit
    };
}

function zoomAt(factor,x,y){
    const old=scale;
    const next=Math.max(MIN_ZOOM,Math.min(MAX_ZOOM,old*factor));

    if(Math.abs(next-old)<.0001)return;

    const mx=(x-tx)/old;
    const my=(y-ty)/old;

    scale=next;
    tx=x-mx*scale;
    ty=y-my*scale;

    clamp();
    applyViewport();
}

function zoomCenter(factor){
    zoomAt(factor,W/2,H/2);
}

stage.addEventListener('wheel',e=>{
    e.preventDefault();

    const p=screenPoint(e.clientX,e.clientY);

    zoomAt(
        e.deltaY<0?1.22:1/1.22,
        p.x,
        p.y
    );
},{passive:false});

stage.addEventListener('pointerdown',e=>{
    if(e.target.closest('button'))return;

    stage.setPointerCapture(e.pointerId);

    pointers.set(
        e.pointerId,
        {x:e.clientX,y:e.clientY}
    );

    if(pointers.size===1){
        dragStart={
            x:e.clientX,
            y:e.clientY,
            tx,
            ty
        };

        pinchStart=null;
        stage.classList.add('dragging');

    }else if(pointers.size===2){
        const p=[...pointers.values()];

        const c=screenPoint(
            (p[0].x+p[1].x)/2,
            (p[0].y+p[1].y)/2
        );

        pinchStart={
            dist:Math.hypot(
                p[1].x-p[0].x,
                p[1].y-p[0].y
            ),
            scale,
            cx:c.x,
            cy:c.y,
            tx,
            ty
        };

        dragStart=null;
    }
});

stage.addEventListener('pointermove',e=>{
    if(!pointers.has(e.pointerId))return;

    pointers.set(
        e.pointerId,
        {x:e.clientX,y:e.clientY}
    );

    if(pointers.size===1&&dragStart){
        const r=overlaySvg.getBoundingClientRect();
        const fit=Math.max(r.width/W,r.height/H);

        tx=
            dragStart.tx+
            (e.clientX-dragStart.x)/fit;

        ty=
            dragStart.ty+
            (e.clientY-dragStart.y)/fit;

        clamp();
        applyViewport();
        return;
    }

    if(pointers.size===2&&pinchStart){
        const p=[...pointers.values()];

        const d=Math.hypot(
            p[1].x-p[0].x,
            p[1].y-p[0].y
        );

        const c=screenPoint(
            (p[0].x+p[1].x)/2,
            (p[0].y+p[1].y)/2
        );

        scale=Math.max(
            MIN_ZOOM,
            Math.min(
                MAX_ZOOM,
                pinchStart.scale*d/pinchStart.dist
            )
        );

        const mx=
            (pinchStart.cx-pinchStart.tx)/
            pinchStart.scale;

        const my=
            (pinchStart.cy-pinchStart.ty)/
            pinchStart.scale;

        tx=c.x-mx*scale;
        ty=c.y-my*scale;

        clamp();
        applyViewport();
    }
});

function releasePointer(e){
    pointers.delete(e.pointerId);

    if(!pointers.size){
        dragStart=null;
        pinchStart=null;
        stage.classList.remove('dragging');

    }else if(pointers.size===1){
        const p=[...pointers.values()][0];

        dragStart={
            x:p.x,
            y:p.y,
            tx,
            ty
        };

        pinchStart=null;
    }
}

stage.addEventListener('pointerup',releasePointer);
stage.addEventListener('pointercancel',releasePointer);

zoomIn.onclick=e=>{
    e.stopPropagation();
    zoomCenter(1.45);
};

zoomOut.onclick=e=>{
    e.stopPropagation();
    zoomCenter(1/1.45);
};


/* ---------- SLUITEN ---------- */

closeBtn.onclick=e=>{
    e.stopPropagation();

    if(window.parent!==window){
        window.parent.postMessage(
            {type:'wn-radar-fullscreen-close'},
            '*'
        );
        return;
    }

    if(history.length>1){
        history.back();
        return;
    }

    location.href='https://weernieuws.info/radar.php';
};


/* ---------- TIJD ---------- */

function currentServer(){
    return serverNow+(performance.now()-receivedAt)/1000;
}

function liveTime(){
    return currentServer()-delay;
}

function lastRadarTime(){
    const pairs=Array.isArray(radarManifest?.pairs)
        ?radarManifest.pairs
        :[];

    if(!pairs.length)return liveTime();

    const time=+pairs[pairs.length-1].b_timestamp;

    return Number.isFinite(time)
        ?time
        :liveTime();
}

function radarLiveTime(){
    return Math.min(
        liveTime(),
        lastRadarTime()
    );
}

function displayTime(){
    if(playbackActive)return playbackTime;

    return rainEnabled
        ?radarLiveTime()
        :currentServer();
}

function historyStartTime(){
    const pairs=Array.isArray(radarManifest?.pairs)
        ?radarManifest.pairs
        :[];

    let start=
        lastRadarTime()-
        HISTORY_SECONDS;

    if(pairs.length){
        const first=+pairs[0].a_timestamp;

        if(Number.isFinite(first)){
            start=Math.max(start,first);
        }
    }

    return start;
}


/* ---------- CONTROLS ---------- */

function updatePlaybackControls(){
    if(stormOnly()){
        playBtn.disabled=lightningLoading;
        stopBtn.disabled=lightningLoading;

        playBtn.textContent='Actueel';
        stopBtn.textContent='24 uur';

        playBtn.setAttribute(
            'aria-pressed',
            stormRange==='current'
        );

        stopBtn.setAttribute(
            'aria-pressed',
            stormRange==='24h'
        );

        return;
    }

    const can=
        rainEnabled&&
        !!radarManifest?.pairs?.length;

    playBtn.disabled=
        !can||
        playbackLoading||
        playbackActive;

    stopBtn.disabled=
        !playbackActive;

    playBtn.textContent=
        playbackLoading
        ?'Laden…'
        :'▶ Afspelen';

    stopBtn.textContent='■ Stop';

    playBtn.setAttribute(
        'aria-pressed',
        playbackActive
    );

    stopBtn.setAttribute(
        'aria-pressed',
        'false'
    );
}

function resetToCurrentLightning(){
    stormRange='current';
    flashes=flashesCurrent;

    lastLightningDisplayTime=null;
    lastLightningTick=null;

    clearLightning();
}

function applyLayers(){
    radarCanvas.style.display=
        rainEnabled?'':'none';

    stormLayer.style.display=
        stormEnabled?'':'none';

    rainToggle.setAttribute(
        'aria-pressed',
        rainEnabled
    );

    stormToggle.setAttribute(
        'aria-pressed',
        stormEnabled
    );

    radarDirty=true;

    updatePlaybackControls();

    syncLightningForTime(
        displayTime(),
        false
    );
}

rainToggle.onclick=e=>{
    e.stopPropagation();

    rainEnabled=!rainEnabled;

    if(!rainEnabled&&playbackActive){
        stopPlayback();
    }

    /* 24 uur hoort nooit mee te gaan naar de minutenradar. */
    resetToCurrentLightning();

    applyLayers();
};

stormToggle.onclick=e=>{
    e.stopPropagation();

    stormEnabled=!stormEnabled;

    applyLayers();
};


/* ---------- PLAYBACK ---------- */

async function preloadPlaybackRadar(){
    const pairs=radarManifest?.pairs||[];

    const start=historyStartTime();
    const end=lastRadarTime();

    const rw=+radarManifest?.radar?.width||W;
    const rh=+radarManifest?.radar?.height||H;

    const fw=+radarManifest?.flow?.width||185;
    const fh=+radarManifest?.flow?.height||219;

    const jobs=[];

    for(const p of pairs){
        const a=+p.a_timestamp;
        const b=+p.b_timestamp;

        if(
            !Number.isFinite(a)||
            !Number.isFinite(b)||
            b<start||
            a>end
        )continue;

        const af=frameFile(p.a);
        const bf=frameFile(p.b);
        const ff=String(p.file||'');

        if(af)jobs.push(fetchRadarBinary(af,rw*rh));
        if(bf)jobs.push(fetchRadarBinary(bf,rw*rh));
        if(ff)jobs.push(fetchRadarBinary(ff,fw*fh*4));
    }

    await Promise.all(jobs);
}

async function startPlayback(){
    if(
        playbackLoading||
        playbackActive||
        !rainEnabled||
        !radarManifest?.pairs?.length
    )return;

    playbackLoading=true;
    updatePlaybackControls();

    try{
        await preloadPlaybackRadar();
    }catch(e){
        console.error('Radarhistorie laden mislukt:',e);

        playbackLoading=false;
        updatePlaybackControls();
        return;
    }

    playbackLoading=false;
    playbackActive=true;

    playbackTime=historyStartTime();
    playbackLastPerf=performance.now();
    playbackHoldUntil=0;

    lastLightningDisplayTime=null;
    lastLightningTick=null;

    clearLightning();

    syncLightningForTime(
        playbackTime,
        false
    );

    ensureRadarPair(playbackTime);

    radarDirty=true;

    updatePlaybackControls();
}

function stopPlayback(){
    playbackActive=false;
    playbackHoldUntil=0;

    lastLightningDisplayTime=null;
    lastLightningTick=null;

    clearLightning();

    const now=displayTime();

    syncLightningForTime(now,false);
    ensureRadarPair(now);

    radarDirty=true;

    updatePlaybackControls();
}

function updatePlayback(nowMs){
    if(!playbackActive)return;

    if(playbackHoldUntil){
        if(nowMs<playbackHoldUntil){
            playbackLastPerf=nowMs;
            return;
        }

        playbackTime=historyStartTime();
        playbackLastPerf=nowMs;
        playbackHoldUntil=0;

        lastLightningDisplayTime=null;
        lastLightningTick=null;

        clearLightning();

        syncLightningForTime(
            playbackTime,
            false
        );

        ensureRadarPair(playbackTime);

        radarDirty=true;
        return;
    }

    const elapsed=Math.max(
        0,
        (nowMs-playbackLastPerf)/1000
    );

    playbackLastPerf=nowMs;

    playbackTime+=
        elapsed*
        PLAYBACK_RATE;

    const end=lastRadarTime();

    if(playbackTime>=end){
        playbackTime=end;

        playbackHoldUntil=
            nowMs+
            PLAYBACK_END_HOLD_MS;
    }
}


/* ---------- ONWEER ACTUEEL / 24U ---------- */

async function setStormRange(mode){
    if(!stormOnly())return;

    stormRange=mode;
    lightningLoading=true;

    updatePlaybackControls();

    try{
        if(mode==='24h'){
            if(
                !flashes24.length||
                Date.now()-last24Load>DAY24_REFRESH_MS
            ){
                await loadLightning('24h');
            }

            flashes=flashes24;

        }else{
            if(!flashesCurrent.length){
                await loadLightning('current');
            }

            flashes=flashesCurrent;
        }

        lastLightningDisplayTime=null;
        lastLightningTick=null;

        clearLightning();

        syncLightningForTime(
            displayTime(),
            false
        );

    }finally{
        lightningLoading=false;
        updatePlaybackControls();
    }
}

playBtn.onclick=e=>{
    e.stopPropagation();

    if(stormOnly()){
        setStormRange('current');
    }else{
        startPlayback();
    }
};

stopBtn.onclick=e=>{
    e.stopPropagation();

    if(stormOnly()){
        setStormRange('24h');
    }else{
        stopPlayback();
    }
};

function clock(timestamp){
    return new Intl.DateTimeFormat(
        'nl-NL',
        {
            hour:'2-digit',
            minute:'2-digit',
            second:'2-digit',
            timeZone:'Europe/Amsterdam'
        }
    ).format(
        new Date(timestamp*1000)
    );
}

function number(value){
    return new Intl.NumberFormat(
        'nl-NL'
    ).format(
        +value||0
    );
}


/* ---------- WEBGL ---------- */

let gl=null;
let radarProgram=null;
let radarVao=null;

let radarTexA=null;
let radarTexB=null;
let radarTexFlow=null;

let radarManifest=null;
let radarCurrentPair=null;
let radarCurrentKey='';
let radarLoadingKey='';

let radarReady=false;
let radarLoadToken=0;

let radarDpr=1;
let radarDirty=true;

let radarLastRender=0;
let radarLastPairCheck=0;

const radarBinaryCache=new Map();

function shader(type,source){
    const s=gl.createShader(type);

    gl.shaderSource(s,source);
    gl.compileShader(s);

    if(!gl.getShaderParameter(s,gl.COMPILE_STATUS)){
        throw new Error(
            gl.getShaderInfoLog(s)
        );
    }

    return s;
}

function program(vertexSource,fragmentSource){
    const p=gl.createProgram();

    const vs=shader(
        gl.VERTEX_SHADER,
        vertexSource
    );

    const fs=shader(
        gl.FRAGMENT_SHADER,
        fragmentSource
    );

    gl.attachShader(p,vs);
    gl.attachShader(p,fs);

    gl.linkProgram(p);

    gl.deleteShader(vs);
    gl.deleteShader(fs);

    if(!gl.getProgramParameter(p,gl.LINK_STATUS)){
        throw new Error(
            gl.getProgramInfoLog(p)
        );
    }

    return p;
}

function texture(
    unit,
    internalFormat,
    width,
    height,
    format,
    data,
    filter
){
    const t=gl.createTexture();

    gl.activeTexture(
        gl.TEXTURE0+unit
    );

    gl.bindTexture(
        gl.TEXTURE_2D,
        t
    );

    gl.pixelStorei(
        gl.UNPACK_ALIGNMENT,
        1
    );

    gl.texParameteri(
        gl.TEXTURE_2D,
        gl.TEXTURE_MIN_FILTER,
        filter
    );

    gl.texParameteri(
        gl.TEXTURE_2D,
        gl.TEXTURE_MAG_FILTER,
        filter
    );

    gl.texParameteri(
        gl.TEXTURE_2D,
        gl.TEXTURE_WRAP_S,
        gl.CLAMP_TO_EDGE
    );

    gl.texParameteri(
        gl.TEXTURE_2D,
        gl.TEXTURE_WRAP_T,
        gl.CLAMP_TO_EDGE
    );

    gl.texImage2D(
        gl.TEXTURE_2D,
        0,
        internalFormat,
        width,
        height,
        0,
        format,
        gl.UNSIGNED_BYTE,
        data
    );

    return t;
}

function initRadarGL(){
    gl=radarCanvas.getContext(
        'webgl2',
        {
            alpha:true,
            antialias:false,
            depth:false,
            stencil:false,
            premultipliedAlpha:true,
            preserveDrawingBuffer:false,
            powerPreference:'low-power'
        }
    );

    if(!gl)return false;

    const vertexSource=
`#version 300 es
in vec2 a_pos;
void main(){
    gl_Position=vec4(a_pos,0.0,1.0);
}`;

    const fragmentSource=
`#version 300 es

precision highp float;

uniform sampler2D u_a,u_b,u_flow;
uniform vec2 u_canvas_px,u_fit_offset,u_pan,u_map_size;
uniform float u_dpr,u_fit_scale,u_zoom,u_t,u_flow_max,u_rate_max;

out vec4 outColor;

float decodeRate(float e){
    if(e<=0.0001)return 0.0;
    return exp(e*log(1.0+u_rate_max))-1.0;
}

float texRate(sampler2D tex,ivec2 p){
    ivec2 hi=ivec2(u_map_size)-ivec2(1);

    return decodeRate(
        texelFetch(
            tex,
            clamp(p,ivec2(0),hi),
            0
        ).r
    );
}

float radarRate(sampler2D tex,vec2 uv){
    vec2 p=uv*u_map_size-0.5;
    ivec2 i=ivec2(floor(p));
    vec2 f=fract(p);

    float r00=texRate(tex,i);
    float r10=texRate(tex,i+ivec2(1,0));
    float r01=texRate(tex,i+ivec2(0,1));
    float r11=texRate(tex,i+ivec2(1,1));

    float w00=(1.0-f.x)*(1.0-f.y);
    float w10=f.x*(1.0-f.y);
    float w01=(1.0-f.x)*f.y;
    float w11=f.x*f.y;

    float coverage=0.0;
    float sum=0.0;

    if(r00>=0.12){coverage+=w00;sum+=w00*r00;}
    if(r10>=0.12){coverage+=w10;sum+=w10*r10;}
    if(r01>=0.12){coverage+=w01;sum+=w01*r01;}
    if(r11>=0.12){coverage+=w11;sum+=w11*r11;}

    if(coverage<0.50)return 0.0;

    return sum/coverage;
}

vec4 radarColor(float r){
    if(r<0.12)return vec4(0.0);
    if(r<0.50)return vec4(184.0,216.0,242.0,255.0)/255.0;
    if(r<1.0)return vec4(138.0,187.0,230.0,255.0)/255.0;
    if(r<2.0)return vec4(95.0,158.0,217.0,255.0)/255.0;
    if(r<5.0)return vec4(62.0,130.0,199.0,255.0)/255.0;
    if(r<10.0)return vec4(43.0,102.0,173.0,255.0)/255.0;
    if(r<20.0)return vec4(28.0,77.0,133.0,255.0)/255.0;
    if(r<30.0)return vec4(229.0,57.0,53.0,255.0)/255.0;
    if(r<50.0)return vec4(139.0,30.0,30.0,255.0)/255.0;
    return vec4(17.0,17.0,17.0,255.0)/255.0;
}

void main(){
    vec2 css=vec2(
        gl_FragCoord.x/u_dpr,
        (u_canvas_px.y-gl_FragCoord.y)/u_dpr
    );

    vec2 base=
        (css-u_fit_offset)/
        u_fit_scale;

    vec2 mapPixel=
        (base-u_pan)/
        u_zoom;

    if(
        mapPixel.x<0.0||
        mapPixel.y<0.0||
        mapPixel.x>=u_map_size.x||
        mapPixel.y>=u_map_size.y
    ){
        outColor=vec4(0.0);
        return;
    }

    vec2 uv=
        mapPixel/
        u_map_size;

    vec4 packed=
        texture(
            u_flow,
            uv
        );

    vec2 ab=
        (packed.rg*2.0-1.0)*
        u_flow_max;

    vec2 ba=
        (packed.ba*2.0-1.0)*
        u_flow_max;

    vec2 uvA=
        uv-
        (ab*u_t)/
        u_map_size;

    vec2 uvB=
        uv-
        (ba*(1.0-u_t))/
        u_map_size;

    float rateA=
        radarRate(
            u_a,
            uvA
        );

    float rateB=
        radarRate(
            u_b,
            uvB
        );

    outColor=
        radarColor(
            mix(
                rateA,
                rateB,
                u_t
            )
        );
}`;

    radarProgram=program(
        vertexSource,
        fragmentSource
    );

    radarVao=gl.createVertexArray();

    gl.bindVertexArray(
        radarVao
    );

    const buffer=gl.createBuffer();

    gl.bindBuffer(
        gl.ARRAY_BUFFER,
        buffer
    );

    gl.bufferData(
        gl.ARRAY_BUFFER,
        new Float32Array(
            [-1,-1,3,-1,-1,3]
        ),
        gl.STATIC_DRAW
    );

    const position=
        gl.getAttribLocation(
            radarProgram,
            'a_pos'
        );

    gl.enableVertexAttribArray(
        position
    );

    gl.vertexAttribPointer(
        position,
        2,
        gl.FLOAT,
        false,
        0,
        0
    );

    radarTexA=texture(
        0,
        gl.R8,
        W,
        H,
        gl.RED,
        new Uint8Array(W*H),
        gl.LINEAR
    );

    radarTexB=texture(
        1,
        gl.R8,
        W,
        H,
        gl.RED,
        new Uint8Array(W*H),
        gl.LINEAR
    );

    radarTexFlow=texture(
        2,
        gl.RGBA8,
        185,
        219,
        gl.RGBA,
        new Uint8Array(185*219*4),
        gl.LINEAR
    );

    gl.useProgram(
        radarProgram
    );

    gl.uniform1i(
        gl.getUniformLocation(
            radarProgram,
            'u_a'
        ),
        0
    );

    gl.uniform1i(
        gl.getUniformLocation(
            radarProgram,
            'u_b'
        ),
        1
    );

    gl.uniform1i(
        gl.getUniformLocation(
            radarProgram,
            'u_flow'
        ),
        2
    );

    gl.disable(gl.DEPTH_TEST);
    gl.disable(gl.CULL_FACE);
    gl.disable(gl.BLEND);

    resizeRadarCanvas();

    return true;
}

function resizeRadarCanvas(){
    if(!gl)return;

    const r=stage.getBoundingClientRect();

    radarDpr=Math.max(
        1,
        Math.min(
            RADAR_DPR_MAX,
            devicePixelRatio||1
        )
    );

    const w=Math.max(
        1,
        Math.round(
            r.width*
            radarDpr
        )
    );

    const h=Math.max(
        1,
        Math.round(
            r.height*
            radarDpr
        )
    );

    if(
        radarCanvas.width!==w||
        radarCanvas.height!==h
    ){
        radarCanvas.width=w;
        radarCanvas.height=h;

        gl.viewport(
            0,
            0,
            w,
            h
        );

        radarDirty=true;
    }
}

function radarCacheVersion(name){
    if(
        String(name).startsWith(
            'flow_'
        )
    ){
        return String(
            radarManifest?.flow?.model
            ||
            'legacy'
        );
    }

    return'radar';
}

function radarUrl(name){
    return(
        location.pathname+
        '?radar_file='+
        encodeURIComponent(name)+
        '&v='+
        encodeURIComponent(
            radarCacheVersion(name)
        )
    );
}

async function fetchRadarBinary(name,size){
    const cacheKey=
        String(name)+
        '|'+
        radarCacheVersion(name);

    if(radarBinaryCache.has(cacheKey)){
        return radarBinaryCache.get(cacheKey);
    }

    const promise=(async()=>{
        const r=await fetch(
            radarUrl(name),
            {cache:'force-cache'}
        );

        if(!r.ok){
            throw new Error(
                `${name}: HTTP ${r.status}`
            );
        }

        const b=new Uint8Array(
            await r.arrayBuffer()
        );

        if(b.byteLength!==size){
            throw new Error(
                `${name}: ${b.byteLength} bytes, verwacht ${size}`
            );
        }

        return b;
    })();

    radarBinaryCache.set(
        cacheKey,
        promise
    );

    try{
        return await promise;
    }catch(e){
        radarBinaryCache.delete(cacheKey);
        throw e;
    }
}

function uploadTexture(
    t,
    unit,
    internalFormat,
    w,
    h,
    format,
    data
){
    gl.activeTexture(
        gl.TEXTURE0+unit
    );

    gl.bindTexture(
        gl.TEXTURE_2D,
        t
    );

    gl.pixelStorei(
        gl.UNPACK_ALIGNMENT,
        1
    );

    gl.texImage2D(
        gl.TEXTURE_2D,
        0,
        internalFormat,
        w,
        h,
        0,
        format,
        gl.UNSIGNED_BYTE,
        data
    );
}

function frameFile(stamp){
    const row=(radarManifest?.frames||[])
        .find(
            x=>
                String(x.stamp)===
                String(stamp)
        );

    return row
        ?String(row.file||'')
        :'';
}

function targetRadarPair(now){
    const pairs=
        radarManifest?.pairs||
        [];

    if(!pairs.length)return null;

    for(const p of pairs){
        const a=+p.a_timestamp;
        const b=+p.b_timestamp;

        if(
            Number.isFinite(a)&&
            Number.isFinite(b)&&
            now>=a&&
            now<b
        ){
            return p;
        }
    }

    return now<
        +pairs[0].a_timestamp
        ?pairs[0]
        :pairs[pairs.length-1];
}

async function loadRadarPair(pair){
    if(!gl||!radarManifest||!pair)return;

    const a=String(pair.a||'');
    const b=String(pair.b||'');
    const key=
        a+
        '_'+
        b+
        '|'+
        String(
            radarManifest?.flow?.model
            ||
            'legacy'
        );

    if(
        !a||
        !b||
        (key===radarCurrentKey&&radarReady)||
        key===radarLoadingKey
    )return;

    const af=frameFile(a);
    const bf=frameFile(b);
    const ff=String(pair.file||'');

    if(!af||!bf||!ff)return;

    const rw=+radarManifest.radar?.width||W;
    const rh=+radarManifest.radar?.height||H;
    const fw=+radarManifest.flow?.width||185;
    const fh=+radarManifest.flow?.height||219;

    radarLoadingKey=key;

    const token=
        ++radarLoadToken;

    try{
        const[
            ra,
            rb,
            flow
        ]=await Promise.all([
            fetchRadarBinary(
                af,
                rw*rh
            ),
            fetchRadarBinary(
                bf,
                rw*rh
            ),
            fetchRadarBinary(
                ff,
                fw*fh*4
            )
        ]);

        if(token!==radarLoadToken)return;

        uploadTexture(
            radarTexA,
            0,
            gl.R8,
            rw,
            rh,
            gl.RED,
            ra
        );

        uploadTexture(
            radarTexB,
            1,
            gl.R8,
            rw,
            rh,
            gl.RED,
            rb
        );

        uploadTexture(
            radarTexFlow,
            2,
            gl.RGBA8,
            fw,
            fh,
            gl.RGBA,
            flow
        );

        radarCurrentPair={
            ...pair,
            radarWidth:rw,
            radarHeight:rh,
            flowMax:+radarManifest.flow?.max_px||32,
            rateMax:+radarManifest.radar?.rate_max_mm_h||100
        };

        radarCurrentKey=key;
        radarReady=true;
        radarDirty=true;

        if(radarBinaryCache.size>64){
            const keys=[
                ...radarBinaryCache.keys()
            ];

            for(
                let i=0;
                i<keys.length-48;
                i++
            ){
                radarBinaryCache.delete(
                    keys[i]
                );
            }
        }

    }catch(e){
        console.error(
            'Radar-paar laden mislukt:',
            e
        );

    }finally{
        if(radarLoadingKey===key){
            radarLoadingKey='';
        }
    }
}

function ensureRadarPair(now){
    const p=targetRadarPair(now);

    if(!p)return;

    const key=
        String(p.a||'')+
        '_'+
        String(p.b||'')+
        '|'+
        String(
            radarManifest?.flow?.model
            ||
            'legacy'
        );

    if(key!==radarCurrentKey){
        loadRadarPair(p);
    }
}

async function loadRadarManifest(){
    if(!gl)return;

    try{
        const r=await fetch(
            location.pathname+
            '?radar_manifest=1&v='+
            Date.now(),
            {cache:'no-store'}
        );

        if(!r.ok){
            throw new Error(
                'HTTP '+r.status
            );
        }

        const d=await r.json();

        if(
            !Array.isArray(d.frames)||
            !Array.isArray(d.pairs)||
            !d.radar||
            !d.flow
        ){
            throw new Error(
                'Ongeldig manifest'
            );
        }

        radarManifest=d;

        ensureRadarPair(
            displayTime()
        );

        updatePlaybackControls();

    }catch(e){
        console.error(
            'Radar manifest:',
            e
        );
    }
}

function renderRadar(nowMs){
    if(!gl||!rainEnabled)return;

    if(
        !radarDirty&&
        nowMs-radarLastRender<
        1000/RADAR_MAX_FPS
    )return;

    radarLastRender=nowMs;
    radarDirty=false;

    if(playbackActive){
        ensureRadarPair(
            displayTime()
        );

    }else if(
        nowMs-radarLastPairCheck>500
    ){
        radarLastPairCheck=nowMs;

        ensureRadarPair(
            displayTime()
        );
    }

    gl.clearColor(0,0,0,0);
    gl.clear(gl.COLOR_BUFFER_BIT);

    if(
        !radarReady||
        !radarCurrentPair
    )return;

    const a=+radarCurrentPair.a_timestamp;
    const b=+radarCurrentPair.b_timestamp;

    let progress=
        (displayTime()-a)/
        (b-a);

    progress=
        Number.isFinite(progress)
        ?Math.max(0,Math.min(1,progress))
        :0;

    /*
     * Volledig lineaire tijd.
     * De stabiele beweging komt uit het LK velocity field.
     */
    const visualProgress=
        progress;

    const r=stage.getBoundingClientRect();

    const fit=Math.max(
        r.width/W,
        r.height/H
    );

    const ox=(r.width-W*fit)/2;
    const oy=(r.height-H*fit)/2;

    gl.useProgram(
        radarProgram
    );

    gl.bindVertexArray(
        radarVao
    );

    gl.uniform2f(
        gl.getUniformLocation(
            radarProgram,
            'u_canvas_px'
        ),
        radarCanvas.width,
        radarCanvas.height
    );

    gl.uniform1f(
        gl.getUniformLocation(
            radarProgram,
            'u_dpr'
        ),
        radarDpr
    );

    gl.uniform2f(
        gl.getUniformLocation(
            radarProgram,
            'u_fit_offset'
        ),
        ox,
        oy
    );

    gl.uniform1f(
        gl.getUniformLocation(
            radarProgram,
            'u_fit_scale'
        ),
        fit
    );

    gl.uniform2f(
        gl.getUniformLocation(
            radarProgram,
            'u_pan'
        ),
        tx,
        ty
    );

    gl.uniform1f(
        gl.getUniformLocation(
            radarProgram,
            'u_zoom'
        ),
        scale
    );

    gl.uniform2f(
        gl.getUniformLocation(
            radarProgram,
            'u_map_size'
        ),
        radarCurrentPair.radarWidth,
        radarCurrentPair.radarHeight
    );

    gl.uniform1f(
        gl.getUniformLocation(
            radarProgram,
            'u_t'
        ),
        visualProgress
    );

    gl.uniform1f(
        gl.getUniformLocation(
            radarProgram,
            'u_flow_max'
        ),
        radarCurrentPair.flowMax
    );

    gl.uniform1f(
        gl.getUniformLocation(
            radarProgram,
            'u_rate_max'
        ),
        radarCurrentPair.rateMax
    );

    gl.drawArrays(
        gl.TRIANGLES,
        0,
        3
    );
}

function radarAnimationLoop(t){
    renderRadar(t);
    requestAnimationFrame(
        radarAnimationLoop
    );
}


/* ---------- BLIKSEM ---------- */

function flashKey(f){
    const t=(+f.timestamp).toFixed(3);
    const id=String(f.flash_id||'');

    return id
        ?id+'_'+t
        :[
            t,
            (+f.lat).toFixed(5),
            (+f.lon).toFixed(5)
        ].join('_');
}

function lowerBound(a,t){
    let l=0,h=a.length;

    while(l<h){
        const m=(l+h)>>1;

        if(+a[m].timestamp<t)l=m+1;
        else h=m;
    }

    return l;
}

function upperBound(a,t){
    let l=0,h=a.length;

    while(l<h){
        const m=(l+h)>>1;

        if(+a[m].timestamp<=t)l=m+1;
        else h=m;
    }

    return l;
}

function rgb(h){
    h=h.replace('#','');

    return{
        r:parseInt(h.slice(0,2),16),
        g:parseInt(h.slice(2,4),16),
        b:parseInt(h.slice(4,6),16)
    };
}

function mixColor(a,b,t){
    a=rgb(a);
    b=rgb(b);

    t=Math.max(
        0,
        Math.min(1,t)
    );

    return`rgb(${
        Math.round(a.r+(b.r-a.r)*t)
    },${
        Math.round(a.g+(b.g-a.g)*t)
    },${
        Math.round(a.b+(b.b-a.b)*t)
    })`;
}

function ageStyle(age,maxAge){
    const p=
        Math.max(
            0,
            Math.min(maxAge,age)
        )/
        maxAge;

    let fill;

    if(p<=.25){
        fill=mixColor(
            '#fff200',
            '#ffcf00',
            p/.25
        );

    }else if(p<=.5){
        fill=mixColor(
            '#ffcf00',
            '#ff9d00',
            (p-.25)/.25
        );

    }else if(p<=.75){
        fill=mixColor(
            '#ff9d00',
            '#ff5a00',
            (p-.5)/.25
        );

    }else{
        fill=mixColor(
            '#ff5a00',
            '#e02020',
            (p-.75)/.25
        );
    }

    return{
        fill,
        opacity:1-p*.18
    };
}

function lightningAgeWindow(){
    if(rainEnabled){
        return RADAR_FLASH_WINDOW_SECONDS;
    }

    return stormRange==='24h'
        ?STORM_24H_SECONDS
        :lifetime;
}

function lightningStyle(age){
    return rainEnabled
        ?{
            fill:'#ffeb00',
            opacity:1
        }
        :ageStyle(
            age,
            lightningAgeWindow()
        );
}

/*
 * Minutenradar:
 * altijd 1 px rand.
 *
 * Alleen onweer actueel:
 * rand uitsluitend eerste 10 minuten.
 *
 * 24 uur:
 * nooit een rand.
 */
function hasMarkerBorder(age){
    if(rainEnabled)return true;

    if(
        stormRange==='24h'
    ){
        return false;
    }

    return age<=
        MARKER_BORDER_SECONDS;
}

function paintMarker(el,age){
    const s=lightningStyle(age);
    const border=hasMarkerBorder(age);

    el.setAttribute(
        'r',
        markerRadius()
    );

    el.setAttribute(
        'fill',
        s.fill
    );

    el.setAttribute(
        'fill-opacity',
        s.opacity
    );

    el.setAttribute(
        'stroke',
        border
        ?'#282828'
        :'none'
    );

    el.setAttribute(
        'stroke-width',
        border
        ?MARKER_BORDER_WIDTH
        :0
    );

    el.setAttribute(
        'stroke-opacity',
        border
        ?.9
        :0
    );
}

function createRipple(f){
    const p=project(
        +f.lat,
        +f.lon
    );

    const c=document.createElementNS(
        NS,
        'circle'
    );

    c.setAttribute(
        'class',
        'flash-ripple'
    );

    c.setAttribute(
        'cx',
        p.x
    );

    c.setAttribute(
        'cy',
        p.y
    );

    rippleLayer.appendChild(c);

    const start=performance.now();

    function frame(now){
        const q=Math.min(
            1,
            (now-start)/1250
        );

        const easing=
            1-
            Math.pow(
                1-q,
                2
            );

        c.setAttribute(
            'r',
            (
                MARKER_RADIUS+
                easing*
                28
            )/
            scale
        );

        c.style.strokeOpacity=
            String(
                .9*
                (1-q)
            );

        if(q<1){
            requestAnimationFrame(frame);
        }else{
            c.remove();
        }
    }

    requestAnimationFrame(frame);
}

function addFlash(f,now,newFlash=false){
    const ts=+f.timestamp;
    const lat=+f.lat;
    const lon=+f.lon;

    if(
        !Number.isFinite(ts)||
        !Number.isFinite(lat)||
        !Number.isFinite(lon)
    )return;

    const key=flashKey(f);

    if(active.has(key))return;

    const p=project(lat,lon);

    const c=document.createElementNS(
        NS,
        'circle'
    );

    c.setAttribute(
        'class',
        'flash-marker'
    );

    c.setAttribute(
        'cx',
        p.x
    );

    c.setAttribute(
        'cy',
        p.y
    );

    paintMarker(
        c,
        now-ts
    );

    if(
        newFlash&&
        playbackActive
    ){
        c.style.opacity='0';
        c.style.transition=
            'opacity .15s linear';
    }

    flashLayer.appendChild(c);

    active.set(
        key,
        {
            el:c,
            timestamp:ts
        }
    );

    if(newFlash){
        if(playbackActive){
            requestAnimationFrame(
                ()=>
                    requestAnimationFrame(
                        ()=>
                            c.style.opacity='1'
                    )
            );
        }else{
            createRipple(f);
        }
    }
}

function clearLightning(){
    for(const x of active.values()){
        x.el.remove();
    }

    active.clear();
    rippleLayer.replaceChildren();
}

function syncLightningForTime(now,animate=true){
    if(!Number.isFinite(now))return;

    if(
        lastLightningDisplayTime!==null&&
        now<lastLightningDisplayTime
    ){
        clearLightning();

        lastLightningDisplayTime=null;
        animate=false;
    }

    const windowSeconds=
        lightningAgeWindow();

    const from=lowerBound(
        flashes,
        now-windowSeconds
    );

    const to=upperBound(
        flashes,
        now
    );

    const wanted=new Set();

    for(let i=from;i<to;i++){
        const f=flashes[i];
        const ts=+f.timestamp;
        const key=flashKey(f);

        wanted.add(key);

        if(!active.has(key)){
            const fresh=
                animate&&
                lastLightningDisplayTime!==null&&
                ts>lastLightningDisplayTime&&
                ts<=now;

            addFlash(
                f,
                now,
                fresh
            );
        }
    }

    for(const[key,row]of active){
        if(!wanted.has(key)){
            row.el.remove();
            active.delete(key);
        }
    }

    /*
     * Ook bestaande markers opnieuw stylen:
     * hierdoor verdwijnt exact na 10 minuten hun rand.
     */
    for(const row of active.values()){
        paintMarker(
            row.el,
            now-row.timestamp
        );
    }

    lastLightningDisplayTime=now;

    updateStatus(now);
}

function updateStatus(now,force=false){
    const perf=performance.now();

    if(
        !force&&
        playbackActive&&
        perf-lastStatusPaint<100
    )return;

    lastStatusPaint=perf;

    const count=active.size;

    let mode;

    if(!stormEnabled){
        mode='Onweer uit';

    }else if(stormOnly()){
        mode=
            stormRange==='24h'
            ?'24 uur'
            :'Actueel';

    }else{
        mode=
            playbackActive
            ?'Afspelen'
            :'Live';
    }

    status.innerHTML=`
    <div class="map-status-left">

    <span>${mode}</span>

    <span class="map-status-separator">·</span>

    ${
        stormEnabled
        ?`
        <span>
        <strong>${count}</strong>
        ${count===1?'ontlading':'ontladingen'}
        </span>

        <span class="map-status-separator">·</span>
        `
        :''
    }

    <span>${clock(now)}</span>

    </div>

    <div class="map-status-right">

    <span>
    Totaal Nederland:
    <strong>${number(nlToday)}</strong>
    </span>

    </div>`;
}

function updateTimeline(nowMs){
    updatePlayback(nowMs);

    const now=displayTime();

    if(playbackActive){
        if(
            lastLightningTick!==null&&
            nowMs-lastLightningTick<1000/30
        )return;

        lastLightningTick=nowMs;

    }else{
        const sec=Math.floor(now);

        if(sec===lastLightningTick)return;

        lastLightningTick=sec;
    }

    syncLightningForTime(
        now,
        true
    );
}

function synchronize(){
    lastLightningDisplayTime=null;
    lastLightningTick=null;

    clearLightning();

    const now=displayTime();

    syncLightningForTime(
        now,
        false
    );

    updateMarkerSizes();

    if(rainEnabled){
        ensureRadarPair(now);
    }

    radarDirty=true;
}


/* ---------- DATA ---------- */

async function loadLightning(range='current'){
    try{
        const r=await fetch(
            location.pathname+
            '?json='+
            (
                range==='24h'
                ?'24h'
                :'current'
            )+
            '&v='+
            Date.now(),
            {cache:'no-store'}
        );

        if(!r.ok){
            throw new Error(
                'HTTP '+r.status
            );
        }

        const d=await r.json();

        const rows=(
            Array.isArray(d.flashes)
            ?d.flashes
            :[]
        )
        .filter(
            x=>
                Number.isFinite(+x.timestamp)&&
                Number.isFinite(+x.lat)&&
                Number.isFinite(+x.lon)
        )
        .sort(
            (a,b)=>
                +a.timestamp-
                +b.timestamp
        );

        serverNow=
            +d.server_now||
            Date.now()/1000;

        receivedAt=
            performance.now();

        delay=
            +d.live_delay_seconds||
            600;

        lifetime=
            +d.flash_lifetime_seconds||
            3600;

        nlToday=
            +d.netherlands_today||
            0;

        if(range==='24h'){
            flashes24=rows;
            last24Load=Date.now();

            if(
                stormOnly()&&
                stormRange==='24h'
            ){
                flashes=flashes24;
                synchronize();
            }

        }else{
            flashesCurrent=rows;

            if(
                !(
                    stormOnly()&&
                    stormRange==='24h'
                )
            ){
                flashes=flashesCurrent;

                if(!playbackActive){
                    synchronize();
                }
            }
        }

    }catch(e){
        console.error(
            'Bliksemgegevens:',
            e
        );

        status.innerHTML=
            '<div class="map-status-left">Bliksemgegevens tijdelijk niet beschikbaar</div>';
    }
}


/* ---------- START ---------- */

function animationLoop(t){
    updateTimeline(t);
    requestAnimationFrame(animationLoop);
}

updateAdminLayers();
applyViewport();
applyLayers();

if(initRadarGL()){
    loadRadarManifest();

    setInterval(
        loadRadarManifest,
        RADAR_MANIFEST_REFRESH_MS
    );

    new ResizeObserver(()=>{
        resizeRadarCanvas();
        radarDirty=true;
    }).observe(stage);

    requestAnimationFrame(
        radarAnimationLoop
    );

}else{
    rainEnabled=false;

    rainToggle.disabled=true;

    rainToggle.setAttribute(
        'aria-pressed',
        'false'
    );

    radarCanvas.style.display='none';

    applyLayers();
}

loadLightning('current');

setInterval(
    ()=>loadLightning('current'),
    DATA_REFRESH_MS
);

setInterval(
    ()=>{
        if(
            stormOnly()&&
            stormRange==='24h'
        ){
            loadLightning('24h');
        }
    },
    DAY24_REFRESH_MS
);

requestAnimationFrame(animationLoop);
</script>

</body>
</html>