Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/nowcasting-app/components/helpers/globalState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { LoadingState, NationalEndpointStates, SitesEndpointStates } from "../types";
import { ActiveUnit, NationalAggregation } from "../map/types";
import { DateTime } from "luxon";
import { SatelliteChannel } from "./satelliteLayer";

export function get30MinNow(offsetMinutes = 0) {
// this is a function to get the date of now, but rounded up to the closest 30 minutes
Expand Down Expand Up @@ -82,6 +83,8 @@ export type GlobalStateType = {
sitesLoadingState: LoadingState<SitesEndpointStates>;
nHourForecast: number;
nationalAggregationLevel: NationalAggregation;
showCloudLayer: boolean;
activeChannel: SatelliteChannel;
};

export const { useGlobalState, getGlobalState, setGlobalState } =
Expand Down Expand Up @@ -130,7 +133,9 @@ export const { useGlobalState, getGlobalState, setGlobalState } =
message: "Loading data"
},
nHourForecast: 4,
nationalAggregationLevel: NationalAggregation.GSP
nationalAggregationLevel: NationalAggregation.GSP,
showCloudLayer: false,
activeChannel: "IR_016"
});

export default useGlobalState;
219 changes: 219 additions & 0 deletions apps/nowcasting-app/components/helpers/satelliteLayer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { fromArrayBuffer } from "geotiff";

export const SATELLITE_CHANNELS = [
"IR_016",
"IR_039",
"IR_087",
"IR_097",
"IR_108",
"IR_120",
"IR_134",
"VIS006",
"VIS008",
"WV_062",
"WV_073"
] as const;
export type SatelliteChannel = (typeof SATELLITE_CHANNELS)[number];

export type TifLayerData = {
imageDataUrl: string;
bounds: [number, number, number, number];
};

const API_PREFIX = process.env.NEXT_PUBLIC_QUARTZ_API_URL || "https://api-dev.quartz.solar";

// --- double buffer slots ---
const SLOT_A = { layer: "satellite-layer-a", source: "satellite-source-a" };
const SLOT_B = { layer: "satellite-layer-b", source: "satellite-source-b" };
let activeSlot = SLOT_A;

async function getToken(): Promise<string> {
const res = await fetch("/api/get_token");
if (!res.ok) throw new Error("Failed to get auth token");
const data = await res.json();
return data.accessToken as string;
}

export async function fetchSatelliteTif(
channel: SatelliteChannel,
timestamp: string
): Promise<ArrayBuffer | null> {
const token = await getToken();
const apiUrl = `${API_PREFIX}/satellite/?channel=${encodeURIComponent(
channel
)}&timestamp=${encodeURIComponent(timestamp)}`;

const maxRetries = 5;
let attempt = 0;

while (attempt < maxRetries) {
const apiRes = await fetch(apiUrl, {
headers: { Authorization: `Bearer ${token}` }
});

if (apiRes.status === 429) {
attempt++;
if (attempt >= maxRetries) {
throw new Error("Satellite API rate limited: max retries reached (429)");
}
// Retry after 1.2 to 1.5 seconds (1.some fraction seconds)
const delayMs = 1200 + Math.random() * 300;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}

if (apiRes.status === 404) return null;
if (!apiRes.ok) throw new Error(`Satellite API error: ${apiRes.status}`);

const contentType = apiRes.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
const { url } = await apiRes.json();
const s3Res = await fetch(url);
if (!s3Res.ok) throw new Error(`S3 fetch failed: ${s3Res.status}`);
return s3Res.arrayBuffer();
}
return apiRes.arrayBuffer();
}
return null;
}

function mercToWgs84(x: number, y: number): [number, number] {
const lon = (x / 20037508.34) * 180;
const lat = (Math.atan(Math.exp((y / 20037508.34) * Math.PI)) * 360) / Math.PI - 90;
return [lon, lat];
}

