File size: 2,969 Bytes
9d2d895 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 | import { getRpcBaseUrl } from '@/services/rpc-client';
import type { WebcamEntry, WebcamCluster, ListWebcamsResponse, GetWebcamImageResponse } from '@/generated/client/worldmonitor/webcam/v1/service_client';
import { WebcamServiceClient } from '@/services/generated-rpc-clients';
const client = new WebcamServiceClient(getRpcBaseUrl(), {
fetch: (...args) => globalThis.fetch(...args),
});
const emptyResponse: ListWebcamsResponse = { webcams: [], clusters: [], totalInView: 0 };
// Client-side image cache (9 min, under Windy's 10-min token expiry)
const IMAGE_CACHE_MS = 9 * 60 * 1000;
const IMAGE_CACHE_MAX = 200;
const imageCacheMap = new Map<string, { data: GetWebcamImageResponse; expires: number }>();
export async function fetchWebcams(
zoom: number,
bounds: { w: number; s: number; e: number; n: number },
): Promise<ListWebcamsResponse> {
try {
return await client.listWebcams({
zoom,
boundW: bounds.w,
boundS: bounds.s,
boundE: bounds.e,
boundN: bounds.n,
});
} catch (err) {
console.warn('[webcams] fetch failed:', err);
return emptyResponse;
}
}
export async function fetchWebcamImage(webcamId: string): Promise<GetWebcamImageResponse> {
// Check client cache
const cached = imageCacheMap.get(webcamId);
if (cached && cached.expires > Date.now()) return cached.data;
try {
const result = await client.getWebcamImage({ webcamId });
if (!result.error) {
if (imageCacheMap.size >= IMAGE_CACHE_MAX) {
const oldest = imageCacheMap.keys().next().value;
if (oldest) imageCacheMap.delete(oldest);
}
imageCacheMap.set(webcamId, { data: result, expires: Date.now() + IMAGE_CACHE_MS });
}
return result;
} catch (err) {
console.warn('[webcams] image fetch failed:', err);
return {
thumbnailUrl: '', playerUrl: '', title: '',
windyUrl: `https://www.windy.com/webcams/${webcamId}`,
lastUpdated: '', error: 'unavailable',
};
}
}
// Category mapping for marker rendering
export const WEBCAM_CATEGORIES: Record<string, { color: string; emoji: string }> = {
traffic: { color: '#ffd700', emoji: '\u{1F697}' }, // ๐
city: { color: '#00d4ff', emoji: '\u{1F3D9}\uFE0F' }, // ๐๏ธ
landscape: { color: '#45b7d1', emoji: '\u{1F3D4}\uFE0F' }, // ๐๏ธ
nature: { color: '#96ceb4', emoji: '\u{1F33F}' }, // ๐ฟ
beach: { color: '#f4a460', emoji: '\u{1F3D6}\uFE0F' }, // ๐๏ธ
water: { color: '#4169e1', emoji: '\u{1F30A}' }, // ๐
other: { color: '#888888', emoji: '\u{1F4F7}' }, // ๐ท
};
export function getClusterCellSize(zoom: number): number {
if (zoom < 3) return 8;
if (zoom <= 4) return 5;
if (zoom <= 6) return 2;
if (zoom <= 8) return 0.5;
return 0.5;
}
export function getCategoryStyle(category: string) {
return WEBCAM_CATEGORIES[category] ?? WEBCAM_CATEGORIES.other!;
}
export type { WebcamEntry, WebcamCluster, GetWebcamImageResponse };
|