Weather Forecast Display for Dashboard 2

A flexible size weather forecast display using data from OpenWeatherMap's OneCall 3.0 API.

This is the default location - Charing Cross of course.

You can pick any location by passing in a location name, latitude and longitude.

The template shows current and forecast data for the next 12 hours, and for 7 days ahead.

If any weather alerts exist for the period, only the first is shown, below the current conditions data.

Unusually severe winds, or rain and snow accumulation in any forecast period are noted under the relevant icon, and the icon is coloured accordingly. (I don't know what colour a gale is but in the USA maritime gale warning flags are red...)

1 Like

The height of a dashboard widget is fixed as a multiple of the height of a row in the ui-page theme (48, 36 or 32 pixels).
The widget is rendered as a variable width according to the browser window and whether or not the page navigation sidebar is displayed.
Therefore the widget contents are variable size to maintain the appearance.

The background colour varies according to the current temperature.
These colours are definitely not the best choice, especially the very hot one, which I think should be more of an Aussie desert red.
The weather alert text is not legible on this magenta background either.

2 Likes

First a flow to request and cache data from the OpenWeatherMap OneCall 3.0 API

[{"id":"450acd183ae97cdb","type":"group","z":"6babbb5b5eab71f6","name":"Openweathermap  OneCall 3.0  NB Expects API key in global.openweathermapid","style":{"label":true},"nodes":["72e3382f76cad715","a86fbda3874a56d5","cd621a92e9c85ab1","c028023f5173983a","9bcf80ba2174c86e","1e7cad2e8c192809","886d58069370e4c7","dbb420d96a983698"],"x":19,"y":19,"w":652,"h":172},{"id":"72e3382f76cad715","type":"function","z":"6babbb5b5eab71f6","g":"450acd183ae97cdb","name":"Cached or API call","func":"const owmid = global.get(\"openweathermapid\") \nconst maxdailyOWMcalls = 250 // First 1000 API calls per day are free\n\n// Get coordinates from msg (or set defaults)\nconst lat = msg.payload.latitude || 51.5074\nconst lon = msg.payload.longitude || -0.1278\nconst location = msg.payload.location ? encodeURIComponent(msg.payload.location) : \"Charing Cross, London\"\n\n// use msg.url to retain location name - even if data is cached\nmsg.url = \"https://api.openweathermap.org/data/3.0/onecall\"\nmsg.url += \"?lat=\" + lat + \"&lon=\" + lon\nmsg.url += \"&units=metric&exclude=minutely\"\nmsg.url += \"&location=\" + location\nmsg.url += \"&appid=\" + owmid\n\n// Create cache key per location\nconst key = msg.payload.location\n\n// Retrieve cache object from flow context\nlet cache = flow.get(\"weatherCache\") || {}\nlet record = cache[key]\n\nconst FIFTEEN_MIN = 15 * 60 * 1000\nconst now = Date.now()\n\nif (record && (now - record.timestamp < FIFTEEN_MIN)) {\n    // Cached data still fresh\n    msg.payload = record.data;\n    msg.cached = true;\n    node.status({fill:\"green\",shape:\"dot\",text: msg.payload.location + \" Cache hit\"})\n    return [msg, null];  \n} else {\n    // Cache missing or expired\n    let apicallstoday = flow.get(\"OWMcallstoday\") ?? 0\n    msg.cached = false;\n    if (apicallstoday < maxdailyOWMcalls) {\n        node.status({ fill: \"green\", shape: \"dot\", text: msg.payload.location + \" Cache miss\" })\n        apicallstoday++\n        flow.set(\"OWMcallstoday\", apicallstoday)\n        return [null, msg] \n    }  \n    else {\n        node.error(\"Too many API calls\")\n        node.status({ fill: \"red\", shape: \"dot\", text: \"Too Many API Calls\" })\n        return [null, null]\n    }\n}","outputs":2,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":375,"y":60,"wires":[["1e7cad2e8c192809"],["a86fbda3874a56d5"]]},{"id":"a86fbda3874a56d5","type":"http request","z":"6babbb5b5eab71f6","g":"450acd183ae97cdb","name":"Openweathermap","method":"GET","ret":"obj","paytoqs":"ignore","url":"","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[],"x":375,"y":105,"wires":[["cd621a92e9c85ab1"]]},{"id":"cd621a92e9c85ab1","type":"function","z":"6babbb5b5eab71f6","g":"450acd183ae97cdb","name":"To cache","func":"const location = (new URL(msg.url)).searchParams.get('location')\nmsg.payload.location = location\nmsg.payload.url = msg.url\nconst key = msg.payload.location\nlet cache = flow.get(\"weatherCache\") || {};\ncache[key] = {\n    data: msg.payload, \n    timestamp: Date.now()\n};\n\nflow.set(\"weatherCache\", cache);\n\nreturn msg;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"url","module":"url"}],"x":555,"y":105,"wires":[["1e7cad2e8c192809"]]},{"id":"c028023f5173983a","type":"inject","z":"6babbb5b5eab71f6","g":"450acd183ae97cdb","name":"Daily reset","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"00 00 * * *","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":155,"y":150,"wires":[["9bcf80ba2174c86e"]]},{"id":"9bcf80ba2174c86e","type":"change","z":"6babbb5b5eab71f6","g":"450acd183ae97cdb","name":"Reset cache & API limit","rules":[{"t":"delete","p":"apicallstoday","pt":"flow"},{"t":"delete","p":"weatherCache","pt":"flow"}],"action":"","property":"","from":"","to":"","reg":false,"x":395,"y":150,"wires":[[]]},{"id":"1e7cad2e8c192809","type":"junction","z":"6babbb5b5eab71f6","g":"450acd183ae97cdb","x":645,"y":60,"wires":[["dbb420d96a983698"]]},{"id":"886d58069370e4c7","type":"inject","z":"6babbb5b5eab71f6","g":"450acd183ae97cdb","name":"Default location","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":145,"y":60,"wires":[["72e3382f76cad715"]]},{"id":"dbb420d96a983698","type":"junction","z":"6babbb5b5eab71f6","g":"450acd183ae97cdb","x":645,"y":165,"wires":[["fc1e310ae95a96ca"]]}]

And second the weather widget itself

[{"id":"df06a118d9fd4c82","type":"group","z":"6babbb5b5eab71f6","style":{"stroke":"#0722fc","stroke-opacity":"1","fill":"none","fill-opacity":"1","label":true,"label-position":"nw","color":"#0722fc"},"nodes":["fc1e310ae95a96ca","9dd112296bda8803","9f27b8b89a58b286","45bfdbee8c3d10e3","0e68b11bb1fd4c17","c28b9b3670a06822","fb304d29da5c02d2"],"x":24,"y":199,"w":562,"h":217},{"id":"fc1e310ae95a96ca","type":"function","z":"6babbb5b5eab71f6","g":"df06a118d9fd4c82","name":"Massage data","func":"// ============================================================================\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}\n// =========================\n// Temp to background colour\n// =========================\nfunction backgroundcolor(temp) {\nlet color\nif (temp < -15) color = \"oklch(85% 0.05 290)\"       // 1. Pale Indigo/Purple \nelse if (temp < -10) color = \"oklch(87% 0.07 310)\"  // 2. Pale Violet \nelse if (temp < -5) color = \"oklch(89% 0.09 265)\"   // 3.  Pale Deep Blue \nelse if (temp < 0) color = \"oklch(91% 0.10 250)\"    // 4. Pale Blue\nelse if (temp < 5) color = \"oklch(93% 0.10 210)\"    // 5. Very Light Blue/Cyan \nelse if (temp < 10) color = \"oklch(93% 0.08 150)\"   // 6. Very Light Green\nelse if (temp < 15) color = \"oklch(95% 0.10 110)\"   // 7. Extremely Light Lime \nelse if (temp < 20) color = \"oklch(96% 0.10 95)\"    // 8. Almost White Yellow\nelse if (temp < 25) color = \"oklch(93% 0.12 60)\"    // 9. Pale Orange\nelse if (temp < 30) color = \"oklch(91% 0.15 15)\"    // 10. Pale Red/Salmon\nelse if (temp < 35) color = \"oklch(87% 0.17 5)\"     // 11. Deeper Pale Red\nelse                color = \"oklch(85% 0.22 10)\"    // 12. Light Magenta-Red\nreturn  color\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\nconst tz  = msg.payload.timezone;\nconst tzo = msg.payload.timezone_offset;\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 = getLocalTime(\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\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 = backgroundcolor(msg.payload.current.temp)\nmsg.payload.location = msg.payload.location.substring(0, 30)\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 = getLocalTime(alert.start, msg.payload.timezone, msg.payload.timezone_offset);\n            const e = getLocalTime(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 = getLocalTime(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 = h.rain[\"1h\"] + \" mm\"\n        note = \"Rain\"\n        h.color = \"blue\" \n    } else if (h.snow && h.snow[\"1h\"] >= 0.5) {\n        note = h.snow[\"1h\"] +\" mm\"\n        note = \"Snow\"\n        h.color = \"white\"\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        note: note\n    };\n});\n\n// ============================================================================\n// DAILY\n// ============================================================================\nmsg.payload.daily = (msg.payload.daily || []).map(d => {\n\n    const lt = getLocalTime(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        d.color = \"blue\"\n    } else if (d.snow && d.snow >= 2) {\n        note = d.snow\n        note = \"Snow\"\n        d.color = \"white\"\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        note: note\n    };\n});\n\n\nreturn msg;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"url","module":"url"}],"x":130,"y":240,"wires":[["9dd112296bda8803","45bfdbee8c3d10e3","9f27b8b89a58b286","0e68b11bb1fd4c17"]]},{"id":"9dd112296bda8803","type":"ui-template","z":"6babbb5b5eab71f6","g":"df06a118d9fd4c82","group":"737e097eb125e416","page":"","ui":"","name":"OWM Icons 8x9","order":2,"width":"0","height":"0","head":"","format":"<template>\n  <div class=\"dashboard-container\">\n    <div class=\"content-wrap\" :style=\"{backgroundColor: msg.payload.color}\">\n      <!-- === LOCATION ROW === -->\n      <div class=\"location-row\">\n        <div class=\"location-left\"> {{ msg.payload.location }}</div>\n        <div class=\"location-right\">\n          @ {{ msg.payload.forecasttime }}\n        </div>\n      </div>\n      <!-- === CURRENT CONDITIONS ROW === -->\n      <div class=\"current\">\n        <div class=\"currentitem\">\n          <span class=\"currentlabel\">Temp</span> {{ msg.payload.temp }}\n        </div>\n        <div class=\"currentitem\">\n          <span class=\"currentlabel\">Wind</span> {{ msg.payload.wind }}\n        </div>\n        <div class=\"currentitem\">\n          <span class=\"currentlabel\">Hum</span> {{ msg.payload.humidity }}\n        </div>\n        <div class=\"currentitem\">\n          <span class=\"currentlabel\">Pres</span> {{ msg.payload.pressure }}\n        </div>\n      </div>\n\n      <!-- === OPTIONAL COMMENTS ROW === -->\n      <div class=\"comments-row\" v-if=\"msg.payload.comments\">\n        {{ msg.payload.comments }}\n      </div>\n\n      <!--                         HOURLY                           -->\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          <!-- Time above icon -->\n          <div class=\"icon-time\">{{ h.hhmm }}</div>\n          <!-- Icon (shadow wrapper) -->\n          <div class=\"icon-filter-wrap\">\n            <div class=\"weather-icon\" :style=\"{\n                 '--icon': `url('/icons2/${h.weather[0].icon}.svg')`,\n                 color: h.color || '#222'\n               }\">\n            </div>\n          </div>\n          <!-- Temp below icon -->\n          <div class=\"icon-temp\">{{ Math.round(h.temp) }}°</div>\n          <!-- Note below temp -->\n          <div class=\"icon-note\" v-if=\"h.note\">\n            {{ h.note }}\n          </div>\n        </div>\n      </div>\n\n      <!--                         DAILY                            -->\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          <!-- Day name -->\n          <div class=\"icon-day\">{{ d.ddd }}</div>\n          <!-- Icon  -->\n          <div class=\"icon-filter-wrap\">\n            <div class=\"weather-icon\" :style=\"{\n                 '--icon': `url('/icons2/${d.weather[0].icon}.svg')`,\n                 color: d.color || '#222'\n               }\">\n            </div>\n          </div>\n          <!-- Temperature high/low -->\n          <div class=\"icon-temp\">\n            {{ Math.round(d.temp.max) }}/{{ Math.round(d.temp.min) }}°\n          </div>\n          <!-- Notes -->\n          <div class=\"icon-note\" v-if=\"d.note\">\n            {{ d.note }}\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>","storeOutMessages":true,"passthru":true,"resendOnRefresh":true,"templateScope":"local","className":"","x":355,"y":240,"wires":[[]]},{"id":"9f27b8b89a58b286","type":"ui-template","z":"6babbb5b5eab71f6","g":"df06a118d9fd4c82","group":"737e097eb125e416","page":"","ui":"","name":"OWM Icons 5x6","order":6,"width":"5","height":"6","head":"","format":"<template>\n  <div class=\"dashboard-container\">\n    <div class=\"content-wrap\" :style=\"{backgroundColor: msg.payload.color}\">\n      <!-- === LOCATION ROW === -->\n      <div class=\"location-row\">\n        <div class=\"location-left\"> {{ msg.payload.location }}</div>\n        <div class=\"location-right\">\n          @ {{ msg.payload.forecasttime }}\n        </div>\n      </div>\n      <!-- === CURRENT CONDITIONS ROW === -->\n      <div class=\"current\">\n        <div class=\"currentitem\">\n          <span class=\"currentlabel\">Temp</span> {{ msg.payload.temp }}\n        </div>\n        <div class=\"currentitem\">\n          <span class=\"currentlabel\">Wind</span> {{ msg.payload.wind }}\n        </div>\n        <div class=\"currentitem\">\n          <span class=\"currentlabel\">Hum</span> {{ msg.payload.humidity }}\n        </div>\n        <div class=\"currentitem\">\n          <span class=\"currentlabel\">Pres</span> {{ msg.payload.pressure }}\n        </div>\n      </div>\n\n      <!-- === OPTIONAL COMMENTS ROW === -->\n      <div class=\"comments-row\" v-if=\"msg.payload.comments\">\n        {{ msg.payload.comments }}\n      </div>\n\n      <!--                         HOURLY                           -->\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          <!-- Time above icon -->\n          <div class=\"icon-time\">{{ h.hhmm }}</div>\n          <!-- Icon (shadow wrapper) -->\n          <div class=\"icon-filter-wrap\">\n            <div class=\"weather-icon\" :style=\"{\n                 '--icon': `url('/icons2/${h.weather[0].icon}.svg')`,\n                 color: h.color || '#222'\n               }\">\n            </div>\n          </div>\n          <!-- Temp below icon -->\n          <div class=\"icon-temp\">{{ Math.round(h.temp) }}°</div>\n          <!-- Note below temp -->\n          <div class=\"icon-note\" v-if=\"h.note\">\n            {{ h.note }}\n          </div>\n        </div>\n      </div>\n\n      <!--                         DAILY                            -->\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          <!-- Day name -->\n          <div class=\"icon-day\">{{ d.ddd }}</div>\n          <!-- Icon  -->\n          <div class=\"icon-filter-wrap\">\n            <div class=\"weather-icon\" :style=\"{\n                 '--icon': `url('/icons2/${d.weather[0].icon}.svg')`,\n                 color: d.color || '#222'\n               }\">\n            </div>\n          </div>\n          <!-- Temperature high/low -->\n          <div class=\"icon-temp\">\n            {{ Math.round(d.temp.max) }}/{{ Math.round(d.temp.min) }}°\n          </div>\n          <!-- Notes -->\n          <div class=\"icon-note\" v-if=\"d.note\">\n            {{ d.note }}\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>","storeOutMessages":true,"passthru":true,"resendOnRefresh":true,"templateScope":"local","className":"","x":355,"y":375,"wires":[[]]},{"id":"45bfdbee8c3d10e3","type":"ui-template","z":"6babbb5b5eab71f6","g":"df06a118d9fd4c82","group":"737e097eb125e416","page":"","ui":"","name":"OWM Icons 6x7","order":5,"width":"6","height":"7","head":"","format":"<template>\n  <div class=\"dashboard-container\">\n    <div class=\"content-wrap\" :style=\"{backgroundColor: msg.payload.color}\">\n      <!-- === LOCATION ROW === -->\n      <div class=\"location-row\">\n        <div class=\"location-left\"> {{ msg.payload.location }}</div>\n        <div class=\"location-right\">\n          @ {{ msg.payload.forecasttime }}\n        </div>\n      </div>\n      <!-- === CURRENT CONDITIONS ROW === -->\n      <div class=\"current\">\n        <div class=\"currentitem\">\n          <span class=\"currentlabel\">Temp</span> {{ msg.payload.temp }}\n        </div>\n        <div class=\"currentitem\">\n          <span class=\"currentlabel\">Wind</span> {{ msg.payload.wind }}\n        </div>\n        <div class=\"currentitem\">\n          <span class=\"currentlabel\">Hum</span> {{ msg.payload.humidity }}\n        </div>\n        <div class=\"currentitem\">\n          <span class=\"currentlabel\">Pres</span> {{ msg.payload.pressure }}\n        </div>\n      </div>\n\n      <!-- === OPTIONAL COMMENTS ROW === -->\n      <div class=\"comments-row\" v-if=\"msg.payload.comments\">\n        {{ msg.payload.comments }}\n      </div>\n\n      <!--                         HOURLY                           -->\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          <!-- Time above icon -->\n          <div class=\"icon-time\">{{ h.hhmm }}</div>\n          <!-- Icon (shadow wrapper) -->\n          <div class=\"icon-filter-wrap\">\n            <div class=\"weather-icon\" :style=\"{\n                 '--icon': `url('/icons2/${h.weather[0].icon}.svg')`,\n                 color: h.color || '#222'\n               }\">\n            </div>\n          </div>\n          <!-- Temp below icon -->\n          <div class=\"icon-temp\">{{ Math.round(h.temp) }}°</div>\n          <!-- Note below temp -->\n          <div class=\"icon-note\" v-if=\"h.note\">\n            {{ h.note }}\n          </div>\n        </div>\n      </div>\n\n      <!--                         DAILY                            -->\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          <!-- Day name -->\n          <div class=\"icon-day\">{{ d.ddd }}</div>\n          <!-- Icon  -->\n          <div class=\"icon-filter-wrap\">\n            <div class=\"weather-icon\" :style=\"{\n                 '--icon': `url('/icons2/${d.weather[0].icon}.svg')`,\n                 color: d.color || '#222'\n               }\">\n            </div>\n          </div>\n          <!-- Temperature high/low -->\n          <div class=\"icon-temp\">\n            {{ Math.round(d.temp.max) }}/{{ Math.round(d.temp.min) }}°\n          </div>\n          <!-- Notes -->\n          <div class=\"icon-note\" v-if=\"d.note\">\n            {{ d.note }}\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>","storeOutMessages":true,"passthru":true,"resendOnRefresh":true,"templateScope":"local","className":"","x":355,"y":330,"wires":[[]]},{"id":"0e68b11bb1fd4c17","type":"ui-template","z":"6babbb5b5eab71f6","g":"df06a118d9fd4c82","group":"737e097eb125e416","page":"","ui":"","name":"OWM Icons 7x8","order":4,"width":"7","height":"8","head":"","format":"<template>\n  <div class=\"dashboard-container\">\n    <div class=\"content-wrap\" :style=\"{backgroundColor: msg.payload.color}\">\n      <!-- === LOCATION ROW === -->\n      <div class=\"location-row\">\n        <div class=\"location-left\"> {{ msg.payload.location }}</div>\n        <div class=\"location-right\">\n          @ {{ msg.payload.forecasttime }}\n        </div>\n      </div>\n      <!-- === CURRENT CONDITIONS ROW === -->\n      <div class=\"current\">\n        <div class=\"currentitem\">\n          <span class=\"currentlabel\">Temp</span> {{ msg.payload.temp }}\n        </div>\n        <div class=\"currentitem\">\n          <span class=\"currentlabel\">Wind</span> {{ msg.payload.wind }}\n        </div>\n        <div class=\"currentitem\">\n          <span class=\"currentlabel\">Hum</span> {{ msg.payload.humidity }}\n        </div>\n        <div class=\"currentitem\">\n          <span class=\"currentlabel\">Pres</span> {{ msg.payload.pressure }}\n        </div>\n      </div>\n\n      <!-- === OPTIONAL COMMENTS ROW === -->\n      <div class=\"comments-row\" v-if=\"msg.payload.comments\">\n        {{ msg.payload.comments }}\n      </div>\n\n      <!--                         HOURLY                           -->\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          <!-- Time above icon -->\n          <div class=\"icon-time\">{{ h.hhmm }}</div>\n          <!-- Icon (shadow wrapper) -->\n          <div class=\"icon-filter-wrap\">\n            <div class=\"weather-icon\" :style=\"{\n                 '--icon': `url('/icons2/${h.weather[0].icon}.svg')`,\n                 color: h.color || '#222'\n               }\">\n            </div>\n          </div>\n          <!-- Temp below icon -->\n          <div class=\"icon-temp\">{{ Math.round(h.temp) }}°</div>\n          <!-- Note below temp -->\n          <div class=\"icon-note\" v-if=\"h.note\">\n            {{ h.note }}\n          </div>\n        </div>\n      </div>\n\n      <!--                         DAILY                            -->\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          <!-- Day name -->\n          <div class=\"icon-day\">{{ d.ddd }}</div>\n          <!-- Icon  -->\n          <div class=\"icon-filter-wrap\">\n            <div class=\"weather-icon\" :style=\"{\n                 '--icon': `url('/icons2/${d.weather[0].icon}.svg')`,\n                 color: d.color || '#222'\n               }\">\n            </div>\n          </div>\n          <!-- Temperature high/low -->\n          <div class=\"icon-temp\">\n            {{ Math.round(d.temp.max) }}/{{ Math.round(d.temp.min) }}°\n          </div>\n          <!-- Notes -->\n          <div class=\"icon-note\" v-if=\"d.note\">\n            {{ d.note }}\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>","storeOutMessages":true,"passthru":true,"resendOnRefresh":true,"templateScope":"local","className":"","x":355,"y":285,"wires":[[]]},{"id":"c28b9b3670a06822","type":"ui-template","z":"6babbb5b5eab71f6","g":"df06a118d9fd4c82","group":"","page":"c01a9a1536e052af","ui":"","name":"CSS","order":0,"width":0,"height":0,"head":"","format":"/* 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 sizes to content */\n.content-wrap {\n    width: 100%;\n    container-type: inline-size; /* 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}\n\n.location-row, .comments-row, .current, .currentitem, .currentlabel,\n.icon-time, .icon-day, .icon-temp, .icon-note {\n    filter: none !important; /* filter used on icons looks awful on 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: clamp(14px, 6cqi, 50px);\n    white-space: nowrap;\n}\n\n.location-right {\n    font-size: clamp(10px, 4cqi, 40px);\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: orangered;\n    font-size: clamp(10px, 3.5cqi, 20px); /* minimum, preferred, max size */\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-left: 1rem;\n    padding-right: 1rem;\n    font-size: clamp(10px, 3.8cqi, 24px); \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@container (max-width: 500px) { /* Hide labels when container width is below 500px */\n    .currentlabel {\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    align-items: flex-start;\n    gap: 12px;\n    width: 100%;\n    box-sizing: border-box;\n}\n\n.hourly-row {\n    width: 95%;\n    margin: 0 auto;\n    margin-top: 2cqh;\n}\n.daily-row {\n    width: 85%;\n    margin: 0 auto;\n    margin-top: 2cqh;\n}\n\n/* Hourly: 12 icons */\n.hourly-row .icon-wrapper {\n    flex: 0 0 calc((100% - 11 * 12px) / 12);  /* flexible for 12 icons and 11 gaps based on widget size */\n}\n\n/* Daily: 8 icons */\n.daily-row .icon-wrapper {\n    flex: 0 0 calc((100% - 7 * 12px) / 8);\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))\n        drop-shadow(0 2px 3px rgba(0,0,0,0.28))\n        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/* === Various text items === */\n.icon-time, .icon-temp, .icon-note, .icon-day {\n    font-size: clamp(9px, 2.8cqi, 20px);\n    margin: 0;\n    line-height: 1.2;\n}\n","storeOutMessages":true,"passthru":true,"resendOnRefresh":true,"templateScope":"page:style","className":"","x":510,"y":240,"wires":[[]]},{"id":"fb304d29da5c02d2","type":"comment","z":"6babbb5b5eab71f6","g":"df06a118d9fd4c82","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. It expects to find the key in a global context variable.\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 that.\n\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 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\nD\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, in Firefox 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":100,"y":300,"wires":[]},{"id":"737e097eb125e416","type":"ui-group","name":"icons","page":"c01a9a1536e052af","width":"8","height":1,"order":1,"showTitle":true,"className":"","visible":"true","disabled":"false","groupType":"default"},{"id":"c01a9a1536e052af","type":"ui-page","name":"andon","ui":"e9c974f7c1d080d1","path":"/andon","icon":"home","layout":"grid","theme":"0d92c765bfad87e6","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":"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":"0d92c765bfad87e6","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":"a232df0f556f92d4","type":"global-config","env":[],"modules":{"@flowfuse/node-red-dashboard":"1.29.0"}}]

My browser developer console shows an error in the way the background colour is passed into the template:
errorCaptured TypeError: can't access property "color", msg.payload is undefined

<div class="content-wrap" :style="{backgroundColor: msg.payload.color}">

I would be grateful if someone could fix this - and explain why chatgpt suggested backgroundColor, when the css selector should be background-color!

Just in case anyone wants something similar with UIBUILDER, I do have a quick example that comes out like this (yes, the text needs some work!):

It isn't perfect but I wanted to see how easy it might be to convert from a Vue template to vanilla HTML. (Hint: not that hard).

1 Like

I see that my CSS does not explicitly set the text colour, so the text is white on a dark-themed DB2 page too.
Definitely an omission.
.dashboard-container {color: #222;}, or similar, should fix it.

1 Like

By the way, I think this is a more robust getLocalTime function. It also just uses the browser's locale to get the local time/day.

getLocalTime = (dt) => {
    if (dt < 31536000000) dt = dt * 1000 // Convert from seconds to milliseconds if needed
    const date = new Date(dt)
    return {
        day: date.toLocaleDateString('en-US', { weekday: 'short', }),
        time: date.toTimeString().slice(0, 5),
    }
}

The strange number is the number of milliseconds since midnight Jan 1st 1970 which is where JavaScript millisecond values start.

:smiley:

Hmm. I'm suspicious of my getLocalTime function too because of the en-US locale but it seems to work so I left it.

I wanted the time displayed at top right to be the local time in the forecast location - Flying Fish Cove is on Christmas Island, while my browser is in England.
One could equally well argue that the browser's timezone is the better choice.

Then I considered a browser in England, Node-red in the Amazon and forecast location in the Pacific. Mine eyes dazzled...

99.999% of usage would display the forecast for Here, using a Node-red server Here and the question would not arise.

Now I'm envisaging a version specially for @TotallyInformation in Sheffield using location: "Next Door" and a random variation applied to each forecast period :smiley:

In my amended version, that is only used to return the days in English. It is possible to replace that with a navigator property that returns the browser's locale.

Ah, OK, that wasn't clear. However, I don't believe you need to do your jiggle with the date objects. According to my understanding, the datetime millisecond number is automatically treated as UTC but the functions that convert to strings or locale all assume the Locale time. So no need to convert.

You should, I think, be able to use this to get the correct locale time:

date.toLocaleTimeString([], { timeZone: timezone, hour: "2-digit", minute: "2-digit" })

Here is the full amended version. Uses the browser's locale for the text and the passed in timezone.

getLocalTime = (dt, timezone, timezone_offset = 0) => {
    if (dt < 31536000000) dt = dt * 1000 // Convert from seconds to milliseconds if needed
    const date = new Date(dt)
    if (!timezone) timezone = `UTC+${timezone_offset}`
    return {
        day: date.toLocaleDateString([], { weekday: 'short', timeZone: timezone, }),
        time: date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: timezone, }),
    }
}

