Compare commits
23 commits
Author | SHA1 | Date | |
---|---|---|---|
cfe916ec72 | |||
01b9644243 | |||
![]() |
52b83d05bb | ||
![]() |
a09d50af2c | ||
![]() |
c92d9aae0d | ||
![]() |
d4cf4e1c3e | ||
![]() |
fa1e702173 | ||
![]() |
cd615cb020 | ||
![]() |
d2afc8469d | ||
![]() |
a1f84465cc | ||
![]() |
7a14101e01 | ||
![]() |
5e238aa2aa | ||
![]() |
e611803bd7 | ||
![]() |
0710f2238c | ||
![]() |
7ed314b5bf | ||
![]() |
e446084d62 | ||
![]() |
8109c6d3f6 | ||
![]() |
aa12f028a5 | ||
![]() |
757e5ce6c8 | ||
![]() |
f82540c677 | ||
![]() |
fe411cd27c | ||
![]() |
b983a34bc9 | ||
![]() |
5d079e1208 |
16 changed files with 591 additions and 272 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -28,3 +28,4 @@ coverage
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
*~
|
||||||
|
|
41
CHANGELOG.md
41
CHANGELOG.md
|
@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## 0.1.11 - 2025-10-06
|
||||||
|
### Changed
|
||||||
|
- update source code info for about page
|
||||||
|
|
||||||
|
## 0.1.10 - 2024-06-09
|
||||||
|
### Changed
|
||||||
|
- Fix URL bug when refreshing weather radar.
|
||||||
|
|
||||||
|
## 0.1.9 - 2024-06-09
|
||||||
|
### Changed
|
||||||
|
- Warning notification should not butt up against forecast panel.
|
||||||
|
- Add timestamp param to bust cache when refreshing radar images.
|
||||||
|
|
||||||
|
## 0.1.8 - 2024-06-09
|
||||||
|
### Added
|
||||||
|
- Add link to national radar map (live image).
|
||||||
|
- Add refresh buttons to weather data pages.
|
||||||
|
### Changed
|
||||||
|
- Convert all view components to use Composition API.
|
||||||
|
|
||||||
|
## 0.1.7 - 2024-06-08
|
||||||
|
### Added
|
||||||
|
- Add basic support for active weather alerts.
|
||||||
|
|
||||||
|
## 0.1.6 - 2024-06-08
|
||||||
|
### Changed
|
||||||
|
- Add proper About page.
|
||||||
|
|
||||||
|
## 0.1.5 - 2024-06-08
|
||||||
|
### Changed
|
||||||
|
- Tighten up the forecast period panels a bit.
|
||||||
|
|
||||||
|
## 0.1.4 - 2024-06-08
|
||||||
|
### Changed
|
||||||
|
- Fix horizontal scroll for main forecast panel.
|
||||||
|
|
||||||
|
## 0.1.3 - 2024-06-08
|
||||||
|
### Changed
|
||||||
|
- Tell user to refresh the page, if access to location has failed.
|
||||||
|
- Make buttons half-wide on home screen (desktop mode).
|
||||||
|
|
||||||
## 0.1.2 - 2024-06-08
|
## 0.1.2 - 2024-06-08
|
||||||
### Added
|
### Added
|
||||||
- Add "use my current location" button to home page.
|
- Add "use my current location" button to home page.
|
||||||
|
|
4
package-lock.json
generated
4
package-lock.json
generated
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "myweather",
|
"name": "myweather",
|
||||||
"version": "0.1.2",
|
"version": "0.1.11",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "myweather",
|
"name": "myweather",
|
||||||
"version": "0.1.2",
|
"version": "0.1.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.5.2",
|
"@fortawesome/fontawesome-svg-core": "^6.5.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.5.2",
|
"@fortawesome/free-solid-svg-icons": "^6.5.2",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "myweather",
|
"name": "myweather",
|
||||||
"version": "0.1.2",
|
"version": "0.1.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
@ -3,12 +3,12 @@ import { RouterLink, RouterView } from 'vue-router'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<div class="page-wrapper">
|
||||||
<header>
|
<header>
|
||||||
<router-link to="/">
|
<router-link to="/">
|
||||||
<img src="/nwslogo.gif" width="38" height="38" />
|
<img src="/nwslogo.gif" width="38" height="38" />
|
||||||
<h5 class="is-size-5 has-text-weight-bold">Weather</h5>
|
<h5 class="is-size-5 has-text-weight-bold">Weather</h5>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div style="padding: 0.5rem;">
|
<div style="padding: 0.5rem;">
|
||||||
|
@ -18,6 +18,13 @@ import { RouterLink, RouterView } from 'vue-router'
|
||||||
</keep-alive>
|
</keep-alive>
|
||||||
</router-view>
|
</router-view>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="content">
|
||||||
|
<router-link to="/about">About this app</router-link>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
10
src/appsettings.js
Normal file
10
src/appsettings.js
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
|
||||||
|
import packageData from '../package.json'
|
||||||
|
|
||||||
|
const appsettings = {
|
||||||
|
appTitle: packageData.name,
|
||||||
|
appVersion: packageData.version,
|
||||||
|
appDependencies: packageData.dependencies,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default appsettings
|
|
@ -1 +1,15 @@
|
||||||
@import './custom.scss';
|
@import './custom.scss';
|
||||||
|
|
||||||
|
|
||||||
|
/* nb. these are here to force footer to bottom of screen */
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import HomeView from '../views/HomeView.vue'
|
import HomeView from '../views/HomeView.vue'
|
||||||
import WeatherView from '../views/WeatherView.vue'
|
import WeatherView from '../views/WeatherView.vue'
|
||||||
|
import AlertsView from '../views/AlertsView.vue'
|
||||||
import HourlyView from '../views/HourlyView.vue'
|
import HourlyView from '../views/HourlyView.vue'
|
||||||
import EditListView from '../views/EditListView.vue'
|
import EditListView from '../views/EditListView.vue'
|
||||||
|
|
||||||
|
@ -17,6 +18,11 @@ const router = createRouter({
|
||||||
name: 'weather',
|
name: 'weather',
|
||||||
component: WeatherView,
|
component: WeatherView,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/alerts',
|
||||||
|
name: 'alerts',
|
||||||
|
component: AlertsView,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/hourly',
|
path: '/hourly',
|
||||||
name: 'hourly',
|
name: 'hourly',
|
||||||
|
|
|
@ -10,6 +10,7 @@ const getDefaults = () => {
|
||||||
coordinates,
|
coordinates,
|
||||||
cityState,
|
cityState,
|
||||||
weather: null,
|
weather: null,
|
||||||
|
alerts: null,
|
||||||
forecast: null,
|
forecast: null,
|
||||||
radarLatestURL: null,
|
radarLatestURL: null,
|
||||||
radarLoopURL: null,
|
radarLoopURL: null,
|
||||||
|
@ -25,21 +26,26 @@ export const useWeatherStore = defineStore('weather', {
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
|
|
||||||
clearWeather() {
|
clearWeather(keepCoordinates) {
|
||||||
|
if (!keepCoordinates) {
|
||||||
this.setCoordinates(null)
|
this.setCoordinates(null)
|
||||||
this.setCityState(null)
|
this.setCityState(null)
|
||||||
|
}
|
||||||
this.setWeather(null)
|
this.setWeather(null)
|
||||||
this.setForecast(null)
|
this.setForecast(null)
|
||||||
this.radarLatestURL = null
|
this.radarLatestURL = null
|
||||||
this.radarLoopURL = null
|
this.radarLoopURL = null
|
||||||
|
this.alerts = null
|
||||||
},
|
},
|
||||||
|
|
||||||
async getWeather() {
|
async getWeather() {
|
||||||
|
|
||||||
if (!this.weather) {
|
if (!this.weather) {
|
||||||
|
let url
|
||||||
|
let response
|
||||||
|
|
||||||
const url = `https://api.weather.gov/points/${this.coordinates}`
|
url = `https://api.weather.gov/points/${this.coordinates}`
|
||||||
const response = await fetch(url)
|
response = await fetch(url)
|
||||||
const weather = await response.json()
|
const weather = await response.json()
|
||||||
if (weather.status == 404) {
|
if (weather.status == 404) {
|
||||||
throw new Error(`Data not found for ${this.coordinates}`)
|
throw new Error(`Data not found for ${this.coordinates}`)
|
||||||
|
@ -57,6 +63,65 @@ export const useWeatherStore = defineStore('weather', {
|
||||||
this.radarLoopURL = `https://radar.weather.gov/ridge/standard/${station}_loop.gif`
|
this.radarLoopURL = `https://radar.weather.gov/ridge/standard/${station}_loop.gif`
|
||||||
|
|
||||||
this.setWeather(weather)
|
this.setWeather(weather)
|
||||||
|
|
||||||
|
// fetch zone to get its official id
|
||||||
|
url = weather.properties.forecastZone
|
||||||
|
response = await fetch(url)
|
||||||
|
const zone = await response.json()
|
||||||
|
|
||||||
|
// fetch alerts for zone
|
||||||
|
url = `https://api.weather.gov/alerts/active/zone/${zone.properties.id}`
|
||||||
|
response = await fetch(url)
|
||||||
|
const zoneAlerts = await response.json()
|
||||||
|
|
||||||
|
// fetch county to get its official id
|
||||||
|
url = weather.properties.county
|
||||||
|
response = await fetch(url)
|
||||||
|
const county = await response.json()
|
||||||
|
|
||||||
|
// fetch alerts for county
|
||||||
|
url = `https://api.weather.gov/alerts/active/zone/${county.properties.id}`
|
||||||
|
response = await fetch(url)
|
||||||
|
const countyAlerts = await response.json()
|
||||||
|
|
||||||
|
const newAlerts = {}
|
||||||
|
|
||||||
|
// use latest timestamp from either zone or county
|
||||||
|
newAlerts.updated = zoneAlerts.updated
|
||||||
|
if (countyAlerts.updated > zoneAlerts.updated) {
|
||||||
|
newAlerts.updated = countyAlerts.updated
|
||||||
|
}
|
||||||
|
|
||||||
|
// collect all alert "features" but de-duplicate them
|
||||||
|
newAlerts.features = {}
|
||||||
|
for (let feature of zoneAlerts.features) {
|
||||||
|
newAlerts.features[feature.properties.id] = feature
|
||||||
|
}
|
||||||
|
for (let feature of countyAlerts.features) {
|
||||||
|
newAlerts.features[feature.properties.id] = feature
|
||||||
|
}
|
||||||
|
newAlerts.features = Object.values(newAlerts.features)
|
||||||
|
|
||||||
|
// put "likely" before "possible" alerts
|
||||||
|
newAlerts.features.sort((a, b) => {
|
||||||
|
|
||||||
|
if (a.properties.certainty == 'Likely' && b.properties.certainty != 'Likely') {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if (a.properties.certainty != 'Likely' && b.properties.certainty == 'Likely') {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// if (a.properties.certainty == b.properties.certainty) {
|
||||||
|
// return 0
|
||||||
|
// }
|
||||||
|
|
||||||
|
// TODO: what else should this do?
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// we have our final alerts
|
||||||
|
this.alerts = newAlerts
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.weather
|
return this.weather
|
||||||
|
@ -66,7 +131,7 @@ export const useWeatherStore = defineStore('weather', {
|
||||||
|
|
||||||
if (!this.forecast) {
|
if (!this.forecast) {
|
||||||
|
|
||||||
const weather = await this.getWeather(this.coordinates)
|
const weather = await this.getWeather()
|
||||||
|
|
||||||
const url = weather.properties.forecast
|
const url = weather.properties.forecast
|
||||||
const response = await fetch(url)
|
const response = await fetch(url)
|
||||||
|
|
|
@ -1,15 +1,57 @@
|
||||||
|
<script setup>
|
||||||
|
import appsettings from '../appsettings'
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="about">
|
<div class="about">
|
||||||
<h1>This is an about page</h1>
|
<h4 class="is-size-4">{{ appsettings.appTitle }} {{ appsettings.appVersion }}</h4>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<p class="block">
|
||||||
|
This app was built in rather a hurry, to replace my personal
|
||||||
|
usage of the original
|
||||||
|
<a href="https://mobile.weather.gov" target="_blank">
|
||||||
|
<span class="icon-text">
|
||||||
|
<o-icon icon="external-link" />
|
||||||
|
<span>mobile.weather.gov</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
site which was being decommissioned.
|
||||||
|
(See also
|
||||||
|
<a href="https://www.weather.gov/media/notification/pdf_2023_24/scn24-51_mobile_decommissioning.pdf" target="_blank">
|
||||||
|
<span class="icon-text">
|
||||||
|
<o-icon icon="external-link" />
|
||||||
|
<span>this statement</span>
|
||||||
|
</span>
|
||||||
|
</a>.)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="block">
|
||||||
|
Source code is available at
|
||||||
|
<a href="https://forgejo.wuttaproject.org/lance/myweather" target="_blank">
|
||||||
|
<span class="icon-text">
|
||||||
|
<o-icon icon="external-link" />
|
||||||
|
<span>https://forgejo.wuttaproject.org/lance/myweather</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="block">
|
||||||
|
This app is primarily for personal use, but if you also find it useful and
|
||||||
|
have suggestions, email me at <a href="mailto:lance@edbob.org">lance@edbob.org</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<o-field label="Dependencies">
|
||||||
|
<ul>
|
||||||
|
<li v-for="(ver, pkg, i) in appsettings.appDependencies"
|
||||||
|
:key="pkg">
|
||||||
|
{{ pkg }} {{ ver }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</o-field>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.about {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
65
src/views/AlertsView.vue
Normal file
65
src/views/AlertsView.vue
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, onActivated } from 'vue'
|
||||||
|
import { useWeatherStore } from '../stores/weather'
|
||||||
|
|
||||||
|
const weatherStore = useWeatherStore()
|
||||||
|
|
||||||
|
const refreshing = ref(false)
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
refreshing.value = true
|
||||||
|
weatherStore.getWeather().then(() => {
|
||||||
|
refreshing.value = false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function refreshAlerts() {
|
||||||
|
refreshing.value = true
|
||||||
|
weatherStore.clearWeather(true)
|
||||||
|
await weatherStore.getWeather()
|
||||||
|
refreshing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<div class="block"
|
||||||
|
style="display: flex; align-items: center; justify-content: space-between;">
|
||||||
|
<o-button variant="primary"
|
||||||
|
icon-left="arrow-left"
|
||||||
|
@click="$router.push('/weather')">
|
||||||
|
Back
|
||||||
|
</o-button>
|
||||||
|
<o-button variant="primary"
|
||||||
|
icon-left="refresh"
|
||||||
|
@click="refreshAlerts()"
|
||||||
|
:disabled="refreshing">
|
||||||
|
{{ refreshing ? "Refreshing" : "Refresh" }}
|
||||||
|
</o-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h5 class="is-size-5">{{ weatherStore.cityState }}</h5>
|
||||||
|
<h5 class="is-size-5">Alerts</h5>
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<o-collapse v-for="feature in weatherStore.alerts?.features || []"
|
||||||
|
variant="warning"
|
||||||
|
:open="false">
|
||||||
|
<template #trigger>
|
||||||
|
<o-notification :variant="feature.properties.certainty == 'Likely' ? 'danger' : 'warning'"
|
||||||
|
icon="plus"
|
||||||
|
icon-size="small"
|
||||||
|
style="cursor: pointer;">
|
||||||
|
{{ feature.properties.event }} ({{ feature.properties.severity }}, {{ feature.properties.certainty }})
|
||||||
|
</o-notification>
|
||||||
|
</template>
|
||||||
|
<div class="notification">
|
||||||
|
<h5 class="is-size-5 block">{{ feature.properties.headline }}</h5>
|
||||||
|
<p class="block" style="white-space: pre-wrap;">{{ feature.properties.description }}</p>
|
||||||
|
</div>
|
||||||
|
</o-collapse>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</template>
|
|
@ -1,22 +1,12 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { mapStores } from 'pinia'
|
|
||||||
import { useLocationStore } from '../stores/location'
|
import { useLocationStore } from '../stores/location'
|
||||||
</script>
|
|
||||||
|
|
||||||
<script>
|
const locationStore = useLocationStore()
|
||||||
export default {
|
|
||||||
|
|
||||||
computed: {
|
function deleteLocation(location) {
|
||||||
...mapStores(useLocationStore),
|
locationStore.removeLocation(location)
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
|
|
||||||
deleteLocation(location) {
|
|
||||||
this.locationStore.removeLocation(location)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -1,70 +1,75 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { mapStores } from 'pinia'
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { useWeatherStore } from '../stores/weather'
|
import { useWeatherStore } from '../stores/weather'
|
||||||
import { useLocationStore } from '../stores/location'
|
import { useLocationStore } from '../stores/location'
|
||||||
</script>
|
import { useOruga } from '@oruga-ui/oruga-next'
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
|
|
||||||
data() {
|
const router = useRouter()
|
||||||
return {
|
const locationStore = useLocationStore()
|
||||||
coordinates: null,
|
const weatherStore = useWeatherStore()
|
||||||
loading: false,
|
const oruga = useOruga()
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
const coordinates = ref(null)
|
||||||
...mapStores(useWeatherStore, useLocationStore),
|
const coordinatesInput = ref()
|
||||||
},
|
const loading = ref(false)
|
||||||
|
const locationAccessBlocked = ref(false)
|
||||||
|
|
||||||
methods: {
|
|
||||||
|
|
||||||
useCurrentLocation() {
|
function useCurrentLocation() {
|
||||||
|
|
||||||
if (location.protocol == 'http:') {
|
if (locationAccessBlocked.value) {
|
||||||
alert("Sorry, this only works for secure sites.")
|
alert("You must refresh the page first, then try again.")
|
||||||
}
|
|
||||||
|
|
||||||
navigator.geolocation.getCurrentPosition(loc => {
|
|
||||||
this.coordinates = `${loc.coords.latitude},${loc.coords.longitude}`
|
|
||||||
this.loadCoordinates()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
editLocations() {
|
|
||||||
this.$router.push('/edit-list')
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadCoordinates() {
|
|
||||||
if (!this.coordinates) {
|
|
||||||
this.$refs.coordinates.focus()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const parts = this.coordinates.split(/(?: +| *\, *)/)
|
navigator.geolocation.getCurrentPosition(loc => {
|
||||||
|
coordinates.value = `${loc.coords.latitude},${loc.coords.longitude}`
|
||||||
|
loadCoordinates()
|
||||||
|
}, error => {
|
||||||
|
if (error.code == 1) { // PERMISSION_DENIED
|
||||||
|
locationAccessBlocked.value = true
|
||||||
|
}
|
||||||
|
alert(`error.code = ${error.code}\n\n${error.message}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function editLocations() {
|
||||||
|
router.push('/edit-list')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function loadCoordinates() {
|
||||||
|
if (!coordinates.value) {
|
||||||
|
coordinatesInput.value.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = coordinates.value.split(/(?: +| *\, *)/)
|
||||||
const pattern = /^ *-?\d+(?:\.\d+)? *$/
|
const pattern = /^ *-?\d+(?:\.\d+)? *$/
|
||||||
if (parts.length != 2
|
if (parts.length != 2
|
||||||
|| !parts[0].match(pattern)
|
|| !parts[0].match(pattern)
|
||||||
|| !parts[1].match(pattern)) {
|
|| !parts[1].match(pattern)) {
|
||||||
|
|
||||||
this.$oruga.notification.open({
|
oruga.notification.open({
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
message: "Coordinates are not valid.",
|
message: "Coordinates are not valid.",
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
})
|
})
|
||||||
this.$refs.coordinates.focus()
|
coordinatesInput.value.focus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.coordinates = `${parts[0]},${parts[1]}`
|
coordinates.value = `${parts[0]},${parts[1]}`
|
||||||
|
|
||||||
this.loading = true
|
loading.value = true
|
||||||
const url = `https://api.weather.gov/points/${this.coordinates}`
|
const url = `https://api.weather.gov/points/${coordinates.value}`
|
||||||
const response = await fetch(url)
|
const response = await fetch(url)
|
||||||
const weather = await response.json()
|
const weather = await response.json()
|
||||||
this.loading = false
|
loading.value = false
|
||||||
if (weather.status == 404) {
|
if (weather.status == 404) {
|
||||||
this.$oruga.notification.open({
|
oruga.notification.open({
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
message: weather.title || "Data not found!",
|
message: weather.title || "Data not found!",
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
|
@ -79,24 +84,24 @@ export default {
|
||||||
const state = weather.properties.relativeLocation.properties.state
|
const state = weather.properties.relativeLocation.properties.state
|
||||||
const cityState = `${city}, ${state}`
|
const cityState = `${city}, ${state}`
|
||||||
|
|
||||||
this.locationStore.addLocation(coords, cityState)
|
locationStore.addLocation(coords, cityState)
|
||||||
this.weatherStore.setCoordinates(coords)
|
weatherStore.setCoordinates(coords)
|
||||||
this.weatherStore.setCityState(cityState)
|
weatherStore.setCityState(cityState)
|
||||||
this.weatherStore.setWeather(weather)
|
weatherStore.setWeather(weather)
|
||||||
|
|
||||||
// clear this so user sees empty input when they return
|
// clear this so user sees empty input when they return
|
||||||
this.coordinates = null
|
coordinates.value = null
|
||||||
this.$router.push('/weather')
|
router.push('/weather')
|
||||||
},
|
|
||||||
|
|
||||||
showWeather(location) {
|
|
||||||
this.weatherStore.clearWeather()
|
|
||||||
this.weatherStore.setCoordinates(location.coordinates)
|
|
||||||
this.weatherStore.setCityState(location.cityState)
|
|
||||||
this.$router.push('/weather')
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function showWeather(location) {
|
||||||
|
weatherStore.clearWeather()
|
||||||
|
weatherStore.setCoordinates(location.coordinates)
|
||||||
|
weatherStore.setCityState(location.cityState)
|
||||||
|
router.push('/weather')
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -105,7 +110,7 @@ export default {
|
||||||
|
|
||||||
<o-field grouped>
|
<o-field grouped>
|
||||||
<o-input v-model="coordinates"
|
<o-input v-model="coordinates"
|
||||||
ref="coordinates"
|
ref="coordinatesInput"
|
||||||
placeholder="coordinates"
|
placeholder="coordinates"
|
||||||
clearable />
|
clearable />
|
||||||
<o-button variant="primary"
|
<o-button variant="primary"
|
||||||
|
@ -116,6 +121,9 @@ export default {
|
||||||
</o-button>
|
</o-button>
|
||||||
</o-field>
|
</o-field>
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-half">
|
||||||
|
|
||||||
<div v-for="location in locationStore.locations"
|
<div v-for="location in locationStore.locations"
|
||||||
:key="location.coordinates"
|
:key="location.coordinates"
|
||||||
class="location">
|
class="location">
|
||||||
|
@ -146,6 +154,9 @@ export default {
|
||||||
</o-button>
|
</o-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -1,45 +1,37 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { mapStores } from 'pinia'
|
import { ref, onActivated } from 'vue'
|
||||||
import { useWeatherStore } from '../stores/weather'
|
import { useWeatherStore } from '../stores/weather'
|
||||||
</script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
|
|
||||||
data() {
|
const weatherStore = useWeatherStore()
|
||||||
return {
|
|
||||||
coordinates: null,
|
const coordinates = ref(null)
|
||||||
hourly: null,
|
const chunks = ref([])
|
||||||
chunks: [],
|
const refreshing = ref(false)
|
||||||
|
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
if (coordinates.value != weatherStore.coordinates) {
|
||||||
|
refreshing.value = true
|
||||||
|
chunks.value = []
|
||||||
|
fetchHourly().then(() => {
|
||||||
|
refreshing.value = false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
|
|
||||||
computed: {
|
async function fetchHourly() {
|
||||||
...mapStores(useWeatherStore),
|
|
||||||
},
|
|
||||||
|
|
||||||
activated() {
|
const weather = await weatherStore.getWeather()
|
||||||
if (this.coordinates != this.weatherStore.coordinates) {
|
coordinates.value = weatherStore.coordinates
|
||||||
this.hourly = null
|
|
||||||
this.chunks = []
|
|
||||||
this.fetchHourly()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
|
|
||||||
async fetchHourly() {
|
|
||||||
|
|
||||||
const weather = await this.weatherStore.getWeather()
|
|
||||||
this.coordinates = this.weatherStore.coordinates
|
|
||||||
|
|
||||||
const url = weather.properties.forecastHourly
|
const url = weather.properties.forecastHourly
|
||||||
const response = await fetch(url)
|
const response = await fetch(url)
|
||||||
this.hourly = await response.json()
|
const hourly = await response.json()
|
||||||
|
|
||||||
this.chunks = []
|
chunks.value = []
|
||||||
let chunk = null
|
let chunk = null
|
||||||
for (let period of this.hourly.properties.periods.slice(0, 12)) {
|
for (let period of hourly.properties.periods.slice(0, 12)) {
|
||||||
const date = new Date(period.startTime).toLocaleDateString('en-US', {
|
const date = new Date(period.startTime).toLocaleDateString('en-US', {
|
||||||
dateStyle: 'full',
|
dateStyle: 'full',
|
||||||
})
|
})
|
||||||
|
@ -48,29 +40,46 @@ export default {
|
||||||
date,
|
date,
|
||||||
periods: [],
|
periods: [],
|
||||||
}
|
}
|
||||||
this.chunks.push(chunk)
|
chunks.value.push(chunk)
|
||||||
}
|
}
|
||||||
chunk.periods.push(period)
|
chunk.periods.push(period)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
renderTime(period) {
|
|
||||||
|
async function refreshHourly() {
|
||||||
|
refreshing.value = true
|
||||||
|
weatherStore.clearWeather(true)
|
||||||
|
chunks.value = []
|
||||||
|
await fetchHourly()
|
||||||
|
refreshing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderTime(period) {
|
||||||
const date = new Date(period.startTime)
|
const date = new Date(period.startTime)
|
||||||
return date.toLocaleTimeString('en-US', {timeStyle: 'short'})
|
return date.toLocaleTimeString('en-US', {timeStyle: 'short'})
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<main>
|
||||||
|
|
||||||
|
<div class="block"
|
||||||
|
style="display: flex; align-items: center; justify-content: space-between;">
|
||||||
<o-button variant="primary"
|
<o-button variant="primary"
|
||||||
size="small"
|
|
||||||
icon-left="arrow-left"
|
icon-left="arrow-left"
|
||||||
@click="$router.push('/weather')">
|
@click="$router.push('/weather')">
|
||||||
Back
|
Back
|
||||||
</o-button>
|
</o-button>
|
||||||
|
<o-button variant="primary"
|
||||||
|
icon-left="refresh"
|
||||||
|
@click="refreshHourly()"
|
||||||
|
:disabled="refreshing">
|
||||||
|
{{ refreshing ? "Refreshing" : "Refresh" }}
|
||||||
|
</o-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h5 class="is-size-5">{{ weatherStore.cityState }}</h5>
|
<h5 class="is-size-5">{{ weatherStore.cityState }}</h5>
|
||||||
<br />
|
<br />
|
||||||
|
|
|
@ -1,67 +1,99 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { mapStores } from 'pinia'
|
import { ref, computed, onActivated } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { useWeatherStore } from '../stores/weather'
|
import { useWeatherStore } from '../stores/weather'
|
||||||
import { useLocationStore } from '../stores/location'
|
import { useLocationStore } from '../stores/location'
|
||||||
</script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
|
|
||||||
data() {
|
const router = useRouter()
|
||||||
return {
|
const locationStore = useLocationStore()
|
||||||
coordinates: null,
|
const weatherStore = useWeatherStore()
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
const coordinates = ref(null)
|
||||||
...mapStores(useWeatherStore, useLocationStore),
|
const refreshing = ref(false)
|
||||||
|
const timestamp = ref(new Date().getTime())
|
||||||
|
|
||||||
panelHeadingTitle() {
|
|
||||||
if (!this.weatherStore.forecast) {
|
const panelHeadingTitle = computed(() => {
|
||||||
|
if (!weatherStore.forecast) {
|
||||||
return "Forecast"
|
return "Forecast"
|
||||||
}
|
}
|
||||||
|
|
||||||
let generated = new Date(this.weatherStore.forecast.properties.generatedAt)
|
let generated = new Date(weatherStore.forecast.properties.generatedAt)
|
||||||
generated = generated.toLocaleTimeString('en-US', {timeStyle: 'short'})
|
generated = generated.toLocaleTimeString('en-US', {timeStyle: 'short'})
|
||||||
return `Forecast @ ${generated}`
|
return `Forecast @ ${generated}`
|
||||||
},
|
})
|
||||||
},
|
|
||||||
|
|
||||||
activated() {
|
|
||||||
this.fetchWeather()
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
const radarLatestURL = computed(() => {
|
||||||
|
if (weatherStore.weather) {
|
||||||
|
return `${weatherStore.radarLatestURL}?t=${timestamp.value}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
coordinatesUpdated(coordinates) {
|
|
||||||
this.weatherStore.clearWeather()
|
|
||||||
this.weatherStore.setCoordinates(coordinates)
|
|
||||||
this.fetchWeather()
|
|
||||||
},
|
|
||||||
|
|
||||||
async fetchWeather() {
|
const radarLoopURL = computed(() => {
|
||||||
|
if (weatherStore.weather) {
|
||||||
|
return `${weatherStore.radarLoopURL}?t=${timestamp.value}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
if (!this.weatherStore.coordinates) {
|
|
||||||
this.$router.push('/')
|
onActivated(() => {
|
||||||
|
|
||||||
|
if (!weatherStore.coordinates) {
|
||||||
|
router.push('/')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const forecast = await this.weatherStore.getForecast()
|
refreshing.value = true
|
||||||
this.coordinates = this.weatherStore.coordinates
|
fetchWeather().then(() => {
|
||||||
},
|
refreshing.value = false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
showHourly(period) {
|
|
||||||
this.$router.push('/hourly')
|
async function coordinatesUpdated(coords) {
|
||||||
},
|
refreshing.value = true
|
||||||
},
|
weatherStore.clearWeather()
|
||||||
|
weatherStore.setCoordinates(coords)
|
||||||
|
await fetchWeather()
|
||||||
|
refreshing.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function fetchWeather() {
|
||||||
|
await weatherStore.getForecast()
|
||||||
|
coordinates.value = weatherStore.coordinates
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function refreshWeather() {
|
||||||
|
refreshing.value = true
|
||||||
|
weatherStore.clearWeather(true)
|
||||||
|
timestamp.value = new Date().getTime()
|
||||||
|
await fetchWeather()
|
||||||
|
refreshing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function getShortForecast(period) {
|
||||||
|
return period.shortForecast.replace(/Showers And Thunderstorms/g, 'T-storms')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function showHourly(period) {
|
||||||
|
router.push('/hourly')
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<main>
|
||||||
<div style="display: flex; flex-direction: column;">
|
<div style="display: flex; flex-direction: column;">
|
||||||
|
|
||||||
<o-field>
|
<div class="block"
|
||||||
|
style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
<o-select v-if="weatherStore.weather"
|
<o-select v-if="weatherStore.weather"
|
||||||
v-model="coordinates"
|
v-model="coordinates"
|
||||||
@update:model-value="coordinatesUpdated">
|
@update:model-value="coordinatesUpdated">
|
||||||
|
@ -71,7 +103,24 @@ export default {
|
||||||
{{ location.cityState }}
|
{{ location.cityState }}
|
||||||
</option>
|
</option>
|
||||||
</o-select>
|
</o-select>
|
||||||
</o-field>
|
<o-button variant="primary"
|
||||||
|
icon-left="refresh"
|
||||||
|
@click="refreshWeather()"
|
||||||
|
:disabled="refreshing">
|
||||||
|
{{ refreshing ? "Refreshing" : "Refresh" }}
|
||||||
|
</o-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="weatherStore.alerts?.features?.length"
|
||||||
|
class="block">
|
||||||
|
<o-notification variant="warning"
|
||||||
|
icon="warning"
|
||||||
|
@click="$router.push('/alerts')"
|
||||||
|
style="cursor: pointer;">
|
||||||
|
<p class="has-text-weight-bold">Watches In Effect</p>
|
||||||
|
<p>Updated {{ new Date(weatherStore.alerts.updated).toLocaleTimeString('en-US', {timeStyle: 'short'}) }}</p>
|
||||||
|
</o-notification>
|
||||||
|
</div>
|
||||||
|
|
||||||
<nav class="panel weather-panel">
|
<nav class="panel weather-panel">
|
||||||
|
|
||||||
|
@ -82,12 +131,13 @@ export default {
|
||||||
<div class="panel-block">
|
<div class="panel-block">
|
||||||
|
|
||||||
<div v-if="weatherStore.forecast"
|
<div v-if="weatherStore.forecast"
|
||||||
style="display: flex;">
|
style="display: flex; overflow-x: scroll;">
|
||||||
|
|
||||||
<div v-for="period in weatherStore.forecast.properties.periods"
|
<div v-for="period in weatherStore.forecast.properties.periods"
|
||||||
:key="period.number"
|
:key="period.number"
|
||||||
class="weather-period is-size-7 has-text-weight-bold"
|
class="weather-period is-size-7 has-text-weight-bold"
|
||||||
:class="{daytime: period.isDaytime}"
|
:class="{daytime: period.isDaytime}"
|
||||||
|
style="cursor: pointer;"
|
||||||
@click="showHourly(period)">
|
@click="showHourly(period)">
|
||||||
|
|
||||||
<p>{{ period.name }}</p>
|
<p>{{ period.name }}</p>
|
||||||
|
@ -96,7 +146,7 @@ export default {
|
||||||
<img :src="period.icon" />
|
<img :src="period.icon" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p>{{ period.shortForecast }}</p>
|
<p>{{ getShortForecast(period) }}</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<span v-if="period.isDaytime">Hi</span>
|
<span v-if="period.isDaytime">Hi</span>
|
||||||
|
@ -116,16 +166,23 @@ export default {
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<nav class="panel weather-panel">
|
<nav class="panel weather-panel">
|
||||||
<p class="panel-heading">
|
<div class="panel-heading"
|
||||||
Radar
|
style="display: flex; align-items: center; justify-content: space-between;">
|
||||||
</p>
|
<p>Radar</p>
|
||||||
|
<o-button tag="a"
|
||||||
|
href="https://radar.weather.gov/ridge/standard/CONUS-LARGE_loop.gif"
|
||||||
|
target="_blank"
|
||||||
|
icon-left="external-link">
|
||||||
|
National Radar
|
||||||
|
</o-button>
|
||||||
|
</div>
|
||||||
<div class="panel-block">
|
<div class="panel-block">
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<img :src="weatherStore.radarLatestURL" />
|
<img :src="radarLatestURL" />
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<img :src="weatherStore.radarLoopURL" />
|
<img :src="radarLoopURL" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -139,6 +196,7 @@ export default {
|
||||||
|
|
||||||
.weather-period {
|
.weather-period {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
2
tasks.py
2
tasks.py
|
@ -24,7 +24,7 @@ def release(c):
|
||||||
version = js['version']
|
version = js['version']
|
||||||
|
|
||||||
# build the app, create zip archive
|
# build the app, create zip archive
|
||||||
c.run('npm run build')
|
c.run("bash -lc 'nvm use lts/iron; npm run build'")
|
||||||
os.chdir('dist')
|
os.chdir('dist')
|
||||||
filename = f'myweather-{version}.zip'
|
filename = f'myweather-{version}.zip'
|
||||||
c.run(f'zip --recurse-paths {filename} *')
|
c.run(f'zip --recurse-paths {filename} *')
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue