Files
weather/resources/js/pages/Weather.vue

359 lines
18 KiB
Vue

<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed } from 'vue';
interface CurrentWeather {
temperature: number;
temperatureUnit: string;
shortForecast: string;
detailedForecast: string | null;
windSpeed: string | null;
windDirection: string | null;
humidity: number | null;
dewpoint: number | null;
precipitationProbability: number | null;
isDaytime: boolean;
icon: string;
}
interface HourlyForecast {
time: string;
hour: string;
temperature: number;
temperatureUnit: string;
icon: string;
shortForecast: string;
precipitationProbability: number | null;
}
interface WeeklyForecast {
name: string;
temperature: number;
temperatureUnit: string;
icon: string;
shortForecast: string;
precipitationProbability: number | null;
windSpeed: string | null;
windDirection: string | null;
detailedForecast: string | null;
}
interface Location {
latitude: number | null;
longitude: number | null;
name: string;
}
interface Background {
imageUrl: string | null;
licenseHtml: string | null;
}
const props = defineProps<{
current: CurrentWeather | null;
hourlyForecast: HourlyForecast[];
weeklyForecast: WeeklyForecast[];
location: Location;
reportedAt: string | null;
background: Background;
}>();
const weatherIcons: Record<string, string> = {
sun: 'M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M2 12h2m16 0h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41M12 6a6 6 0 100 12 6 6 0 000-12z',
'cloud-sun':
'M12 2v1m0 18v1m9-10h1M2 12h1m15.5-6.5l.7.7M4.2 19.8l.7-.7m0-13.4l-.7.7m15.6 13.4l-.7-.7M17 12a5 5 0 10-9.6 1.8M6.3 18h10.4a4 4 0 000-8h-.3A5.5 5.5 0 006.3 18z',
cloud: 'M3 15h.01M6 15h.01M9 18h.01M12 15h.01M15 18h.01M18 15h.01M21 15h.01M6.34 8A6 6 0 0118 10h1a4 4 0 014 4 4 4 0 01-4 4H6a4 4 0 01-4-4 4 4 0 014-4h.34z',
'cloud-rain':
'M16 13v8m-4-6v6m-4-4v4m-4-2a4 4 0 014-4h.87a5.5 5.5 0 0110.26 0H18a4 4 0 014 4 4 4 0 01-4 4',
'cloud-snow': 'M20 17.58A5 5 0 0018 8h-1.26A8 8 0 104 16.25m4 2l.5.5m3.5-.5l.5.5m-.5 3.5l.5.5m3-.5l.5.5',
'cloud-lightning': 'M19 16.9A5 5 0 0018 7h-1.26a8 8 0 10-11.62 9m7.88-4l-3 5h4l-3 5',
'cloud-fog': 'M4 14h.01M8 14h.01M12 14h.01M16 14h.01M20 14h.01M4 18h.01M8 18h.01M12 18h.01M16 18h.01M20 18h.01M6.34 6A6 6 0 0118 8h1a4 4 0 010 8H6a4 4 0 010-8h.34z',
wind: 'M9.59 4.59A2 2 0 1111 8H2m10.59 11.41A2 2 0 1014 16H2m15.73-8.27A2.5 2.5 0 1119.5 12H2',
};
function getIconPath(iconName: string): string {
return weatherIcons[iconName] || weatherIcons.cloud;
}
const currentTime = computed(() => {
return new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
});
const currentDate = computed(() => {
return new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' });
});
function parseWindSpeed(windSpeed: string | null): number {
if (!windSpeed) return 0;
const match = windSpeed.match(/(\d+)/);
return match ? parseInt(match[1]) : 0;
}
// Sunrise/sunset times (in hours, 24h format)
const sunriseHour = 6.75; // 6:45 AM
const sunsetHour = 17.5; // 5:30 PM
const sunPosition = computed(() => {
const now = new Date();
const currentHour = now.getHours() + now.getMinutes() / 60;
// Calculate progress through daylight hours (0 = sunrise, 1 = sunset)
let t = (currentHour - sunriseHour) / (sunsetHour - sunriseHour);
// Clamp t between 0 and 1 for daytime, outside for night
const isDaytime = t >= 0 && t <= 1;
if (!isDaytime) {
// Nighttime: show moon at top of arc
return { cx: 100, cy: 10, isNight: true };
}
// Quadratic bezier curve: M 10 70 Q 100 -10 190 70
// Start point (P0): (10, 70)
// Control point (P1): (100, -10)
// End point (P2): (190, 70)
const p0 = { x: 10, y: 70 };
const p1 = { x: 100, y: -10 };
const p2 = { x: 190, y: 70 };
// Quadratic bezier formula: B(t) = (1-t)²P0 + 2(1-t)tP1 + t²P2
const oneMinusT = 1 - t;
const cx = oneMinusT * oneMinusT * p0.x + 2 * oneMinusT * t * p1.x + t * t * p2.x;
const cy = oneMinusT * oneMinusT * p0.y + 2 * oneMinusT * t * p1.y + t * t * p2.y;
return { cx: Math.round(cx), cy: Math.round(cy), isNight: false };
});
const formattedSunrise = computed(() => {
const hours = Math.floor(sunriseHour);
const minutes = Math.round((sunriseHour - hours) * 60);
return `${hours}:${minutes.toString().padStart(2, '0')} AM`;
});
const formattedSunset = computed(() => {
const hours = Math.floor(sunsetHour) - 12;
const minutes = Math.round((sunsetHour - Math.floor(sunsetHour)) * 60);
return `${hours}:${minutes.toString().padStart(2, '0')} PM`;
});
</script>
<template>
<Head title="Weather" />
<div class="relative min-h-screen overflow-hidden bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
<!-- Dynamic background image based on weather -->
<div
v-if="background.imageUrl"
class="absolute inset-0 bg-cover bg-center transition-opacity duration-1000"
:style="{ backgroundImage: `url(${background.imageUrl})` }"
></div>
<!-- Fallback gradient background -->
<div v-else class="absolute inset-0 bg-gradient-to-br from-slate-800 via-slate-700 to-slate-900"></div>
<!-- Overlay for better text readability -->
<div class="absolute inset-0 bg-gradient-to-b from-black/40 via-black/50 to-black/70"></div>
<!-- Main content -->
<div class="relative z-10 mx-auto min-h-screen max-w-6xl p-6 lg:p-8">
<!-- Header -->
<header class="text-shadow mb-8 flex items-center justify-between">
<div class="flex items-center gap-2">
<svg class="h-8 w-8 text-blue-400 drop-shadow-lg" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="getIconPath('cloud-sun')" />
</svg>
<span class="text-lg font-medium text-white/80">Weather</span>
</div>
<div class="text-right text-sm text-white/60">
<div>{{ currentDate }}</div>
<div class="text-lg font-medium text-white/80">{{ currentTime }}</div>
</div>
</header>
<div v-if="current" class="page-container">
<!-- Current Forecast Section -->
<div class="currentForecast">
<div class="forecast flex flex-col justify-center">
<div class="shortDescription">
<h1 class="text-shadow-lg text-4xl font-light tracking-wide text-white md:text-5xl">
{{ current.shortForecast }}
</h1>
<p class="text-shadow mt-2 text-lg text-white/70">
{{ current.detailedForecast || 'Current conditions' }}
</p>
</div>
<div class="currentTemp mt-8">
<div class="flex items-start">
<span class="text-shadow-lg text-8xl font-thin tracking-tighter text-white md:text-9xl">
{{ current.temperature }}
</span>
<span class="text-shadow-lg mt-4 text-4xl font-thin text-white/70">
°{{ current.temperatureUnit }}
</span>
</div>
<div class="text-shadow mt-4 flex items-center gap-4 text-white/70">
<span class="flex items-center gap-1">
<svg class="h-4 w-4 drop-shadow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{{ location.name }}
</span>
<span v-if="current.humidity">Humidity: {{ current.humidity }}%</span>
</div>
</div>
</div>
<!-- Secondary Info -->
<div class="secondaryInfo space-y-4">
<!-- Wind Card -->
<div class="windContainer rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-lg">
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center gap-2 text-white/60">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="getIconPath('wind')" />
</svg>
<span>Wind Status</span>
</div>
<span class="text-xl font-semibold text-white">{{ current.windSpeed || 'N/A' }}</span>
</div>
<!-- Wind visualization -->
<div class="flex h-16 items-end justify-between gap-1">
<div
v-for="i in 20"
:key="i"
class="flex-1 rounded-t bg-gradient-to-t from-blue-500/40 to-blue-400/60"
:style="{ height: `${20 + Math.sin(i * 0.5) * 15 + Math.random() * 30}%` }"
></div>
</div>
<div class="mt-2 text-center text-sm text-white/40">
Direction: {{ current.windDirection || 'N/A' }}
</div>
</div>
<!-- Sunrise/Sunset Arc -->
<div class="solarClock rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-lg">
<div class="mb-4 flex items-center justify-between text-sm text-white/60">
<div class="flex items-center gap-1">
<svg class="h-4 w-4 text-orange-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2L13.09 8.26L18 6L14.74 10.91L21 12L14.74 13.09L18 18L13.09 15.74L12 22L10.91 15.74L6 18L9.26 13.09L3 12L9.26 10.91L6 6L10.91 8.26L12 2Z" />
</svg>
<span>Sunrise</span>
</div>
<div class="flex items-center gap-1">
<span>Sunset</span>
<svg class="h-4 w-4 text-orange-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2L13.09 8.26L18 6L14.74 10.91L21 12L14.74 13.09L18 18L13.09 15.74L12 22L10.91 15.74L6 18L9.26 13.09L3 12L9.26 10.91L6 6L10.91 8.26L12 2Z" />
</svg>
</div>
</div>
<!-- Sun arc -->
<div class="relative h-20">
<svg class="h-full w-full" viewBox="0 0 200 80">
<defs>
<linearGradient id="arcGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="rgb(251, 146, 60)" stop-opacity="0.3" />
<stop offset="50%" stop-color="rgb(250, 204, 21)" stop-opacity="0.6" />
<stop offset="100%" stop-color="rgb(251, 146, 60)" stop-opacity="0.3" />
</linearGradient>
</defs>
<path
d="M 10 70 Q 100 -10 190 70"
fill="none"
stroke="url(#arcGradient)"
stroke-width="2"
stroke-dasharray="4 4"
/>
<!-- Sun position indicator (daytime) -->
<circle
v-if="!sunPosition.isNight"
:cx="sunPosition.cx"
:cy="sunPosition.cy"
r="8"
fill="rgb(250, 204, 21)"
class="drop-shadow-lg transition-all duration-1000"
/>
<!-- Moon indicator (nighttime) -->
<g v-else :transform="`translate(${sunPosition.cx - 8}, ${sunPosition.cy - 8})`" class="transition-all duration-1000">
<path
d="M8 0C3.6 0 0 3.6 0 8s3.6 8 8 8c1.1 0 2.1-.2 3-.6-1.8-1.1-3-3.1-3-5.4s1.2-4.3 3-5.4C10.1.2 9.1 0 8 0z"
fill="rgb(226, 232, 240)"
class="drop-shadow-lg"
/>
</g>
</svg>
</div>
<div class="flex justify-between text-xs text-white/40">
<span>{{ formattedSunrise }}</span>
<span>{{ formattedSunset }}</span>
</div>
</div>
</div>
</div>
<!-- Hourly Forecast Strip -->
<div v-if="hourlyForecast.length > 0" class="my-6 rounded-2xl border border-white/10 bg-white/5 p-4 backdrop-blur-lg">
<h3 class="mb-4 text-sm font-medium text-white/60">Hourly Forecast</h3>
<div class="flex justify-between gap-4 overflow-x-auto">
<div
v-for="(hour, index) in hourlyForecast"
:key="index"
class="flex min-w-16 flex-col items-center gap-2"
:class="{ 'rounded-xl bg-white/10 px-3 py-2': index === 0 }"
>
<span class="text-xs text-white/60">{{ index === 0 ? 'Now' : hour.hour }}</span>
<svg class="h-6 w-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" :d="getIconPath(hour.icon)" />
</svg>
<span class="text-sm font-medium text-white">{{ hour.temperature }}°</span>
</div>
</div>
</div>
<!-- Weekly Forecast -->
<div v-if="weeklyForecast.length > 0" class="weeklyReport">
<h3 class="col-span-full mb-4 text-sm font-medium text-white/60">7-Day Forecast</h3>
<div class="col-span-full grid grid-cols-7 gap-2">
<div
v-for="(day, index) in weeklyForecast"
:key="index"
class="flex flex-col items-center rounded-2xl border border-white/10 bg-white/5 p-4 backdrop-blur-lg transition-all hover:bg-white/10"
>
<span class="text-sm font-medium text-white/80">{{ day.name.substring(0, 3) }}</span>
<svg class="my-3 h-8 w-8 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" :d="getIconPath(day.icon)" />
</svg>
<span class="text-xl font-semibold text-white">{{ day.temperature }}°</span>
<span v-if="day.precipitationProbability" class="mt-1 text-xs text-blue-400">
{{ day.precipitationProbability }}%
</span>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="flex min-h-96 flex-col items-center justify-center text-center">
<svg class="mb-4 h-16 w-16 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" :d="getIconPath('cloud')" />
</svg>
<h2 class="text-xl font-medium text-white/60">No Weather Data Available</h2>
<p class="mt-2 text-white/40">Run the ingestion command to load weather data:</p>
<code class="mt-2 rounded bg-white/10 px-3 py-1 text-sm text-white/60">php artisan weather:ingest</code>
</div>
<!-- Footer -->
<footer v-if="reportedAt" class="mt-8 text-center text-xs text-white/30">
Last updated: {{ reportedAt }}
</footer>
</div>
<!-- Image attribution (bottom right corner) -->
<div
v-if="background.licenseHtml"
class="absolute bottom-4 right-4 z-20 max-w-xs rounded-lg bg-black/40 px-3 py-2 text-xs text-white/60 backdrop-blur-sm"
>
<span v-html="background.licenseHtml"></span>
</div>
</div>
</template>