As far as I can tell, your function does give the same output as mine - the local time in the target location.
I don't understand how to use your style definition

getLocalTime = (dt, timezone, timezone_offset = 0) => {

So I used

function getLocalTimeTI (dt, timezone, timezone_offset = 0) {
    if (dt < 31536000000) dt = dt * 1000 // Convert from seconds to milliseconds if needed
    const date = new Date(dt)
    if (!timezone) timezone = `UTC+${timezone_offset}`
    return {
        day: date.toLocaleDateString([], { weekday: 'short', timeZone: timezone, }),
        time: date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: timezone, }),
    }
}

For example, at 18:07 UTC, a forecast retrieved for Kathmandu shows 23:52, "Tue", "Wed" etc. Nepali time is UTC +5h45m :upside_down_face:

And at 18:16 it shows 00:05, "Wed", "Thu" etc

1 Like

It is the same as your function, just using a => style function rather than the old style. The difference being that an arrow function never creates its own this context but rather inherits its parent. In this case, it makes no real difference.

Oh, and for fun, I've started adding enhanced hover tooltips that shown more info:


(sorry the system cursor isn't shown on that example).

My intention is to eventually add a click/touch handler to show a lot more detailed information in a dialog.

If I get time and have the energy, I may turn this into a web component. :smiley: I'd like to add an option to show the per-minute data as well thought.

1 Like

I think the tooltip looks nice, a good match for the overall look of the widget.

The ability to show even more info for a given day could be good too. I wonder if a popover dialog or popup toaster would be nicer, especially on a small screen.

I may add a tiny moon phase graphic in between the day names, probably not on small screens. cf the current data labels.

On a public facing page there should be a link and credit to OpenWeatherMap.

I've fixed the console msg.payload not defined error - will update the code today sometime.

Gotta make arrangements for different units. There are benighted spots where pressure is still measured in inches of mercury!

I intend to use this as excuse to try out the newer dialog HTML features since you've already used leading-edge CSS. :smiley:

Normally, I try to avoid leading-edge features so as not to lock out people using older tablets or phones for home automation.

I should also point out that I'm going to make an adjustment to the tooltip to allow it to use &#10; in the text to allow line breaks by adding white-space: pre-line; to the CSS.

I have a separate MQTT feed that tracks sun and moon position and I want to add those to the display as it can help with (the sun position anyway) working out whether to have lights on or not:

:+1:

Haha, I have made some prep for that in my own version.

window.formatTemp = (temp, post = '°C') => `${Math.round(temp)}${post}`
window.formatWind = (mpsec, post = 'mph', conv = 2.23694) => `${Math.round(mpsec * conv)}${post}`

I completely agree but I spent a lot of time with chatgpt trying to adapt to the dashboard continuously resizing widget width. This is by far the simplest and most successful approach we tried.

I meant to include a note about browser compatibility.

In general, everything should work on reasonably modern versions of browsers, but I am unsure what versions of Safari are available on slightly older devices.
I have Firefox Focus on Android, which I'm pretty sure cannot cope with cqi font sizes.
This is the widget from my phone in portrait view. The times don't work properly but It's not dreadful.
Possibly a smaller minimum in the css font-size: clamp(9px, 2.8cqi, 20px); would fix it. I think it looked unreasonably small on a PC with smaller widget config size.

Perhaps if you are writing for uibuilder this variable display width is not an issue?

It wasn't meant as a criticism. :smiley: The use of clamp is fairly inspired in fact. And you are right, it does resize very nicely.

Yes, that is the main issue since you have no choice on iOS but to use Safari and Apple are notorious for being slow keeping up with browser standards.

I'm actually considering whether to put some telemetry (optional of course) into UIBUILDER to see whether my stance of targeting browsers from early 2019 is actually worth it.

Yes, Firefox has also fallen behind in some areas I think.

Not helping that developments in CSS and JavaScript are happening so rapidly.

Not yet tried this on mobile ...

OK, just tried on my Pixel 8a with Vivaldi - looks perfect! Also looks OK on my iPhone 11 Pro Max (running latest Safari).

I always try to think about mobile though I'm not a fan of using phones to browse websites, I have a Microsoft Surface Pro 7 which is full W11 but also full touch & pen interface which is what I use instead of a tablet. So I don't always remember to test.

The tooltips on my version do work on touch on both Android and iOS though which is nice.

If you want the code, let me know, happy to share.

I suppose you could always code in a fallback for older devices as well. I do wonder though - how many devices lack support for cqi font sizes? As far as I know cqi sizing has been pretty standard since ~2023.

Firefox didn't get these until Feb 2023. Other main browsers got it in Sept 2022. So if you are using an iPad for example from before maybe 2016, you might have issues.

How's this for moon phase?

Hmm.
The only browser I have which does not understand container queries is Firefox Focus, and that might be out of date information.
And the only person I know who uses Firefox Focus is me.
I'm not prepared to develop specially for such a limited audience!