From 8bbd39fec2ceca9998fed5e777700af4bd7eb799 Mon Sep 17 00:00:00 2001
From: core <core@coredoes.dev>
Date: Sat, 26 Oct 2024 22:41:12 -0400
Subject: [PATCH] web client - command system

---
 bun.lockb                         | Bin 0 -> 1244 bytes
 package.json                      |   1 +
 wxbox-web/src/lib/Map.svelte      |  84 ++++++++++
 wxbox-web/src/routes/+page.svelte | 269 +++++++++++++++++++++++++++---
 4 files changed, 329 insertions(+), 25 deletions(-)
 create mode 100755 bun.lockb
 create mode 100644 package.json
 create mode 100644 wxbox-web/src/lib/Map.svelte

diff --git a/bun.lockb b/bun.lockb
new file mode 100755
index 0000000000000000000000000000000000000000..bded5171cab60b91783595cbf62273e6e64274e9
GIT binary patch
literal 1244
zcmY#Z)GsYA(of3F(@)JSQ%EY!;{sycoc!eMw9K4T-L(9o+{6;yG6OCq1_p+nO}g%L
zdqtK^`8-AHF8{^-OHAJI?YW=s{Ac3h`Sb5jvh`sBDgpwKS`grXP;j~d%CCYcfbt;}
z4+8^C0fT=$&zzFPn`;<RRFKYrSp+nj!2xPMNDS&wq6`GN0ofg>{D1uakR^#Sk$@tg
zd5jDYbCHc^sh;mt$m}B$<{6>Dc3!ct=xAzbdP$+*vG-Bi(&VC^e7pDV*$(Lqy^e=A
zKM!Ki{mXmi%|!7%I<GjM&%H4<%8mCovS9?AK&mw9MjwDi%@rukWm8;~te2TrT#}fR
zqX$cXdLc!rsdfrR1`5TQRjK)DItnHVi8-0+dHHEvFgO1D4*?)P*d5Fe85StbWm9Tw
zWCzrU!w3VYIujz)gUru>s%wI)k2W%kEwunT(+qBhA%-&<K<OKnu9={|W?(o2H3dea
zhC^{`Zdq!Po@+%(YEfQdPH;(4W?s6Tf+4~p3%Er;peDIM6F1P`KsOm;by7}hVp>jW
ziC%GKUUEiBNkOrdzJ5_^dS-D+QKep9L2g#DUVc%!KC&`>T@<dafu50`iC#&16(qC=
G9|-_y?Y;#7

literal 0
HcmV?d00001

diff --git a/package.json b/package.json
new file mode 100644
index 0000000..9a3d797
--- /dev/null
+++ b/package.json
@@ -0,0 +1 @@
+{ "dependencies": { "leaflet.sync": "^0.2.4" } }
\ No newline at end of file
diff --git a/wxbox-web/src/lib/Map.svelte b/wxbox-web/src/lib/Map.svelte
new file mode 100644
index 0000000..65384b3
--- /dev/null
+++ b/wxbox-web/src/lib/Map.svelte
@@ -0,0 +1,84 @@
+<script lang="ts">
+    import type {ActionReturn} from "svelte/action";
+    import type {TileLayer, Map as LeafletMap} from "leaflet";
+
+    interface Props {
+        map: LeafletMap | null,
+        selected: boolean,
+        baseLayer: "osm",
+        dataLayer: "noaa_mrms_merged_composite_reflectivity_qc" | null,
+        overlayLayers: string[]
+    }
+    let { map = $bindable(null), selected, baseLayer, dataLayer, overlayLayers } = $props();
+
+    let mapEl: HTMLElement;
+    let L;
+    let layer0: TileLayer;
+    let layer1: TileLayer;
+
+    $inspect(dataLayer);
+
+    $effect(() => {
+        console.log("layer0", layer0, map, baseLayer);
+        if (!L) return;
+
+        if (layer0 && map) {
+            layer0.removeFrom(map);
+        }
+
+        if (baseLayer === "osm") {
+            layer0 = L.tileLayer(
+                'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
+                {
+                    attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
+                }
+            );
+            layer0.addTo(map);
+        }
+    });
+
+    $effect(() => {
+        console.log("layer1", layer1, map, dataLayer);
+        if (!L) return;
+
+        if (layer1 && map) {
+            layer1.removeFrom(map);
+        }
+
+        if (dataLayer === "noaa_mrms_merged_composite_reflectivity_qc") {
+            layer1 = L.tileLayer(
+                'http://localhost:8080/noaa_mrms_merged_composite_reflectivity_qc/{z}/{x}/{y}.png',
+                {
+                    attribution: '&copy; NOAA, &copy; wxbox'
+                }
+            );
+            layer1.addTo(map);
+        }
+    });
+
+    async function mapAction(node: HTMLElement): Promise<ActionReturn> {
+        L = await import('leaflet');
+        await import("leaflet.sync");
+
+        map = L.map(mapEl, {
+            center: [39.83, -98.583],
+            zoom: 5
+        });
+
+        if (!map) return {};
+    }
+</script>
+
+<div class="map" class:mapselected={selected} bind:this={mapEl} use:mapAction></div>
+
+<style>
+    .map {
+        flex: 1;
+        box-sizing: border-box;
+        border: 2px solid transparent;
+    }
+    .mapselected {
+        box-sizing: border-box;
+        border: 2px solid red;
+    }
+</style>
\ No newline at end of file
diff --git a/wxbox-web/src/routes/+page.svelte b/wxbox-web/src/routes/+page.svelte
index bcd4402..2acedca 100644
--- a/wxbox-web/src/routes/+page.svelte
+++ b/wxbox-web/src/routes/+page.svelte
@@ -1,51 +1,270 @@
 <script lang="ts">
-    import type {ActionReturn} from "svelte/action";
-    import type {TileLayer, Map as LeafletMap} from "leaflet";
+    import Map from "$lib/Map.svelte";
 
-    let mapEl: HTMLElement;
-    let map: LeafletMap;
-    let L;
+    let map1 = $state(null);
+    let map2 = $state(null);
+    let map3 = $state(null);
+    let map4 = $state(null);
 
-    async function mapAction(node: HTMLElement): ActionReturn {
-        L = await import('leaflet');
+    let view: "one" | "two" | "four" = $state("one");
 
-        map = L.map(mapEl, {
-            center: [51.505, -0.09],
-            zoom: 13
-        });
+    $effect(() => {
+        if (!map1) return;
+        if (!map2) return;
+        if (!map3) return;
+        if (!map4) return;
+        // resize the shown maps
+        if (view === "one") {
+            map1.invalidateSize();
+        } else if (view === "two") {
+            map1.invalidateSize();
+            map2.invalidateSize();
+        } else if (view === "four") {
+            map1.invalidateSize();
+            map2.invalidateSize();
+            map3.invalidateSize();
+            map4.invalidateSize();
+        }
+    })
 
-        L.control.layers(
-            {
-                "OpenStreetMap": L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { attribution: "&copy;" })
-            },
-            {
-                "NOAA/MRMS CONUS Reflectivity at Lowest Altitude": L.tileLayer("http://localhost:8080/noaa_mrms_merged_composite_reflectivity_qc/{z}/{x}/{y}.png", { attribution: "&copy; NOAA" })
-            }
-        ).addTo(map);
+    $effect(() => {
+        if (map1 && map2) {
+            map2.setView(map1.getCenter(), map1.getZoom());
+            map1.sync(map2);
+        }
+        if (map1 && map3) {
+            map3.setView(map1.getCenter(), map1.getZoom());
+            map1.sync(map3);
+        }
+        if (map1 && map4) {
+            map4.setView(map1.getCenter(), map1.getZoom());
+            map1.sync(map4);
+        }
 
-        return {
-            destroy: () => {
+        if (map2 && map1) map2.sync(map1);
+        if (map2 && map3) map2.sync(map3);
+        if (map2 && map4) map2.sync(map4);
 
+        if (map3 && map1) map3.sync(map1);
+        if (map3 && map2) map3.sync(map2);
+        if (map3 && map4) map3.sync(map4);
+
+        if (map4 && map1) map4.sync(map1);
+        if (map4 && map2) map4.sync(map2);
+        if (map4 && map3) map4.sync(map3);
+    });
+
+    let mode: "global" | "view" | "paneSelect" | "pane" | "baseLayerSelect" | "dataLayerSelect" | "overlayLayerSelect" | "dataNOAA" | "dataNOAAMRMS" = $state("global");
+    let pane: "map1" | "map2" | "map3" | "map4" = $state("map1");
+
+    interface MenuItem {
+        display: string,
+        keyboard: string,
+        disabled: boolean,
+        visible: boolean,
+        action: () => void
+    }
+
+    let globalMenu: MenuItem[] = $derived([
+        { display: "view", keyboard: "v", disabled: false, visible: true, action: () => { mode = "view" } },
+        { display: "pane", keyboard: "p", disabled: false, visible: true, action: () => { mode = "paneSelect"} },
+    ]);
+    let viewMenu: MenuItem[] = $derived([
+        { display: "back", keyboard: "Escape", disabled: false, visible: true, action: () => { mode = "global" }},
+        { display: "view: 1", keyboard: "1", disabled: view === "one", visible: true, action: () => { view = "one" }},
+        { display: "view: 2", keyboard: "2", disabled: view === "two", visible: true, action: () => { view = "two" }},
+        { display: "view: 4", keyboard: "4", disabled: view === "four", visible: true, action: () => { view = "four" }}
+    ]);
+    let paneSelectMenu: MenuItem[] = $derived([
+        { display: "back", keyboard: "Escape", disabled: false, visible: true, action: () => { mode = "global" }},
+        { display: "pane: 1", keyboard: "1", disabled: pane === "map1", visible: true, action: () => { pane = "map1" }},
+        { display: "pane: 2", keyboard: "2", disabled: pane === "map2", visible: view === "two" || view === "four", action: () => { pane = "map2" }},
+        { display: "pane: 3", keyboard: "3", disabled: pane === "map3", visible: view === "four", action: () => { pane = "map3" }},
+        { display: "pane: 4", keyboard: "4", disabled: pane === "map4", visible: view === "four", action: () => { pane = "map4" }},
+        { display: "select", keyboard: "Enter", disabled: false, visible: true, action: () => { mode = "pane" }},
+    ]);
+    let paneMenu: MenuItem[] = $derived([
+        { display: "back", keyboard: "Escape", disabled: false, visible: true, action: () => { mode = "paneSelect" }},
+        { display: "base layer", keyboard: "b", disabled: false, visible: true, action: () => { mode = "baseLayerSelect" }},
+        { display: "data layer", keyboard: "d", disabled: false, visible: true, action: () => { mode = "dataLayerSelect" }},
+        { display: "overlays", keyboard: "o", disabled: false, visible: true, action: () => { mode = "overlayLayerSelect" }},
+    ]);
+
+    interface MutexLayerSet<ValidOpts> {
+        map1: ValidOpts | null,
+        map2: ValidOpts | null,
+        map3: ValidOpts | null,
+        map4: ValidOpts | null
+    }
+    interface OverlayLayerSet<ValidOpts> {
+        map1: ValidOpts[],
+        map2: ValidOpts[],
+        map3: ValidOpts[],
+        map4: ValidOpts[]
+    }
+
+    let baseLayer: MutexLayerSet<"osm"> = $state({
+        map1: "osm",
+        map2: "osm",
+        map3: "osm",
+        map4: "osm"
+    });
+    let dataLayer: MutexLayerSet<"noaa_mrms_merged_composite_reflectivity_qc" | null> = $state({
+        map1: null,
+        map2: null,
+        map3: null,
+        map4: null
+    });
+    let overlayLayers: OverlayLayerSet<string[]> = $state({
+        map1: [],
+        map2: [],
+        map3: [],
+        map4: []
+    });
+
+    $inspect(dataLayer);
+
+    let baseLayerMenu: MenuItem[] = $derived([
+        { display: "back", keyboard: "Escape", disabled: false, visible: true, action: () => { mode = "pane" }},
+        { display: "OpenStreetMap", keyboard: "o", disabled: false, visible: true, action: () => {
+            baseLayer[pane] = "osm";
+        }},
+    ]);
+    let dataLayerMenu: MenuItem[] = $derived([
+        { display: "back", keyboard: "Escape", disabled: false, visible: true, action: () => { mode = "pane" }},
+        { display: "none", keyboard: "0", disabled: false, visible: true, action: () => { dataLayer[pane] = null }},
+        { display: "noaa", keyboard: "n", disabled: false, visible: true, action: () => { mode = "dataNOAA" }},
+    ]);
+    let dataNOAAMenu: MenuItem[] = $derived([
+        { display: "back", keyboard: "Escape", disabled: false, visible: true, action: () => { mode = "dataLayerSelect" }},
+        { display: "multi-radar multi-sensor", keyboard: "m", disabled: false, visible: true, action: () => { mode = "dataNOAAMRMS" }},
+    ]);
+    let dataNOAAMRMSMenu: MenuItem[] = $derived([
+        { display: "back", keyboard: "Escape", disabled: false, visible: true, action: () => { mode = "dataNOAA" }},
+        { display: "composite reflectivity - merged qc", keyboard: "r", disabled: dataLayer[pane] === "noaa_mrms_merged_composite_reflectivity_qc", visible: true, action: () => {
+            dataLayer[pane] = "noaa_mrms_merged_composite_reflectivity_qc";
+        }}
+    ]);
+    let overlayLayerMenu: MenuItem[] = $derived([
+        { display: "back", keyboard: "Escape", disabled: false, visible: true, action: () => { mode = "pane" }},
+    ]);
+
+    let currentMenu: MenuItem[] = $derived.by(() => {
+        if (mode === "global") {
+            return globalMenu;
+        } else if (mode === "view") {
+            return viewMenu;
+        } else if (mode === "paneSelect") {
+            return paneSelectMenu;
+        } else if (mode === "pane") {
+            return paneMenu;
+        } else if (mode === "baseLayerSelect") {
+            return baseLayerMenu;
+        } else if (mode === "dataLayerSelect") {
+            return dataLayerMenu;
+        } else if (mode === "overlayLayerSelect") {
+            return overlayLayerMenu;
+        } else if (mode === "dataNOAA") {
+            return dataNOAAMenu;
+        } else if (mode === "dataNOAAMRMS") {
+            return dataNOAAMRMSMenu;
+        } else {
+            return [
+                { display: "Invalid submenu :( go back to root", keyboard: "Escape", disabled: false, visible: true, action: () => { mode = "global" }}
+            ]
+        }
+    });
+
+    let paneModes = ["paneSelect", "pane", "baseLayerSelect", "dataLayerSelect", "overlayLayerSelect", "dataNOAA", "dataNOAAMRMS"];
+
+    let status: string = $derived.by(() => {
+        let s = mode;
+
+        if (paneModes.includes(mode)) {
+            s += " " + pane;
+        }
+
+        return s;
+    })
+
+    function key(e: KeyboardEvent) {
+        let k = e.key;
+        for (let menuItem of currentMenu) {
+            if (k === menuItem.keyboard) {
+                menuItem.action();
+                return;
             }
         }
     }
 </script>
 
-<div class="container">
-    <div class="map" bind:this={mapEl} use:mapAction></div>
+<svelte:window onkeydown={key} />
+
+<div class="outercontainer">
+    <div class="toolbar">
+        <h1>wxbox</h1>
+        <span>{status}</span>
+        {#each currentMenu as menuItem}
+            {#if menuItem.visible}
+                {@const index = menuItem.display.indexOf(menuItem.keyboard)}
+                <button disabled={menuItem.disabled} onclick={menuItem.action}>
+                    {#if index !== -1}
+                        {menuItem.display.substring(0, index)}<u>{menuItem.display.charAt(index)}</u>{menuItem.display.substring(index+1)}
+                    {:else}
+                        {menuItem.display} (<u>{menuItem.keyboard}</u>)
+                    {/if}
+                </button>
+            {/if}
+        {/each}
+    </div>
+    <div class="container">
+        <Map selected={pane === "map1" && (paneModes.includes(mode))} bind:map={map1} baseLayer={baseLayer.map1} dataLayer={dataLayer.map1} overlayLayers={overlayLayers.map1} />
+        {#if view === "two" || view === "four"}
+            <Map selected={pane === "map2" && (paneModes.includes(mode))} bind:map={map2} baseLayer={baseLayer.map2} dataLayer={dataLayer.map2} overlayLayers={overlayLayers.map2} />
+        {/if}
+    </div>
+    {#if view === "four"}
+        <div class="container">
+            <Map selected={pane === "map3" && (paneModes.includes(mode))} bind:map={map3} baseLayer={baseLayer.map3} dataLayer={dataLayer.map3} overlayLayers={overlayLayers.map3} />
+            <Map selected={pane === "map4" && (paneModes.includes(mode))} bind:map={map4} baseLayer={baseLayer.map4} dataLayer={dataLayer.map4} overlayLayers={overlayLayers.map4} />
+        </div>
+    {/if}
+    <div class="footer">
+        <p>built with &lt;3</p>
+        <p>coredoes.dev :)</p>
+    </div>
 </div>
 
 <style>
+    .toolbar {
+        margin: 0;
+        font-size: 12px;
+        display: flex;
+        flex-direction: row;
+    }
+    .toolbar h1 {
+        margin: 0;
+    }
     :global(html body) {
         margin: 0;
         padding: 0;
     }
     .container {
         display: flex;
-        flex-direction: column;
+        flex-direction: row;
+        flex: 1;
     }
-    .map {
+    .outercontainer {
+        display: flex;
+        flex-direction: column;
         width: 100vw;
         height: 100vh;
     }
+    .footer {
+        display: flex;
+        justify-content: space-between;
+    }
+    .footer p {
+        margin: 0;
+    }
 </style>
\ No newline at end of file