Skip to content

Instantly share code, notes, and snippets.

@joshualyon
Last active June 28, 2024 01:56
Show Gist options
  • Save joshualyon/3f83f3605c8d1bd431a4876063b37f38 to your computer and use it in GitHub Desktop.
Save joshualyon/3f83f3605c8d1bd431a4876063b37f38 to your computer and use it in GitHub Desktop.
Open Weather Map POC Tile
<script>
/*
VERSION: 2023-09-23
The API Key, Latitude, and Longitude are now set in the Tile Settings
when you edit an individual tile.
You can also adjust the setting here instead and it will apply as your
base setting across ALL instances of this tile unless explicitly
overridden in an individual tile's settings
*/
var API_KEY = ''; //this is set in tile settings now
var LAT=33,LON=-96; //this is set in tile settings now
var REFRESH_INTERVAL = 3 * 60 * 60 * 1000; //3 hours in milliseconds, set in tile settings now
</script>
<!-- Do not edit below -->
<script type="application/json" id="tile-settings">
{
"schema": "0.1.0",
"settings": [
{"type": "STRING", "label": "Open Weather API Key", "name": "apiKey"},
{
"name": "location",
"default": "33,-96",
"placeholder": "33,-96",
"label": "Location (lat, lon)",
"type": "STRING"
},
{
"default": "imperial",
"label": "Units",
"values": ["imperial", "metric"],
"type": "ENUM",
"name": "units"
},
{"label": "Language (see docs)", "type": "STRING", "name": "lang"},
{
"values": ["2-5multi", "2-5onecall", "3-0onecall"],
"name": "apiPreference",
"default": "2-5multi",
"label": "API Preference",
"type": "ENUM"
},
{
"values": [
{"label": "Default", "value": "default"},
{"label": "Today", "value": "today-only"},
{"label": "Today (Wide)", "value": "today-wide"},
{"label": "Today (Mini)", "value": "today-mini"},
{"label": "Forecast", "value": "forecast-only"},
{"label": "Forecast (Horizontal)", "value": "forecast-horizontal"}
],
"type": "ENUM",
"label": "Layout",
"name": "layout",
"default": "default"
},
{
"type": "BOOLEAN",
"default": true,
"name": "showLocationName",
"label": "Show Location Name",
"showIf": ["layout", "==", "today-wide"]
},
{
"type": "BOOLEAN",
"default": true,
"name": "useDefaultBackground",
"label": "Use Included Background"
},
{
"name": "showAqi",
"type": "BOOLEAN",
"label": "Show AQI (Air Quality)",
"default": false
},
{
"default": false,
"name": "isCustomRefreshInterval",
"type": "BOOLEAN",
"label": "Custom Refresh Interval"
},
{
"type": "NUMBER",
"showIf": ["isCustomRefreshInterval", "==", true],
"label": "Refresh Interval (minutes)",
"default": 180,
"name": "refreshInterval"
}
],
"name": "Open Weather Imported",
"dimensions": {"width": 3, "height": 2}
}
</script>
<!-- Do not edit above -->
<script src="https://cdnjs.cloudflare.com/polyfill/v3/polyfill.min.js?features=Promise,Promise.allSettled,Object.assign,Intl"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@2"></script>
<script src="https://cdn.jsdelivr.net/npm/axios@0.27.2"></script>
<script src="https://cdn.sharptools.io/js/custom-tiles.js"></script>
<div id="app" :data-layout="appLayout" :data-temperature-digits="temperatureDigits" :class="appClasses">
<!-- TODAY data -->
<div class="today">
<div class="weather-icon">
<img :src="weatherIconImageUrl">
<span class="hide">{{weatherIcon}}</span>
</div>
<div class="weather-summary">{{weatherSummary}}</div>
<div class="temperature">{{temperature}}</div>
<div class="overview">
<span class="feels-like" v-show="feelsLike">{{getPhrase('feels_like')}} {{feelsLike}}</span>
<span class="air-quality" v-show="aqiIndex" v-text="aqiIndex" :class="getAqiClass(aqiIndex)"></span>
</div>
<div class="high-low" v-show="highTemp || lowTemp">
<div class="high">{{highTemp}}</div>
<div class="low">{{lowTemp}}</div>
</div>
<div class="sunset-and-sunrise" v-show="sunsetTime || sunriseTime">
<div class="sunrise-time" v-text="sunriseTime"></div>
<div class="inline-icon sun-icon">
<img :src="getMeteoconUrl('horizon', 'fill')">
</div>
<div class="sunset-time" v-text="sunsetTime"></div>
</div>
<div class="extras">
<!-- wind speed -->
<div class="wind-speed" v-show="windSpeed">
<span class="value" v-text="windSpeed"></span>
<span class="units" v-text="windUoM"></span>
<div class="inline-icon invert"><img :src="getErikFlowerIconUrl('strong-wind')"></div>
</div>
<!-- humidity -->
<div class="humidity" v-show="humidity">
<span class="value" v-text="humidity"></span><!--
--><span class="units">%</span>
<div class="inline-icon invert"><img :src="getErikFlowerIconUrl('raindrop')"></div>
</div>
<!-- precipitation -->
<div class="precipitation" v-show="precipitation">
<span class="value" v-text="precipitation"></span><!--
--><span class="units">%</span>
<div class="inline-icon invert"><img :src="getErikFlowerIconUrl('umbrella')"></div>
</div>
</div>
</div>
<!-- FORECAST DATA -->
<table class="forecast" ref="forecastTable">
<tbody><tr class="day" v-for="day in forecast">
<td class="day-of-week">{{getDoW(day)}}</td>
<td class="weather-icon" align="center"><img :src="getIconUrl(day)"></td>
<td class="high" align="right">{{getHigh(day)}}</td>
<td class="low" align="right">{{getLow(day)}}</td>
</tr>
</tbody></table>
<div class="location-name" v-show="showLocationName && locationName" v-text="locationName"></div>
<div class="error" v-if="error">
<span v-text="error"></span>
</div>
</div>
<style>
:root {
--unit: 1vh; /* fallback value for old browsers that don't support min */
--1u: var(--unit);
--2u: calc(2 * var(--unit));
--3u: calc(3 * var(--unit));
--font-size: 5vh;
--line-height: calc(1.5 * var(--font-size));
}
@supports (width: min(1vh, 1vw)) {
:root {
--unit: min(1vh, 1vw); /* newer browsers should support this */
}
}
html, body { height: 100%; margin: 0; font-size: var(--font-size); }
#app { height: 100%; display: flex; justify-content: space-evenly}
#app.default-background { background: linear-gradient(52deg, rgba(12,5,147,1) 0%, rgba(16,16,172,1) 30%, rgba(113,0,255,1) 100%); }
/* Base Template */
.location-name { position: absolute; top: var(--3u); left: var(--3u);}
.today { text-align: center; }
.today .temperature { font-size: 20vh; } /* minor padding left to visual center (account for deg symbol) */
.today .feels-like { opacity: 0.8; }
.today .weather-summary { text-transform: capitalize; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; }
.today .weather-icon img { max-width: 30vw; max-height: 30vh; }
.today .extras, .today .extras > div, .today .high-low, .today .sunset-and-sunrise { display: flex; justify-content: space-around; }
.space-evenly-supported .today .extras, .space-evenly-supported .today .high-low, .space-evenly-supported .today .sunset-and-sunrise {
justify-content: space-evenly;
}
.today .high-low > *:not(.air-quality) { opacity: 0.8; }
.inline-icon { width: 2em; position: relative; } /* position relative, so the img can be absolute relative to its parent */
.inline-icon img { height: 2em; width: 2em; position: absolute; top: 0; left: 0; } /* pull it out of the document flow for sizing */
.inline-icon.invert { filter: invert(1); }
.today .extras .inline-icon img { margin-top: -0.2em; } /* adjust the icon height so it feels more inline */
.today .sunset-and-sunrise .inline-icon img { margin-top: -0.1em; } /* technically 0.2 is the same here too, but the horizon line is small, so this feels better */
.high-low .low::before {
content: "L: "
}
.high-low .high::before {
content: "H: "
}
.today .air-quality {
display:inline-block; border-radius: 3px; height: 1.5em; width: 1.5em; background: grey;
text-shadow: 0 0 5px #00000099; font-weight: 500;
box-shadow: rgb(0 0 0 / 35%) 0px 5px 15px;
position: relative;
}
.air-quality.good { background: #5cc725; }
.air-quality.fair { background: #fab427; }
.air-quality.moderate { background: #f8861f; }
.air-quality.poor { background: #f72114; }
.air-quality.very-poor { background: #b32118; }
.forecast td { padding: 0; }
/* .forecast .day { display: flex; justify-content: space-around; } */
/* .forecast .day { height: 16vh; } */ /* 1/6 height */
.forecast .day .weather-icon { width: 5vw; }
.forecast .day .weather-icon img { height: 10vh; }
/* START: TEMPLATES */
/***************************
*
* Template: default
*
*****/
[data-layout="default"] .today { height: 90vh; width: 40vw; margin-right: 5vw; margin-top: 5vh; }
[data-layout="default"] .forecast { --number-of-items: 4; --font-size: calc(24vh / var(--number-of-items)); height: 90vh; width: 45vw; font-size: var(--font-size); margin-right: 5vw; margin-top: 5vh; }
[data-layout="default"] .today .temperature { margin-top: -2vh; margin-bottom: -2vh; padding-left: 3vw; }
[data-layout="default"] .sunset-and-sunrise { display: none; }
[data-layout="default"] .extras { display: none; }
[data-layout="default"] .location-name { display: none; }
/***************************
*
* Template: today-only
*
*****/
[data-layout="today-only"] .today { width: 100vw; }
[data-layout="today-only"] .forecast { display: none; }
[data-layout="today-only"] .today .temperature { line-height: 1em; }
[data-layout="today-only"] .location-name { display: none; }
/***************************
*
* Template: forecast-only
*
*****/
[data-layout="forecast-only"] .today { display: none; }
[data-layout="forecast-only"] .forecast { width: 100vw; height:100vh; padding: 0 1.5em; --number-of-items: 4; --font-size: calc(24vh / var(--number-of-items)); font-size: var(--font-size); }
[data-layout="forecast-only"] .location-name { display: none; }
/***************************
*
* Template: forecast-horizontal
*
*****/
[data-layout="forecast-horizontal"] .today { display: none; }
[data-layout="forecast-horizontal"] .forecast { width: 100vw; }
[data-layout="forecast-horizontal"] .location-name { display: none; }
/* Apply flexbox to the table body and its rows */
[data-layout="forecast-horizontal"] .forecast {
height: calc(100% - 1.5em);
margin: 0.75em 0;
}
[data-layout="forecast-horizontal"] .forecast tbody {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: space-around; /* fallback for old browsers */
/* Remove default table spacing and borders */
width: 100%;
height: 100%;
padding: 0;
margin: 0;
list-style: none;
}
/* newer browsers should support space-evenly */
[data-layout="forecast-horizontal"].space-evenly-supported .forecast tbody {
justify-content: space-evenly;
}
/* Style each table cell */
[data-layout="forecast-horizontal"] .forecast .day {
padding: 0.5em;
display: flex;
flex-direction: column;
justify-content: space-between;
}
/* Center the content in each cell */
[data-layout="forecast-horizontal"] .forecast .day td {
text-align: center;
}
/* Optionally, adjust styles for specific cells like day of the week, weather icon, high, and low */
[data-layout="forecast-horizontal"] .forecast .day-of-week {
font-size: calc(2 * var(--font-size));
}
[data-layout="forecast-horizontal"] .forecast .day .weather-icon {
width: auto;
}
[data-layout="forecast-horizontal"] .forecast .day .weather-icon img {
height: calc(var(--font-size) * 5)
}
[data-layout="forecast-horizontal"] .forecast .high {
font-size: calc(2.75 * var(--font-size));
}
[data-layout="forecast-horizontal"] .forecast .low {
font-size: calc(1.75 * var(--font-size));
opacity: 0.8;
margin-top: -0.5em;
margin-bottom: 0.5em;
}
/***************************
*
* Template: today-wide
*
*****/
/* Emulates the standard 'Weather' tile for devices */
[data-layout="today-wide"] {
--font-size: 8vh;
--line-height: calc(1.5 * var(--font-size));
--main-content-y: 22.5vh;
display: block!important;
font-size: var(--font-size);
}
[data-layout="today-wide"] .today {
width: 100vw;
display: flex;
flex-direction: column;
}
[data-layout="today-wide"] .forecast { display: none; }
/* bottom-left corner */
/* descriptive weather summary */
[data-layout="today-wide"] .weather-summary {
position: absolute;
text-align: left;
bottom: calc((3 * var(--1u)) + var(--line-height)); /* offset by the sunset/sunrise being below it */
left: calc(3 * var(--1u));
}
[data-layout="today-wide"] .sunset-and-sunrise {
position: absolute;
text-align: left;
bottom: calc(3 * var(--1u));
left: calc(3 * var(--1u));
}
/* bottom-right corner */
/* descriptive weather summary */
[data-layout="today-wide"] .extras {
position: absolute;
bottom: calc(3 * var(--1u));
right: calc(3 * var(--1u));
}
/* move the windspeed up to its own line above the percentages */
[data-layout="today-wide"] .extras .wind-speed {
position: absolute;
bottom: calc(var(--line-height)); /* offset by the sunset/sunrise being below it */
right: 0; /* already within the .extras, so right 'padding' is already there */
}
/* central content */
[data-layout="today-wide"] .weather-icon {
position: absolute;
top: calc(var(--main-content-y) - 2.5vh); /* split the difference of it being 5vh taller than the temperature */
right: 55vw;
height: calc(35 * var(--1u));
width: calc(35 * var(--1u));
padding-right: 2vw;
}
[data-layout="today-wide"] .today .weather-icon img { max-height: 100%; max-width: 100%; }
[data-layout="today-wide"] .temperature {
position: absolute;
top: var(--main-content-y);
left: 45vw;
line-height: 1em;
font-size: 30vh;
}
[data-layout="today-wide"] .overview {
position: absolute;
top: calc(var(--main-content-y) + 30vh); /* offset by the height of the temperature element */
left: 45vw;
padding-left: 2vw;
}
[data-layout="today-wide"] .feels-like {
text-transform: lowercase;
font-size: 85%;
}
[data-layout="today-wide"] .high-low {
display: none; /* TODO: make this optional */
position: absolute;
top: calc(var(--main-content-y) + 30vh + var(--line-height)); /* offset by the height of the temperature element + feels-like */
left: 45vw;
padding-left: 2vw;
font-size: 85%;
}
[data-layout="today-wide"] .high-low .low {
margin-left: 2vw;
}
[data-layout="today-wide"] .today .air-quality {
border: 1px solid transparent;
text-shadow: none; /* reset to none */
box-shadow: none; /* reset to none */
background: none;
width: 3em; /* space for our prefix text */
/* positioning is a bit unique here since we are relative to the parent 'overview' box */
position: absolute;
left: calc(55vw - 2px - 3em - var(--3u)); /* see below for details on this calculation */
top: calc(-1 * var(--main-content-y) - 30vh + var(--3u)); /* reset to zero from the overview offset */
}
/* Explanation of left position for air-quality:
The parent of .air-quality is .overview which is already absolute left 45vw, so we are positioned relative to that parent element
+ Adding 55vw takes us to 100%
+ Then we subtract the width of the border on the element 1px + 1px = 2px
+ And substract the width of the element itself (3em fixed size)
+ And remove any additional padding we want (--3u)
+ 2px is right on the edge (accounting for borders), so we offset it by our default space amount
*/
[data-layout="today-wide"] .air-quality.good { border-color: #5cc725; }
[data-layout="today-wide"] .air-quality.fair { border-color: #fab427; }
[data-layout="today-wide"] .air-quality.moderate { border-color: #f8861f; }
[data-layout="today-wide"] .air-quality.poor { border-color: #f72114; }
[data-layout="today-wide"] .air-quality.very-poor { border-color: #b32118; }
[data-layout="today-wide"] span.air-quality::before {
content: "AQI: ";
font-size: 0.8em;
top: -0.1em;
display: inline-block;
position: relative;
padding-right: 0.25em;
}
/***************************
*
* Template: today-mini
*
*****/
[data-layout="today-mini"] .today { width: 100vw; }
[data-layout="today-mini"] .forecast { display: none; }
[data-layout="today-mini"] .today .temperature { line-height: 1em; }
[data-layout="today-mini"] .location-name { display: none; }
[data-layout="today-mini"] .overview { display: none; }
[data-layout="today-mini"] .sunset-and-sunrise { display: none; }
[data-layout="today-mini"] .extras { display: none; }
[data-layout="today-mini"] .weather-summary { display: none; }
[data-layout="today-mini"] .weather-icon {
position: absolute;
left: 5vw;
top: 18vh;
height: 40vh;
width: 40vw;
}
[data-layout="today-mini"][data-temperature-digits="3"] .weather-icon {
left: 2vw;
top: 22vh;
width: 37vw;
}
[data-layout="today-mini"] .weather-icon img {
max-width: 100%;
max-height: 100%;
}
[data-layout="today-mini"] .temperature {
position: absolute;
left: 47vw;
right: 5vw;
top: 24vh;
font-size: 30vh;
}
[data-layout="today-mini"][data-temperature-digits="3"] .temperature {
left: 37vw;
font-size: 26vh;
top: 26vh;
}
[data-layout="today-mini"] .high-low {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
left: 25vw;
right: var(--3u);
top: 60vh;
font-size: 15vh;
--gap: 4vw;
}
[data-layout="today-mini"] .high {
padding-right: var(--gap);
order: 1;
}
[data-layout="today-mini"] .low {
padding-left: var(--gap);
order: 3;
}
[data-layout="today-mini"] .high::before, [data-layout="today-mini"] .low::before {
content: " ";
}
/* Put a pipe between them (border was too tall) */
[data-layout="today-mini"] .high-low::before {
content: " ";
order: 2;
margin-top: 2vh;
font-weight: 100;
opacity: 0.8;
border-right: 1px solid rgba(255,255,255,0.6);
height: 16vh;
width: 0px;
display: block;
}
/* END: TEMPLATES */
.error {
position: absolute;
top: 0;
left: 0;
right: 0;
background: #e11111;
padding: 0.5em 1em;
box-shadow: 0 0 10px 5px rgb(0 0 0 / 50%);
}
.hide { display: none; }
</style>
<script>
var BASE_URL = 'https://api.openweathermap.org/data/'
var noDecimal = new Intl.NumberFormat(navigator.language, { maximumFractionDigits: 0 });
function stripDecimal(v){
if(v != null && v !== false){
v = noDecimal.format(v)
}
return v;
}
function formatTemperature(v){
v = stripDecimal(v)
return (v || '') + '°'
}
function formatPercent(v){
v = stripDecimal(v)
return (v || '') + '%'
}
function isNullOrEmpty(v){ return v == null || v === "" };
var LANGUAGE_MAP = {"af":{"feels_like":"Hitte-indeks"},"al":{"feels_like":"ndjehet si"},"ar":{"feels_like":"مؤشر الحرارة"},"az":{"feels_like":"istilik indeksi"},"bg":{"feels_like":"топлинен индекс"},"ca":{"feels_like":"índex de calor"},"cz":{"feels_like":"zdánlivá teplota"},"da":{"feels_like":"Føles som"},"de":{"feels_like":"Hitzeindex"},"el":{"feels_like":"δείκτης θερμότητας"},"en":{"feels_like":"Feels Like"},"eu":{"feels_like":"bero-indizea"},"fa":{"feels_like":"شاخص گرما"},"fi":{"feels_like":"lämpöindeksi"},"fr":{"feels_like":"indice de chaleur"},"gl":{"feels_like":"índice de calor"},"he":{"feels_like":"מד חום"},"hi":{"feels_like":"ताप सूचकांक"},"hr":{"feels_like":"indeks topline"},"hu":{"feels_like":"hő index"},"id":{"feels_like":"Indeks panas"},"it":{"feels_like":"indice di calore"},"ja":{"feels_like":"暑さ指数"},"kr":{"feels_like":"열 지수"},"la":{"feels_like":"siltuma indekss"},"lt":{"feels_like":"šilumos indeksas"},"mk":{"feels_like":"индекс на топлина"},"no":{"feels_like":"varmeindeks"},"nl":{"feels_like":"warmte-index"},"pl":{"feels_like":"indeks ciepła"},"pt":{"feels_like":"índice de calor"},"pt_br":{"feels_like":"índice de calor"},"ro":{"feels_like":"Index de caldura"},"ru":{"feels_like":"тепловой индекс"},"sv":{"feels_like":"värmeindex"},"se":{"feels_like":"värmeindex"},"sk":{"feels_like":"tepelný index"},"sl":{"feels_like":"toplotni indeks"},"sp":{"feels_like":"índice de calor"},"es":{"feels_like":"índice de calor"},"sr":{"feels_like":"топлотни индекс"},"th":{"feels_like":"ดัชนีความร้อน"},"tr":{"feels_like":"ısı indeksi"},"ua":{"feels_like":"індекс тепла"},"uk":{"feels_like":"індекс тепла"},"vi":{"feels_like":"chỉ số nhiệt"},"zh_cn":{"feels_like":"热度指数"},"zh_tw":{"feels_like":"熱度指數"},"zu":{"feels_like":"uzizwa"}};
var METEO_CODE_TO_NAME = {
"01d": "clear-day", //clear
"01n": "clear-night",
"02d": "overcast-day", //clouds
"02n": "overcast-night",
"03d": "cloudy", //scattered clouds
"03n": "cloudy",
"04d": "overcast", //broken clouds
"04n": "overcast",
"09d": "rain", //shower rain
"09n": "rain",
"10d": "partly-cloudy-day-rain", //rain
"10n": "partly-cloudy-night-rain",
"11d": "thunderstorms", //thunderstorm
"11n": "thunderstorms",
"13d": "snow", //snow
"13n": "snow",
"50d": "mist", //mist/fog
"50n": "mist"
}
new Vue({
el: "#app",
data: function() {
return {
// message: 'Hello Vue!',
weather: { current: {}, daily: []},
airQuality: null,
// today: null,
// forecast: [],
locationName: null,
units: "imperial",
apiPreference: "2-5multi",
error: null,
showAqi: false,
appLayout: "default",
background: "default",
showLocationName: true, //only for certain layouts (eg. today-wide)
formatters: {
dayOfWeek: this.getDayOfWeekFormatter(),
shortTime: this.getTimeFormatter()
}
}
},
computed: {
appClasses: function(){
var classes = [];
if(this.background === 'default')
classes.push('default-background')
if(getIsSpaceEvenlySupported())
classes.push('space-evenly-supported')
return classes;
},
apiVersion: function(){
if(this.apiPreference.indexOf('3-0') >= 0){
return '3.0'
}
else{
return '2.5'
}
},
isOneCall: function(){
return this.apiPreference.indexOf("onecall") > 0;
},
hasCurrent: function(){
return this.weather && this.weather.current && this.weather.current.temp != null //check for an arbitrary value within
&& Array.isArray(this.weather.current.weather) && this.weather.current.weather.length > 0; //and we have the current weather array set
},
hasForecast: function(){ return this.weather && Array.isArray(this.weather.daily) && this.weather.daily.length > 0; },
aqiIndex: function(){ return this.airQuality && this.airQuality.main && this.airQuality.main.aqi; },
forecast: function(){
if(!this.hasForecast)
return [];
if(this.isOneCall)
return this.weather.daily.slice(1,7);
else
return this.weather.daily.slice(1,5); //non one-calls provide a 5 day forecast, but only the fourth day is complete
}, //remove the today's element
todaysForecast: function(){
if(this.hasForecast)
return this.weather.daily[0];
},
temperature: function(){ return formatTemperature(this.hasCurrent && this.weather.current.temp); },
temperatureDigits: function(){
if(!this.hasCurrent)
return 0;
return stripDecimal(this.weather.current.temp).length
},
feelsLike: function(){ return formatTemperature(this.hasCurrent && this.weather.current.feels_like); },
weatherIcon: function(){ return this.hasCurrent && this.weather.current.weather[0].icon || null; },
weatherIconImageUrl: function(){ return this.weatherIcon && this.getIconUrl(this.weatherIcon); },
weatherSummary: function(){ return this.hasCurrent && this.weather.current.weather[0].description || null; },
highTemp: function(){
//the temp_max comes from a separate call to Open Meteo to workaround the limitations with the
// 2.5 Multi call from Open Weather to get the high/low temperature for the current day
if(this.hasCurrent && this.weather.current.temp_max != null){
// console.log("Using CURRENT weather for today's High/Low")
return formatTemperature(this.weather.current.temp_max);
}
// console.log("Falling back to FORECAST for today's High/Low")
return formatTemperature(this.hasForecast && this.weather.daily[0].temp.max);
},
lowTemp: function(){
if(this.hasCurrent && this.weather.current.temp_min != null){
return formatTemperature(this.weather.current.temp_min);
}
return formatTemperature(this.hasForecast && this.weather.daily[0].temp.min);
},
sunsetTime: function(){
if(!this.hasCurrent)
return;
if(!this.weather.current.sunset)
return
var dt = new Date(this.weather.current.sunset * 1000)
return this.formatters.shortTime.format(dt);
},
sunriseTime: function(){
if(!this.hasCurrent)
return;
if(!this.weather.current.sunrise)
return
var dt = new Date(this.weather.current.sunrise * 1000)
return this.formatters.shortTime.format(dt);
},
humidity: function(){ return stripDecimal(this.hasCurrent && this.weather.current.humidity); },
precipitation: function(){
if(!this.todaysForecast)
return
var precip = this.todaysForecast.pop;
if(precip == null)
return
return stripDecimal(precip * 100);
},
windSpeed: function(){
return stripDecimal(this.hasCurrent && this.weather.current.wind_speed);
},
windUoM: function(){
var uom = 'mph';
if(this.units === 'metric') uom = 'm/s'
return uom;
}
},
methods: {
getOWMParams: function(){
var p = this.getPosition();
var params = '?lat=' + p.lat + '&lon=' + p.lon + '&appid=' + API_KEY + '&units=' + this.units;
//if we have a valid language entered, append that parameter
if(this.isValidLanguage(this.lang)){
params += '&lang=' + this.lang;
}
return params;
},
getOpenMeteoParams: function(){
var p = this.getPosition();
var params = '?latitude=' + p.lat + '&longitude=' + p.lon + '&units=' + this.units + '&timezone=auto';
if(this.units == "imperial"){ // metric can use the defaults: celcius, km/h, mm
params = params + "&temperature_unit=fahrenheit&windspeed_unit=mph&precipitation_unit=inch"
}
return params;
},
getPosition: function(){
//TODO: make a query to geocode a position input as latitude/longitude
return {
lat: LAT,
lon: LON,
}
},
//simplify to a single API call
getOneWeather: function(){
var url = BASE_URL + this.apiVersion + "/onecall" + this.getOWMParams() + '&exclude=minutely,hourly';
var vm = this;
var p1 = axios.get(url).then(function(response){
// console.log(response);
vm.weather = response.data;
}).catch(function(err){
if(err && err.response && err.response.status === 401)
vm.error = "OneCall may not be supported with your API Key. Try '2-5multi' in tile preferences."
});
var promises = [p1];
if(this.showAqi)
promises.push(this.getAqi())
if(this.showLocationName)
promises.push(this.getLocationName())
return Promise.all(promises)
},
getAqi: function(){
var url = BASE_URL + '2.5/air_pollution' + this.getOWMParams();
var vm = this;
return axios.get(url).then(function(response){
// console.log(response.data)
vm.airQuality = response.data.list[0];
});
},
getLocationName: function(){
//setup the variables we will need
var lang = this.getLanguage();
var position = this.getPosition();
var key = 'openweather_reverseGeo_' + position.lat + '_' + position.lon;
//stub a function for getting the name from an array result
//so we can re-use it in cached and query approaches
var getNameFromResult = function(items){
var name;
if(!Array.isArray(items) || items.length <= 0)
return; //do nothing, bad result
var match = items[0];
name = match.name; //default name
if(match.local_names && match.local_names[lang])
name = match.local_names[lang]; //locale specific name
return name; //return the final name
}
//check if we have the location name cached
var value = localStorage.getItem(key)
// console.log('localStorage:'+key+'=', value)
//if we got a cached result from local storage, let's use that
try{
var items = JSON.parse(value)
this.locationName = getNameFromResult(items);
//and if we got a valid name, bail out early since we have what we need
if(this.locationName){
console.debug('Using cached location name:', this.locationName)
return;
}
}catch(error){
console.warn('Error parsing cached location geo result. Falling back to API call.', error)
}
//otherwise continue with the API call
//https://api.openweathermap.org/geo/1.0/reverse?lat=33.123456&lon=-97.12345&limit=5&appid=XXXXX
var url = BASE_URL.replace('data/', 'geo/') + '1.0/reverse' + this.getOWMParams(); //really just need lat, lon, and appid...but doesn't hurt to include lang and units
var vm = this;
return axios.get(url).then(function(response){
console.log('Location name query result:', response.data)
var items = response.data;
vm.locationName = getNameFromResult(items);
//if we have a locationName, let's cache the whole result (in case we change approaches in the future or user changes locales or anything)
if(vm.locationName){
console.log('Location name matched: ' + vm.locationName + ' - caching result:', response.data)
localStorage.setItem(key, JSON.stringify(items));
}
});
},
getWeather: function(){
//get the weather and the forecast in one call
var weatherPromise = this.getWeatherToday();
var forecastPromise = this.getForecast()
var promises = [weatherPromise, forecastPromise];
if(this.showAqi)
promises.push(this.getAqi())
if(this.showLocationName)
promises.push(this.getLocationName())
return Promise.all(promises);
},
//current weather from OWM mixed with todays "forecast" from OpenMeteo
// OWM would result in partial forecasts for today with the 2.5 Multi calls (OneWeather calls are not impacted by this)
getWeatherToday: function(){
//Open Weather Map: get today's weather
var url = BASE_URL + this.apiVersion + '/weather' + this.getOWMParams();
var vm = this;
var owmPromise = axios.get(url)
//Open Meteo: daily forecast for temperatures when falling back to 2.5 OWM call
var baseMeteoUrl = "https://api.open-meteo.com/v1/forecast"
var meteoExtras = "&daily=temperature_2m_max&daily=temperature_2m_min&forecast_days=1"
var meteoUrl = baseMeteoUrl + this.getOpenMeteoParams() + meteoExtras;
var meteoPromise = axios.get(meteoUrl)
//get both responses at once
return Promise.allSettled([owmPromise, meteoPromise]).then(function(results){
var owmResponse = results[0].status !== "rejected" ? results[0].value : null;
var meteoResponse = results[1].status !== "rejected" ? results[1].value : null;
//we at least need the basic owmResponse
if(owmResponse == null){
throw new Error("Failed to get response from Open Weather");
}
// console.log(response.data)
var current = owmResponse.data;
//remap to fit the OneWeather model
current.temp = owmResponse.data.main.temp;
current.feels_like = owmResponse.data.main.feels_like;
current.humidity = owmResponse.data.main.humidity;
//sunset and sunrise
if(owmResponse.data.sys.sunrise && owmResponse.data.sys.sunset){
current.sunrise = owmResponse.data.sys.sunrise;
current.sunset = owmResponse.data.sys.sunset;
}
//wind speed
if(owmResponse.data.wind.speed != null){
current.wind_speed = owmResponse.data.wind.speed
}
//precipitation should come from forecast as OWM is PAST 1H rain volume
//Open Meteo Fallback for min/max temp
if(meteoResponse){
current.temp_max = meteoResponse.data.daily.temperature_2m_max[0];
current.temp_min = meteoResponse.data.daily.temperature_2m_min[0];
}
//then store it
vm.weather.current = current;
//copy the location name too
// vm.locationName = owmResponse.data.name; //this is unreliable, so we use a separate API call now if the user wants this information
});
},
getForecast: function(){
var url = BASE_URL + this.apiVersion + '/forecast' + this.getOWMParams();
var vm = this;
return axios.get(url).then(function(response){
// console.log(response.data)
var daily3hours = response.data.list;
//merge the every three hours into a daily
vm.weather.daily = vm.mergeForecast(daily3hours);
});
},
//take a 3 hour forecast and merge it into a daily
mergeForecast: function(data){
//group the 3 hour elements into 'days'
var grouping = {}
var ONE_DAY = 24 * 60 * 60 * 1000;
//stub out the base object
var now = new Date();
for(var i=0;i<7;i++){
var dt = new Date(now.valueOf() + (i * ONE_DAY))
var key = dt.getDate()
grouping[key] = {items: [], timestamp: dt}; //setup an empty array
}
//loop through the items and map them into their date
// for(var item of data){
for(var i=0; i<data.length;i++){
var item = data[i];
var dt = new Date(item.dt * 1000);
var key = dt.getDate()
grouping[key].items.push(item);
}
var formatted = [];
//run the computations on each key
for(var key in grouping){
var items = grouping[key].items;
//grab just the required attribute(s) into an array
var icons = items.map(function(item){ return item.weather[0].icon});
var mins = items.map(function(item){ return item.main.temp_min });
var maxes = items.map(function(item){ return item.main.temp_max });
var pops = items.map(function(item){ return item.pop })
//filter out the night icons for our needs
var dayIcons = icons.filter(function(item){ return item[2] !== 'n' }); //the last character shouldn't be n (night)
//sort to get the minimum min and the maximum max
var min = mins.sort(function(a,b){ return a-b; })[0];
var max = maxes.sort(function(a,b){ return a-b; })[maxes.length - 1]
var icon = this.getMostOftenElement(dayIcons);
var pop = pops.sort(function(a,b){ return a-b; })[maxes.length - 1]; //max percent chance of precipitation
var day = {
"dt": grouping[key].timestamp.valueOf() / 1000,
"weather": [{"icon": icon}],
"temp": {
"max": max,
"min": min
},
"pop": pop
}
formatted.push(day)
}
//sort it by date (low to high)
formatted.sort(function(a,b){ return a.dt - b.dt });
//reformat the summary object
return formatted;
},
getMostOftenElement: function(array){
if(array.length == 0)
return null;
var modeMap = {};
var maxEl = array[0], maxCount = 1;
for(var i = 0; i < array.length; i++){
var el = array[i];
if(modeMap[el] == null)
modeMap[el] = 1;
else
modeMap[el]++;
if(modeMap[el] > maxCount)
{
maxEl = el;
maxCount = modeMap[el];
}
}
return maxEl;
},
//FORECAST helpers
getAqiClass: function(index){
let text = this.getAqiText(index);
return text.toLowerCase().replace(" ", "-");
},
getAqiText: function(index){
//if we weren't supplied an index, just use the aqiIndex
if(index == null)
index = this.aqiIndex;
switch(index){
case 1: return "Good";
case 2: return "Fair";
case 3: return "Moderate";
case 4: return "Poor";
case 5: return "Very Poor";
default: return "Unknown"
}
},
getDoW: function(day){
var dt = new Date(day.dt * 1000);
if(dt != null)
return this.formatters.dayOfWeek.format(dt);
},
getIcon: function(day){ return day.weather[0].icon; },
getIconUrl: function(dayOrCode){
//if we're given a raw string, use it. Otherwise assume it's a day object and get the icon code from it
var code = (typeof dayOrCode === "string") ? dayOrCode : this.getIcon(dayOrCode)
// return this.getOWIconUrl(code); //uncomment this to use the original OWM icons
return this.getMeteoconUrl(code);
},
getHigh: function(day){ return formatTemperature(day.temp.max); },
getLow: function(day){ return formatTemperature(day.temp.min); },
getMeteoconUrl: function(code, iconStyle){
//See: https://basmilius.github.io/weather-icons/index-line.html
var name = METEO_CODE_TO_NAME[code] || code; //in case the raw icon name is passed in
if(!iconStyle) iconStyle = 'line'
return 'https://basmilius.github.io/weather-icons/production/' + iconStyle + '/all/' + name + '.svg'
},
getOWIconUrl: function(code){
return code && 'https://openweathermap.org/img/wn/' + code + '@2x.png'
},
getErikFlowerIconUrl: function(icon){
var version = '2.0.12';
return 'https://raw.githubusercontent.com/erikflowers/weather-icons/' + version + '/svg/wi-' + icon + '.svg';
},
getLanguage: function(lang){
//allow the language code to be passed in or fallback to the data model
if(lang == null)
lang = this.lang
//if it's a valid language, use it
if(this.isValidLanguage(this.lang))
return this.lang;
else
return "en"; //default to english
},
isValidLanguage: function(lang){
if(isNullOrEmpty(lang))
return false;
return LANGUAGE_MAP[lang] != null;
},
getPhrase: function(phrase){
var lang = this.getLanguage()
return LANGUAGE_MAP[lang][phrase] || '';
},
getDayOfWeekFormatter: function(){
var lang = this.getLanguage();
if(typeof lang === 'string') lang.replace('_', '-') //BCP 47 uses `-` whereas OWM uses `_`
return new Intl.DateTimeFormat(lang, { weekday: "short" });
},
getTimeFormatter: function(){
var lang = this.getLanguage();
if(typeof lang === 'string') lang.replace('_', '-') //BCP 47 uses `-` whereas OWM uses `_`
try {
return new Intl.DateTimeFormat('en-US', { timeStyle: 'short' });
} catch (e) {
// Fallback for browsers that don't support `timeStyle`
return new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit' });
}
},
setBackground: function(){
var cssSnippet = '';
if(this.background === 'default')
cssSnippet = 'html { background: linear-gradient(52deg, rgba(12,5,147,1) 0%, rgba(16,16,172,1) 30%, rgba(113,0,255,1) 100%) }';
// Check if a style tag with a specific ID already exists
var styleTag = document.getElementById('custom-style');
// If it doesn't exist, create a new style tag
if (!styleTag) {
styleTag = document.createElement('style');
styleTag.id = 'custom-style';
document.head.appendChild(styleTag);
}
// Set the CSS content of the style tag to the provided CSS snippet
styleTag.innerHTML = cssSnippet;
},
initialize: function(){
//get the weather once
var vm = this;
if(this.isOneCall){
this.getOneWeather().then(function(){
//then if it succeeds, schedule it to run periodically
setInterval(vm.getOneWeather, REFRESH_INTERVAL)
})
}
else{
this.getWeather().then(function(){
//then if it succeeds, schedule it to run periodically
setInterval(vm.getWeather, REFRESH_INTERVAL)
})
}
}
},
watch: {
//when the list of forecast items changes
forecast: function(items, oldItems){
//as long as it's a valid array of items
if(Array.isArray(items) && items.length > 0){
//update our CSS variable so we can calculate a font-size to better fit the number of items
this.$refs.forecastTable.style.setProperty("--number-of-items", items.length); //OWM 2.5 Multi = 4 items, OneCall = 6 items
}
}
},
mounted: function(){
var vm = this;
stio.ready(function(data){
if(!isNullOrEmpty(data.settings.apiKey)){
API_KEY = data.settings.apiKey;
}
if(!isNullOrEmpty(data.settings.layout)){
vm.appLayout = data.settings.layout;
}
if(!isNullOrEmpty(data.settings.showLocationName)){
vm.showLocationName = data.settings.showLocationName;
}
if(!isNullOrEmpty(data.settings.useDefaultBackground)){
var value;
var useDefault = data.settings.useDefaultBackground;
if(useDefault)
value = "default"
vm.background = value;
// vm.setBackground(); //let it happen reactively
}
if(!isNullOrEmpty(data.settings.location)){
var re = /^(-?\d+(\.\d+)?),\s*(-?\d+(\.\d+)?)$/
var match = data.settings.location.match(re);
//[m, lat, latp, lon, lonp]
LAT = match[1]; //lat
LON = match[3]; //lon
}
if(!isNullOrEmpty(data.settings.units)){
vm.units = data.settings.units;
}
if(!isNullOrEmpty(data.settings.lang)){
vm.lang = data.settings.lang
vm.formatters.dayOfWeek = vm.getDayOfWeekFormatter();
vm.formatters.shortTime = vm.getTimeFormatter();
}
if(!isNullOrEmpty(data.settings.apiPreference)){
vm.apiPreference = data.settings.apiPreference;
}
if(!isNullOrEmpty(data.settings.showAqi)){
vm.showAqi = !!data.settings.showAqi;
}
if(data.settings.isCustomRefreshInterval === true && !isNullOrEmpty(data.settings.refreshInterval)){
try{
var interval = data.settings.refreshInterval * 60 * 1000;
//must be at least a minute
if(interval >= (60 * 1000)){
console.log('Using CUSTOM refresh interval', data.settings.refreshInterval)
REFRESH_INTERVAL = interval;
if(data.settings.refreshInterval < 10)
stio.showToast("Weather Custom Tile: a refresh interval faster than 10 minutes is not recommended", "red")
}
else{
console.error('Invalid refresh interval', data.settings.refreshInterval)
}
}
catch(error){
console.error('Invalid refresh interval. Using default.')
}
}
vm.initialize()
});
}
});
/* Other helpers. Will get hoisted for use above */
function getIsSpaceEvenlySupported(){
var testElement = document.createElement('div');
testElement.style.display = 'flex';
testElement.style.justifyContent = 'space-evenly';
testElement.style.visibility = 'hidden';
testElement.style.position = 'absolute';
testElement.style.width = '0';
testElement.style.height = '0';
// Append to body temporarily for testing
document.body.appendChild(testElement);
// Check computed style
var isSupported = window.getComputedStyle(testElement).justifyContent === 'space-evenly';
// Remove the test element
document.body.removeChild(testElement);
var isModernVersion = checkBrowserVersion()
return isSupported && isModernVersion;
}
function checkBrowserVersion() {
var userAgent = navigator.userAgent;
// Check Chrome
var chromeMatch = userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
if (chromeMatch) {
var chromeVersion = parseInt(chromeMatch[2], 10);
if (chromeVersion >= 60) {
return true;
}
}
// Check Safari
var safariMatch = userAgent.match(/Version\/([0-9]+)\.([0-9]+)(?:\.([0-9]+)?) Safari/);
if (safariMatch) {
var safariVersion = parseInt(safariMatch[1], 10);
if (safariVersion >= 11) {
return true;
}
}
return false;
}
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment