mirror of
https://github.com/furyhawk/home_stack.git
synced 2026-07-21 10:16:47 +00:00
252 lines
9.2 KiB
TypeScript
252 lines
9.2 KiB
TypeScript
import React from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { Spinner, Text, Box } from '@chakra-ui/react';
|
|
import { MapContainer, TileLayer, Marker, Popup, ScaleControl, ZoomControl } from 'react-leaflet';
|
|
import L from 'leaflet';
|
|
import 'leaflet/dist/leaflet.css';
|
|
import './leaflet-overrides.css';
|
|
import { WeatherService } from '@/client/sdk.gen';
|
|
import { getWeatherIcon } from './weatherUtils';
|
|
import { MapInvalidator } from './MapComponents';
|
|
|
|
type ApiNestedResponse<P> = { data?: P };
|
|
|
|
interface AirTempStation { id: string; deviceId: string; name: string; location: { latitude: number; longitude: number; }; }
|
|
interface AirTempDataPoint { stationId: string; value: number; }
|
|
interface AirTempReading { timestamp: string; data: AirTempDataPoint[]; }
|
|
interface AirTemperaturePayload { stations?: AirTempStation[]; readings?: AirTempReading[]; readingType?: string; readingUnit?: string; }
|
|
|
|
interface WindDataPoint { stationId: string; value: number | null; }
|
|
interface WindReading { timestamp: string; data: WindDataPoint[]; }
|
|
interface WindDirectionPayload { stations?: any[]; readings?: WindReading[]; readingType?: string; readingUnit?: string; }
|
|
|
|
interface Forecast { area: string; forecast: string; }
|
|
interface ValidPeriod { start: string; end: string; }
|
|
interface TwoHourForecastItem { forecasts?: Forecast[]; valid_period: ValidPeriod; }
|
|
interface TwoHourForecastPayload {
|
|
items?: TwoHourForecastItem[];
|
|
area_metadata?: Array<{ name: string; label_location: { latitude: number; longitude: number } }>;
|
|
}
|
|
|
|
interface ForecastWithLocation {
|
|
area: string;
|
|
forecast: string;
|
|
location: { latitude: number; longitude: number };
|
|
}
|
|
|
|
interface WeatherMapProps {
|
|
forecastData?: ApiNestedResponse<TwoHourForecastPayload>;
|
|
tempData?: ApiNestedResponse<AirTemperaturePayload>;
|
|
windData?: ApiNestedResponse<WindDirectionPayload>;
|
|
}
|
|
|
|
const center: [number, number] = [1.3521, 103.8198];
|
|
|
|
const escapeHtml = (value: string) =>
|
|
value
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
|
|
const formatTime = (timestamp?: string) => {
|
|
if (!timestamp) {
|
|
return 'N/A';
|
|
}
|
|
|
|
const date = new Date(timestamp);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return 'N/A';
|
|
}
|
|
|
|
return date.toLocaleString('en-SG', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
day: '2-digit',
|
|
month: 'short',
|
|
});
|
|
};
|
|
|
|
const makeTemperatureMarkerHtml = ({
|
|
forecastIcon,
|
|
temperature,
|
|
cardinal,
|
|
}: {
|
|
forecastIcon: string;
|
|
temperature: string;
|
|
cardinal: string;
|
|
}) => `
|
|
<div class="weather-marker weather-marker--temp">
|
|
<div class="weather-marker__icon">${forecastIcon}</div>
|
|
<div class="weather-marker__temp">${temperature}</div>
|
|
<div class="weather-marker__wind">${cardinal}</div>
|
|
</div>
|
|
`;
|
|
|
|
const makeForecastMarkerHtml = ({
|
|
area,
|
|
forecastIcon,
|
|
}: {
|
|
area: string;
|
|
forecastIcon: string;
|
|
}) => `
|
|
<div class="weather-marker weather-marker--forecast">
|
|
<div class="weather-marker__icon">${forecastIcon}</div>
|
|
<div class="weather-marker__area">${escapeHtml(area)}</div>
|
|
</div>
|
|
`;
|
|
|
|
const WeatherMap: React.FC<WeatherMapProps> = ({ forecastData, tempData, windData }) => {
|
|
const { data: fetchedTempData, isLoading: tempLoading, error: tempError } = useQuery<ApiNestedResponse<AirTemperaturePayload>>({
|
|
queryKey: ['weather', 'air-temperature'],
|
|
queryFn: () => WeatherService.getAirTemperature() as Promise<ApiNestedResponse<AirTemperaturePayload>>,
|
|
refetchInterval: 1000 * 60 * 30,
|
|
enabled: !tempData,
|
|
});
|
|
|
|
const { data: fetchedWindData, isLoading: windLoading, error: windError } = useQuery<ApiNestedResponse<WindDirectionPayload>>({
|
|
queryKey: ['weather', 'wind-direction'],
|
|
queryFn: () => WeatherService.getWindDirection() as Promise<ApiNestedResponse<WindDirectionPayload>>,
|
|
refetchInterval: 1000 * 60 * 30,
|
|
enabled: !windData,
|
|
});
|
|
|
|
const { data: fetchedForecastData, isLoading: forecastLoading, error: forecastError } = useQuery<ApiNestedResponse<TwoHourForecastPayload>>({
|
|
queryKey: ['weather', 'two-hour-forecast'],
|
|
queryFn: () => WeatherService.getTwoHourForecast() as Promise<ApiNestedResponse<TwoHourForecastPayload>>,
|
|
refetchInterval: 1000 * 60 * 30,
|
|
enabled: !forecastData,
|
|
});
|
|
|
|
const actualTempData = tempData || fetchedTempData;
|
|
const actualWindData = windData || fetchedWindData;
|
|
const actualForecastData = forecastData || fetchedForecastData;
|
|
|
|
const isLoading = (!tempData && tempLoading) || (!windData && windLoading) || (!forecastData && forecastLoading);
|
|
|
|
if (isLoading) return <Spinner />;
|
|
|
|
const hasError = (!tempData && tempError) || (!windData && windError) || (!forecastData && forecastError) ||
|
|
!actualTempData?.data || !actualWindData?.data || !actualForecastData?.data;
|
|
if (hasError) {
|
|
return <Text color="red.500">Error loading map data</Text>;
|
|
}
|
|
|
|
const tempStations = actualTempData.data?.stations || [];
|
|
const tempReadings = actualTempData.data?.readings || [];
|
|
const windReadings = actualWindData.data?.readings || [];
|
|
const items = actualForecastData.data?.items || [];
|
|
const forecasts = items[0]?.forecasts || [];
|
|
const areaMetadata = actualForecastData.data?.area_metadata || [];
|
|
|
|
if (tempStations.length === 0 || tempReadings.length === 0) {
|
|
return <Text>No map data available</Text>;
|
|
}
|
|
|
|
const latestTempReading = tempReadings[0];
|
|
const latestWindReading = windReadings[0];
|
|
|
|
if (!latestTempReading || !latestTempReading.data) {
|
|
return <Text>No map data available</Text>;
|
|
}
|
|
|
|
const latestReadingTime = formatTime(latestTempReading.timestamp);
|
|
const tempStationMap = new Map(tempStations.map((st: AirTempStation) => [st.id, st]));
|
|
const windDataMap = new Map(latestWindReading?.data?.map((rd: WindDataPoint) => [rd.stationId, rd.value]) || []);
|
|
|
|
const getCardinalDirection = (deg: number | null | undefined) => {
|
|
if (deg === null || deg === undefined) return 'N/A';
|
|
const dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
|
|
const idx = Math.round((deg % 360) / 22.5) % 16;
|
|
return dirs[idx];
|
|
};
|
|
|
|
const forecastsWithLocation = forecasts
|
|
.map(f => ({
|
|
area: f.area,
|
|
forecast: f.forecast,
|
|
location: areaMetadata.find(a => a.name === f.area)?.label_location,
|
|
}))
|
|
.filter(f => f.location) as ForecastWithLocation[];
|
|
|
|
return (
|
|
<Box className="weather-map-shell">
|
|
<Box className="weather-map-banner">
|
|
<Text className="weather-map-title">Singapore Live Weather Map</Text>
|
|
<Text className="weather-map-meta">
|
|
Updated {latestReadingTime} | {tempStations.length} stations
|
|
</Text>
|
|
</Box>
|
|
|
|
<MapContainer center={center} zoom={11} className="weather-map-container" zoomControl={false}>
|
|
<TileLayer
|
|
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
|
attribution="© OpenStreetMap contributors © CARTO"
|
|
/>
|
|
<MapInvalidator />
|
|
<ZoomControl position="bottomright" />
|
|
<ScaleControl position="bottomleft" imperial={false} />
|
|
|
|
{latestTempReading.data.map((reading: AirTempDataPoint) => {
|
|
const station = tempStationMap.get(reading.stationId);
|
|
if (!station || reading.value === null) return null;
|
|
const windDeg = windDataMap.get(reading.stationId) as number | null | undefined;
|
|
const cardinal = getCardinalDirection(windDeg);
|
|
|
|
return (
|
|
<Marker
|
|
key={reading.stationId}
|
|
position={[station.location.latitude, station.location.longitude]}
|
|
icon={L.divIcon({
|
|
html: makeTemperatureMarkerHtml({
|
|
forecastIcon: getWeatherIcon(forecastsWithLocation.find(f => f.area === station.name)?.forecast || 'Fair'),
|
|
temperature: `${reading.value.toFixed(1)}°C`,
|
|
cardinal,
|
|
}),
|
|
className: 'custom-weather-marker',
|
|
iconSize: [78, 72],
|
|
iconAnchor: [39, 72],
|
|
})}
|
|
>
|
|
<Popup>
|
|
<Box p={1}>
|
|
<Box fontWeight="bold">{station.name}</Box>
|
|
<Box fontSize="sm">Temperature: {reading.value.toFixed(1)}°C</Box>
|
|
<Box fontSize="sm">Wind Direction: {cardinal}</Box>
|
|
</Box>
|
|
</Popup>
|
|
</Marker>
|
|
);
|
|
})}
|
|
|
|
{forecastsWithLocation
|
|
.filter(f => !tempStations.some((s: AirTempStation) => s.name === f.area))
|
|
.map(f => (
|
|
<Marker
|
|
key={f.area}
|
|
position={[f.location.latitude, f.location.longitude]}
|
|
icon={L.divIcon({
|
|
html: makeForecastMarkerHtml({
|
|
area: f.area,
|
|
forecastIcon: getWeatherIcon(f.forecast),
|
|
}),
|
|
className: 'custom-weather-marker',
|
|
iconSize: [92, 62],
|
|
iconAnchor: [46, 62],
|
|
})}
|
|
>
|
|
<Popup>
|
|
<Box p={1}>
|
|
<Box fontWeight="bold">{f.area}</Box>
|
|
<Box fontSize="sm">Forecast: {f.forecast}</Box>
|
|
</Box>
|
|
</Popup>
|
|
</Marker>
|
|
))}
|
|
</MapContainer>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default WeatherMap; |