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

$LIVE='/home/weernieuws/data/lightning/live.json';
$ARCHIVE='/home/weernieuws/data/lightning/archive';
$GEO='/home/weernieuws/assets/geo';
$WEB=$GEO.'/europe/web';
$MUNICIPAL_LINES=$WEB.'/nederland_gemeente_lines.geojson';
$PROVINCES=$GEO.'/provincies_cbs.geojson';
$PROVINCE_LINES=$WEB.'/nederland_province_lines.geojson';
$COUNTRY_LINES=$WEB.'/country_lines_overview.geojson';
$RADAR_DIR='/home/weernieuws/public/radar_live';

$DELAY=600;
$FLASH_WINDOW=300;

/*
 * Bliksemhistorie automatisch laten aansluiten op
 * de volledige beschikbare radarhistorie.
 */
$READBACK=$DELAY+$FLASH_WINDOW+420;

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

if(is_file($manifestPath)){

    $manifest=json_decode(
        (string)@file_get_contents($manifestPath),
        true
    );

    $oldest=null;

    if(is_array($manifest)){

        foreach(($manifest['pairs']??[]) as $pair){

            if(!is_array($pair)){
                continue;
            }

            foreach(['a_timestamp','b_timestamp'] as $key){

                if(!is_numeric($pair[$key]??null)){
                    continue;
                }

                $ts=(float)$pair[$key];

                if(
                    $oldest===null||
                    $ts<$oldest
                ){
                    $oldest=$ts;
                }
            }
        }

        /*
         * Fallback wanneer een manifest wel frames maar
         * onverhoopt geen bruikbare pairs bevat.
         */
        if($oldest===null){

            foreach(($manifest['frames']??[]) as $frame){

                if(
                    !is_array($frame)||
                    !is_numeric($frame['timestamp']??null)
                ){
                    continue;
                }

                $ts=(float)$frame['timestamp'];

                if(
                    $oldest===null||
                    $ts<$oldest
                ){
                    $oldest=$ts;
                }
            }
        }
    }

    if($oldest!==null){

        /*
         * Oudste radarbeeld + volledige flash-window
         * + 5 minuten veilige marge.
         */
        $required=
            (int)ceil(
                microtime(true)-
                $oldest+
                $FLASH_WINDOW+
                300
            );

        $READBACK=max(
            $READBACK,
            $required
        );
    }
}

$W=740.0;$H=877.0;
$MIN_LAT=50.59;$MAX_LAT=53.68;$MIN_LON=3.08;$MAX_LON=7.48;
$MAP_SCALE_X=1.025;

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

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

    $raw=@file_get_contents($p);

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

    $d=json_decode($raw,true);

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

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,$MAP_SCALE_X;

    $x=(($lon-$MIN_LON)/($MAX_LON-$MIN_LON))*$W;

    $y=(
        1-
        (
            merc_y($lat)-merc_y($MIN_LAT)
        )/
        (
            merc_y($MAX_LAT)-merc_y($MIN_LAT)
        )
    )*$H;

    $x=$W/2+($x-$W/2)*$MAP_SCALE_X;

    return[$x,$y];
}

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

    if($t==='Polygon'){
        $polys=[$c];
    }elseif($t==='MultiPolygon'){
        $polys=$c;
    }elseif($t==='GeometryCollection'){
        $out=[];

        foreach(($g['geometries']??[]) as $x){
            if(
                is_array($x)&&
                ($v=polygon_path($x))!==''
            ){
                $out[]=$v;
            }
        }

        return implode(' ',$out);
    }else{
        return'';
    }

    $out=[];

    foreach($polys as $poly){
        if(!is_array($poly))continue;

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

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

                $first=false;
            }

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

    return implode(' ',$out);
}

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

    if($t==='LineString'){
        $lines=[$c];
    }elseif($t==='MultiLineString'){
        $lines=$c;
    }elseif($t==='GeometryCollection'){
        $out=[];

        foreach(($g['geometries']??[]) as $x){
            if(
                is_array($x)&&
                ($v=line_path($x))!==''
            ){
                $out[]=$v;
            }
        }

        return implode(' ',$out);
    }else{
        return'';
    }

    $out=[];

    foreach($lines as $line){
        $first=true;

        foreach($line 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]
            );

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

            $first=false;
        }
    }

    return implode(' ',$out);
}

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 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 implode(
        '|',
        [
            number_format($t,3,'.',''),
            number_format((float)$f['lat'],5,'.',''),
            number_format((float)$f['lon'],5,'.','')
        ]
    );
}

function normalize_flash(
    array $f,
    float $cut,
    float $now
):?array{
    global $MIN_LAT,$MAX_LAT,$MIN_LON,$MAX_LON;

    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<$MIN_LAT-.2||
        $lat>$MAX_LAT+.2||
        $lon<$MIN_LON-.2||
        $lon>$MAX_LON+.2
    ){
        return null;
    }

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

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

    $consume=
        static function(array $rows)
        use(&$unique,$cut,$now){

            foreach($rows as $f){
                if(!is_array($f))continue;

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

                if($r!==null){
                    $unique[
                        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(
            $unique
        );

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

    return[
        'server_now'=>$now,
        'live_delay_seconds'=>$delay,
        'flashes'=>$rows
    ];
}

if(isset($_GET['json'])){
    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,
            $DELAY,
            $READBACK
        ),
        JSON_UNESCAPED_UNICODE|
        JSON_UNESCAPED_SLASHES
    );

    exit;
}

$municipalLines=load_json($MUNICIPAL_LINES);
$provinces=load_json($PROVINCES);
$provinceLines=load_json($PROVINCE_LINES);
$countryLines=load_json($COUNTRY_LINES);
?>
<!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>Neerslagradar</title>

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

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

.map-stage{
position:absolute;
inset:0;
overflow:hidden;
background:transparent;
cursor:grab;
touch-action:none;
user-select:none;
-webkit-user-select:none
}

.map-stage.dragging{
cursor:grabbing
}

.map-svg,
.radar-canvas{
position:absolute;
inset:0;
display:block;
width:100%;
height:100%;
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:transparent
}

.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;
pointer-events:none
}

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

.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;
pointer-events:none
}

.flash-marker{
fill:#ffeb00;
stroke:rgb(40 40 40);
stroke-width:2;
stroke-opacity:.92;
vector-effect:non-scaling-stroke;
pointer-events:none
}

.fullscreen-btn{
position:absolute;right:12px;bottom:60px;z-index:20;
width:40px;height:40px;padding:0;
border:1px solid rgba(255,255,255,.64);border-radius:14px;
background:rgba(255,255,255,.88);
box-shadow:0 4px 14px rgba(15,23,42,.09);
color:#334155;display:grid;place-items:center;cursor:pointer
}
.fullscreen-btn svg{
width:24px;height:24px;fill:none;stroke:currentColor;stroke-width:2;
stroke-linecap:round;stroke-linejoin:round
}

