From 8a6ff5a2c15422badf0bc9c8166787dc23a71072 Mon Sep 17 00:00:00 2001 From: Yuval Adam <_@yuv.al> Date: Mon, 23 Sep 2024 10:29:53 +0200 Subject: Move all geshem/ files to root --- app/components/Datetime.tsx | 28 ++++++++++++ app/components/Geshem.tsx | 80 ++++++++++++++++++++++++++++++++ app/components/Map.tsx | 99 ++++++++++++++++++++++++++++++++++++++++ app/components/Slider.tsx | 45 ++++++++++++++++++ app/components/config.tsx | 19 ++++++++ app/favicon.ico | Bin 0 -> 32038 bytes app/fonts/GeistMonoVF.woff | Bin 0 -> 67864 bytes app/fonts/GeistVF.woff | Bin 0 -> 66268 bytes app/globals.css | 58 +++++++++++++++++++++++ app/layout.tsx | 35 ++++++++++++++ app/page.tsx | 6 +++ app/public/apple-touch-icon.png | Bin 0 -> 1059 bytes app/public/manifest.json | 15 ++++++ app/public/privacy.html | 30 ++++++++++++ app/public/screenshot.png | Bin 0 -> 382294 bytes 15 files changed, 415 insertions(+) create mode 100644 app/components/Datetime.tsx create mode 100644 app/components/Geshem.tsx create mode 100644 app/components/Map.tsx create mode 100644 app/components/Slider.tsx create mode 100644 app/components/config.tsx create mode 100644 app/favicon.ico create mode 100644 app/fonts/GeistMonoVF.woff create mode 100644 app/fonts/GeistVF.woff create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 app/public/apple-touch-icon.png create mode 100644 app/public/manifest.json create mode 100644 app/public/privacy.html create mode 100644 app/public/screenshot.png (limited to 'app') diff --git a/app/components/Datetime.tsx b/app/components/Datetime.tsx new file mode 100644 index 0000000..0a8fbcf --- /dev/null +++ b/app/components/Datetime.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { DateTime as LuxonDateTime } from "luxon"; + +interface DateTimeProps { + images: string[], + slider: number +} + +export function DateTime({ images, slider }: DateTimeProps) { + let datetime = null; + + if (images.length > 0) { + const ds = images[slider].substring(5, 18); + datetime = LuxonDateTime.fromFormat(ds, "yyyyMMdd/HHmm", { + zone: "utc", + }).setZone("Asia/Jerusalem"); + } + + const date = datetime ? datetime.toFormat("dd/MM/y") : ""; + const time = datetime ? datetime.toFormat("HH:mm") : ""; + + return ( +
+
{date}
+
{time}
+
+ ); +} diff --git a/app/components/Geshem.tsx b/app/components/Geshem.tsx new file mode 100644 index 0000000..50659be --- /dev/null +++ b/app/components/Geshem.tsx @@ -0,0 +1,80 @@ +"use client" + +import React, { useState, useEffect } from "react"; +// import { BrowserRouter, Route, Routes } from "react-router-dom"; + +import { Map } from "./Map"; +import { Slider } from "./Slider"; +import { DateTime } from "./Datetime"; +import { IMAGES_BASE_URL, PLAYBACK_HOURS, PLAYBACK_SLOTS } from "./config"; + +// import "./Geshem.css"; +import "rc-slider/assets/index.css"; + +// export function App() { +// return ( +// +// +// } /> +// } /> +// +// +// ); +// } + +interface GeshemProps { + date?: string +} + +export function Geshem({ date }: GeshemProps) { + const [images, setImages] = useState([]); + const [playback] = useState( + date || + new URL(window.location.toString()).searchParams.get("history") || + undefined + ); + const [slider, setSlider] = useState(playback ? PLAYBACK_SLOTS : 9); + + useEffect(() => { + const fetchImages = async () => + fetch(`${IMAGES_BASE_URL}/imgs.json`) + .then(res => res.json()) + .then(imgs => imgs["280"]) + .then(setImages); + + const buildPlayback = async () => { + const date = playback; + const hours = Array.from(Array(PLAYBACK_HOURS).keys()).map( + h => `${String(h).padStart(2, "0")}` + ); + const minutes = Array.from(Array(6).keys()).map( + m => `${String(m * 10).padStart(2, "0")}` + ); + const paths = hours.reduce( + (acc, h) => + acc.concat(minutes.map(m => `imgs/${date}/${h}${m}/280.png`)), + [] + ); + setImages(paths); + }; + + let timer: number; + if (playback) buildPlayback(); + else { + fetchImages(); + timer = window.setInterval(fetchImages, 60 * 1000); + } + + return () => { + if (timer !== undefined) clearInterval(timer); + }; + }, [playback]); + + return ( + <> + + + + + ); +} diff --git a/app/components/Map.tsx b/app/components/Map.tsx new file mode 100644 index 0000000..39f28fa --- /dev/null +++ b/app/components/Map.tsx @@ -0,0 +1,99 @@ +import React, { useState, useEffect, useRef } from "react"; +// @ts-ignore +import mapboxgl from '!mapbox-gl'; // eslint-disable-line import/no-webpack-loader-syntax + +import { + MAPBOX_ACCESS_TOKEN, + IMAGE_COORDINATES, + IMAGES_BASE_URL +} from "./config"; + +import "mapbox-gl/dist/mapbox-gl.css"; + +interface MapProps { + slider: number, + images: string[], +} + +export function Map({ slider, images }: MapProps) { + const mapContainer = useRef(null); + const map = useRef(null); + + const [lng] = useState(35); + const [lat] = useState(31.9); + const [zoom] = useState(6.3); + const [loaded, setLoaded] = useState(false); + + const prevImages = useRef(images).current; + + useEffect(() => { + if (map.current) return; + + map.current = new mapboxgl.Map({ + accessToken: MAPBOX_ACCESS_TOKEN, + container: mapContainer.current, + style: "mapbox://styles/mapbox/dark-v9", + center: [lng, lat], + zoom: zoom, + minZoom: 5, + maxZoom: 10, + hash: false, + }); + + map.current.on("style.load", () => { + setLoaded(true); + }); + }); + + useEffect(() => { + if (!loaded) return; + + // remove old layers + prevImages.forEach((img) => { + if (!images.includes(img)) { + map.current.removeLayer(`layer-${img}`); + map.current.removeSource(`source-${img}`); + } + }); + + // add new layers + images.forEach((img, i) => { + if (!prevImages.includes(img) && !map.current.getSource(`source-${img}`)) { + map.current.addSource(`source-${img}`, { + type: "image", + url: `${IMAGES_BASE_URL}/${img}`, + coordinates: IMAGE_COORDINATES + }); + map.current.addLayer({ + id: `layer-${img}`, + source: `source-${img}`, + type: "raster", + paint: { + "raster-opacity": 0, + "raster-opacity-transition": { + duration: 0 + } + } + }); + } + }); + }, [loaded, prevImages, images]); + + useEffect(() => { + if (!loaded || !images.length) return; + images[slider] && map.current.setPaintProperty(`layer-${images[slider]}`, "raster-opacity", 0.85); + return () => { + // callback will hide the previous layer with the previous slider value + map.current.setPaintProperty(`layer-${images[slider]}`, "raster-opacity", 0); + } + }, [loaded, slider, images]); + + return ( +
+
+
+ ); +} diff --git a/app/components/Slider.tsx b/app/components/Slider.tsx new file mode 100644 index 0000000..50223fc --- /dev/null +++ b/app/components/Slider.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import RcSlider, { SliderProps, SliderRef } from 'rc-slider/lib/Slider'; +import { PLAYBACK_SLOTS } from "./config"; + +// use workaround from: https://github.com/react-component/slider/issues/835#issuecomment-1201805736 +const CustomSlider = RcSlider as React.ForwardRefExoticComponent & React.RefAttributes>; + +interface GeshemSliderProps { + playback?: string, + slider: number, + setSlider: React.Dispatch> +} + +export function Slider({ playback, slider, setSlider }: GeshemSliderProps) { + const handleStyle = { + height: 40, + width: 40, + border: 0, + marginTop: -10, + boxShadow: ".5px .5px 2px 1px rgba(0,0,0,.32)" + }; + + const railStyle = { + height: 20, + backgroundColor: "#3498db" + }; + + const trackStyle = { + display: "none" + }; + + return ( +
+ setSlider(val)} + /> +
+ ); +} diff --git a/app/components/config.tsx b/app/components/config.tsx new file mode 100644 index 0000000..4dc3d6a --- /dev/null +++ b/app/components/config.tsx @@ -0,0 +1,19 @@ +const hostname = window !== undefined && window.location && window.location.hostname; + +const PRODUCTION = hostname && hostname.includes("geshem"); + +export const IMAGES_BASE_URL = PRODUCTION ? "https://imgs.geshem.space" : ""; + +export const MAPBOX_ACCESS_TOKEN = + "pk.eyJ1IjoieXV2YWRtIiwiYSI6ImNpcnMxbzBuaTAwZWdoa25oczlzZmkwbHcifQ.UHtLngbKm9O8945pJm23Nw"; + +export const IMAGE_COORDINATES = [ + [31.7503896894, 34.4878044232], + [37.8574239563, 34.5078463729], + [37.7157066403, 29.4538271687], + [31.9347389087, 29.4373462909] +]; + +export const PLAYBACK_HOURS = 2; + +export const PLAYBACK_SLOTS = (PLAYBACK_HOURS * 6) - 1; \ No newline at end of file diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..a97277e Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/fonts/GeistMonoVF.woff b/app/fonts/GeistMonoVF.woff new file mode 100644 index 0000000..f2ae185 Binary files /dev/null and b/app/fonts/GeistMonoVF.woff differ diff --git a/app/fonts/GeistVF.woff b/app/fonts/GeistVF.woff new file mode 100644 index 0000000..1b62daa Binary files /dev/null and b/app/fonts/GeistVF.woff differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..62b9a76 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,58 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --background: #ffffff; + --foreground: #171717; +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +body { + color: var(--foreground); + background: var(--background); + font-family: Arial, Helvetica, sans-serif; +} + +@layer utilities { + .text-balance { + text-wrap: balance; + } +} + +#datetime { + position: absolute; + top: 20px; + left: 20px; + color: white; + font-family: monospace; +} + +#date { + font-size: 1.4em; +} + +#time { + font-size: 2.8em; +} + +#slider { + position: fixed; + bottom: 8vh; + width: 30vw; + margin-left: 35vw; +} + +@media only screen and (max-device-width: 812px) { + #slider { + width: 80vw; + margin-left: 8vw; + z-index: 10; + } +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..14a4e0c --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,35 @@ +import type { Metadata } from "next"; +import localFont from "next/font/local"; +import "./globals.css"; + +const geistSans = localFont({ + src: "./fonts/GeistVF.woff", + variable: "--font-geist-sans", + weight: "100 900", +}); +const geistMono = localFont({ + src: "./fonts/GeistMonoVF.woff", + variable: "--font-geist-mono", + weight: "100 900", +}); + +export const metadata: Metadata = { + title: "Geshem.space | Real-Time Israel Rain Radar Visualization", + description: "Track Israel's rainfall in real-time with Geshem.space's advanced radar visualizer. Get accurate, up-to-date precipitation data and forecasts for precise weather monitoring across Israel.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..a44da7b --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,6 @@ +import { Geshem } from "@/app/components/Geshem"; +export default function Home() { + return ( + + ); +} diff --git a/app/public/apple-touch-icon.png b/app/public/apple-touch-icon.png new file mode 100644 index 0000000..7c93f47 Binary files /dev/null and b/app/public/apple-touch-icon.png differ diff --git a/app/public/manifest.json b/app/public/manifest.json new file mode 100644 index 0000000..20d2b0d --- /dev/null +++ b/app/public/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "Geshem", + "name": "Geshem.space Rain Radar", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/app/public/privacy.html b/app/public/privacy.html new file mode 100644 index 0000000..3976c2c --- /dev/null +++ b/app/public/privacy.html @@ -0,0 +1,30 @@ + + + + + + + + + + + + Privacy Policy | geshem.space + + + + +

We do not collect any data.

+ + diff --git a/app/public/screenshot.png b/app/public/screenshot.png new file mode 100644 index 0000000..7fd104c Binary files /dev/null and b/app/public/screenshot.png differ -- cgit v1.3.1