Weather display
Edit : Fixed console error, added moon-phase, added finer grained weather icons & moon phase.
[{"id":"aae3b45877bf45a1","type":"group","z":"6babbb5b5eab71f6","name":"Weather dashboard","style":{"stroke":"#0722fc","fill":"#bfdbef","fill-opacity":"0.32","label":true,"color":"#0722fc"},"nodes":["17184e37d93c25e0","bdc4567fd3ca5a28","a36453613d8e0af1","81fd42cc4adc9897","db24b0e70c23d3c9"],"x":14,"y":19,"w":812,"h":122},{"id":"17184e37d93c25e0","type":"function","z":"6babbb5b5eab71f6","g":"aae3b45877bf45a1","name":"Massage data","func":"// ============================================================================\n// ICONS\n// The openweathermap API includes a day/night weather icon name eg 19n\n// and a finer grained weather condifion code eg 511.\n// Github includes various icon sets for both of these. I've used\n// https://github.com/isneezy/open-weather-icons/tree/master/src and\n// https://github.com/rickellis/SVG-Weather-Icons/tree/master/OpenWeatherMap\n// They can safely be merged into a single directory.\n// Of course you need to set httpStatic in settings.js. \n// I have httpStatic: '/home/nodered/.node-red/node-red-static/',\n//\n// Moon phases are provided as fraction of moon visible. 0 is new moon, .5 is full.\n// Sets of 28 icons are available on github\n// I used the wi-moon-... set from https://github.com/erikflowers/weather-icons/tree/master/svg\n// ============================================================================\nlet iconsource = \"id\" // \"id or icon\". msg.payload.daily[n].weather[0] contains both\nconst icondirectories = {\"id\": \"icons/\", \"icon\": \"icons/\", \"moon\": \"moon/\"}\n\nfunction moonPhaseToIcon(phase) {\n const moonIcons = [\"wi-moon-new.svg\", \"wi-moon-waxing-crescent-1.svg\", \"wi-moon-waxing-crescent-2.svg\", \"wi-moon-waxing-crescent-3.svg\", \"wi-moon-waxing-crescent-4.svg\", \"wi-moon-waxing-crescent-5.svg\", \"wi-moon-waxing-crescent-6.svg\",\n \"wi-moon-first-quarter.svg\", \"wi-moon-waxing-gibbous-1.svg\", \"wi-moon-waxing-gibbous-2.svg\", \"wi-moon-waxing-gibbous-3.svg\", \"wi-moon-waxing-gibbous-4.svg\", \"wi-moon-waxing-gibbous-5.svg\", \"wi-moon-waxing-gibbous-6.svg\",\n \"wi-moon-full.svg\", \"wi-moon-waning-gibbous-1.svg\", \"wi-moon-waning-gibbous-2.svg\", \"wi-moon-waning-gibbous-3.svg\", \"wi-moon-waning-gibbous-4.svg\", \"wi-moon-waning-gibbous-5.svg\", \"wi-moon-waning-gibbous-6.svg\",\n \"wi-moon-third-quarter.svg\", \"wi-moon-waning-crescent-1.svg\", \"wi-moon-waning-crescent-2.svg\", \"wi-moon-waning-crescent-3.svg\", \"wi-moon-waning-crescent-4.svg\", \"wi-moon-waning-crescent-5.svg\", \"wi-moon-waning-crescent-6.svg\"]\n\n let idx = Math.floor(phase * 28 + 0.5);\nif (idx >= 28) idx = 0; // wrap-around (since 1.0 ≈ 0.0 New Moon)\nreturn '/' + icondirectories.moon + moonIcons[idx];\n}\n// ============================================================================\n// Convert timestamp → local day + time (HH:mm)\n// ============================================================================\nfunction getLocalTime(dt, timezone, timezone_offset) {\n const date = new Date(dt * 1000);\n\n let localeString;\n if (timezone) {\n localeString = date.toLocaleString(\"en-US\", { timeZone: timezone });\n } else {\n const hrs = timezone_offset / 3600;\n const sign = hrs >= 0 ? \"+\" : \"-\";\n localeString = date.toLocaleString(\"en-US\", {\n timeZone: \"Etc/GMT\" + sign + Math.abs(hrs)\n });\n }\n\n const local = new Date(localeString);\n\n const days = [\"Sun\",\"Mon\",\"Tue\",\"Wed\",\"Thu\",\"Fri\",\"Sat\"];\n return {\n day: days[local.getDay()],\n time:\n (local.getHours().toString().padStart(2, \"0\")) + \":\" +\n (local.getMinutes().toString().padStart(2, \"0\"))\n };\n}\nfunction getLocalTimeTI (dt, timezone, timezone_offset = 0) {\n if (dt < 31536000000) dt = dt * 1000 // Convert from seconds to milliseconds if needed\n const date = new Date(dt)\n if (!timezone) timezone = `UTC+${timezone_offset}`\n return {\n day: date.toLocaleDateString([], { weekday: 'short', timeZone: timezone, }),\n time: date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: timezone, }),\n }\n}\n// =========================\n// Temp to background colour\n// =========================\nfunction tempToColor(temp) {\n const colours = [\n '#6C7AFF', '#80A0FF', '#8CC4FF', '#A0E0FF',\n '#C4F8FF', '#E8FDFD', '#FFF3C4', '#FFDFB0',\n '#FFB990', '#E68A5C', '#D46C48', '#C04E2F'\n ];\n const bucketSize = 5;\n let idx;\n if (temp <= -15) {\n idx = 0;\n } else if (temp >= 35) {\n idx = colours.length - 1;\n } else {\n idx = Math.floor((temp + 15) / bucketSize);\n idx = Math.max(0, Math.min(colours.length - 1, idx));\n }\n return colours[idx];\n}\n// ============================================================================\n// Convert degrees → compass\n// ============================================================================\nfunction degreesToCompass(deg) {\n const directions = [\"N\", \"NNE\", \"NE\", \"ENE\", \"E\", \"ESE\", \"SE\", \"SSE\",\n \"S\", \"SSW\", \"SW\", \"WSW\", \"W\", \"WNW\", \"NW\", \"NNW\", \"N\"];\n return directions[Math.round(((deg % 360) / 22.5))];\n}\n\n\n////////////////////////////////////////////////////////////////////////////////////////////////////\n\nconst tz = msg.payload.timezone;\nconst tzo = msg.payload.timezone_offset;\n\n\n// force a local copy of msg.payload to avoid poisoning the cached object\nmsg.payload = JSON.parse(JSON.stringify(msg.payload)) // deep copy\n\nmsg.payload.forecasttime = getLocalTimeTI(\n msg.payload.current.dt,\n msg.payload.timezone,\n msg.payload.timezone_offset\n).time;\n\nconst location = (new URL(msg.payload.url)).searchParams.get('location')\nmsg.payload.location = location.substring(0,34)\n\n// Current wind formatting\nmsg.payload.current.wind_point = degreesToCompass(msg.payload.current.wind_deg);\nmsg.payload.current.wind_knots = msg.payload.current.wind_speed * 1.944;\nmsg.payload.current.wind_mph = msg.payload.current.wind_speed * 2.237;\n\nmsg.payload.temp = Math.round(msg.payload.current.temp) + \"°C\"\nmsg.payload.humidity = msg.payload.current.humidity + \"%\"\nmsg.payload.pressure = msg.payload.current.pressure + \"hPa\"\nmsg.payload.wind = Math.round(msg.payload.current.wind_mph ) + \"mph \" + degreesToCompass(msg.payload.current.wind_deg)\n\nmsg.payload.color = tempToColor(msg.payload.current.temp)\n\nmsg.payload.comments = \"\";\nif (msg.payload.alerts) {\n for (const alert of msg.payload.alerts) {\n if (alert.tags?.length > 0) {\n const s = getLocalTimeTI(alert.start, msg.payload.timezone, msg.payload.timezone_offset);\n const e = getLocalTimeTI(alert.end, msg.payload.timezone, msg.payload.timezone_offset);\n msg.payload.comments =\n `${alert.event} ${s.day} ${s.time} - ${e.day} ${e.time}`;\n break;\n }\n }\n}\nmsg.payload.comments = msg.payload.comments.substring(0, 60)\n\n// ============================================================================\n// HOURLY\n// ============================================================================\nmsg.payload.hourly = (msg.payload.hourly || []).map(h => {\n\n const lt = getLocalTimeTI(h.dt, tz, tzo);\n\n // Build 'note' \n let note = null;\n if (h.wind_speed >= 13.8) {\n note = h.wind_speed + \"m/s\"\n note = \"Wind\"\n note = h.wind_speed > 42.7 ? \"Hurricane\" : h.wind_speed > 28.4 ? \"Storm\" : \"Gale\"\n h.color = \"oklch(0.4 0.01 330)\"\n h.color = \"red\"\n } else if (h.rain && h.rain[\"1h\"] >= 0.5) {\n note = Math.round(h.rain[\"1h\"]) + \"mm\"\n // note = \"Rain\"\n h.color = \"blue\" \n } else if (h.snow && h.snow[\"1h\"] >= 0.5) {\n note = Math.round(h.snow[\"1h\"]) +\"mm\"\n// note = \"Snow\"\n h.color = \"white\"\n }\n let iconfile\n if (iconsource == \"id\") {\n iconfile = icondirectories.id + h.weather[0].id + \".svg\"\n }\n else {\n iconfile = icondirectories.icon + h.weather[0].icon + \".svg\"\n }\n return {\n dt: h.dt,\n temp: h.temp,\n// icon: h?.weather?.[0]?.icon || \"\", // Doesn't work, don't know why\n// id: h?.weather?.[0]?.id || \"\",\n weather: h.weather,\n color: h.color,\n hhmm: lt.time,\n rain: h?.rain || 0,\n snow: h?.snow || 0,\n wind: h?.wind_speed || 0,\n icon: iconfile,\n note: note\n };\n});\n\n// ============================================================================\n// DAILY\n// ============================================================================\nmsg.payload.daily = (msg.payload.daily || []).map(d => {\n\n const lt = getLocalTimeTI(d.dt, tz, tzo);\n let note = null\n\n if (d.wind_speed >= 13.8) {\n note = d.wind_speed;\n note = \"Wind\"\n note = d.wind_speed > 42.7 ? \"Hurricane\" : d.wind_speed > 28.4 ? \"Storm\" : \"Gale\"\n d.color = \"red\"\n } else if (d.rain && d.rain >= 2) {\n note = d.rain\n note = \"Rain\"\n note = Math.round(d.rain) + \"mm\"\n d.color = \"blue\"\n } else if (d.snow && d.snow >= 2) {\n note = d.snow\n note = \"Snow\"\n note = Math.round(d.snow) + \"mm\"\n d.color = \"white\"\n }\n let iconfile\n if (iconsource == \"id\") {\n iconfile = icondirectories.id + d.weather[0].id + \".svg\"\n }\n else {\n iconfile = icondirectories.icon + d.weather[0].icon + \".svg\"\n }\n \n return {\n dt: d.dt,\n temp: d.temp,\n weather: d.weather,\n color: d.color,\n ddd: lt.day,\n max: Math.round(d.temp.max),\n min: Math.round(d.temp.min),\n rain: d?.rain || 0,\n snow: d?.snow || 0,\n wind: d?.wind_speed || 0,\n icon: iconfile,\n moon: moonPhaseToIcon (d?.moon_phase || 0),\n note: note\n };\n});\n\n\nreturn msg;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"url","module":"url"}],"x":120,"y":100,"wires":[["81fd42cc4adc9897","db24b0e70c23d3c9"]]},{"id":"bdc4567fd3ca5a28","type":"comment","z":"6babbb5b5eab71f6","g":"aae3b45877bf45a1","name":"Notes","info":"OWM call specifies metric units, but I show wind speed as mph\nIf you change to other units the template will still say °C but it won't convert\n\nAPI limits\nThis uses OpenWeatherMap OneCall 3.0 which requires a key.\nIt's free up to 1000 calls a day but there's a fee if you exceed that.\nSo it caches results for up to 15 minutes and limits the number of calls to 250 a day. \nIf you don't access the API from anywere else you can change it.\nThe flow expects to find the key in a global context variable.\n\nLocation\nTo get a forecast for a specific location inject an object eg\n{\n \"location\": \"Flying Fish Cove\",\n \"latitude\": -10.426302,\n \"longitude\": 105.67324\n}\nJust a timestamp will use the defaults set in function \"Cached or API call\"\n\nBackground colour\nThe widget background varies according to the location's current temperature.\nColours and change temps are set in function \"Massage data\"\n\nIcons\nThe API returns both an icon 10d and a finer grained weather condition code.\nThere are icon sets available on github for both.\nYou can choose which set to display at the top of function \"massage data\"\nMoon phase icons are now available.\n\nExceptional weather\nStrong winds, heavy rainfall and snow are indicated by a text note under the icon and icon colour:\nwind > red (because coastal storm warning flags in the US are red)\nRain > blue \nSnow > white \nThe trigger levels are arbitrary and only one indication can be shown per time period.\nEasy to include additional extreme conditions - ice, hail, UV index etc.\n\nWidget size\nThe template width will vary according to the browser window, whether the left menu bar is displayed etc.\nConsequently the contents have to scale to fit, hence cqi font sizes etc. \nTemplate height is fixed as a multiple of the page theme row height, so sometimes there may be\nwhite space below the display.\n\nOn my laptop the widget seems to work at sizes 8x9, 7x8, 6x7 and 5x6 when the theme row height is 48px.\nFor theme row height 32px, to avoid a vertical scrollbar at some widget sizes, add one more row eg 7x9\nIf it occupise the full width of the ui-group, use auto.","x":750,"y":60,"wires":[]},{"id":"a36453613d8e0af1","type":"ui-template","z":"6babbb5b5eab71f6","g":"aae3b45877bf45a1","group":"","page":"c01a9a1536e052af","ui":"","name":"","order":0,"width":"11","height":"4","head":"","format":"/* */\n:root {\n --locationsize: clamp(14px, 6cqi, 50px);\n --timesize: clamp(10px, 4cqi, 40px);\n --alertsize: clamp(10px, 3.5cqi, 20px);\n --currentlysize: clamp(10px, 3.8cqi, 24px);\n --iconsfontsize: clamp(9px, 2.8cqi, 20px);\n\n --alertcolor: orangered;\n\n --moon-size: 3.5cqi; /* 3cqi seems about right */\n --moon-lit: #ffff80; /* colour the moon face */\n --moon-dark: transparent; /* try rgba(26, 26, 26, 0.35) for a visible dark circle, or transparent */\n \n}\n\n/* Outer container because the template height is fixed but contents may not fill it at small sizes */\n.dashboard-container {\n width: 100%;\n height: auto;\n padding: 0;\n border: none;\n background: none;\n}\n\n/* Inner container vertical size fits content */\n.content-wrap {\n width: 100%;\n container-type: inline-size;\n /* Enables container queries font sizes */\n display: flex;\n flex-direction: column;\n padding: 5px;\n border: 4px solid black;\n border-radius: 12px;\n box-shadow: 4px 4px 28px rgba(0, 0, 0, 0.3) inset;\n box-sizing: border-box;\n color: #222;\n}\n\n.location-row, .comments-row, .current, .currentitem, .currentlabel,\n.icon-time, .icon-day, .icon-temp, .icon-note {\n filter: none !important; /* filter looks awful if applied to text */\n text-shadow:\n 1px 1px 2px rgba(0, 0, 0, 0.55),\n 0px 0px 4px rgba(0, 0, 0, 0.40),\n 0px 0px 6px rgba(0, 0, 0, 0.25);\n}\n\n/* === Location Row === */\n.location-row {\n display: flex;\n justify-content: space-between;\n align-items: baseline;\n width: 97%;\n margin: 0 auto;\n font-family: \"Times New Roman\", serif;\n line-height: 1.1;\n}\n\n.location-left {\n font-size: var(--locationsize);\n white-space: nowrap;\n}\n\n.location-right {\n font-size: var(--timesize);\n white-space: nowrap;\n}\n\n/* === Comments row === */\n.comments-row {\n display: flex;\n justify-content: flex-end;\n margin-right: 2%;\n white-space: nowrap;\n color: var(--alertcolor);\n font-size: var(--alertsize);\n}\n\n/* === Current conditions row === */\n.current {\n display: flex;\n justify-content: space-between;\n align-items: baseline;\n width: 100%;\n padding: 0 1rem;\n font-size: var(--currentlysize);\n}\n\n/* Each value unit (Temp, Wind, Hum, Pres) */\n.currentitem {\n display: flex;\n gap: 0.25em;\n align-items: baseline;\n}\n\n.currentlabel {\n font-size: 0.7em; /* (70% of row font size) */\n opacity: 0.8;\n}\n\n@container (max-width: 500px) { /* Hide labels when container width is below 500px */\n .currentlabel {\n display: none;\n }\n .moon-wrapper {\n display: none;\n }\n}\n\n/* === Hourly and Daily rows === */\n.hourly-row, .daily-row {\n display: flex;\n flex-direction: row;\n justify-content: center;\n margin: 0 auto;\n align-items: flex-start;\n box-sizing: border-box;\n}\n\n.hourly-row {\n width: 95%;\n margin-top: 2cqh;\n gap: 4px;\n}\n\n.daily-row {\n width: 85%;\n margin: 0 auto;\n margin-top: 2cqh;\n position: relative; /* required to absolutely position moons */\n gap: 5px;\n}\n\n/* Hourly: 12 icons */\n.hourly-row .icon-wrapper {\n display: flex;\n flex-direction: column;\n align-items: center;\n min-width: 0;\n text-align: center;\n flex: 1 1 0;\n /*gap: 2px; NO - this belongs on the row not each icon\n flex: 0 0 calc((100% - 11 * 12px) / 12); flex: 1 1 0 seems better */\n}\n\n/* Daily: 8 icons */\n.daily-row .icon-wrapper {\n display: flex;\n flex-direction: column;\n align-items: center;\n min-width: 0;\n text-align: center;\n /*gap: 2px;*/\n /*flex: 0 0 calc((100% - 7 * 12px) / 8);*/\n flex: 1 1 0;\n position: relative;\n}\n\n/* === Various text items === */\n.icon-time, .icon-temp, .icon-note, .icon-day {\n font-size: var(--iconsfontsize);\n margin: 0;\n line-height: 1.2;\n}\n\n/* === Icon Wrapper === * /\n.icon-wrapper {\n display: flex;\n flex-direction: column;\n align-items: center;\n min-width: 0;\n text-align: center;\n gap: 2px;\n} */\n\n/* === Icon wrapper for drop shadow === */\n.icon-filter-wrap {\n width: 100%;\n max-width: 60px;\n aspect-ratio: 1 / 1;\n display: flex;\n justify-content: center;\n align-items: center;\n filter:\n drop-shadow(0 0.5px 0.8px rgba(0, 0, 0, 0.55)) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.28)) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.18));\n}\n\n/* === Mask to colorise weather icon === */\n.weather-icon {\n width: 100%;\n aspect-ratio: 1 / 1;\n max-width: 60px;\n background-color: currentColor;\n mask-image: var(--icon);\n -webkit-mask-image: var(--icon);\n mask-repeat: no-repeat;\n -webkit-mask-repeat: no-repeat;\n mask-size: contain;\n -webkit-mask-size: contain;\n mask-position: center;\n -webkit-mask-position: center;\n}\n\n.moon-wrapper {\n position: absolute;\n width: var(--moon-size);\n height: var(--moon-size);\n border-radius: 50%;\n top: 0;\n left: 90%;\n background-color: var(--moon-dark);\n overflow: visible;\n filter: drop-shadow(0 0.5px 0.8px rgba(0, 0, 0, 0.55)) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.28)) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.18));\n}\n\n.moon-wrapper::after {\n content: \"\";\n position: absolute;\n inset: 0;\n background-color: var(--moon-lit);\n mask-image: var(--mask);\n -webkit-mask-image: var(--mask);\n mask-repeat: no-repeat;\n -webkit-mask-repeat: no-repeat;\n mask-size: cover;\n -webkit-mask-size: cover;\n mask-position: center;\n -webkit-mask-position: center;\n mask-mode: alpha;\n -webkit-mask-mode: alpha;\n border-radius: 50%;\n}","storeOutMessages":true,"passthru":true,"resendOnRefresh":true,"templateScope":"page:style","className":"","x":730,"y":100,"wires":[[]]},{"id":"81fd42cc4adc9897","type":"ui-template","z":"6babbb5b5eab71f6","g":"aae3b45877bf45a1","group":"737e097eb125e416","page":"","ui":"","name":"Weather auto (8)","order":3,"width":"0","height":"0","head":"","format":"<template>\n <div class=\"dashboard-container\">\n <div class=\"content-wrap\" :style=\"`background-color: ${msg?.payload?.color ?? 'aliceblue'}`\">\n\n <!-- === LOCATION ROW === -->\n <div class=\"location-row\">\n <div class=\"location-left\">{{ msg?.payload?.location ?? 'Unknown' }}</div>\n <div class=\"location-right\">@ {{ msg?.payload?.forecasttime ?? ''}}</div>\n </div>\n\n <!-- === CURRENT CONDITIONS ROW === -->\n <div class=\"current\">\n <div class=\"currentitem\"><span class=\"currentlabel\">Temp</span> {{ msg?.payload?.temp ?? '?'}}</div>\n <div class=\"currentitem\"><span class=\"currentlabel\">Wind</span> {{ msg?.payload?.wind ?? '?'}}</div>\n <div class=\"currentitem\"><span class=\"currentlabel\">Hum</span> {{ msg?.payload?.humidity ?? '?'}}</div>\n <div class=\"currentitem\"><span class=\"currentlabel\">Pres</span> {{ msg?.payload?.pressure ?? '?'}}</div>\n </div>\n\n <!-- === WEATHER WARNINGS ROW (If any) === -->\n <div class=\"comments-row\" v-if=\"msg?.payload?.comments\">\n {{ msg.payload.comments }}\n </div>\n\n <!-- === HOURLY ROW === -->\n <div class=\"hourly-row\">\n <div class=\"icon-wrapper\" v-for=\"(h,index) in msg?.payload?.hourly?.slice(0,12)\" :key=\"'h'+index\">\n <div class=\"icon-time\">{{ h.hhmm }}</div>\n <div class=\"icon-filter-wrap\">\n <div class=\"weather-icon\" :style=\"{\n '--icon': `url('/${h.icon}')`,\n color: h.color || '#222'\n }\"></div>\n </div>\n <div class=\"icon-temp\">{{ Math.round(h.temp) }}°</div>\n <div class=\"icon-note\" v-if=\"h.note\" \n :style=\"{ color: h.color || '' }\"> {{ h.note }}</div>\n </div>\n </div>\n\n <!-- === DAILY ROW (with moon phase) === -->\n <div class=\"daily-row\">\n <div class=\"icon-wrapper\" v-for=\"(d,index) in msg?.payload?.daily?.slice(0,8)\" :key=\"'d'+index\">\n <!-- Moon with mask + ring, now scalable -->\n <div class=\"moon-wrapper\"\n :style=\"`--mask: url('${d.moon}')`\">\n </div>\n\n <div class=\"icon-day\">{{ d.ddd }}</div>\n <div class=\"icon-filter-wrap\">\n <div class=\"weather-icon\" :style=\"{\n '--icon': `url('/${d.icon}')`,\n color: d.color || '#222'\n }\"></div>\n </div>\n <div class=\"icon-temp\">{{ Math.round(d.temp.max) }}/{{ Math.round(d.temp.min) }}°</div>\n <div class=\"icon-note\" v-if=\"d.note\" \n :style=\"{ color: d.color || '' }\"> {{ d.note }}</div>\n </div>\n </div>\n\n </div>\n </div>\n</template>\n","storeOutMessages":true,"passthru":true,"resendOnRefresh":true,"templateScope":"local","className":"","x":370,"y":100,"wires":[[]]},{"id":"db24b0e70c23d3c9","type":"ui-template","z":"6babbb5b5eab71f6","g":"aae3b45877bf45a1","group":"737e097eb125e416","page":"","ui":"","name":"Weather 5x6 for 48px theme row ","order":2,"width":"5","height":"6","head":"","format":"<template>\n <div class=\"dashboard-container\">\n <div class=\"content-wrap\" :style=\"`background-color: ${msg?.payload?.color ?? 'aliceblue'}`\">\n\n <!-- === LOCATION ROW === -->\n <div class=\"location-row\">\n <div class=\"location-left\">{{ msg?.payload?.location ?? 'Unknown' }}</div>\n <div class=\"location-right\">@ {{ msg?.payload?.forecasttime ?? ''}}</div>\n </div>\n\n <!-- === CURRENT CONDITIONS ROW === -->\n <div class=\"current\">\n <div class=\"currentitem\"><span class=\"currentlabel\">Temp</span> {{ msg?.payload?.temp ?? '?'}}</div>\n <div class=\"currentitem\"><span class=\"currentlabel\">Wind</span> {{ msg?.payload?.wind ?? '?'}}</div>\n <div class=\"currentitem\"><span class=\"currentlabel\">Hum</span> {{ msg?.payload?.humidity ?? '?'}}</div>\n <div class=\"currentitem\"><span class=\"currentlabel\">Pres</span> {{ msg?.payload?.pressure ?? '?'}}</div>\n </div>\n\n <!-- === WEATHER WARNINGS ROW (If any) === -->\n <div class=\"comments-row\" v-if=\"msg?.payload?.comments\">\n {{ msg.payload.comments }}\n </div>\n\n <!-- === HOURLY ROW === -->\n <div class=\"hourly-row\">\n <div class=\"icon-wrapper\" v-for=\"(h,index) in msg?.payload?.hourly?.slice(0,12)\" :key=\"'h'+index\">\n <div class=\"icon-time\">{{ h.hhmm }}</div>\n <div class=\"icon-filter-wrap\">\n <div class=\"weather-icon\" :style=\"{\n '--icon': `url('/${h.icon}')`,\n color: h.color || '#222'\n }\"></div>\n </div>\n <div class=\"icon-temp\">{{ Math.round(h.temp) }}°</div>\n <div class=\"icon-note\" v-if=\"h.note\" \n :style=\"{ color: h.color || '' }\"> {{ h.note }}</div>\n </div>\n </div>\n\n <!-- === DAILY ROW (with moon phase) === -->\n <div class=\"daily-row\">\n <div class=\"icon-wrapper\" v-for=\"(d,index) in msg?.payload?.daily?.slice(0,8)\" :key=\"'d'+index\">\n <!-- Moon with mask + ring, now scalable -->\n <div class=\"moon-wrapper\"\n :style=\"`--mask: url('${d.moon}')`\">\n </div>\n\n <div class=\"icon-day\">{{ d.ddd }}</div>\n <div class=\"icon-filter-wrap\">\n <div class=\"weather-icon\" :style=\"{\n '--icon': `url('/${d.icon}')`,\n color: d.color || '#222'\n }\"></div>\n </div>\n <div class=\"icon-temp\">{{ Math.round(d.temp.max) }}/{{ Math.round(d.temp.min) }}°</div>\n <div class=\"icon-note\" v-if=\"d.note\" \n :style=\"{ color: d.color || '' }\"> {{ d.note }}</div>\n </div>\n </div>\n\n </div>\n </div>\n</template>\n","storeOutMessages":true,"passthru":true,"resendOnRefresh":true,"templateScope":"local","className":"","x":410,"y":60,"wires":[[]]},{"id":"c01a9a1536e052af","type":"ui-page","name":"weather","ui":"e9c974f7c1d080d1","path":"/weather","icon":"home","layout":"grid","theme":"57e1923decfbf45b","breakpoints":[{"name":"Default","px":"0","cols":"3"},{"name":"Tablet","px":"576","cols":"6"},{"name":"Small Desktop","px":"768","cols":"9"},{"name":"Desktop","px":"1024","cols":"12"}],"order":1,"className":"","visible":true,"disabled":false},{"id":"737e097eb125e416","type":"ui-group","name":"weather","page":"c01a9a1536e052af","width":"8","height":1,"order":1,"showTitle":true,"className":"","visible":"true","disabled":"false","groupType":"default"},{"id":"e9c974f7c1d080d1","type":"ui-base","name":"My Dashboard","path":"/dashboard","appIcon":"","includeClientData":true,"acceptsClientConfig":["ui-notification","ui-control"],"showPathInSidebar":false,"headerContent":"page","navigationStyle":"default","titleBarStyle":"default","showReconnectNotification":true,"notificationDisplayTime":1,"showDisconnectNotification":true,"allowInstall":true},{"id":"57e1923decfbf45b","type":"ui-theme","name":"Basic Blue Theme","colors":{"surface":"#4d58ff","primary":"#0094ce","bgPage":"#eeeeee","groupBg":"#ffffff","groupOutline":"#cccccc"},"sizes":{"pagePadding":"12px","groupGap":"12px","groupBorderRadius":"4px","widgetGap":"2px","density":"default"}},{"id":"661d772012ee1c68","type":"global-config","env":[],"modules":{"@flowfuse/node-red-dashboard":"1.29.0"}}]