.brand-title{
fill:rgb(30 41 59);
font-size:26px;
font-weight:800;
dominant-baseline:hanging;
pointer-events:none
}

.brand-date{
fill:rgb(30 41 59);
font-size:16px;
font-weight:600;
dominant-baseline:hanging;
pointer-events:none
}
</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 meet"
>

<rect
class="map-background"
width="740"
height="877"
/>

<g id="baseViewport">

<?php
foreach(
    (
        $provinces['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 meet"
>

<defs>

<mask
id="nlCountryMask"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="740"
height="877"
>

<rect
width="740"
height="877"
fill="black"
/>

<?php
foreach(
    (
        $provinces['features']
        ??
        []
    )
    as
    $f
):

$p=
    polygon_path(
        is_array(
            $f['geometry']
            ??
            null
        )
        ?$f['geometry']
        :[]
    );

if($p===''){
    continue;
}
?>

<path
d="<?=htmlspecialchars($p,ENT_QUOTES,'UTF-8')?>"
fill="white"
stroke="white"
stroke-width="12"
stroke-linejoin="round"
/>

<?php endforeach; ?>

</mask>

</defs>


<g id="overlayViewport">


<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="provinceLayer">

<?php
foreach(
    (
        $provinceLines['features']
        ??
        []
    )
    as
    $f
):

$p=
    line_path(
        is_array(
            $f['geometry']
            ??
            null
        )
        ?$f['geometry']
        :[]
    );

if($p===''){
    continue;
}
?>

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

<?php endforeach; ?>

</g>


<g
id="countryLayer"
mask="url(#nlCountryMask)"
>

<?php
foreach(
    (
        $countryLines['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="flashLayer"></g>


</g>


<text
class="brand-title"
x="26"
y="24"
>
Neerslagradar
</text>


<text
class="brand-date"
id="brandDate"
x="26"
y="64"
></text>


</svg>

<button class="fullscreen-btn" id="fullscreenBtn" type="button" aria-label="Volledig scherm openen">
<svg viewBox="0 0 24 24"><path d="M8 3H3v5M16 3h5v5M8 21H3v-5M16 21h5v-5"/></svg>
</button>

</div>


<script>
'use strict';


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

const W=740;
const H=877;

const MIN_LAT=50.59;
const MAX_LAT=53.68;
const MIN_LON=3.08;
const MAX_LON=7.48;

const MAP_SCALE_X=1.025;

const SRC_MIN_LAT=47;
const SRC_MAX_LAT=56.5;
const SRC_MIN_LON=-1.5;
const SRC_MAX_LON=12.5;

const FLASH_WINDOW=300;

const RADAR_FPS=30;
const RADAR_DPR_MAX=2;

const DATA_REFRESH_MS=10000;
const MANIFEST_REFRESH_MS=30000;

const MIN_ZOOM=1;
const MAX_ZOOM=10;

const EXTERNAL_PLAYBACK_RATE=360;

/*
 * Compenseert het visuele afremmen rond echte 5-minutenframes.
 * 0    = lineaire interpolatie zoals voorheen
 * 0.42 = sneller bij de framegrenzen, rustiger in het midden
 */
const FRAME_SPEED_COMPENSATION=0.0;

const PARENT_ORIGINS=
    new Set(
        [
            'https://weernieuws.info',
            'https://www.weernieuws.info'
        ]
    );


const stage=
    document.getElementById(
        'mapStage'
    );

const overlaySvg=
    document.getElementById(
        'overlaySvg'
    );

const baseViewport=
    document.getElementById(
        'baseViewport'
    );

const overlayViewport=
    document.getElementById(
        'overlayViewport'
    );

const radarCanvas=
    document.getElementById(
        'radarCanvas'
    );

const flashLayer=
    document.getElementById(
        'flashLayer'
    );

const brandDate=
    document.getElementById(
        'brandDate'
    );


const fullscreenBtn=document.getElementById('fullscreenBtn');
const FULLSCREEN_URL='https://data.weernieuws.info/lightning_test.php';

let scale=1;
let tx=0;
let ty=0;

let dragStart=null;
let pinchStart=null;
let touchScrollLastY=null;

const pointers=
    new Map();

const activeFlashes=
    new Map();


let flashes=[];

let serverNow=
    Date.now()/1000;

let receivedAt=
    performance.now();

let delay=600;

let lastLightningTime=null;
let lastSecond=null;
let lastBrandMinute=null;


let controlledTime=null;

let externalPlaying=false;
let externalPlaybackLast=0;

let lightningVisible=true;
let lastParentUpdate=0;


/* =========================================================
   PROJECTIE
   ========================================================= */

function merc(lat){

    const r=
        Math.max(
            -85.05112878,
            Math.min(
                85.05112878,
                +lat
            )
        )
        *
        Math.PI/
        180;

    return Math.log(
        Math.tan(
            Math.PI/4+
            r/2
        )
    );
}


const mercMin=
    merc(
        MIN_LAT
    );

const mercMax=
    merc(
        MAX_LAT
    );

const srcMercMin=
    merc(
        SRC_MIN_LAT
    );

const srcMercMax=
    merc(
        SRC_MAX_LAT
    );


function project(
    lat,
    lon
){

    let x=
        (
            (+lon-MIN_LON)
            /
            (
                MAX_LON-
                MIN_LON
            )
        )
        *
        W;


    const y=
        (
            1-
            (
                merc(+lat)-
                mercMin
            )
            /
            (
                mercMax-
                mercMin
            )
        )
        *
        H;


    x=
        W/2+
        (
            x-
            W/2
        )
        *
        MAP_SCALE_X;


    return{
        x,
        y
    };
}


/* =========================================================
   PAN / ZOOM
   ========================================================= */

function updateMarkerSizes(){

    for(
        const entry
        of activeFlashes.values()
    ){

        entry.el.setAttribute(
            'r',
            5/scale
        );
    }
}


function applyViewport(){

    const transform=
        `translate(${tx} ${ty}) scale(${scale})`;


    baseViewport.setAttribute(
        'transform',
        transform
    );


    overlayViewport.setAttribute(
        'transform',
        transform
    );


    updateMarkerSizes();

    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.min(
            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();
}


stage.addEventListener(
    'wheel',
    event=>{

        event.preventDefault();


        const point=
            screenPoint(
                event.clientX,
                event.clientY
            );


        zoomAt(
            event.deltaY<0
            ?1.18
            :1/1.18,

            point.x,
            point.y
        );
    },
    {
        passive:false
    }
);


function requestParentScroll(
    dy
){

    if(
        !Number.isFinite(dy)||
        Math.abs(dy)<0.01||
        window.parent===window
    ){
        return;
    }


    for(
        const origin
        of PARENT_ORIGINS
    ){

        parent.postMessage(
            {
                type:
                    'wn-radar-page-scroll',

                dy
            },
            origin
        );
    }
}


stage.addEventListener(
    'pointerdown',
    event=>{

        stage.setPointerCapture(
            event.pointerId
        );


        pointers.set(
            event.pointerId,
            {
                x:
                    event.clientX,

                y:
                    event.clientY
            }
        );


        if(
            pointers.size===
            1
        ){

            if(
                event.pointerType===
                'touch'
            ){

                touchScrollLastY=
                    event.screenY;


                dragStart=null;
                pinchStart=null;


                stage.classList.remove(
                    'dragging'
                );


            }else{

                touchScrollLastY=null;


                dragStart={

                    x:
                        event.clientX,

                    y:
                        event.clientY,

                    tx,
                    ty
                };


                pinchStart=null;


                stage.classList.add(
                    'dragging'
                );
            }


        }else if(
            pointers.size===
            2
        ){

            touchScrollLastY=null;


            const points=
                [
                    ...pointers.values()
                ];


            const center=
                screenPoint(
                    (
                        points[0].x+
                        points[1].x
                    )/2,

                    (
                        points[0].y+
                        points[1].y
                    )/2
                );


            pinchStart={

                dist:
                    Math.hypot(
                        points[1].x-
                        points[0].x,

                        points[1].y-
                        points[0].y
                    ),

                scale,

                cx:
                    center.x,

                cy:
                    center.y,

                tx,
                ty
            };


            dragStart=null;
        }
    }
);


stage.addEventListener(
    'pointermove',
    event=>{

        if(
            !pointers.has(
                event.pointerId
            )
        ){
            return;
        }


        pointers.set(
            event.pointerId,
            {
                x:
                    event.clientX,

                y:
                    event.clientY
            }
        );


        if(
            event.pointerType===
            'touch'&&
            pointers.size===
            1
        ){

            if(
                touchScrollLastY!==
                null
            ){

                requestParentScroll(
                    touchScrollLastY-
                    event.screenY
                );
            }


            touchScrollLastY=
                event.screenY;


            return;
        }


        if(
            pointers.size===
            1&&
            dragStart
        ){

            const rect=
                overlaySvg.getBoundingClientRect();


            const fit=
                Math.min(
                    rect.width/W,
                    rect.height/H
                );


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


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


            clamp();

            applyViewport();


            return;
        }


        if(
            pointers.size===
            2&&
            pinchStart
        ){

            const points=
                [
                    ...pointers.values()
                ];


            const dist=
                Math.hypot(
                    points[1].x-
                    points[0].x,

                    points[1].y-
                    points[0].y
                );


            const center=
                screenPoint(
                    (
                        points[0].x+
                        points[1].x
                    )/2,

                    (
                        points[0].y+
                        points[1].y
                    )/2
                );


            scale=
                Math.max(
                    MIN_ZOOM,
                    Math.min(
                        MAX_ZOOM,

                        pinchStart.scale*
                        (
                            dist/
                            pinchStart.dist
                        )
                    )
                );


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


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


            tx=
                center.x-
                mx*
                scale;


            ty=
                center.y-
                my*
                scale;


            clamp();

            applyViewport();
        }
    }
);


function releasePointer(
    event
){

    pointers.delete(
        event.pointerId
    );


    if(
        !pointers.size
    ){

        dragStart=null;
        pinchStart=null;
        touchScrollLastY=null;


        stage.classList.remove(
            'dragging'
        );


        return;
    }


    if(
        pointers.size===
        1
    ){

        dragStart=null;
        pinchStart=null;
        touchScrollLastY=null;


        stage.classList.remove(
            'dragging'
        );
    }
}


stage.addEventListener(
    'pointerup',
    releasePointer
);


stage.addEventListener(
    'pointercancel',
    releasePointer
);


fullscreenBtn?.addEventListener('click',event=>{
    event.preventDefault();
    event.stopPropagation();

    try{
        window.top.location.href=FULLSCREEN_URL;
    }catch(_){
        window.location.href=FULLSCREEN_URL;
    }
});


/* =========================================================
   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 value=
        +pairs[
            pairs.length-1
        ].b_timestamp;


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


function radarLiveTime(){

    return Math.min(
        liveTime(),
        lastRadarTime()
    );
}


function displayTime(){

    return controlledTime===
        null

        ?radarLiveTime()

        :controlledTime;
}


function radarTimes(){

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


    if(
        !pairs.length
    ){
        return[];
    }


    const result=[];


    const first=
        +pairs[0].a_timestamp;


    if(
        Number.isFinite(
            first
        )
    ){
        result.push(
            first
        );
    }


    for(
        const pair
        of
        pairs
    ){

        const value=
            +pair.b_timestamp;


        if(
            Number.isFinite(
                value
            )&&
            !result.includes(
                value
            )
        ){
            result.push(
                value
            );
        }
    }


    return result.sort(
        (
            a,
            b
        )=>
            a-b
    );
}


function localStamp(
    timestamp
){

    const values={};


    new Intl.DateTimeFormat(
        'en-GB',
        {
            year:
                'numeric',

            month:
                '2-digit',

            day:
                '2-digit',

            hour:
                '2-digit',

            minute:
                '2-digit',

            hourCycle:
                'h23',

            timeZone:
                'Europe/Amsterdam'
        }
    )
    .formatToParts(
        new Date(
            timestamp*
            1000
        )
    )
    .forEach(
        part=>{

            values[
                part.type
            ]=
                part.value;
        }
    );


    return(
        (
            values.year
            ||
            ''
        )
        +
        (
            values.month
            ||
            ''
        )
        +
        (
            values.day
            ||
            ''
        )
        +
        (
            values.hour
            ||
            ''
        )
        +
        (
            values.minute
            ||
            ''
        )
    );
}


function nearestRadarStamp(
    now
){

    const times=
        radarTimes();


    if(
        !times.length
    ){
        return'';
    }


    let result=
        times[0];


    for(
        const time
        of
        times
    ){

        if(
            time<=
            now
        ){

            result=
                time;


        }else{

            break;
        }
    }


    return localStamp(
        result
    );
}


function nlDatumtekst(
    timestamp
){

    let text=
        new Intl.DateTimeFormat(
            'nl-NL',
            {
                weekday:
                    'long',

                day:
                    'numeric',

                month:
                    'long',

                year:
                    'numeric',

                hour:
                    '2-digit',

                minute:
                    '2-digit',

                hour12:
                    false,

                timeZone:
                    'Europe/Amsterdam'
            }
        )
        .format(
            new Date(
                timestamp*
                1000
            )
        );


    text=
        text.charAt(0).toUpperCase()+
        text.slice(1);


    return(
        text.replace(
            /\s+om\s+/,
            ', '
        )
        +
        ' uur'
    );
}


function updateBranding(
    timestamp
){

    const minute=
        Math.floor(
            timestamp/
            60
        );


    if(
        minute===
        lastBrandMinute
    ){
        return;
    }


    lastBrandMinute=
        minute;


    brandDate.textContent=
        nlDatumtekst(
            timestamp
        );
}


/* =========================================================
   PARENT
   ========================================================= */

function postParent(
    data
){

    if(
        window.parent===
        window
    ){
        return;
    }


    for(
        const origin
        of
        PARENT_ORIGINS
    ){

        parent.postMessage(
            data,
            origin
        );
    }
}


function notifyParent(
    force=false
){

    const perf=
        performance.now();


    if(
        !force&&
        perf-
        lastParentUpdate<
        100
    ){
        return;
    }


    lastParentUpdate=
        perf;


    const now=
        displayTime();


    postParent(
        {
            type:
                'wn-radar-time',

            timestamp:
                now,

            stamp:
                nearestRadarStamp(
                    now
                ),

            playing:
                externalPlaying,

            lightning:
                lightningVisible
        }
    );
}


function sendReady(){

    postParent(
        {
            type:
                'wn-radar-ready',

            stamps:
                radarTimes()
                .map(
                    localStamp
                ),

            timestamp:
                displayTime(),

            stamp:
                nearestRadarStamp(
                    displayTime()
                ),

            playing:
                externalPlaying,

            lightning:
                lightningVisible
        }
    );
}


function setControlledTime(
    timestamp
){

    const times=
        radarTimes();


    if(
        !times.length
    ){
        return;
    }


    controlledTime=
        Math.max(
            times[0],
            Math.min(
                times[
                    times.length-1
                ],
                timestamp
            )
        );


    externalPlaying=false;
    externalPlaybackLast=0;


    lastLightningTime=null;
    lastSecond=null;
    lastBrandMinute=null;


    ensureRadarPair(
        controlledTime
    );


    syncLightning(
        controlledTime
    );


    updateBranding(
        controlledTime
    );


    radarDirty=true;


    notifyParent(
        true
    );
}


function setControlledStamp(
    stamp
){

    const timestamp=
        radarTimes()
        .find(
            time=>
                localStamp(
                    time
                )===
                String(
                    stamp
                    ||
                    ''
                )
        );


    if(
        Number.isFinite(
            timestamp
        )
    ){

        setControlledTime(
            timestamp
        );
    }
}


function goRadarNow(){

    controlledTime=null;

    externalPlaying=false;
    externalPlaybackLast=0;


    lastLightningTime=null;
    lastSecond=null;
    lastBrandMinute=null;


    const now=
        displayTime();


    ensureRadarPair(
        now
    );


    syncLightning(
        now
    );


    updateBranding(
        now
    );


    radarDirty=true;


    notifyParent(
        true
    );
}


/* =========================================================
   WEBGL
   ========================================================= */

let gl=null;

let radarProgram=null;
let radarVao=null;


/*
 * Losse dynamische textures blijven beschikbaar
 * voor live frames / handmatig springen.
 */
let radarDynA=null;
let radarDynB=null;
let radarDynFlow=null;


/*
 * Dit zijn de textures die op dit moment
 * daadwerkelijk door de shader gebruikt worden.
 */
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();


/*
 * Bij Play worden alle frames + flowvelden hier
 * vooraf als GPU-textures opgeslagen.
 *
 * Daardoor hoeft op een 5-minutengrens alleen
 * van texture-referentie gewisseld te worden.
 */
let radarGpuFrames=
    new Map();

let radarGpuFlows=
    new Map();

let radarGpuSignature='';

let radarGpuBuild=null;


/* =========================================================
   WEBGL HELPERS
   ========================================================= */

function compileShader(
    type,
    source
){

    const shader=
        gl.createShader(
            type
        );


    gl.shaderSource(
        shader,
        source
    );


    gl.compileShader(
        shader
    );


    if(
        !gl.getShaderParameter(
            shader,
            gl.COMPILE_STATUS
        )
    ){

        throw new Error(
            gl.getShaderInfoLog(
                shader
            )
        );
    }


    return shader;
}


function makeProgram(
    vertexSource,
    fragmentSource
){

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


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


    const program=
        gl.createProgram();


    gl.attachShader(
        program,
        vs
    );


    gl.attachShader(
        program,
        fs
    );


    gl.linkProgram(
        program
    );


    gl.deleteShader(
        vs
    );


    gl.deleteShader(
        fs
    );


    if(
        !gl.getProgramParameter(
            program,
            gl.LINK_STATUS
        )
    ){

        throw new Error(
            gl.getProgramInfoLog(
                program
            )
        );
    }


    return program;
}


function texture(
    unit,
    internalFormat,
    width,
    height,
    format,
    data,
    filter
){

    const tex=
        gl.createTexture();


    gl.activeTexture(
        gl.TEXTURE0+
        unit
    );


    gl.bindTexture(
        gl.TEXTURE_2D,
        tex
    );


    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 tex;
}


function uploadTexture(
    tex,
    unit,
    internalFormat,
    width,
    height,
    format,
    data
){

    gl.activeTexture(
        gl.TEXTURE0+
        unit
    );


    gl.bindTexture(
        gl.TEXTURE_2D,
        tex
    );


    gl.pixelStorei(
        gl.UNPACK_ALIGNMENT,
        1
    );


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


function bindRadarTextures(){

    gl.activeTexture(
        gl.TEXTURE0
    );

    gl.bindTexture(
        gl.TEXTURE_2D,
        radarTexA
    );


    gl.activeTexture(
        gl.TEXTURE1
    );

    gl.bindTexture(
        gl.TEXTURE_2D,
        radarTexB
    );


    gl.activeTexture(
        gl.TEXTURE2
    );

    gl.bindTexture(
        gl.TEXTURE_2D,
        radarTexFlow
    );
}


function pairKey(
    pair
){

    return(
        String(
            pair?.a
            ||
            ''
        )
        +
        '_'
        +
        String(
            pair?.b
            ||
            ''
        )
    );
}


function pairState(
    pair
){

    return{
        ...pair,

        radarWidth:
            +radarManifest?.radar?.width
            ||
            W,

        radarHeight:
            +radarManifest?.radar?.height
            ||
            H,

        flowMax:
            +radarManifest?.flow?.max_px
            ||
            32,

        rateMax:
            +radarManifest?.radar?.rate_max_mm_h
            ||
            100
    };
}


/* =========================================================
   WEBGL INIT
   ========================================================= */

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;
uniform sampler2D u_b;
uniform sampler2D u_flow;

uniform vec2 u_canvas_px;
uniform float u_dpr;

uniform vec2 u_fit_offset;
uniform float u_fit_scale;

uniform vec2 u_pan;
uniform float u_zoom;

uniform vec2 u_source_size;

uniform float u_t;
uniform float u_flow_max;
uniform float u_rate_max;

uniform vec2 u_display_merc;
uniform vec2 u_source_merc;

out vec4 outColor;


vec2 sourceUV(
    vec2 mapPixel
){

    float x0=
        370.0+
        (
            mapPixel.x-
            370.0
        )/
        1.025;


    float lon=
        3.08+
        (
            x0/
            740.0
        )*
        (
            7.48-
            3.08
        );


    float merc=
        u_display_merc.x+
        (
            1.0-
            mapPixel.y/
            877.0
        )*
        (
            u_display_merc.y-
            u_display_merc.x
        );


    float u=
        (
            lon-
            (-1.5)
        )
        /
        (
            12.5-
            (-1.5)
        );


    float v=
        (
            u_source_merc.y-
            merc
        )
        /
        (
            u_source_merc.y-
            u_source_merc.x
        );


    return vec2(
        u,
        v
    );
}


float decodeRate(
    float encoded
){

    if(
        encoded<=
        0.0001
    ){
        return 0.0;
    }


    return(
        exp(
            encoded*
            log(
                1.0+
                u_rate_max
            )
        )
        -
        1.0
    );
}


float texRate(
    sampler2D tex,
    ivec2 point
){

    ivec2 high=
        ivec2(
            u_source_size
        )
        -
        ivec2(
            1
        );


    return decodeRate(
        texelFetch(
            tex,
            clamp(
                point,
                ivec2(
                    0
                ),
                high
            ),
            0
        ).r
    );
}


float radarRate(
    sampler2D tex,
    vec2 uv
){

    vec2 point=
        uv*
        u_source_size
        -
        0.5;


    ivec2 base=
        ivec2(
            floor(
                point
            )
        );


    vec2 f=
        fract(
            point
        );


    float r00=
        texRate(
            tex,
            base
        );


    float r10=
        texRate(
            tex,
            base+
            ivec2(
                1,
                0
            )
        );


    float r01=
        texRate(
            tex,
            base+
            ivec2(
                0,
                1
            )
        );


    float r11=
        texRate(
            tex,
            base+
            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 rate
){

    if(rate<0.12)
        return vec4(0.0);

    if(rate<0.50)
        return vec4(
            184.0,
            216.0,
            242.0,
            255.0
        )/255.0;

    if(rate<1.0)
        return vec4(
            138.0,
            187.0,
            230.0,
            255.0
        )/255.0;

    if(rate<2.0)
        return vec4(
            95.0,
            158.0,
            217.0,
            255.0
        )/255.0;

    if(rate<5.0)
        return vec4(
            62.0,
            130.0,
            199.0,
            255.0
        )/255.0;

    if(rate<10.0)
        return vec4(
            43.0,
            102.0,
            173.0,
            255.0
        )/255.0;

    if(rate<20.0)
        return vec4(
            28.0,
            77.0,
            133.0,
            255.0
        )/255.0;

    if(rate<30.0)
        return vec4(
            229.0,
            57.0,
            53.0,
            255.0
        )/255.0;

    if(rate<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>=
        740.0||
        mapPixel.y>=
        877.0
    ){

        outColor=
            vec4(
                0.0
            );

        return;
    }


    vec2 uv=
        sourceUV(
            mapPixel
        );


    if(
        uv.x<
        0.0||
        uv.y<
        0.0||
        uv.x>
        1.0||
        uv.y>
        1.0
    ){

        outColor=
            vec4(
                0.0
            );

        return;
    }


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


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


    float rateA=
        radarRate(
            u_a,
            uvA
        );


    float rateB=
        radarRate(
            u_b,
            uvB
        );


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


    radarProgram=
        makeProgram(
            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
    );


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


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


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


    radarTexA=
        radarDynA;

    radarTexB=
        radarDynB;

    radarTexFlow=
        radarDynFlow;


    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.uniform2f(
        gl.getUniformLocation(
            radarProgram,
            'u_display_merc'
        ),
        mercMin,
        mercMax
    );


    gl.uniform2f(
        gl.getUniformLocation(
            radarProgram,
            'u_source_merc'
        ),
        srcMercMin,
        srcMercMax
    );


    gl.disable(
        gl.DEPTH_TEST
    );


    gl.disable(
        gl.CULL_FACE
    );


    gl.disable(
        gl.BLEND
    );


    resizeRadarCanvas();


    return true;
}


/* =========================================================
   CANVAS
   ========================================================= */

function resizeRadarCanvas(){

    if(
        !gl
    ){
        return;
    }


    const rect=
        stage.getBoundingClientRect();


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


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


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


    if(
        radarCanvas.width!==
        width||
        radarCanvas.height!==
        height
    ){

        radarCanvas.width=
            width;


        radarCanvas.height=
            height;


        gl.viewport(
            0,
            0,
            width,
            height
        );


        radarDirty=true;
    }
}


/* =========================================================
   DATA
   ========================================================= */

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 response=
                    await fetch(
                        radarUrl(
                            name
                        ),
                        {
                            cache:
                                'force-cache'
                        }
                    );


                if(
                    !response.ok
                ){

                    throw new Error(
                        name+
                        ': HTTP '+
                        response.status
                    );
                }


                const bytes=
                    new Uint8Array(
                        await response.arrayBuffer()
                    );


                if(
                    bytes.byteLength!==
                    size
                ){

                    throw new Error(
                        name+
                        ': verkeerd formaat'
                    );
                }


                return bytes;
            }
        )();


    radarBinaryCache.set(
        cacheKey,
        promise
    );


    try{

        return await promise;


    }catch(error){

        radarBinaryCache.delete(
            cacheKey
        );


        throw error;
    }
}


function frameFile(
    stamp,
    manifest=radarManifest
){

    const frame=
        (
            manifest?.frames
            ??
            []
        )
        .find(
            row=>
                String(
                    row.stamp
                )===
                String(
                    stamp
                )
        );


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


/* =========================================================
   VOLLEDIGE BROWSERPRELOAD
   ========================================================= */

async function preloadRadarHistory(
    manifest=radarManifest
){

    if(
        !manifest
    ){
        return;
    }


    const rw=
        +manifest.radar?.width
        ||
        W;


    const rh=
        +manifest.radar?.height
        ||
        H;


    const fw=
        +manifest.flow?.width
        ||
        185;


    const fh=
        +manifest.flow?.height
        ||
        219;


    const jobs=[];


    for(
        const pair
        of
        manifest.pairs
        ||
        []
    ){

        const a=
            frameFile(
                pair.a,
                manifest
            );


        const b=
            frameFile(
                pair.b,
                manifest
            );


        const flow=
            String(
                pair.file
                ||
                ''
            );


        if(a){

            jobs.push(
                fetchRadarBinary(
                    a,
                    rw*
                    rh
                )
            );
        }


        if(b){

            jobs.push(
                fetchRadarBinary(
                    b,
                    rw*
                    rh
                )
            );
        }


        if(flow){

            jobs.push(
                fetchRadarBinary(
                    flow,
                    fw*
                    fh*
                    4
                )
            );
        }
    }


    await Promise.all(
        jobs
    );
}


/* =========================================================
   GPU PRELOAD
   ========================================================= */

function manifestSignature(
    manifest
){

    return JSON.stringify(
        [
            (
                manifest?.frames
                ||
                []
            )
            .map(
                row=>[
                    row.stamp,
                    row.file
                ]
            ),

            (
                manifest?.pairs
                ||
                []
            )
            .map(
                row=>[
                    row.a,
                    row.b,
                    row.file
                ]
            ),

            manifest?.radar?.width,
            manifest?.radar?.height,

            manifest?.flow?.width,
            manifest?.flow?.height,
            manifest?.flow?.model
        ]
    );
}


function deleteTextureMap(
    map
){

    for(
        const tex
        of
        map.values()
    ){

        gl.deleteTexture(
            tex
        );
    }
}


async function prepareRadarGpuHistory(){

    if(
        !gl||
        !radarManifest
    ){
        return;
    }


    const manifest=
        radarManifest;


    const signature=
        manifestSignature(
            manifest
        );


    if(
        signature===
        radarGpuSignature&&
        radarGpuFrames.size&&
        radarGpuFlows.size
    ){

        return;
    }


    if(
        radarGpuBuild?.sig===
        signature
    ){

        return radarGpuBuild.promise;
    }


    const promise=
        (
            async()=>{

                /*
                 * Eerst alle binaries in geheugen/browsercache.
                 */
                await preloadRadarHistory(
                    manifest
                );


                const rw=
                    +manifest.radar?.width
                    ||
                    W;


                const rh=
                    +manifest.radar?.height
                    ||
                    H;


                const fw=
                    +manifest.flow?.width
                    ||
                    185;


                const fh=
                    +manifest.flow?.height
                    ||
                    219;


                const frameTextures=
                    new Map();


                const flowTextures=
                    new Map();


                try{

                    /*
                     * Alle echte radarframes vooraf naar GPU.
                     */
                    for(
                        const frame
                        of
                        manifest.frames
                        ||
                        []
                    ){

                        const stamp=
                            String(
                                frame.stamp
                                ||
                                ''
                            );


                        const file=
                            String(
                                frame.file
                                ||
                                ''
                            );


                        if(
                            !stamp||
                            !file
                        ){
                            continue;
                        }


                        const bytes=
                            await fetchRadarBinary(
                                file,
                                rw*
                                rh
                            );


                        frameTextures.set(
                            stamp,

                            texture(
                                3,
                                gl.R8,
                                rw,
                                rh,
                                gl.RED,
                                bytes,
                                gl.LINEAR
                            )
                        );
                    }


                    /*
                     * Ook alle optical-flowvelden vooraf naar GPU.
                     */
                    for(
                        const pair
                        of
                        manifest.pairs
                        ||
                        []
                    ){

                        const file=
                            String(
                                pair.file
                                ||
                                ''
                            );


                        const key=
                            pairKey(
                                pair
                            );


                        if(
                            !file||
                            !key
                        ){
                            continue;
                        }


                        const bytes=
                            await fetchRadarBinary(
                                file,
                                fw*
                                fh*
                                4
                            );


                        flowTextures.set(
                            key,

                            texture(
                                3,
                                gl.RGBA8,
                                fw,
                                fh,
                                gl.RGBA,
                                bytes,
                                gl.LINEAR
                            )
                        );
                    }


                    /*
                     * Manifest veranderde tijdens preload?
                     * Dan direct opnieuw voor de nieuwste versie.
                     */
                    if(
                        manifest!==
                        radarManifest
                    ){

                        deleteTextureMap(
                            frameTextures
                        );


                        deleteTextureMap(
                            flowTextures
                        );


                        return prepareRadarGpuHistory();
                    }


                    deleteTextureMap(
                        radarGpuFrames
                    );


                    deleteTextureMap(
                        radarGpuFlows
                    );


                    radarGpuFrames=
                        frameTextures;


                    radarGpuFlows=
                        flowTextures;


                    radarGpuSignature=
                        signature;


                }catch(error){

                    deleteTextureMap(
                        frameTextures
                    );


                    deleteTextureMap(
                        flowTextures
                    );


                    throw error;
                }
            }
        )();


    radarGpuBuild={
        sig:
            signature,

        promise
    };


    try{

        await promise;


    }finally{

        if(
            radarGpuBuild?.promise===
            promise
        ){

            radarGpuBuild=null;
        }
    }
}


/* =========================================================
   PAIR ACTIVEREN
   ========================================================= */

function activateCachedPair(
    pair
){

    const a=
        radarGpuFrames.get(
            String(
                pair.a
                ||
                ''
            )
        );


    const b=
        radarGpuFrames.get(
            String(
                pair.b
                ||
                ''
            )
        );


    const flow=
        radarGpuFlows.get(
            pairKey(
                pair
            )
        );


    if(
        !a||
        !b||
        !flow
    ){

        return false;
    }


    /*
     * Geen upload.
     * Alleen texture-objecten wisselen.
     */
    radarTexA=
        a;


    radarTexB=
        b;


    radarTexFlow=
        flow;


    radarCurrentPair=
        pairState(
            pair
        );


    radarCurrentKey=
        pairKey(
            pair
        );


    radarReady=true;
    radarDirty=true;


    return true;
}


function targetRadarPair(
    now
){

    const pairs=
        radarManifest?.pairs
        ??
        [];


    if(
        !pairs.length
    ){
        return null;
    }


    for(
        const pair
        of
        pairs
    ){

        const a=
            +pair.a_timestamp;


        const b=
            +pair.b_timestamp;


        if(
            Number.isFinite(a)&&
            Number.isFinite(b)&&
            now>=a&&
            now<b
        ){

            return pair;
        }
    }


    return now<
        +pairs[0].a_timestamp

        ?pairs[0]

        :pairs[
            pairs.length-1
        ];
}


/* =========================================================
   FALLBACK DYNAMISCHE PAIRLOAD
   ========================================================= */

async function loadRadarPair(
    pair
){

    if(
        !gl||
        !radarManifest||
        !pair
    ){
        return;
    }


    const key=
        pairKey(
            pair
        );


    if(
        !key||
        key===
        radarCurrentKey||
        key===
        radarLoadingKey
    ){

        return;
    }


    /*
     * Tijdens playback normaal altijd raak.
     */
    if(
        activateCachedPair(
            pair
        )
    ){

        return;
    }


    const aFile=
        frameFile(
            pair.a
        );


    const bFile=
        frameFile(
            pair.b
        );


    const flowFile=
        String(
            pair.file
            ||
            ''
        );


    if(
        !aFile||
        !bFile||
        !flowFile
    ){

        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[
            rasterA,
            rasterB,
            flow
        ]=
            await Promise.all(
                [
                    fetchRadarBinary(
                        aFile,
                        rw*
                        rh
                    ),

                    fetchRadarBinary(
                        bFile,
                        rw*
                        rh
                    ),

                    fetchRadarBinary(
                        flowFile,
                        fw*
                        fh*
                        4
                    )
                ]
            );


        if(
            token!==
            radarLoadToken
        ){

            return;
        }


        uploadTexture(
            radarDynA,
            0,
            gl.R8,
            rw,
            rh,
            gl.RED,
            rasterA
        );


        uploadTexture(
            radarDynB,
            1,
            gl.R8,
            rw,
            rh,
            gl.RED,
            rasterB
        );


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


        radarTexA=
            radarDynA;


        radarTexB=
            radarDynB;


        radarTexFlow=
            radarDynFlow;


        radarCurrentPair=
            pairState(
                pair
            );


        radarCurrentKey=
            key;


        radarReady=true;
        radarDirty=true;


    }catch(error){

        console.error(
            'Radar laden:',
            error
        );


    }finally{

        if(
            radarLoadingKey===
            key
        ){

            radarLoadingKey='';
        }
    }
}


function ensureRadarPair(
    now
){

    const pair=
        targetRadarPair(
            now
        );


    if(
        !pair
    ){
        return;
    }


    const key=
        pairKey(
            pair
        );


    if(
        key===
        radarCurrentKey
    ){

        return;
    }


    /*
     * GPU-cache = onmiddellijke wissel.
     */
    if(
        activateCachedPair(
            pair
        )
    ){

        return;
    }


    /*
     * Alleen fallback wanneer het frame nog niet
     * vooraf in de GPU-cache zit.
     */
    loadRadarPair(
        pair
    );
}


/* =========================================================
   MANIFEST
   ========================================================= */

async function loadRadarManifest(){

    if(
        !gl
    ){
        return;
    }


    try{

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


        if(
            !response.ok
        ){

            throw new Error(
                'HTTP '+
                response.status
            );
        }


        const data=
            await response.json();


        if(
            !Array.isArray(
                data.frames
            )||
            !Array.isArray(
                data.pairs
            )||
            !data.radar||
            !data.flow
        ){

            throw new Error(
                'Ongeldig manifest'
            );
        }


        radarManifest=
            data;


        const times=
            radarTimes();


        if(
            controlledTime!==
            null&&
            times.length
        ){

            controlledTime=
                Math.max(
                    times[0],
                    Math.min(
                        times[
                            times.length-1
                        ],
                        controlledTime
                    )
                );
        }


        ensureRadarPair(
            displayTime()
        );


        radarDirty=true;


        sendReady();

        notifyParent(
            true
        );


    }catch(error){

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


/* =========================================================
   RENDER
   ========================================================= */

function renderRadar(
    nowMs
){

    if(
        !gl
    ){
        return;
    }


    if(
        !radarDirty&&
        nowMs-
        radarLastRender<
        1000/
        RADAR_FPS
    ){

        return;
    }


    radarLastRender=
        nowMs;


    radarDirty=false;


    /*
     * Tijdens play iedere render controleren.
     *
     * Omdat alle textures al in GPU staan is deze
     * grenswissel nu alleen nog pointer/referentie-wissel.
     */
    if(
        externalPlaying||
        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;


    /*
     * De echte klok blijft volledig lineair.
     *
     * Alleen de interpolatiefase wordt gecorrigeerd:
     * - sneller vlak na een echt radarframe;
     * - rustiger halverwege;
     * - weer sneller richting volgend echt radarframe.
     *
     * 0 en 1 blijven exact 0 en 1, dus de echte
     * radarbeelden zelf verschuiven niet.
     */
    const visualProgress=
        Math.max(
            0,
            Math.min(
                1,
                progress+
                FRAME_SPEED_COMPENSATION*
                Math.sin(
                    progress*
                    Math.PI*
                    2
                )
                /
                (
                    Math.PI*
                    2
                )
            )
        );


    const rect=
        stage.getBoundingClientRect();


    const fit=
        Math.min(
            rect.width/W,
            rect.height/H
        );


    const offsetX=
        (
            rect.width-
            W*fit
        )/2;


    const offsetY=
        (
            rect.height-
            H*fit
        )/2;


    gl.useProgram(
        radarProgram
    );


    gl.bindVertexArray(
        radarVao
    );


    /*
     * De juiste GPU-textures voor deze 5-minutenpair.
     */
    bindRadarTextures();


    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'
        ),
        offsetX,
        offsetY
    );


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


/* =========================================================
   PLAYBACK
   ========================================================= */

async function startExternalPlayback(){

    if(
        radarTimes().length<
        2
    ){
        return;
    }


    /*
     * Cruciale wijziging:
     *
     * voor playback begint staan ALLE radarframes en
     * ALLE flowvelden al als textures in de GPU.
     */
    try{

        await prepareRadarGpuHistory();


    }catch(error){

        console.error(
            'Radar preload:',
            error
        );
    }


    const times=
        radarTimes();


    if(
        times.length<
        2
    ){
        return;
    }


    const start=
        times[0];


    const end=
        times[
            times.length-1
        ];


    if(
        controlledTime===
        null||
        controlledTime>=
        end-
        1
    ){

        controlledTime=
            start;
    }


    /*
     * Wordt nu uit GPU-cache geactiveerd.
     */
    ensureRadarPair(
        controlledTime
    );


    externalPlaying=true;


    externalPlaybackLast=
        performance.now();


    lastLightningTime=null;
    lastSecond=null;


    notifyParent(
        true
    );
}


function pauseExternalPlayback(){

    externalPlaying=false;

    externalPlaybackLast=0;


    notifyParent(
        true
    );
}


function updateExternalPlayback(
    nowMs
){

    if(
        !externalPlaying
    ){
        return;
    }


    if(
        !externalPlaybackLast
    ){

        externalPlaybackLast=
            nowMs;


        return;
    }


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


    externalPlaybackLast=
        nowMs;


    /*
     * Volledig lineaire tijd.
     */
    controlledTime+=
        elapsed*
        EXTERNAL_PLAYBACK_RATE;


    const times=
        radarTimes();


    if(
        times.length<
        2
    ){

        return;
    }


    const start=
        times[0];


    const end=
        times[
            times.length-1
        ];


    const span=
        end-
        start;


    if(
        controlledTime>=
        end
    ){

        controlledTime=
            span>0
            ?start+
                (
                    (
                        controlledTime-
                        start
                    )
                    %
                    span
                )

            :start;


        lastLightningTime=null;
        lastSecond=null;


        clearLightning();
    }


    radarDirty=true;
}


/* =========================================================
   LIGHTNING
   ========================================================= */

function flashKey(
    flash
){

    return[
        (+flash.timestamp)
            .toFixed(
                3
            ),

        (+flash.lat)
            .toFixed(
                5
            ),

        (+flash.lon)
            .toFixed(
                5
            ),

        String(
            flash.flash_id
            ||
            ''
        )
    ]
    .join(
        '_'
    );
}


function lowerBound(
    rows,
    target
){

    let low=0;

    let high=
        rows.length;


    while(
        low<
        high
    ){

        const middle=
            (
                low+
                high
            )
            >>
            1;


        if(
            +rows[middle].timestamp<
            target
        ){

            low=
                middle+
                1;


        }else{

            high=
                middle;
        }
    }


    return low;
}


function upperBound(
    rows,
    target
){

    let low=0;

    let high=
        rows.length;


    while(
        low<
        high
    ){

        const middle=
            (
                low+
                high
            )
            >>
            1;


        if(
            +rows[middle].timestamp<=
            target
        ){

            low=
                middle+
                1;


        }else{

            high=
                middle;
        }
    }


    return low;
}


function clearLightning(){

    for(
        const entry
        of
        activeFlashes.values()
    ){

        entry.el.remove();
    }


    activeFlashes.clear();
}


function addFlash(
    flash
){

    const key=
        flashKey(
            flash
        );


    if(
        activeFlashes.has(
            key
        )
    ){

        return;
    }


    const point=
        project(
            +flash.lat,
            +flash.lon
        );


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


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


    circle.setAttribute(
        'cx',
        point.x
    );


    circle.setAttribute(
        'cy',
        point.y
    );


    circle.setAttribute(
        'r',
        5/scale
    );


    flashLayer.appendChild(
        circle
    );


    activeFlashes.set(
        key,
        {
            el:
                circle,

            timestamp:
                +flash.timestamp
        }
    );
}


function syncLightning(
    now
){

    if(
        !lightningVisible
    ){

        clearLightning();


        lastLightningTime=
            now;


        return;
    }


    if(
        lastLightningTime!==
        null&&
        now<
        lastLightningTime
    ){

        clearLightning();
    }


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


    const to=
        upperBound(
            flashes,
            now
        );


    const wanted=
        new Set();


    for(
        let i=from;
        i<to;
        i++
    ){

        const flash=
            flashes[i];


        const key=
            flashKey(
                flash
            );


        wanted.add(
            key
        );


        addFlash(
            flash
        );
    }


    for(
        const[
            key,
            entry
        ]
        of
        activeFlashes
    ){

        if(
            wanted.has(
                key
            )
        ){

            continue;
        }


        entry.el.remove();


        activeFlashes.delete(
            key
        );
    }


    lastLightningTime=
        now;
}


async function loadLightning(){

    try{

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


        if(
            !response.ok
        ){

            throw new Error(
                'HTTP '+
                response.status
            );
        }


        const data=
            await response.json();


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


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


        receivedAt=
            performance.now();


        delay=
            +data.live_delay_seconds
            ||
            600;


        lastLightningTime=null;


        syncLightning(
            displayTime()
        );


    }catch(error){

        console.error(
            'Lightning:',
            error
        );
    }
}


/* =========================================================
   BEDIENING
   ========================================================= */

window.addEventListener(
    'message',
    event=>{

        if(
            !PARENT_ORIGINS.has(
                event.origin
            )
        ){

            return;
        }


        const data=
            event.data
            ||
            {};


        switch(
            data.type
        ){


        case 'wn-radar-lightning':

            lightningVisible=
                data.enabled===
                true;


            lastLightningTime=null;


            syncLightning(
                displayTime()
            );


            notifyParent(
                true
            );

            break;


        case 'wn-radar-set-stamp':

            setControlledStamp(
                data.stamp
            );

            break;


        case 'wn-radar-set-time':

            if(
                Number.isFinite(
                    +data.timestamp
                )
            ){

                setControlledTime(
                    +data.timestamp
                );
            }

            break;


        case 'wn-radar-now':

            goRadarNow();

            break;


        case 'wn-radar-play':

            startExternalPlayback();

            break;


        case 'wn-radar-pause':

            pauseExternalPlayback();

            break;


        case 'wn-radar-refresh':

            loadRadarManifest();

            loadLightning();

            break;


        case 'wn-radar-get-state':

            sendReady();

            notifyParent(
                true
            );

            break;
        }
    }
);


/* =========================================================
   LOOP
   ========================================================= */

function animationLoop(
    nowMs
){

    updateExternalPlayback(
        nowMs
    );


    renderRadar(
        nowMs
    );


    const now=
        displayTime();


    const second=
        Math.floor(
            now
        );


    if(
        second!==
        lastSecond
    ){

        lastSecond=
            second;


        updateBranding(
            now
        );


        syncLightning(
            now
        );
    }


    if(
        externalPlaying
    ){

        notifyParent();
    }


    requestAnimationFrame(
        animationLoop
    );
}


/* =========================================================
   START
   ========================================================= */

applyViewport();


if(
    initRadarGL()
){

    loadRadarManifest();


    setInterval(
        loadRadarManifest,
        MANIFEST_REFRESH_MS
    );


    new ResizeObserver(
        ()=>{

            resizeRadarCanvas();

            radarDirty=true;
        }
    )
    .observe(
        stage
    );


}else{

    radarCanvas.style.display=
        'none';
}


loadLightning();


setInterval(
    loadLightning,
    DATA_REFRESH_MS
);


updateBranding(
    displayTime()
);


requestAnimationFrame(
    animationLoop
);

</script>

</body>
</html>