Last active
June 28, 2024 01:56
-
-
Save joshualyon/3f83f3605c8d1bd431a4876063b37f38 to your computer and use it in GitHub Desktop.
Open Weather Map POC Tile
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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/[email protected]"></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