export async function decodeTif(buf: ArrayBuffer): Promise<TifLayerData> {
const tiff = await fromArrayBuffer(buf);
const image = await tiff.getImage();
const width = image.getWidth();
const height = image.getHeight();
const data = await image.readRasters();
const [minX, minY, maxX, maxY] = image.getBoundingBox();
const [minLon, minLat] = mercToWgs84(minX, minY);
const [maxLon, maxLat] = mercToWgs84(maxX, maxY);

const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d")!;
const imageData = ctx.createImageData(width, height);
const px = imageData.data;

const bands = Array.isArray(data) ? data : [data];
const band = bands[0] as Float32Array | Uint16Array | Uint8Array;

let minVal = Infinity,
maxVal = -Infinity;
for (let i = 0; i < band.length; i++) {
const v = band[i];
if (isFinite(v)) {
if (v < minVal) minVal = v;
if (v > maxVal) maxVal = v;
}
}
if (!isFinite(minVal)) {
minVal = 0;
maxVal = 1;
}
const range = maxVal - minVal || 1;

for (let i = 0; i < band.length; i++) {
const pi = i * 4;
const v = band[i];
if (isFinite(v)) {
const g = Math.max(0, Math.min(255, ((v - minVal) / range) * 255));
px[pi] = g;
px[pi + 1] = g;
px[pi + 2] = g;
px[pi + 3] = 180;
} else {
px[pi] = px[pi + 1] = px[pi + 2] = px[pi + 3] = 0;
}
}

ctx.putImageData(imageData, 0, 0);
return { imageDataUrl: canvas.toDataURL(), bounds: [minLon, minLat, maxLon, maxLat] };
}

export async function fetchAndDecodeSatelliteTif(
channel: SatelliteChannel,
timestamp: string
): Promise<TifLayerData | null> {
const buf = await fetchSatelliteTif(channel, timestamp);
if (!buf) return null;
return decodeTif(buf);
}

export function applyTifLayerToMap(
map: mapboxgl.Map,
layerData: TifLayerData | null,
_layerId: string,
_sourceId: string,
isVisible = true
): void {
if (!layerData) {
setSatelliteLayerVisibility(map, false, _layerId);
return;
}
const {
imageDataUrl,
bounds: [minLon, minLat, maxLon, maxLat]
} = layerData;
const coords: [[number, number], [number, number], [number, number], [number, number]] = [
[minLon, maxLat],
[maxLon, maxLat],
[maxLon, minLat],
[minLon, minLat]
];

const next = activeSlot === SLOT_A ? SLOT_B : SLOT_A;
const prev = activeSlot;

// load into the inactive slot
const existingSource = map.getSource(next.source) as mapboxgl.ImageSource | undefined;
if (existingSource) {
existingSource.updateImage({ url: imageDataUrl, coordinates: coords });
} else {
map.addSource(next.source, { type: "image", url: imageDataUrl, coordinates: coords });
}

if (!map.getLayer(next.layer)) {
map.addLayer({
id: next.layer,
type: "raster",
source: next.source,
paint: {
"raster-opacity": 0,
"raster-opacity-transition": { duration: 0 }
}
});
}

// once the new image is loaded, crossfade
map.once("idle", () => {
if (!map.getLayer(next.layer)) return;

map.setPaintProperty(next.layer, "raster-opacity-transition", { duration: 300 });
map.setPaintProperty(next.layer, "raster-opacity", isVisible ? 0.6 : 0);

if (map.getLayer(prev.layer)) {
map.setPaintProperty(prev.layer, "raster-opacity-transition", { duration: 300 });
map.setPaintProperty(prev.layer, "raster-opacity", 0);
}

activeSlot = next;
});
}

export function setSatelliteLayerVisibility(
map: mapboxgl.Map,
isVisible: boolean,
_layerId: string
): void {
for (const slot of [SLOT_A, SLOT_B]) {
if (map.getLayer(slot.layer)) {
map.setPaintProperty(slot.layer, "raster-opacity", isVisible ? 0.6 : 0);
}
}
}
128 changes: 125 additions & 3 deletions apps/nowcasting-app/components/map/map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,18 @@ import { ResetIcon } from "../icons/icons";
import {
AGGREGATION_LEVEL_MIN_ZOOM,
AGGREGATION_LEVELS,
MAX_POWER_GENERATED
MAX_POWER_GENERATED,
VIEWS
} from "../../constant";
import {
SATELLITE_CHANNELS,
SatelliteChannel,
TifLayerData,
fetchAndDecodeSatelliteTif,
applyTifLayerToMap,
setSatelliteLayerVisibility
} from "../helpers/satelliteLayer";
import { addMinutesToISODate } from "../helpers/utils";

mapboxgl.accessToken =
"pk.eyJ1IjoiZmxvd2lydHoiLCJhIjoiY2tlcGhtMnFnMWRzajJ2bzhmdGs5ZXVveSJ9.Dq5iSpi54SaajfdMyM_8fQ";
Expand Down Expand Up @@ -63,6 +73,66 @@ const Map: FC<IMap> = ({
const [currentAggregation, setAggregation] = useGlobalState("aggregationLevel");
const [autoZoom] = useGlobalState("autoZoom");
const resetButtonDiv = useRef<HTMLDivElement | null>(null);
const [selectedISOTime] = useGlobalState("selectedISOTime");
const [showCloudLayer, setShowCloudLayer] = useGlobalState("showCloudLayer");
const [activeChannel, setActiveChannel] = useGlobalState("activeChannel");
const showCloudRef = useRef(showCloudLayer);
const channelRef = useRef(activeChannel);
const tifCache = useRef<globalThis.Map<string, TifLayerData>>(new globalThis.Map());
const currentKeyRef = useRef<string | null>(null);
const [isSatelliteLoading, setIsSatelliteLoading] = useState(false);

const SAT_LAYER_ID = "satellite-layer";
const SAT_SOURCE_ID = "satellite-source";

useEffect(() => {
showCloudRef.current = showCloudLayer;
if (!map.current) return;
setSatelliteLayerVisibility(map.current, showCloudLayer, SAT_LAYER_ID);
}, [showCloudLayer]);

useEffect(() => {
channelRef.current = activeChannel;
currentKeyRef.current = null;
if (isMapReady && selectedISOTime) {
applyForTimestamp(activeChannel, selectedISOTime);
}
}, [activeChannel, isMapReady]);

const satCacheKey = (ch: SatelliteChannel, ts: string) => `${ch}__${ts}`;

const fetchIntoCache = async (ch: SatelliteChannel, ts: string): Promise<TifLayerData | null> => {
const key = satCacheKey(ch, ts);
if (tifCache.current.has(key)) return tifCache.current.get(key)!;
const data = await fetchAndDecodeSatelliteTif(ch, ts);
if (data) tifCache.current.set(key, data);
return data;
};

const applyForTimestamp = async (ch: SatelliteChannel, ts: string) => {
if (!map.current || !showCloudRef.current) return;
const key = satCacheKey(ch, ts);
if (currentKeyRef.current === key) return;
setIsSatelliteLoading(true);
try {
const data = await fetchIntoCache(ch, ts);
if (!map.current) return;
currentKeyRef.current = key;
applyTifLayerToMap(map.current, data, SAT_LAYER_ID, SAT_SOURCE_ID, showCloudRef.current);
} finally {
setIsSatelliteLoading(false);
}
};

useEffect(() => {
if (!showCloudLayer || !isMapReady || !selectedISOTime) return;
applyForTimestamp(activeChannel, selectedISOTime);
for (let offset = -3; offset <= 3; offset++) {
if (offset === 0) continue;
const ts = addMinutesToISODate(selectedISOTime, offset * 30);
fetchIntoCache(activeChannel, ts).catch(() => {});
}
}, [selectedISOTime, activeChannel, showCloudLayer, isMapReady]);

// Keep the latest autoZoom value available inside Mapbox event handlers (avoid stale closures)
const autozoomRef = useRef(autoZoom);
Expand Down Expand Up @@ -179,9 +249,61 @@ const Map: FC<IMap> = ({

return (
<div className="relative h-full overflow-hidden bg-ocf-gray-900">
<div className="absolute top-0 left-0 z-10 p-4 min-w-[20rem] w-full">
{controlOverlay(map)}
<div className="absolute top-0 left-0 z-10 p-4 min-w-[20rem] w-full flex flex-col gap-1 pointer-events-none">
<div className="pointer-events-auto">{controlOverlay(map)}</div>
<div
className={`pointer-events-auto flex flex-row items-center justify-end gap-2 transition-all duration-300 ${
title === VIEWS.SOLAR_SITES ? "mt-[240px]" : "mt-3"
}`}
>
{showCloudLayer && (
<select
value={activeChannel}
onChange={(e) => setActiveChannel(e.target.value as SatelliteChannel)}
className="w-24 bg-black text-white text-xs font-semibold py-1 px-1.5 border border-gray-600 outline-none cursor-pointer focus:border-ocf-yellow"
>
{SATELLITE_CHANNELS.map((ch) => (
<option key={ch} value={ch}>
{ch}
</option>
))}
</select>
)}

<button
type="button"
onClick={() => setShowCloudLayer((c) => !c)}
className={`relative inline-flex items-center px-3 py-0.5 text-sm dash:text-lg dash:tracking-wide font-extrabold hover:bg-ocf-yellow hover:text-mapbox-black-700 border border-gray-600 transition-all active:scale-95 ${
showCloudLayer ? "text-black bg-ocf-yellow" : "text-white bg-black"
}`}
>
{isSatelliteLoading && (
<svg
className="animate-spin -ml-1 mr-1.5 h-3.5 w-3.5 text-current"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
)}
Clouds
</button>
</div>
</div>

<div ref={mapContainer} id={`Map-${title}`} data-title={title} className="h-full w-full" />
<div className="map-overlay top">{children}</div>
</div>
Expand Down
Loading
Loading