ENH - refine native weather presentation

This commit is contained in:
Type
2026-08-08 18:57:56 +02:00
parent 921a481510
commit 897aab9ebb
17 changed files with 140 additions and 192 deletions
+3
View File
@@ -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/
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

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>
+2 -1
View File
@@ -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()
}) })
+2 -13
View File
@@ -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
+7 -6
View File
@@ -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', () => {
+15 -80
View File
@@ -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,
+47 -92
View File
@@ -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>
+1
View File
@@ -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,
+2
View File
@@ -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(),