summaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
authorYuval Adam <_@yuv.al>2025-09-24 10:52:38 +0200
committerYuval Adam <_@yuv.al>2025-09-24 10:52:38 +0200
commit2a050b383bcafefb8fc378531d6f15f352fdb5a5 (patch)
tree3170255e4a0b6e5488a3b851d8d9a0873e95747e /src/components
parentc9d9d3a49aed068a7fe3dadcd8dd06f938e6d9b5 (diff)
Override all files with v8 implementation
Diffstat (limited to 'src/components')
-rw-r--r--src/components/Datetime.tsx28
-rw-r--r--src/components/Geshem.tsx65
-rw-r--r--src/components/Map.tsx105
-rw-r--r--src/components/Slider.tsx86
4 files changed, 284 insertions, 0 deletions
diff --git a/src/components/Datetime.tsx b/src/components/Datetime.tsx
new file mode 100644
index 0000000..8b66a4f
--- /dev/null
+++ b/src/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: LuxonDateTime | null = null;
+
+ if (images.length > 0 && images[slider]) {
+ 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 (
+ <div id="datetime" className="absolute top-5 left-5 text-white font-mono">
+ <div id="date" className="text-xl">{date}</div>
+ <div id="time" className="text-4xl">{time}</div>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/src/components/Geshem.tsx b/src/components/Geshem.tsx
new file mode 100644
index 0000000..982d841
--- /dev/null
+++ b/src/components/Geshem.tsx
@@ -0,0 +1,65 @@
+import React, { useState, useEffect } from "react";
+
+import { Map } from "./Map";
+import { Slider } from "./Slider";
+import { DateTime } from "./Datetime";
+
+import { IMAGES_BASE_URL, PLAYBACK_HOURS, PLAYBACK_SLOTS } from "../config";
+
+interface GeshemProps {
+ date?: string;
+}
+
+export function Geshem({ date }: GeshemProps) {
+ const [images, setImages] = useState<string[]>([]);
+ const [playback] = useState(
+ date ||
+ (typeof window !== 'undefined' ? new URL(window.location.toString()).searchParams.get("history") : null) ||
+ 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 as any)["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<string[]>(
+ (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 (
+ <>
+ <Map images={images} slider={slider} />
+ <DateTime images={images} slider={slider} />
+ <Slider slider={slider} playback={playback} setSlider={setSlider} />
+ </>
+ );
+} \ No newline at end of file
diff --git a/src/components/Map.tsx b/src/components/Map.tsx
new file mode 100644
index 0000000..070dc5c
--- /dev/null
+++ b/src/components/Map.tsx
@@ -0,0 +1,105 @@
+import React, { useState, useEffect, useRef } from "react";
+import mapboxgl from 'mapbox-gl';
+
+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<HTMLDivElement>(null);
+ const map = useRef<mapboxgl.Map | null>(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 || !mapContainer.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);
+ });
+ }, [lng, lat, zoom]);
+
+ useEffect(() => {
+ if (!loaded || !map.current) return;
+
+ // remove old layers
+ prevImages.forEach((img) => {
+ if (!images.includes(img)) {
+ if (map.current?.getLayer(`layer-${img}`)) {
+ map.current.removeLayer(`layer-${img}`);
+ }
+ if (map.current?.getSource(`source-${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 || !map.current) return;
+
+ if (images[slider]) {
+ map.current.setPaintProperty(`layer-${images[slider]}`, "raster-opacity", 0.85);
+ }
+
+ return () => {
+ // callback will hide the previous layer with the previous slider value
+ if (images[slider] && map.current?.getLayer(`layer-${images[slider]}`)) {
+ map.current.setPaintProperty(`layer-${images[slider]}`, "raster-opacity", 0);
+ }
+ };
+ }, [loaded, slider, images]);
+
+ return (
+ <div>
+ <div ref={mapContainer} className="map-container h-screen w-screen" />
+ </div>
+ );
+} \ No newline at end of file
diff --git a/src/components/Slider.tsx b/src/components/Slider.tsx
new file mode 100644
index 0000000..22a8f8f
--- /dev/null
+++ b/src/components/Slider.tsx
@@ -0,0 +1,86 @@
+import React, { useCallback, useRef, useState } from "react";
+import { PLAYBACK_SLOTS } from "../config";
+
+interface SliderProps {
+ playback?: string;
+ slider: number;
+ setSlider: React.Dispatch<React.SetStateAction<number>>;
+}
+
+export function Slider({ playback, slider, setSlider }: SliderProps) {
+ const sliderRef = useRef<HTMLDivElement>(null);
+ const [isDragging, setIsDragging] = useState(false);
+
+ const max = playback ? PLAYBACK_SLOTS : 9;
+
+ const handleStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
+ setIsDragging(true);
+ updateSliderValue(e);
+ }, []);
+
+ const updateSliderValue = useCallback((e: MouseEvent | React.MouseEvent | TouchEvent | React.TouchEvent) => {
+ if (!sliderRef.current) return;
+
+ const rect = sliderRef.current.getBoundingClientRect();
+ let clientX: number;
+
+ if ('touches' in e) {
+ clientX = e.touches?.[0]?.clientX || (e as TouchEvent).changedTouches?.[0]?.clientX || 0;
+ } else {
+ clientX = e.clientX;
+ }
+
+ const percentage = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
+ const value = Math.round(percentage * max);
+ setSlider(value);
+ }, [max, setSlider]);
+
+ const handleMove = useCallback((e: MouseEvent | TouchEvent) => {
+ if (isDragging) {
+ e.preventDefault();
+ updateSliderValue(e);
+ }
+ }, [isDragging, updateSliderValue]);
+
+ const handleEnd = useCallback(() => {
+ setIsDragging(false);
+ }, []);
+
+ React.useEffect(() => {
+ if (isDragging) {
+ document.addEventListener('mousemove', handleMove);
+ document.addEventListener('mouseup', handleEnd);
+ document.addEventListener('touchmove', handleMove, { passive: false });
+ document.addEventListener('touchend', handleEnd);
+
+ return () => {
+ document.removeEventListener('mousemove', handleMove);
+ document.removeEventListener('mouseup', handleEnd);
+ document.removeEventListener('touchmove', handleMove);
+ document.removeEventListener('touchend', handleEnd);
+ };
+ }
+ }, [isDragging, handleMove, handleEnd]);
+
+ const percentage = (slider / max) * 100;
+
+ return (
+ <div id="slider" className="py-2.5 fixed bottom-[8vh] w-[30vw] ml-[35vw] max-[812px]:w-[80vw] max-[812px]:ml-[8vw] max-[812px]:z-10">
+ <div
+ ref={sliderRef}
+ className="relative h-5 bg-blue-500 rounded-full cursor-pointer touch-none"
+ onMouseDown={handleStart}
+ onTouchStart={handleStart}
+ >
+ <div
+ className={`absolute -top-2.5 w-10 h-10 bg-white rounded-full shadow-md z-[1] ${
+ isDragging ? 'cursor-grabbing' : 'cursor-grab'
+ }`}
+ style={{
+ left: `calc(${percentage}% - 20px)`
+ }}
+ />
+ </div>
+ </div>
+ );
+} \ No newline at end of file