ENH - refine native weather presentation
@@ -4,3 +4,6 @@ dist/
|
|||||||
*.local
|
*.local
|
||||||
dev-server*.log
|
dev-server*.log
|
||||||
|
|
||||||
|
# Image-generation source files retained locally for asset iteration.
|
||||||
|
/src/assets/img/weather-icons/source/
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import clearIcon from '@/assets/img/weather-icons/clear.webp'
|
||||||
|
import cloudyIcon from '@/assets/img/weather-icons/cloudy.webp'
|
||||||
|
import fogIcon from '@/assets/img/weather-icons/fog.webp'
|
||||||
|
import partlyCloudyIcon from '@/assets/img/weather-icons/partly_cloudy.webp'
|
||||||
|
import rainIcon from '@/assets/img/weather-icons/rain.webp'
|
||||||
|
import snowIcon from '@/assets/img/weather-icons/snow.webp'
|
||||||
|
import sunnyIcon from '@/assets/img/weather-icons/sunny.webp'
|
||||||
|
import thunderIcon from '@/assets/img/weather-icons/thunder.webp'
|
||||||
|
import type { WeatherConditionId } from '@/types/weather'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
condition: WeatherConditionId
|
||||||
|
size?: number
|
||||||
|
timestamp?: number
|
||||||
|
}>(),
|
||||||
|
{ size: 24, timestamp: undefined },
|
||||||
|
)
|
||||||
|
|
||||||
|
const icons: Record<WeatherConditionId, string> = {
|
||||||
|
sunny: sunnyIcon,
|
||||||
|
clear: clearIcon,
|
||||||
|
partly_cloudy: partlyCloudyIcon,
|
||||||
|
cloudy: cloudyIcon,
|
||||||
|
rain: rainIcon,
|
||||||
|
thunder: thunderIcon,
|
||||||
|
fog: fogIcon,
|
||||||
|
snow: snowIcon,
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconSource = computed(() => {
|
||||||
|
if (props.condition !== 'clear' || props.timestamp === undefined) {
|
||||||
|
return icons[props.condition]
|
||||||
|
}
|
||||||
|
|
||||||
|
const hour = new Date(props.timestamp).getUTCHours()
|
||||||
|
return hour >= 7 && hour < 20 ? sunnyIcon : clearIcon
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<img
|
||||||
|
class="weather-condition-icon"
|
||||||
|
:src="iconSource"
|
||||||
|
:width="size"
|
||||||
|
:height="size"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.weather-condition-icon {
|
||||||
|
display: block;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -10,6 +10,7 @@ vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
|
|||||||
const rawWeather: RawWeatherSnapshot = {
|
const rawWeather: RawWeatherSnapshot = {
|
||||||
clock: { day: 5, hour: 17, minute: 20, month: 8, year: 2026 },
|
clock: { day: 5, hour: 17, minute: 20, month: 8, year: 2026 },
|
||||||
condition: 'sunny',
|
condition: 'sunny',
|
||||||
|
nextCondition: 'clear',
|
||||||
rainLevel: 0,
|
rainLevel: 0,
|
||||||
region: 'los_santos',
|
region: 'los_santos',
|
||||||
windSpeed: 2,
|
windSpeed: 2,
|
||||||
@@ -32,7 +33,7 @@ describe('weather store', () => {
|
|||||||
|
|
||||||
expect(nuiCall).toHaveBeenCalledWith('weather:get')
|
expect(nuiCall).toHaveBeenCalledWith('weather:get')
|
||||||
expect(weather.forecast?.condition).toBe('sunny')
|
expect(weather.forecast?.condition).toBe('sunny')
|
||||||
expect(weather.forecast?.hourly).toHaveLength(24)
|
expect(weather.forecast?.hourly).toHaveLength(6)
|
||||||
expect(weather.error).toBeNull()
|
expect(weather.error).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ export type WeatherConditionId =
|
|||||||
| 'fog'
|
| 'fog'
|
||||||
| 'snow'
|
| 'snow'
|
||||||
|
|
||||||
export type WeatherRegionId =
|
export type WeatherRegionId = 'los_santos' | 'blaine_county' | 'cayo_perico'
|
||||||
| 'los_santos'
|
|
||||||
| 'blaine_county'
|
|
||||||
| 'cayo_perico'
|
|
||||||
|
|
||||||
export type WeatherClock = {
|
export type WeatherClock = {
|
||||||
day: number
|
day: number
|
||||||
@@ -24,6 +21,7 @@ export type WeatherClock = {
|
|||||||
export type RawWeatherSnapshot = {
|
export type RawWeatherSnapshot = {
|
||||||
clock: WeatherClock
|
clock: WeatherClock
|
||||||
condition: WeatherConditionId
|
condition: WeatherConditionId
|
||||||
|
nextCondition: WeatherConditionId
|
||||||
rainLevel: number
|
rainLevel: number
|
||||||
region: WeatherRegionId
|
region: WeatherRegionId
|
||||||
windSpeed: number
|
windSpeed: number
|
||||||
@@ -36,17 +34,8 @@ export type HourlyWeather = {
|
|||||||
timestamp: number
|
timestamp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DailyWeather = {
|
|
||||||
condition: WeatherConditionId
|
|
||||||
high: number
|
|
||||||
low: number
|
|
||||||
rainChance: number
|
|
||||||
timestamp: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WeatherForecast = {
|
export type WeatherForecast = {
|
||||||
condition: WeatherConditionId
|
condition: WeatherConditionId
|
||||||
daily: DailyWeather[]
|
|
||||||
feelsLike: number
|
feelsLike: number
|
||||||
hourly: HourlyWeather[]
|
hourly: HourlyWeather[]
|
||||||
humidity: number
|
humidity: number
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import type { RawWeatherSnapshot, WeatherRegionId } from '@/types/weather'
|
import type { RawWeatherSnapshot, WeatherRegionId } from '@/types/weather'
|
||||||
import { buildWeatherForecast } from './weather'
|
import { buildWeatherForecast } from './weather'
|
||||||
|
|
||||||
function snapshot(
|
function snapshot(region: WeatherRegionId = 'los_santos'): RawWeatherSnapshot {
|
||||||
region: WeatherRegionId = 'los_santos',
|
|
||||||
): RawWeatherSnapshot {
|
|
||||||
return {
|
return {
|
||||||
clock: { day: 31, hour: 23, minute: 30, month: 12, year: 2026 },
|
clock: { day: 31, hour: 23, minute: 30, month: 12, year: 2026 },
|
||||||
condition: 'partly_cloudy',
|
condition: 'partly_cloudy',
|
||||||
|
nextCondition: 'rain',
|
||||||
rainLevel: 0.1,
|
rainLevel: 0.1,
|
||||||
region,
|
region,
|
||||||
windSpeed: 4,
|
windSpeed: 4,
|
||||||
@@ -16,13 +15,15 @@ function snapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('weather forecast', () => {
|
describe('weather forecast', () => {
|
||||||
it('builds deterministic 24-hour and seven-day forecasts', () => {
|
it('builds a deterministic six-hour forecast from native weather states', () => {
|
||||||
const first = buildWeatherForecast(snapshot())
|
const first = buildWeatherForecast(snapshot())
|
||||||
const second = buildWeatherForecast(snapshot())
|
const second = buildWeatherForecast(snapshot())
|
||||||
|
|
||||||
expect(first).toEqual(second)
|
expect(first).toEqual(second)
|
||||||
expect(first.hourly).toHaveLength(24)
|
expect(first.hourly).toHaveLength(6)
|
||||||
expect(first.daily).toHaveLength(7)
|
expect(first.hourly[0]?.condition).toBe('partly_cloudy')
|
||||||
|
expect(first.hourly[1]?.condition).toBe('rain')
|
||||||
|
expect(first).not.toHaveProperty('daily')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rolls hourly timestamps into the next year', () => {
|
it('rolls hourly timestamps into the next year', () => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type {
|
import type {
|
||||||
DailyWeather,
|
|
||||||
HourlyWeather,
|
HourlyWeather,
|
||||||
RawWeatherSnapshot,
|
RawWeatherSnapshot,
|
||||||
WeatherConditionId,
|
WeatherConditionId,
|
||||||
@@ -57,37 +56,10 @@ const HUMIDITY: Record<WeatherConditionId, number> = {
|
|||||||
snow: 76,
|
snow: 76,
|
||||||
}
|
}
|
||||||
|
|
||||||
const TRANSITIONS: Record<WeatherConditionId, WeatherConditionId[]> = {
|
|
||||||
sunny: ['sunny', 'sunny', 'clear', 'partly_cloudy'],
|
|
||||||
clear: ['clear', 'sunny', 'partly_cloudy', 'cloudy'],
|
|
||||||
partly_cloudy: ['partly_cloudy', 'clear', 'cloudy', 'rain'],
|
|
||||||
cloudy: ['cloudy', 'partly_cloudy', 'rain', 'fog'],
|
|
||||||
rain: ['rain', 'cloudy', 'partly_cloudy', 'thunder'],
|
|
||||||
thunder: ['rain', 'cloudy', 'thunder', 'partly_cloudy'],
|
|
||||||
fog: ['fog', 'cloudy', 'partly_cloudy', 'clear'],
|
|
||||||
snow: ['snow', 'cloudy', 'snow', 'partly_cloudy'],
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp(value: number, minimum: number, maximum: number): number {
|
function clamp(value: number, minimum: number, maximum: number): number {
|
||||||
return Math.min(maximum, Math.max(minimum, value))
|
return Math.min(maximum, Math.max(minimum, value))
|
||||||
}
|
}
|
||||||
|
|
||||||
function seedFrom(value: string): number {
|
|
||||||
let hash = 2166136261
|
|
||||||
for (let index = 0; index < value.length; index += 1) {
|
|
||||||
hash ^= value.charCodeAt(index)
|
|
||||||
hash = Math.imul(hash, 16777619)
|
|
||||||
}
|
|
||||||
return hash >>> 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function random(seed: number): number {
|
|
||||||
let value = seed + 0x6d2b79f5
|
|
||||||
value = Math.imul(value ^ (value >>> 15), value | 1)
|
|
||||||
value ^= value + Math.imul(value ^ (value >>> 7), value | 61)
|
|
||||||
return ((value ^ (value >>> 14)) >>> 0) / 4294967296
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizedCondition(value: string): WeatherConditionId {
|
function normalizedCondition(value: string): WeatherConditionId {
|
||||||
return CONDITIONS.includes(value as WeatherConditionId)
|
return CONDITIONS.includes(value as WeatherConditionId)
|
||||||
? (value as WeatherConditionId)
|
? (value as WeatherConditionId)
|
||||||
@@ -109,14 +81,6 @@ function temperatureAt(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextCondition(
|
|
||||||
condition: WeatherConditionId,
|
|
||||||
seed: number,
|
|
||||||
): WeatherConditionId {
|
|
||||||
const choices = TRANSITIONS[condition]
|
|
||||||
return choices[Math.floor(random(seed) * choices.length)] ?? condition
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotTimestamp(snapshot: RawWeatherSnapshot): number {
|
function snapshotTimestamp(snapshot: RawWeatherSnapshot): number {
|
||||||
const { year, month, day, hour, minute } = snapshot.clock
|
const { year, month, day, hour, minute } = snapshot.clock
|
||||||
return Date.UTC(year, month - 1, day, hour, minute)
|
return Date.UTC(year, month - 1, day, hour, minute)
|
||||||
@@ -126,62 +90,29 @@ export function buildWeatherForecast(
|
|||||||
input: RawWeatherSnapshot,
|
input: RawWeatherSnapshot,
|
||||||
): WeatherForecast {
|
): WeatherForecast {
|
||||||
const condition = normalizedCondition(input.condition)
|
const condition = normalizedCondition(input.condition)
|
||||||
const region = input.region in REGION_BASE_TEMPERATURE
|
const nextCondition = normalizedCondition(input.nextCondition)
|
||||||
? input.region
|
const region =
|
||||||
: 'los_santos'
|
input.region in REGION_BASE_TEMPERATURE ? input.region : 'los_santos'
|
||||||
const timestamp = snapshotTimestamp(input)
|
const timestamp = snapshotTimestamp(input)
|
||||||
const hourSeed = seedFrom(`${region}:${timestamp}:${condition}`)
|
|
||||||
const hourly: HourlyWeather[] = []
|
const hourly: HourlyWeather[] = []
|
||||||
let forecastCondition = condition
|
|
||||||
|
|
||||||
for (let index = 0; index < 24; index += 1) {
|
for (let index = 0; index < 6; index += 1) {
|
||||||
if (index > 0 && index % 3 === 0) {
|
const forecastCondition = index === 0 ? condition : nextCondition
|
||||||
forecastCondition = nextCondition(forecastCondition, hourSeed + index)
|
|
||||||
}
|
|
||||||
const itemTimestamp = timestamp + index * 3_600_000
|
const itemTimestamp = timestamp + index * 3_600_000
|
||||||
const itemHour = new Date(itemTimestamp).getUTCHours()
|
const itemHour = new Date(itemTimestamp).getUTCHours()
|
||||||
hourly.push({
|
hourly.push({
|
||||||
condition: forecastCondition,
|
condition: forecastCondition,
|
||||||
rainChance: clamp(
|
rainChance: RAIN_CHANCE[forecastCondition],
|
||||||
RAIN_CHANCE[forecastCondition] + Math.round(random(hourSeed + index) * 12 - 6),
|
temperature: temperatureAt(region, forecastCondition, itemHour, 0),
|
||||||
0,
|
|
||||||
100,
|
|
||||||
),
|
|
||||||
temperature: temperatureAt(
|
|
||||||
region,
|
|
||||||
forecastCondition,
|
|
||||||
itemHour,
|
|
||||||
random(hourSeed + index + 100) * 3 - 1.5,
|
|
||||||
),
|
|
||||||
timestamp: itemTimestamp,
|
timestamp: itemTimestamp,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const daily: DailyWeather[] = []
|
|
||||||
let dailyCondition = condition
|
|
||||||
for (let index = 0; index < 7; index += 1) {
|
|
||||||
if (index > 0) dailyCondition = nextCondition(dailyCondition, hourSeed + index * 97)
|
|
||||||
const itemTimestamp = timestamp + index * 86_400_000
|
|
||||||
const variation = random(hourSeed + index * 31) * 4 - 2
|
|
||||||
daily.push({
|
|
||||||
condition: dailyCondition,
|
|
||||||
high: temperatureAt(region, dailyCondition, 14, variation),
|
|
||||||
low: temperatureAt(region, dailyCondition, 4, variation - 1),
|
|
||||||
rainChance: clamp(
|
|
||||||
RAIN_CHANCE[dailyCondition] + Math.round(random(hourSeed + index * 43) * 10 - 5),
|
|
||||||
0,
|
|
||||||
100,
|
|
||||||
),
|
|
||||||
timestamp: itemTimestamp,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentVariation = random(hourSeed) * 2 - 1
|
|
||||||
const temperature = temperatureAt(
|
const temperature = temperatureAt(
|
||||||
region,
|
region,
|
||||||
condition,
|
condition,
|
||||||
input.clock.hour + input.clock.minute / 60,
|
input.clock.hour + input.clock.minute / 60,
|
||||||
currentVariation,
|
0,
|
||||||
)
|
)
|
||||||
const windSpeed = Math.round(Math.max(0, input.windSpeed) * 3.6)
|
const windSpeed = Math.round(Math.max(0, input.windSpeed) * 3.6)
|
||||||
const nativeRainChance = clamp(Math.round(input.rainLevel * 100), 0, 100)
|
const nativeRainChance = clamp(Math.round(input.rainLevel * 100), 0, 100)
|
||||||
@@ -189,10 +120,14 @@ export function buildWeatherForecast(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
condition,
|
condition,
|
||||||
daily,
|
feelsLike:
|
||||||
feelsLike: temperature - Math.round(windSpeed / 18) - (rainChance > 60 ? 1 : 0),
|
temperature - Math.round(windSpeed / 18) - (rainChance > 60 ? 1 : 0),
|
||||||
hourly,
|
hourly,
|
||||||
humidity: clamp(HUMIDITY[condition] + Math.round(input.rainLevel * 8), 0, 100),
|
humidity: clamp(
|
||||||
|
HUMIDITY[condition] + Math.round(input.rainLevel * 8),
|
||||||
|
0,
|
||||||
|
100,
|
||||||
|
),
|
||||||
rainChance,
|
rainChance,
|
||||||
region,
|
region,
|
||||||
temperature,
|
temperature,
|
||||||
|
|||||||
@@ -1,27 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { kCard, kLink, kNavbar, kPage, kPreloader } from 'konsta/vue'
|
import { kCard, kLink, kNavbar, kPage, kPreloader } from 'konsta/vue'
|
||||||
import {
|
import {
|
||||||
Cloud,
|
|
||||||
CloudFog,
|
|
||||||
CloudLightning,
|
|
||||||
CloudRain,
|
|
||||||
CloudSun,
|
CloudSun,
|
||||||
Droplets,
|
Droplets,
|
||||||
Gauge,
|
Gauge,
|
||||||
MoonStar,
|
|
||||||
Navigation,
|
Navigation,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Snowflake,
|
|
||||||
Sun,
|
|
||||||
ThermometerSun,
|
ThermometerSun,
|
||||||
Umbrella,
|
Umbrella,
|
||||||
Wind,
|
Wind,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { computed, type Component } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import WeatherConditionIcon from '@/components/WeatherConditionIcon.vue'
|
||||||
import { usePhoneStore } from '@/stores/phone'
|
import { usePhoneStore } from '@/stores/phone'
|
||||||
import { useWeatherStore } from '@/stores/weather'
|
import { useWeatherStore } from '@/stores/weather'
|
||||||
import type { DailyWeather, WeatherConditionId } from '@/types/weather'
|
import type { WeatherConditionId } from '@/types/weather'
|
||||||
|
|
||||||
const phone = usePhoneStore()
|
const phone = usePhoneStore()
|
||||||
const weather = useWeatherStore()
|
const weather = useWeatherStore()
|
||||||
@@ -31,23 +25,6 @@ const cardColors = {
|
|||||||
textIos: 'text-white',
|
textIos: 'text-white',
|
||||||
}
|
}
|
||||||
|
|
||||||
const conditionIcons: Record<WeatherConditionId, Component> = {
|
|
||||||
sunny: Sun,
|
|
||||||
clear: MoonStar,
|
|
||||||
partly_cloudy: CloudSun,
|
|
||||||
cloudy: Cloud,
|
|
||||||
rain: CloudRain,
|
|
||||||
thunder: CloudLightning,
|
|
||||||
fog: CloudFog,
|
|
||||||
snow: Snowflake,
|
|
||||||
}
|
|
||||||
|
|
||||||
function conditionIcon(condition: WeatherConditionId, timestamp: number): Component {
|
|
||||||
if (condition !== 'clear') return conditionIcons[condition]
|
|
||||||
const hour = new Date(timestamp).getUTCHours()
|
|
||||||
return hour >= 7 && hour < 20 ? Sun : MoonStar
|
|
||||||
}
|
|
||||||
|
|
||||||
function conditionLabel(condition: WeatherConditionId): string {
|
function conditionLabel(condition: WeatherConditionId): string {
|
||||||
return phone.t(`Apps.weather.conditions.${condition}`)
|
return phone.t(`Apps.weather.conditions.${condition}`)
|
||||||
}
|
}
|
||||||
@@ -59,26 +36,6 @@ function formatHour(timestamp: number, index: number): string {
|
|||||||
timeZone: 'UTC',
|
timeZone: 'UTC',
|
||||||
}).format(timestamp)
|
}).format(timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDay(day: DailyWeather, index: number): string {
|
|
||||||
if (index === 0) return phone.t('Apps.weather.today')
|
|
||||||
return new Intl.DateTimeFormat(phone.lang, {
|
|
||||||
timeZone: 'UTC',
|
|
||||||
weekday: 'short',
|
|
||||||
}).format(day.timestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
function temperatureRange(day: DailyWeather): Record<string, string> {
|
|
||||||
const lows = forecast.value?.daily.map((item) => item.low) ?? [day.low]
|
|
||||||
const highs = forecast.value?.daily.map((item) => item.high) ?? [day.high]
|
|
||||||
const minimum = Math.min(...lows)
|
|
||||||
const maximum = Math.max(...highs)
|
|
||||||
const span = Math.max(1, maximum - minimum)
|
|
||||||
return {
|
|
||||||
'--range-left': `${((day.low - minimum) / span) * 58}%`,
|
|
||||||
'--range-width': `${Math.max(18, ((day.high - day.low) / span) * 58)}%`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -97,7 +54,10 @@ function temperatureRange(day: DailyWeather): Record<string, string> {
|
|||||||
:disabled="weather.isLoading"
|
:disabled="weather.isLoading"
|
||||||
@click="weather.refresh(true)"
|
@click="weather.refresh(true)"
|
||||||
>
|
>
|
||||||
<RefreshCw :size="17" :class="{ 'weather-spin': weather.isLoading }" />
|
<RefreshCw
|
||||||
|
:size="17"
|
||||||
|
:class="{ 'weather-spin': weather.isLoading }"
|
||||||
|
/>
|
||||||
</k-link>
|
</k-link>
|
||||||
</template>
|
</template>
|
||||||
</k-navbar>
|
</k-navbar>
|
||||||
@@ -108,12 +68,11 @@ function temperatureRange(day: DailyWeather): Record<string, string> {
|
|||||||
<Navigation :size="13" fill="currentColor" />
|
<Navigation :size="13" fill="currentColor" />
|
||||||
{{ phone.t(`Apps.weather.regions.${forecast.region}`) }}
|
{{ phone.t(`Apps.weather.regions.${forecast.region}`) }}
|
||||||
</div>
|
</div>
|
||||||
<component
|
<WeatherConditionIcon
|
||||||
:is="conditionIcon(forecast.condition, forecast.timestamp)"
|
:condition="forecast.condition"
|
||||||
|
:timestamp="forecast.timestamp"
|
||||||
class="weather-hero__icon"
|
class="weather-hero__icon"
|
||||||
:size="82"
|
:size="82"
|
||||||
:stroke-width="1.25"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
/>
|
||||||
<div class="weather-temperature">{{ forecast.temperature }}°</div>
|
<div class="weather-temperature">{{ forecast.temperature }}°</div>
|
||||||
<strong>{{ conditionLabel(forecast.condition) }}</strong>
|
<strong>{{ conditionLabel(forecast.condition) }}</strong>
|
||||||
@@ -124,23 +83,42 @@ function temperatureRange(day: DailyWeather): Record<string, string> {
|
|||||||
{{ phone.t('Apps.weather.stale') }}
|
{{ phone.t('Apps.weather.stale') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<section class="weather-details" :aria-label="phone.t('Apps.weather.details')">
|
<section
|
||||||
<k-card :colors="cardColors" :content-wrap="false" class="weather-detail-card">
|
class="weather-details"
|
||||||
|
:aria-label="phone.t('Apps.weather.details')"
|
||||||
|
>
|
||||||
|
<k-card
|
||||||
|
:colors="cardColors"
|
||||||
|
:content-wrap="false"
|
||||||
|
class="weather-detail-card"
|
||||||
|
>
|
||||||
<ThermometerSun :size="18" />
|
<ThermometerSun :size="18" />
|
||||||
<span>{{ phone.t('Apps.weather.feelsLike') }}</span>
|
<span>{{ phone.t('Apps.weather.feelsLike') }}</span>
|
||||||
<strong>{{ forecast.feelsLike }}°</strong>
|
<strong>{{ forecast.feelsLike }}°</strong>
|
||||||
</k-card>
|
</k-card>
|
||||||
<k-card :colors="cardColors" :content-wrap="false" class="weather-detail-card">
|
<k-card
|
||||||
|
:colors="cardColors"
|
||||||
|
:content-wrap="false"
|
||||||
|
class="weather-detail-card"
|
||||||
|
>
|
||||||
<Wind :size="18" />
|
<Wind :size="18" />
|
||||||
<span>{{ phone.t('Apps.weather.wind') }}</span>
|
<span>{{ phone.t('Apps.weather.wind') }}</span>
|
||||||
<strong>{{ forecast.windSpeed }} km/h</strong>
|
<strong>{{ forecast.windSpeed }} km/h</strong>
|
||||||
</k-card>
|
</k-card>
|
||||||
<k-card :colors="cardColors" :content-wrap="false" class="weather-detail-card">
|
<k-card
|
||||||
|
:colors="cardColors"
|
||||||
|
:content-wrap="false"
|
||||||
|
class="weather-detail-card"
|
||||||
|
>
|
||||||
<Droplets :size="18" />
|
<Droplets :size="18" />
|
||||||
<span>{{ phone.t('Apps.weather.humidity') }}</span>
|
<span>{{ phone.t('Apps.weather.humidity') }}</span>
|
||||||
<strong>{{ forecast.humidity }}%</strong>
|
<strong>{{ forecast.humidity }}%</strong>
|
||||||
</k-card>
|
</k-card>
|
||||||
<k-card :colors="cardColors" :content-wrap="false" class="weather-detail-card">
|
<k-card
|
||||||
|
:colors="cardColors"
|
||||||
|
:content-wrap="false"
|
||||||
|
class="weather-detail-card"
|
||||||
|
>
|
||||||
<Umbrella :size="18" />
|
<Umbrella :size="18" />
|
||||||
<span>{{ phone.t('Apps.weather.rain') }}</span>
|
<span>{{ phone.t('Apps.weather.rain') }}</span>
|
||||||
<strong>{{ forecast.rainChance }}%</strong>
|
<strong>{{ forecast.rainChance }}%</strong>
|
||||||
@@ -160,53 +138,30 @@ function temperatureRange(day: DailyWeather): Record<string, string> {
|
|||||||
class="weather-hour"
|
class="weather-hour"
|
||||||
>
|
>
|
||||||
<span>{{ formatHour(hour.timestamp, index) }}</span>
|
<span>{{ formatHour(hour.timestamp, index) }}</span>
|
||||||
<component
|
<WeatherConditionIcon
|
||||||
:is="conditionIcon(hour.condition, hour.timestamp)"
|
:condition="hour.condition"
|
||||||
:size="24"
|
:timestamp="hour.timestamp"
|
||||||
:stroke-width="1.7"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
/>
|
||||||
<small v-if="hour.rainChance >= 30">{{ hour.rainChance }}%</small>
|
<small v-if="hour.rainChance >= 30">{{ hour.rainChance }}%</small>
|
||||||
<strong>{{ hour.temperature }}°</strong>
|
<strong>{{ hour.temperature }}°</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</k-card>
|
</k-card>
|
||||||
|
|
||||||
<k-card
|
|
||||||
:colors="cardColors"
|
|
||||||
:content-wrap="false"
|
|
||||||
class="weather-panel weather-daily-panel"
|
|
||||||
>
|
|
||||||
<h2><CloudSun :size="15" />{{ phone.t('Apps.weather.daily') }}</h2>
|
|
||||||
<div
|
|
||||||
v-for="(day, index) in forecast.daily"
|
|
||||||
:key="day.timestamp"
|
|
||||||
class="weather-day"
|
|
||||||
>
|
|
||||||
<strong>{{ formatDay(day, index) }}</strong>
|
|
||||||
<span class="weather-day__condition">
|
|
||||||
<component
|
|
||||||
:is="conditionIcon(day.condition, day.timestamp)"
|
|
||||||
:size="24"
|
|
||||||
:stroke-width="1.7"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<small v-if="day.rainChance >= 30">{{ day.rainChance }}%</small>
|
|
||||||
</span>
|
|
||||||
<span class="weather-day__low">{{ day.low }}°</span>
|
|
||||||
<span class="weather-day__range" :style="temperatureRange(day)">
|
|
||||||
<i></i>
|
|
||||||
</span>
|
|
||||||
<span>{{ day.high }}°</span>
|
|
||||||
</div>
|
|
||||||
</k-card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="weather-empty">
|
<div v-else class="weather-empty">
|
||||||
<k-preloader v-if="weather.isLoading" />
|
<k-preloader v-if="weather.isLoading" />
|
||||||
<CloudSun v-else :size="52" :stroke-width="1.4" />
|
<CloudSun v-else :size="52" :stroke-width="1.4" />
|
||||||
<strong>{{ phone.t(weather.isLoading ? 'Common.loading' : 'Apps.weather.unavailable') }}</strong>
|
<strong>{{
|
||||||
<k-link v-if="!weather.isLoading" component="button" @click="weather.refresh(true)">
|
phone.t(
|
||||||
|
weather.isLoading ? 'Common.loading' : 'Apps.weather.unavailable',
|
||||||
|
)
|
||||||
|
}}</strong>
|
||||||
|
<k-link
|
||||||
|
v-if="!weather.isLoading"
|
||||||
|
component="button"
|
||||||
|
@click="weather.refresh(true)"
|
||||||
|
>
|
||||||
{{ phone.t('Apps.weather.tryAgain') }}
|
{{ phone.t('Apps.weather.tryAgain') }}
|
||||||
</k-link>
|
</k-link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -933,6 +933,7 @@ app.post('/api/:endpoint', (request, response) => {
|
|||||||
data: {
|
data: {
|
||||||
clock: { year: 2026, month: 8, day: 5, hour: 17, minute: 20 },
|
clock: { year: 2026, month: 8, day: 5, hour: 17, minute: 20 },
|
||||||
condition: 'partly_cloudy',
|
condition: 'partly_cloudy',
|
||||||
|
nextCondition: 'rain',
|
||||||
rainLevel: 0.08,
|
rainLevel: 0.08,
|
||||||
region: 'los_santos',
|
region: 'los_santos',
|
||||||
windSpeed: 3.2,
|
windSpeed: 3.2,
|
||||||
|
|||||||
@@ -257,10 +257,12 @@ end
|
|||||||
RegisterNUICallback("weather:get", function(_, cb)
|
RegisterNUICallback("weather:get", function(_, cb)
|
||||||
local coords = GetEntityCoords(PlayerPedId())
|
local coords = GetEntityCoords(PlayerPedId())
|
||||||
local weather_hash = GetPrevWeatherTypeHashName()
|
local weather_hash = GetPrevWeatherTypeHashName()
|
||||||
|
local next_weather_hash = GetNextWeatherTypeHashName()
|
||||||
cb({
|
cb({
|
||||||
success = true,
|
success = true,
|
||||||
data = {
|
data = {
|
||||||
condition = weather_types[weather_hash] or "clear",
|
condition = weather_types[weather_hash] or "clear",
|
||||||
|
nextCondition = weather_types[next_weather_hash] or weather_types[weather_hash] or "clear",
|
||||||
region = weather_region(coords),
|
region = weather_region(coords),
|
||||||
clock = {
|
clock = {
|
||||||
year = GetClockYear(),
|
year = GetClockYear(),
|
||||||
|
|||||||