Travel — Best-time / Weather Widget
A friendly best-time-to-visit weather widget for a fictional fjord destination, drawn entirely in CSS and inline SVG. A twelve-month strip pairs an averaged temperature curve with rainfall bars and a highlighted best-months band, so the ideal window reads at a glance. A current-conditions card shows the weather icon, temperature, hi/lo, feels-like and daylight, and a five-day mini forecast sits alongside. Click any month to learn why it shines or not, and flip the whole panel between Celsius and Fahrenheit.
MCP
Code
:root {
--bg: #fbf7f1;
--ink: #241f1a;
--muted: #6b6259;
--teal: #1f8a8a;
--teal-deep: #166e6e;
--coral: #e8623f;
--sand: #e7d8c3;
--gold: #d9a441;
--card: #fffdfa;
--line: rgba(36, 31, 26, 0.12);
--good: #2f8f5b;
--good-bg: #e4f4ea;
--warn: #b87514;
--warn-bg: #fbeed6;
--bad: #b8543a;
--bad-bg: #f8e2da;
--shadow: 0 18px 44px -24px rgba(36, 31, 26, 0.4);
--radius: 18px;
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: clamp(0.75rem, 3vw, 2.5rem);
background:
radial-gradient(120% 90% at 12% 0%, #fff5e8 0%, transparent 55%),
radial-gradient(110% 90% at 90% 8%, #eef6f3 0%, transparent 50%),
var(--bg);
color: var(--ink);
font-family: "Work Sans", system-ui, -apple-system, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.page {
width: 100%;
max-width: 760px;
}
.widget {
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
}
/* ---------- Hero ---------- */
.hero {
position: relative;
padding: clamp(1rem, 4vw, 1.6rem);
background: linear-gradient(180deg, #fff 0%, #fffaf2 100%);
}
.hero-scene {
position: absolute;
inset: 0;
bottom: 38%;
z-index: 0;
opacity: 0.96;
}
.hero-scene svg {
width: 100%;
height: 100%;
display: block;
}
.hero-scene::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(180deg, transparent 38%, var(--card) 96%);
}
.hero-meta {
position: relative;
z-index: 1;
margin-top: clamp(5.5rem, 22vw, 8rem);
}
.kicker {
margin: 0 0 0.2rem;
font-size: 0.72rem;
letter-spacing: 0.16em;
text-transform: uppercase;
font-weight: 600;
color: var(--teal-deep);
}
.dest {
margin: 0;
font-family: "Fraunces", Georgia, serif;
font-weight: 600;
font-size: clamp(1.9rem, 7vw, 2.7rem);
line-height: 1.05;
letter-spacing: -0.01em;
}
.region {
margin: 0.35rem 0 0.8rem;
color: var(--muted);
font-size: 0.92rem;
}
.hero-badges {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.badge {
font-size: 0.76rem;
font-weight: 600;
padding: 0.32rem 0.62rem;
border-radius: 999px;
background: var(--sand);
color: #5a4a36;
border: 1px solid rgba(90, 74, 54, 0.18);
}
.badge-best {
background: var(--teal);
color: #fff;
border-color: transparent;
}
/* ---------- Toolbar ---------- */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem clamp(1rem, 4vw, 1.6rem);
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
background: #fffdf9;
}
.hint {
margin: 0;
font-size: 0.85rem;
color: var(--muted);
}
.unit-toggle {
display: inline-flex;
background: var(--sand);
border-radius: 999px;
padding: 3px;
border: 1px solid rgba(90, 74, 54, 0.18);
flex: none;
}
.unit {
appearance: none;
border: 0;
background: transparent;
color: #6b5a45;
font: inherit;
font-weight: 600;
font-size: 0.85rem;
padding: 0.3rem 0.75rem;
border-radius: 999px;
cursor: pointer;
transition: background 0.18s, color 0.18s;
}
.unit.on {
background: var(--card);
color: var(--ink);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}
/* ---------- Chart ---------- */
.chart-card {
padding: clamp(1rem, 4vw, 1.6rem);
}
.chart-head {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem 1rem;
margin-bottom: 0.9rem;
}
.chart-head h2 {
margin: 0;
font-family: "Fraunces", Georgia, serif;
font-weight: 600;
font-size: 1.15rem;
}
.legend {
display: flex;
gap: 0.9rem;
margin: 0;
padding: 0;
list-style: none;
font-size: 0.74rem;
color: var(--muted);
}
.legend li {
display: inline-flex;
align-items: center;
gap: 0.34rem;
}
.swatch {
width: 12px;
height: 12px;
border-radius: 3px;
display: inline-block;
}
.swatch-temp {
background: var(--coral);
}
.swatch-rain {
background: rgba(31, 138, 138, 0.4);
}
.swatch-best {
background: rgba(217, 164, 65, 0.32);
border: 1px solid var(--gold);
}
.chart-wrap {
position: relative;
}
.chart {
width: 100%;
height: auto;
display: block;
overflow: visible;
}
.best-band {
fill: rgba(217, 164, 65, 0.16);
stroke: rgba(217, 164, 65, 0.55);
stroke-dasharray: 4 4;
}
.rain-bar {
fill: rgba(31, 138, 138, 0.32);
transition: fill 0.18s;
}
.rain-bar.active {
fill: rgba(31, 138, 138, 0.6);
}
.temp-area {
fill: rgba(232, 98, 63, 0.1);
stroke: none;
}
.temp-line {
fill: none;
stroke: var(--coral);
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
.temp-dot {
fill: #fff;
stroke: var(--coral);
stroke-width: 2.5;
transition: r 0.15s;
}
.temp-dot.active {
fill: var(--coral);
r: 6;
}
.tick {
font-size: 11px;
fill: var(--muted);
font-family: "Work Sans", sans-serif;
}
.month-strip {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 2px;
margin-top: 0.5rem;
}
.month-btn {
appearance: none;
border: 0;
background: transparent;
font: inherit;
font-size: 0.7rem;
font-weight: 600;
color: var(--muted);
padding: 0.4rem 0;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.month-btn:hover {
background: rgba(36, 31, 26, 0.05);
color: var(--ink);
}
.month-btn.best {
color: var(--warn);
}
.month-btn.active {
background: var(--ink);
color: #fff;
}
.month-btn.active.best {
background: var(--gold);
color: #3a2c10;
}
/* ---------- Panels ---------- */
.panels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
background: var(--line);
border-top: 1px solid var(--line);
}
.conditions,
.forecast {
background: var(--card);
padding: clamp(1rem, 4vw, 1.5rem);
}
.cond-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.cond-month {
margin: 0 0 0.1rem;
font-size: 0.78rem;
letter-spacing: 0.1em;
text-transform: uppercase;
font-weight: 600;
color: var(--teal-deep);
}
.cond-temp {
margin: 0;
font-family: "Fraunces", Georgia, serif;
font-weight: 600;
font-size: 3rem;
line-height: 1;
}
.deg {
font-size: 1.3rem;
color: var(--muted);
margin-left: 0.1rem;
}
.cond-desc {
margin: 0.3rem 0 0;
color: var(--muted);
font-size: 0.92rem;
}
.cond-icon {
font-size: 2.6rem;
line-height: 1;
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.12));
}
.cond-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.6rem 1rem;
margin: 1.1rem 0;
}
.cond-stats div {
border-top: 1px solid var(--line);
padding-top: 0.5rem;
}
.cond-stats dt {
font-size: 0.72rem;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--muted);
}
.cond-stats dd {
margin: 0.15rem 0 0;
font-weight: 600;
font-size: 1.05rem;
}
.verdict {
display: flex;
align-items: flex-start;
gap: 0.55rem;
margin: 0;
font-size: 0.86rem;
line-height: 1.45;
padding: 0.7rem 0.8rem;
border-radius: 12px;
background: var(--good-bg);
color: #1f5e3c;
}
.verdict[data-tone="ok"] {
background: var(--warn-bg);
color: #7a4d0c;
}
.verdict[data-tone="bad"] {
background: var(--bad-bg);
color: #7e3522;
}
.verdict-pill {
flex: none;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.18rem 0.5rem;
border-radius: 999px;
background: var(--good);
color: #fff;
}
.verdict[data-tone="ok"] .verdict-pill {
background: var(--warn);
}
.verdict[data-tone="bad"] .verdict-pill {
background: var(--bad);
}
/* ---------- Forecast ---------- */
.fc-title {
margin: 0 0 0.9rem;
font-family: "Fraunces", Georgia, serif;
font-weight: 600;
font-size: 1.1rem;
}
.fc-title span {
font-family: "Work Sans", sans-serif;
font-weight: 500;
font-size: 0.82rem;
color: var(--muted);
}
.fc-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.fc-item {
display: grid;
grid-template-columns: 2.6rem 1.8rem 1fr auto;
align-items: center;
gap: 0.7rem;
padding: 0.5rem 0.6rem;
border-radius: 12px;
background: #fffaf1;
border: 1px solid var(--line);
}
.fc-day {
font-weight: 600;
font-size: 0.85rem;
}
.fc-emoji {
font-size: 1.25rem;
text-align: center;
}
.fc-bar {
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, var(--teal), var(--coral));
position: relative;
}
.fc-bar span {
position: absolute;
top: -2px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #fff;
border: 2px solid var(--coral);
transform: translateX(-50%);
}
.fc-temps {
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.fc-temps b {
color: var(--ink);
}
.fc-temps i {
color: var(--muted);
font-style: normal;
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 1.4rem;
transform: translate(-50%, 1.5rem);
background: var(--ink);
color: #fff;
font-size: 0.85rem;
font-weight: 500;
padding: 0.6rem 1.05rem;
border-radius: 999px;
box-shadow: 0 10px 28px -10px rgba(0, 0, 0, 0.5);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s, transform 0.25s;
z-index: 50;
}
.toast.show {
opacity: 1;
transform: translate(-50%, 0);
}
/* ---------- Focus ---------- */
:focus-visible {
outline: 3px solid var(--teal);
outline-offset: 2px;
border-radius: 6px;
}
/* ---------- Responsive ---------- */
@media (max-width: 560px) {
.panels {
grid-template-columns: 1fr;
}
.toolbar {
flex-direction: column;
align-items: stretch;
gap: 0.65rem;
}
.unit-toggle {
align-self: flex-start;
}
.legend {
width: 100%;
}
}
@media (max-width: 400px) {
.month-btn {
font-size: 0.62rem;
}
.cond-temp {
font-size: 2.6rem;
}
.cond-stats {
grid-template-columns: 1fr 1fr;
}
}(function () {
"use strict";
// --- Fictional climate data for Vuréndal Fjords (all temps in °C) ---
// hi/lo = average daily high/low; feels = perceived; rainDays per month;
// rainMm relative rainfall; daylight hours; tone good/ok/bad; emoji + desc.
var MONTHS = [
{ m: "Jan", full: "January", hi: 1, lo: -5, feels: -8, rainMm: 95, rainDays: 17, day: 6, emoji: "❄️", desc: "Polar dark & frozen", tone: "bad", why: "Deep cold, long darkness and frequent snow keep most trails and ferries closed." },
{ m: "Feb", full: "February", hi: 2, lo: -5, feels: -7, rainMm: 80, rainDays: 15, day: 8, emoji: "🌨️", desc: "Crisp & snowy", tone: "bad", why: "Still firmly winter — bracing, beautiful for aurora hunters but harsh for hiking." },
{ m: "Mar", full: "March", hi: 5, lo: -2, feels: 1, rainMm: 70, rainDays: 13, day: 11, emoji: "🌥️", desc: "Thawing slowly", tone: "bad", why: "Thaw begins but footing is icy and many fjord roads are not yet plowed through." },
{ m: "Apr", full: "April", hi: 9, lo: 2, feels: 7, rainMm: 60, rainDays: 12, day: 14, emoji: "🌦️", desc: "Fresh & changeable", tone: "ok", why: "Waterfalls swell with melt and crowds are thin, but weather swings hour to hour." },
{ m: "May", full: "May", hi: 14, lo: 6, feels: 12, rainMm: 50, rainDays: 10, day: 17, emoji: "🌤️", desc: "Green & blooming", tone: "ok", why: "A lovely shoulder month — lush valleys, fewer visitors, the odd cold snap lingers." },
{ m: "Jun", full: "June", hi: 19, lo: 10, feels: 18, rainMm: 42, rainDays: 8, day: 19, emoji: "☀️", desc: "Mild & bright", tone: "good", why: "Long daylight, gentle temps and low rain — the fjords open and trails are clear." },
{ m: "Jul", full: "July", hi: 22, lo: 12, feels: 22, rainMm: 45, rainDays: 9, day: 18, emoji: "☀️", desc: "Warm & golden", tone: "good", why: "Peak season: warmest water, midnight-sun evenings and every cabin and ferry running." },
{ m: "Aug", full: "August", hi: 21, lo: 12, feels: 21, rainMm: 55, rainDays: 10, day: 16, emoji: "🌤️", desc: "Warm, a touch wetter", tone: "good", why: "Still excellent — ripe cloudberries and warm seas, with a few more passing showers." },
{ m: "Sep", full: "September", hi: 16, lo: 8, feels: 13, rainMm: 70, rainDays: 12, day: 13, emoji: "🍂", desc: "Amber & quiet", tone: "ok", why: "Autumn colour and northern-lights nights return, but rain picks up and days shorten fast." },
{ m: "Oct", full: "October", hi: 10, lo: 4, feels: 6, rainMm: 90, rainDays: 15, day: 10, emoji: "🌧️", desc: "Wet & windy", tone: "bad", why: "The wettest, windiest stretch — atmospheric, but ferries get cancelled often." },
{ m: "Nov", full: "November", hi: 5, lo: 0, feels: 0, rainMm: 95, rainDays: 16, day: 7, emoji: "🌧️", desc: "Grey & raw", tone: "bad", why: "Cold rain turning to sleet with very short days; most of the region winds down." },
{ m: "Dec", full: "December", hi: 2, lo: -3, feels: -6, rainMm: 92, rainDays: 17, day: 5, emoji: "❄️", desc: "Dark & frosty", tone: "bad", why: "Deepest darkness and frost — magical for aurora, but not for fjord-side exploring." }
];
var BEST = [5, 6, 7]; // Jun, Jul, Aug (zero-indexed)
var DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri"];
var FC_EMOJI = ["☀️", "🌤️", "🌦️", "🌧️", "⛅", "🌥️"];
var state = { unit: "c", month: 5 };
// --- Helpers ---
function $(sel) { return document.querySelector(sel); }
function el(tag, ns) {
return document.createElementNS("http://www.w3.org/2000/svg", tag);
}
function toC2(v) { return state.unit === "c" ? v : Math.round(v * 9 / 5 + 32); }
function us() { return state.unit === "c" ? "°C" : "°F"; }
function ud() { return state.unit === "c" ? "°" : "°"; }
// --- Toast ---
var toastEl = $("#toast");
var toastTimer;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("show");
}, 2000);
}
// --- Chart geometry ---
var W = 720, H = 220, PADX = 24, PADTOP = 16, PADBOT = 34;
var plotW = W - PADX * 2;
var step = plotW / (MONTHS.length - 1);
var temps = MONTHS.map(function (d) { return d.hi; });
var tMin = Math.min.apply(null, temps);
var tMax = Math.max.apply(null, temps);
var rainMax = Math.max.apply(null, MONTHS.map(function (d) { return d.rainMm; }));
function xAt(i) { return PADX + step * i; }
function yTemp(t) {
var range = tMax - tMin || 1;
return PADTOP + (1 - (t - tMin) / range) * (H - PADTOP - PADBOT);
}
function buildChart() {
var rainG = $("#rain-bars");
var labelsG = $("#month-labels");
var dotsG = $("#temp-dots");
rainG.textContent = labelsG.textContent = dotsG.textContent = "";
var baseY = H - PADBOT;
var barW = step * 0.5;
MONTHS.forEach(function (d, i) {
// rainfall bar
var bh = (d.rainMm / rainMax) * (H - PADTOP - PADBOT) * 0.62;
var bar = el("rect");
bar.setAttribute("class", "rain-bar");
bar.setAttribute("data-i", i);
bar.setAttribute("x", xAt(i) - barW / 2);
bar.setAttribute("y", baseY - bh);
bar.setAttribute("width", barW);
bar.setAttribute("height", bh);
bar.setAttribute("rx", 3);
rainG.appendChild(bar);
// month tick label
var lbl = el("text");
lbl.setAttribute("class", "tick");
lbl.setAttribute("x", xAt(i));
lbl.setAttribute("y", H - 12);
lbl.setAttribute("text-anchor", "middle");
lbl.textContent = d.m;
labelsG.appendChild(lbl);
// temp dot
var dot = el("circle");
dot.setAttribute("class", "temp-dot");
dot.setAttribute("data-i", i);
dot.setAttribute("cx", xAt(i));
dot.setAttribute("cy", yTemp(d.hi));
dot.setAttribute("r", 4);
dotsG.appendChild(dot);
});
// temperature line + area
var pts = MONTHS.map(function (d, i) { return xAt(i) + "," + yTemp(d.hi); }).join(" ");
$("#temp-line").setAttribute("points", pts);
var areaPts = xAt(0) + "," + baseY + " " + pts + " " + xAt(MONTHS.length - 1) + "," + baseY;
$("#temp-area").setAttribute("points", areaPts);
// best band
var band = $("#best-band");
var bx = xAt(BEST[0]) - step * 0.5;
var bw = (BEST[BEST.length - 1] - BEST[0]) * step + step;
band.setAttribute("x", bx);
band.setAttribute("y", PADTOP - 4);
band.setAttribute("width", bw);
band.setAttribute("height", H - PADBOT - PADTOP + 6);
}
// --- Month strip buttons ---
function buildStrip() {
var strip = $("#month-strip");
strip.textContent = "";
MONTHS.forEach(function (d, i) {
var b = document.createElement("button");
b.type = "button";
b.className = "month-btn" + (BEST.indexOf(i) > -1 ? " best" : "");
b.textContent = d.m;
b.setAttribute("role", "radio");
b.setAttribute("data-i", i);
b.setAttribute("aria-checked", "false");
b.setAttribute("aria-label", d.full + (BEST.indexOf(i) > -1 ? " — best window" : ""));
strip.appendChild(b);
});
}
// --- Pseudo-random but stable forecast from a month seed ---
function seeded(seed) {
var x = Math.sin(seed * 99.91) * 10000;
return x - Math.floor(x);
}
function buildForecast(mi) {
var d = MONTHS[mi];
var list = $("#fc-list");
list.textContent = "";
$("#fc-context").textContent = "· typical " + d.full;
var range = tMax - tMin || 1;
for (var k = 0; k < DAYS.length; k++) {
var r = seeded((mi + 1) * 7 + k * 3);
var r2 = seeded((mi + 1) * 13 + k * 5);
var hi = Math.round(d.hi + (r - 0.5) * 5);
var lo = Math.round(d.lo + (r2 - 0.5) * 4);
// choose an emoji weighted by rain tendency
var wet = d.rainDays / 17;
var idx = r < wet ? (r2 < 0.5 ? 3 : 2) : (r2 < 0.4 ? 0 : r2 < 0.7 ? 1 : 4);
var emoji = FC_EMOJI[idx];
var pos = ((hi - tMin) / range) * 80 + 10;
var li = document.createElement("li");
li.className = "fc-item";
li.innerHTML =
'<span class="fc-day">' + DAYS[k] + '</span>' +
'<span class="fc-emoji" aria-hidden="true">' + emoji + '</span>' +
'<span class="fc-bar"><span style="left:' + pos.toFixed(0) + '%"></span></span>' +
'<span class="fc-temps"><b>' + toC2(hi) + ud() + '</b> <i>' + toC2(lo) + ud() + '</i></span>';
list.appendChild(li);
}
}
// --- Render conditions card ---
function renderConditions() {
var d = MONTHS[state.month];
$("#cond-month").textContent = d.full;
$("#cond-temp-val").textContent = toC2(d.hi);
$("#cond-unit").textContent = us();
$("#cond-desc").textContent = d.desc;
$("#cond-icon").textContent = d.emoji;
$("#cond-hilo").textContent = toC2(d.hi) + ud() + " / " + toC2(d.lo) + ud();
$("#cond-feels").textContent = toC2(d.feels) + ud();
$("#cond-rain").textContent = d.rainDays + " days";
$("#cond-day").textContent = d.day + "h";
var v = $("#cond-verdict");
v.setAttribute("data-tone", d.tone);
var pill = d.tone === "good" ? "Ideal" : d.tone === "ok" ? "Shoulder" : "Off-season";
$("#verdict-pill").textContent = pill;
$("#verdict-text").textContent = d.why;
buildForecast(state.month);
}
// --- Highlight active month across chart + strip ---
function setActive(mi) {
state.month = mi;
document.querySelectorAll(".month-btn").forEach(function (b) {
var on = +b.getAttribute("data-i") === mi;
b.classList.toggle("active", on);
b.setAttribute("aria-checked", on ? "true" : "false");
b.tabIndex = on ? 0 : -1;
});
document.querySelectorAll(".temp-dot").forEach(function (dot) {
dot.classList.toggle("active", +dot.getAttribute("data-i") === mi);
});
document.querySelectorAll(".rain-bar").forEach(function (bar) {
bar.classList.toggle("active", +bar.getAttribute("data-i") === mi);
});
renderConditions();
}
function selectMonth(mi, announce) {
setActive(mi);
if (announce) {
var d = MONTHS[mi];
var label = d.tone === "good" ? "a top pick" : d.tone === "ok" ? "a fair shoulder month" : "best avoided";
toast(d.full + " — " + label);
}
}
// --- Unit toggle ---
function setUnit(u) {
if (state.unit === u) return;
state.unit = u;
document.querySelectorAll(".unit").forEach(function (btn) {
var on = btn.getAttribute("data-unit") === u;
btn.classList.toggle("on", on);
btn.setAttribute("aria-checked", on ? "true" : "false");
});
renderConditions();
toast("Switched to " + us());
}
// --- Wire up events ---
function bind() {
$("#month-strip").addEventListener("click", function (e) {
var btn = e.target.closest(".month-btn");
if (btn) selectMonth(+btn.getAttribute("data-i"), true);
});
$("#month-strip").addEventListener("keydown", function (e) {
var keys = { ArrowRight: 1, ArrowLeft: -1, ArrowUp: -1, ArrowDown: 1 };
if (e.key === "Home") { e.preventDefault(); focusMonth(0); return; }
if (e.key === "End") { e.preventDefault(); focusMonth(MONTHS.length - 1); return; }
if (!(e.key in keys)) return;
e.preventDefault();
var next = (state.month + keys[e.key] + MONTHS.length) % MONTHS.length;
focusMonth(next);
});
function focusMonth(mi) {
selectMonth(mi, true);
var b = document.querySelector('.month-btn[data-i="' + mi + '"]');
if (b) b.focus();
}
// chart dots are clickable too
$("#temp-dots").addEventListener("click", function (e) {
var dot = e.target.closest(".temp-dot");
if (dot) selectMonth(+dot.getAttribute("data-i"), true);
});
document.querySelectorAll(".unit").forEach(function (btn) {
btn.addEventListener("click", function () {
setUnit(btn.getAttribute("data-unit"));
});
});
}
// --- Init ---
buildChart();
buildStrip();
bind();
setActive(state.month);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Travel — Best-time / Weather Widget</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=Work+Sans:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="page" role="main">
<article class="widget" aria-labelledby="dest-title">
<header class="hero">
<div class="hero-scene" aria-hidden="true">
<svg viewBox="0 0 400 180" preserveAspectRatio="xMidYMid slice" role="img">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffd8a8" />
<stop offset="0.55" stop-color="#ffb38a" />
<stop offset="1" stop-color="#ff8f73" />
</linearGradient>
<linearGradient id="far" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#6f8fb0" />
<stop offset="1" stop-color="#4f6f93" />
</linearGradient>
<linearGradient id="near" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2c4763" />
<stop offset="1" stop-color="#1c2f44" />
</linearGradient>
</defs>
<rect width="400" height="180" fill="url(#sky)" />
<circle cx="300" cy="58" r="30" fill="#fff4dc" opacity="0.92" />
<path d="M0 118 L60 78 L110 110 L170 66 L240 112 L300 80 L360 108 L400 86 L400 180 L0 180 Z" fill="url(#far)" />
<path d="M0 150 L50 116 L96 146 L150 104 L210 150 L270 112 L330 150 L400 118 L400 180 L0 180 Z" fill="url(#near)" />
<path d="M150 104 L162 118 L150 116 L142 130 L150 104 Z" fill="#eef4fb" opacity="0.85" />
<g fill="#ffffff" opacity="0.8">
<circle cx="64" cy="44" r="2.5" /><circle cx="120" cy="30" r="1.8" />
<circle cx="40" cy="64" r="1.6" /><circle cx="92" cy="58" r="2" />
</g>
</svg>
</div>
<div class="hero-meta">
<p class="kicker">Best time to visit</p>
<h1 id="dest-title" class="dest">Vuréndal Fjords</h1>
<p class="region">Northern Aurellia · coastal alpine</p>
<div class="hero-badges">
<span class="badge badge-best">★ Best: Jun–Aug</span>
<span class="badge">Shoulder: May, Sep</span>
</div>
</div>
</header>
<section class="toolbar" aria-label="Widget controls">
<p class="hint" id="month-hint">Tap a month to see why it shines — or doesn't.</p>
<div class="unit-toggle" role="radiogroup" aria-label="Temperature unit">
<button type="button" class="unit on" role="radio" aria-checked="true" data-unit="c">°C</button>
<button type="button" class="unit" role="radio" aria-checked="false" data-unit="f">°F</button>
</div>
</section>
<section class="chart-card" aria-labelledby="chart-title">
<div class="chart-head">
<h2 id="chart-title">Year at a glance</h2>
<ul class="legend" aria-hidden="true">
<li><span class="swatch swatch-temp"></span>Avg temp</li>
<li><span class="swatch swatch-rain"></span>Rainfall</li>
<li><span class="swatch swatch-best"></span>Best window</li>
</ul>
</div>
<div class="chart-wrap">
<svg class="chart" viewBox="0 0 720 220" role="img"
aria-label="Twelve-month temperature curve and rainfall bars. Best months are June through August.">
<g id="rain-bars"></g>
<rect id="best-band" class="best-band" rx="6"></rect>
<polyline id="temp-area" class="temp-area"></polyline>
<polyline id="temp-line" class="temp-line"></polyline>
<g id="temp-dots"></g>
<g id="month-labels"></g>
</svg>
<div class="month-strip" id="month-strip" role="radiogroup" aria-label="Select a month"></div>
</div>
</section>
<section class="panels">
<div class="conditions" aria-labelledby="cond-title" aria-live="polite">
<div class="cond-top">
<div>
<p class="cond-month" id="cond-month">June</p>
<h2 id="cond-title" class="cond-temp"><span id="cond-temp-val">17</span><span class="deg" id="cond-unit">°C</span></h2>
<p class="cond-desc" id="cond-desc">Mild & bright</p>
</div>
<div class="cond-icon" id="cond-icon" aria-hidden="true">☀️</div>
</div>
<dl class="cond-stats">
<div><dt>Hi / Lo</dt><dd id="cond-hilo">21° / 12°</dd></div>
<div><dt>Feels like</dt><dd id="cond-feels">18°</dd></div>
<div><dt>Rain days</dt><dd id="cond-rain">8</dd></div>
<div><dt>Daylight</dt><dd id="cond-day">18h</dd></div>
</dl>
<p class="verdict" id="cond-verdict" data-tone="good">
<span class="verdict-pill" id="verdict-pill">Ideal</span>
<span id="verdict-text">Long daylight, gentle temps and low rain — peak fjord season.</span>
</p>
</div>
<div class="forecast" aria-labelledby="fc-title">
<h2 id="fc-title" class="fc-title">5-day outlook <span id="fc-context">· typical June</span></h2>
<ul class="fc-list" id="fc-list"></ul>
</div>
</section>
</article>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Best-time / Weather Widget
A wanderlust-flavoured climate card for the fictional Vuréndal Fjords. The hero is a layered inline-SVG landscape — warm horizon, distant ridgeline and a near snow-capped peak — capped with a Best: Jun–Aug badge. Below it, a year-at-a-glance chart draws an averaged temperature curve over translucent rainfall bars, with a dashed gold band marking the best months so the ideal travel window is obvious at a single look.
Everything is interactive. Click a month in the strip below the chart (or tap a point on the curve) and the current-conditions card updates with that month’s icon, average temperature, hi/lo, feels-like, rain days and daylight hours — plus a colour-coded verdict pill (Ideal, Shoulder or Off-season) that explains why it is or isn’t a good time to go. A five-day mini forecast regenerates to match, with little weather emoji and temperature bars. The month strip is a keyboard-navigable radiogroup: arrow keys, Home and End move between months and announce the pick.
A pill-style °C/°F toggle re-renders every temperature in the widget at once, and a small toast confirms each change. The whole thing is self-contained vanilla HTML, CSS and JS — no images, no libraries — and collapses to a single column with a stacked layout down to 360px.
Illustrative travel UI only — fictional destinations, prices, and maps.