From 01e5f48257bb6694cbc5d73cefbdf9c5b8225438 Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Sat, 11 Jul 2026 01:10:40 -0700 Subject: [PATCH 1/2] =?UTF-8?q?Add=20a=20=C2=B0F/=C2=B0C=20unit=20toggle?= =?UTF-8?q?=20to=20the=20header=20(#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shared units module in nav.js holds the active unit (persisted), converts Fahrenheit API values for display, and notifies pages to repaint on a switch. A segmented °F/°C control is injected into the top-right of every header (left of the view tabs); Weekly, Day and Calendar convert every temperature — cards, dense timeline, trend chart axis/labels, tooltips and the PNG export — while wind, humidity and precip stay as-is. Compare keeps its Fahrenheit comfort slider, so the toggle is hidden there. --- static/app.js | 31 +++++++++++++++++------ static/calendar.js | 4 ++- static/day.js | 7 +++++- static/nav.js | 61 +++++++++++++++++++++++++++++++++++++++++++++- static/style.css | 21 +++++++++++++++- 5 files changed, 113 insertions(+), 11 deletions(-) diff --git a/static/app.js b/static/app.js index 3bd7c77..9e2cdbf 100644 --- a/static/app.js +++ b/static/app.js @@ -88,14 +88,16 @@ async function runGrade() { render(data); } -const fmtTemp = (v) => (v == null ? "—" : `${Math.round(v)}°`); +// Temps flow through the shared unit formatter (°F from the API, shown in the +// active unit); the others are unit-agnostic. +const fmtTemp = (v) => window.Thermograph.fmtTemp(v); const fmtPrecip = (v) => (v == null ? "—" : `${v.toFixed(2)}"`); const fmtWind = (v) => (v == null ? "—" : `${Math.round(v)} mph`); const fmtHumid = (v) => (v == null ? "—" : `${v.toFixed(1)} g/m³`); // Compact cell formatters for the dense recent/forecast table (units live in the // column headers, so values stay short). Dry days label the running dry streak // (see precipCell) rather than the rain amount. -const cTemp = (v) => (v == null ? "—" : `${Math.round(v)}°`); +const cTemp = (v) => window.Thermograph.fmtTemp(v); // active unit, bare degree const cHum = (v) => (v == null ? "—" : `${Math.round(v)}%`); const cWind = (v) => (v == null ? "—" : `${Math.round(v)}`); const cRain = (v) => (v == null ? "—" : v > 0 ? v.toFixed(2) : "·"); @@ -246,6 +248,15 @@ function daysTable(rows, todayDate) { let recentData = null; // /grade response (past) — the base render let forecastData = null; // /forecast response (next 7 days), fetched in the background +// Repaint everything in the newly-picked unit, preserving the already-loaded +// forecast (render() clears it and refetches from cache; restore it right away). +window.Thermograph.onUnitChange(() => { + if (!recentData) return; + const fc = forecastData; + render(recentData); + if (fc) { forecastData = fc; renderSeries(); } +}); + const addDaysISO = (iso, n) => { const d = new Date(iso + "T00:00:00Z"); d.setUTCDate(d.getUTCDate() + n); @@ -592,12 +603,16 @@ function lineChart(cfg) { // One temperature-scale series (High/Low/Feels/Wind/Gust). function tempChart(key, days, n, xFor, C) { const s = SERIES[key](C); + // Temperature series (° suffix) show in the active unit; wind/gust stay in mph. + // The plot keeps its °F domain — only the labels convert — so positions hold. + const isTemp = s.sfx === "°"; + const fmtV = (v) => `${Math.round(isTemp ? window.Thermograph.toUnit(v) : v)}${s.sfx}`; return lineChart({ days, n, xFor, C, key, color: s.color, fan: TEMP_FAN, hasNormals: true, baseZero: false, getVal: (d) => (d[key] ? d[key].value : null), getPct: (d) => (d[key] ? d[key].percentile : null), - fmtAxis: (v) => `${Math.round(v)}${s.sfx}`, - fmtLabel: (v) => `${Math.round(v)}${s.sfx}`, + fmtAxis: fmtV, + fmtLabel: fmtV, legend: `${s.label} (dashed = median)` + `Band shaded by grade tier` + @@ -669,7 +684,9 @@ function attachChartHover(wrap) { if (!g) return `
${label}
`; const pct = g.percentile == null ? "" : ` · ${ord(g.percentile)} pct`; const gc = TIER_COLORS[g.class] || ""; - return `
${label} ${g.value}${unit}${pct} ${g.grade}
`; + // "°" marks a temperature line — show it in the active unit; others are as-is. + const val = unit === "°" ? Math.round(window.Thermograph.toUnit(g.value)) : g.value; + return `
${label} ${val}${unit}${pct} ${g.grade}
`; }; // Pointer events cover mouse, touch and pen with one path, so tapping/dragging @@ -689,7 +706,7 @@ function attachChartHover(wrap) { ${pctLine("Gust", d.gust, " mph", C.gust)} ${pctLine("Precip", d.precip, '"', C.precip)}
Dry ${d.dsr == null ? "—" : d.dsr === 0 ? "rain today" : `${d.dsr} d since rain`}
-
normal ${nt ? Math.round(nt.p50) : "—"}° / ${ni ? Math.round(ni.p50) : "—"}°
`; +
normal ${nt ? fmtTemp(nt.p50) : "—"} / ${ni ? fmtTemp(ni.p50) : "—"}
`; tip.hidden = false; const box = wrap.getBoundingClientRect(); let px = e.clientX - box.left + 12; @@ -730,7 +747,7 @@ function exportTableSvg(C) { const cell = (x, g, isPcp) => { if (!g) return ``; const color = TIER_COLORS[g.class] || C.ink; - const v = isPcp ? `${g.value.toFixed(2)}"` : `${Math.round(g.value)}°`; + const v = isPcp ? `${g.value.toFixed(2)}"` : fmtTemp(g.value); return `${v}` + `${esc(g.grade)}`; }; diff --git a/static/calendar.js b/static/calendar.js index 5a47d30..e131a1e 100644 --- a/static/calendar.js +++ b/static/calendar.js @@ -52,7 +52,9 @@ const SCALE_RAIN = [ ]; const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; -const fmtTemp = (v) => (v == null ? "—" : `${Math.round(v)}°`); +// Temps go through the shared unit formatter (the day tooltip reads it live, so a +// unit switch shows on the next hover); the others are unit-agnostic. +const fmtTemp = (v) => window.Thermograph.fmtTemp(v); const fmtPrecip = (v) => (v == null ? "—" : `${v.toFixed(2)}"`); const fmtWind = (v) => (v == null ? "—" : `${Math.round(v)} mph`); const fmtHumid = (v) => (v == null ? "—" : `${v.toFixed(1)} g/m³`); diff --git a/static/day.js b/static/day.js index 006488f..bb68edf 100644 --- a/static/day.js +++ b/static/day.js @@ -16,7 +16,8 @@ const PRECIP_COLORS = { }; const DRY_COLOR = "#cbb26a"; -const fmtTemp = (v) => (v == null ? "—" : `${Math.round(v)}°`); +// Temps go through the shared unit formatter; the rest are unit-agnostic. +const fmtTemp = (v) => window.Thermograph.fmtTemp(v); const fmtPrecip = (v) => (v == null ? "—" : `${v.toFixed(2)}"`); const fmtWind = (v) => (v == null ? "—" : `${Math.round(v)} mph`); const fmtHumid = (v) => (v == null ? "—" : `${v.toFixed(1)} g/m³`); @@ -141,7 +142,11 @@ function finishDay(data) { window.Thermograph.prefetchViews(selected.lat, selected.lon, "day"); // warm weekly/calendar/forecast } +let lastData = null; // most recent /day response, for repainting on a unit switch +window.Thermograph.onUnitChange(() => { if (lastData) render(lastData); }); + function render(data) { + lastData = data; const d = data.detail; const cell = data.cell; const place = data.place || `${cell.center_lat.toFixed(3)}, ${cell.center_lon.toFixed(3)}`; diff --git a/static/nav.js b/static/nav.js index 9967875..e7f217d 100644 --- a/static/nav.js +++ b/static/nav.js @@ -49,10 +49,68 @@ function refreshNav(lat, lon, date) { }); } +// ---- temperature units (shared °C / °F toggle) ---- +// The API always speaks Fahrenheit; the unit is a pure display concern. Pages +// format temps through `fmtTempU`/`toUnit` and re-render on `onUnitChange`, so the +// stored/plotted values stay in °F and only the numbers shown flip. +const UNIT_KEY = "thermograph:unit"; +let _unit = (localStorage.getItem(UNIT_KEY) === "C") ? "C" : "F"; +const _unitCbs = []; + +function getUnit() { return _unit; } +// A Fahrenheit value as a number in the active unit. +function toUnit(vF) { return _unit === "C" ? (vF - 32) * 5 / 9 : vF; } +// A Fahrenheit value formatted as a rounded temperature in the active unit. +// `withLetter` appends the C/F letter (off by default — the nav toggle shows it). +function fmtTempU(vF, withLetter) { + if (vF == null || isNaN(vF)) return "—"; + return `${Math.round(toUnit(vF))}°${withLetter ? _unit : ""}`; +} +function onUnitChange(cb) { _unitCbs.push(cb); } +function setUnit(u) { + const nu = (u === "C") ? "C" : "F"; + if (nu === _unit) return; + _unit = nu; + try { localStorage.setItem(UNIT_KEY, _unit); } catch (e) {} + document.querySelectorAll(".unit-toggle").forEach(syncUnitToggle); + _unitCbs.forEach((cb) => { try { cb(_unit); } catch (e) {} }); +} + +function syncUnitToggle(wrap) { + wrap.querySelectorAll("button[data-unit]").forEach((b) => { + const on = b.dataset.unit === _unit; + b.classList.toggle("active", on); + b.setAttribute("aria-pressed", on ? "true" : "false"); + }); +} + +// Drop a segmented °F/°C control into the header's top-right, ahead of the view +// switcher. Skipped on Compare, whose comfort slider is inherently °F-based. +function buildUnitToggle() { + const brand = document.querySelector(".brand"); + if (!brand || brand.querySelector(".unit-toggle")) return; + const active = document.querySelector(".view-nav a.active"); + if (active && active.dataset.view === "compare") return; + const wrap = document.createElement("div"); + wrap.className = "unit-toggle"; + wrap.setAttribute("role", "group"); + wrap.setAttribute("aria-label", "Temperature units"); + wrap.innerHTML = '' + + ''; + wrap.addEventListener("click", (e) => { + const b = e.target.closest("button[data-unit]"); + if (b) setUnit(b.dataset.unit); + }); + const nav = brand.querySelector(".view-nav"); + brand.insertBefore(wrap, nav); // sits just left of the view tabs (top-right) + syncUnitToggle(wrap); +} + (function initNav() { document.querySelectorAll(".view-nav a[data-view]").forEach((a) => { a.dataset.base = a.getAttribute("href"); }); + buildUnitToggle(); const loc = loadLastLocation(); if (loc) refreshNav(loc.lat, loc.lon, loc.date); })(); @@ -278,4 +336,5 @@ function prefetchNeighbors(lat, lon) { } } -window.Thermograph = { loadLastLocation, saveLastLocation, getJSON, prefetchViews, hasFreshCache, TTL }; +window.Thermograph = { loadLastLocation, saveLastLocation, getJSON, prefetchViews, hasFreshCache, TTL, + getUnit, toUnit, fmtTemp: fmtTempU, onUnitChange, setUnit }; diff --git a/static/style.css b/static/style.css index 59c2062..bede74b 100644 --- a/static/style.css +++ b/static/style.css @@ -252,6 +252,23 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; } .tk-label { font-weight: 600; } .tk-note { margin: 0 0 16px; font-size: 12.5px; } +/* --- header °F/°C unit toggle (sits at the top-right, left of the tabs) --- */ +.unit-toggle { + margin-left: auto; align-self: center; display: inline-flex; gap: 2px; + background: var(--surface-2); border: 1px solid var(--border); + border-radius: 11px; padding: 3px; +} +.unit-toggle button { + border: 0; background: transparent; color: var(--muted); cursor: pointer; + font-size: 14px; font-weight: 700; padding: 8px 12px; border-radius: 8px; + min-height: 38px; min-width: 44px; line-height: 1; +} +.unit-toggle button.active { background: var(--accent); color: #fff; } +.unit-toggle button:hover:not(.active) { color: var(--text); } +/* The view switcher already claims the right edge; with the toggle to its left, + drop its auto-margin so the two sit together in the corner. */ +.unit-toggle + .view-nav { margin-left: 8px; } + /* --- shared header view switcher (Map · Calendar · Day) --- */ /* The bar is split into equal, fully-clickable segments (one per page) with a clear divider between them, so the whole width is a set of obvious tabs. */ @@ -584,8 +601,10 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; } /* Let the header wrap so the view switcher drops to its own full-width row (and stays a comfortable touch target) instead of crowding the title. */ .brand { flex-wrap: wrap; } - /* Full-width bar; each tab is an equal, fully-tappable third. */ + /* Full-width bar; each tab is an equal, fully-tappable third. The unit toggle + stays on the title row (pushed to the top-right); the tabs wrap below it. */ .view-nav { margin-left: 0; width: 100%; } + .unit-toggle + .view-nav { margin-left: 0; } .view-nav a { min-height: 44px; padding: 8px 12px; } main { padding: 14px 14px 40px; } From 34dd5775456fd02cc743077e078ce0c65d05ae32 Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Sat, 11 Jul 2026 01:11:15 -0700 Subject: [PATCH 2/2] Revert Orchid Sunset palette; restyle location-picker map (#24) * Revert "Merge pull request #22 from griffemi/fem-orchid-palette" This reverts commit f7d16238f2d9fa587912fd3cce2ed1be63598d00, reversing changes made to 34e4fed1f03fd535c8fc26b766a1e6689a2cac00. * Restyle location-picker map: themed CARTO basemap + branded pin Replace the plain gray OpenStreetMap raster in the location picker with a theme-aware CARTO basemap (no API key): sleek "dark matter" tiles under the dark theme, colorful "voyager" tiles under the light theme, so the map matches whichever is active. Swap Leaflet's default blue marker for an accent-colored teardrop pin (the same glyph the Find button uses), and theme the zoom controls + attribution to sit in the app's surfaces. - mappicker.js: theme-aware CARTO tileLayer (retina, CARTO attribution); L.divIcon "mp-pin" used for the marker - style.css: .mp-pin styling, themed .leaflet-bar / attribution, map shadow Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc --------- Co-authored-by: Claude Opus 4.8 --- static/app.js | 24 ++++++------- static/calendar.js | 18 +++++----- static/day.js | 12 +++---- static/mappicker.js | 23 ++++++++++--- static/style.css | 82 ++++++++++++++++++++++++++------------------- 5 files changed, 93 insertions(+), 66 deletions(-) diff --git a/static/app.js b/static/app.js index 9e2cdbf..db39ab3 100644 --- a/static/app.js +++ b/static/app.js @@ -330,12 +330,12 @@ function scrollTableToToday(host, today, hasFc) { // Tier css class -> literal color (mirrors style.css; needed because the chart is // rasterized to PNG, where CSS variables aren't available). const TIER_COLORS = { - "rec-hot": "#7d1a49", "very-hot": "#db2777", "hot": "#f472b6", "warm": "#f9a8d4", - "normal": "#f0dce8", - "cool": "#cbb6f7", "cold": "#9b6ef0", "very-cold": "#6d28d9", "rec-cold": "#3b0764", - "dry": "#cbb26a", - "wet-1": "#f3e8ff", "wet-2": "#e9d5ff", "wet-3": "#d8b4fe", "wet-4": "#c084fc", "wet-5": "#a855f7", - "wet-6": "#9333ea", "wet-7": "#7e22ce", "wet-8": "#6b21a8", "wet-9": "#4c1d95", + "rec-hot": "#8f0e20", "very-hot": "#dd6b52", "hot": "#ef9351", "warm": "#f6c18a", + "normal": "#4a9d5b", + "cool": "#92c5de", "cold": "#4393c3", "very-cold": "#2166ac", "rec-cold": "#0a2f6b", + "dry": "#c9a24a", + "wet-1": "#d9f0d3", "wet-2": "#b7e2b1", "wet-3": "#8fd18f", "wet-4": "#5cba9f", "wet-5": "#35a1c0", + "wet-6": "#2b8cbe", "wet-7": "#226bb3", "wet-8": "#164a97", "wet-9": "#08306b", }; // Percentile grade scale, low -> high. Names are deliberately *relative* to each @@ -381,18 +381,18 @@ function chartPalette() { const css = getComputedStyle(document.documentElement); const v = (name, f) => (css.getPropertyValue(name).trim() || f); const dark = matchMedia("(prefers-color-scheme: dark)").matches; - const hi = v("--very-hot", "#db2777"); // daily-high series = warm tier magenta - const lo = v("--cold", "#9b6ef0"); // daily-low series = cool tier violet + const hi = v("--very-hot", "#dd6b52"); // daily-high series = warm tier red + const lo = v("--cold", "#4393c3"); // daily-low series = cool tier blue return { surface: v("--surface-2", dark ? "#1e242c" : "#eef1f5"), ink: v("--text", dark ? "#e7ecf2" : "#1a2029"), muted: v("--muted", dark ? "#9aa6b2" : "#5b6673"), grid: v("--border", dark ? "#2a323c" : "#dde3ea"), - accent: v("--accent", "#ec4899"), + accent: v("--accent", "#f0803c"), hi, lo, // Distinct line/point accents for the three added series; the graded fan // behind them still uses the shared diverging tier colors (same as temps). - feels: v("--accent", "#ec4899"), // "feels like" = magenta accent + feels: v("--accent", "#f0803c"), // "feels like" = orange accent humid: "#2ea9bf", // humidity = cyan wind: "#5aa9a3", // wind speed = teal gust: "#8f86d8", // wind gust = periwinkle @@ -648,11 +648,11 @@ function precipChart(days, n, xFor, C) { // "Days since last rain" dry-streak color: rain days are blue; each dry day warms // from a light base toward dark red, saturating at 14 consecutive dry days. // (Mirrors the calendar's dry-streak ramp.) -const DRY_BASE = [247, 214, 226], DRY_MAX = [125, 26, 73], DRY_CAP = 14; +const DRY_BASE = [247, 217, 176], DRY_MAX = [122, 13, 26], DRY_CAP = 14; const lerpRgb = (a, b, t) => `rgb(${a.map((x, i) => Math.round(x + (b[i] - x) * t)).join(",")})`; function drynessColor(dsr) { if (dsr == null) return ""; - if (dsr === 0) return "#6d28d9"; // measurable rain that day + if (dsr === 0) return "#2b7bba"; // measurable rain that day return lerpRgb(DRY_BASE, DRY_MAX, Math.min(dsr, DRY_CAP) / DRY_CAP); } diff --git a/static/calendar.js b/static/calendar.js index e131a1e..9e8eaa4 100644 --- a/static/calendar.js +++ b/static/calendar.js @@ -4,26 +4,26 @@ // intentionally re-declares the small shared tier constants so it stays independent // of app.js (the map page). -// 9-step diverging temperature scale ("Orchid Sunset": violet -> blush -> magenta). +// 9-step diverging temperature scale (cold navy -> green -> hot red). const TIER_COLORS = { - "rec-hot": "#7d1a49", "very-hot": "#db2777", "hot": "#f472b6", "warm": "#f9a8d4", - "normal": "#f0dce8", - "cool": "#cbb6f7", "cold": "#9b6ef0", "very-cold": "#6d28d9", "rec-cold": "#3b0764", + "rec-hot": "#8f0e20", "very-hot": "#dd6b52", "hot": "#ef9351", "warm": "#f6c18a", + "normal": "#4a9d5b", + "cool": "#92c5de", "cold": "#4393c3", "very-cold": "#2166ac", "rec-cold": "#0a2f6b", }; -// 9-step rain-intensity ramp (rain-day percentile): light lilac -> orchid -> deep violet. +// 9-step rain-intensity ramp (rain-day percentile): light green -> teal -> dark navy. const PRECIP_COLORS = { - "wet-1": "#f3e8ff", "wet-2": "#e9d5ff", "wet-3": "#d8b4fe", "wet-4": "#c084fc", "wet-5": "#a855f7", - "wet-6": "#9333ea", "wet-7": "#7e22ce", "wet-8": "#6b21a8", "wet-9": "#4c1d95", + "wet-1": "#d9f0d3", "wet-2": "#b7e2b1", "wet-3": "#8fd18f", "wet-4": "#5cba9f", "wet-5": "#35a1c0", + "wet-6": "#2b8cbe", "wet-7": "#226bb3", "wet-8": "#164a97", "wet-9": "#08306b", }; // "Days since last rain" dry-streak color: rain days are blue; each dry day warms // from a light base toward dark red, saturating at 14 consecutive dry days. -const DRY_BASE = [247, 214, 226], DRY_MAX = [125, 26, 73], DRY_CAP = 14; +const DRY_BASE = [247, 217, 176], DRY_MAX = [122, 13, 26], DRY_CAP = 14; const lerpRgb = (a, b, t) => `rgb(${a.map((x, i) => Math.round(x + (b[i] - x) * t)).join(",")})`; function drynessColor(dsr) { if (dsr == null) return ""; - if (dsr === 0) return "#6d28d9"; // measurable rain that day + if (dsr === 0) return "#2b7bba"; // measurable rain that day return lerpRgb(DRY_BASE, DRY_MAX, Math.min(dsr, DRY_CAP) / DRY_CAP); } // low -> high percentile scale, per metric (temp vs precip use different classes) diff --git a/static/day.js b/static/day.js index bb68edf..5dc4320 100644 --- a/static/day.js +++ b/static/day.js @@ -6,15 +6,15 @@ // so the page stays independent of app.js. const TIER_COLORS = { - "rec-hot": "#7d1a49", "very-hot": "#db2777", "hot": "#f472b6", "warm": "#f9a8d4", - "normal": "#f0dce8", - "cool": "#cbb6f7", "cold": "#9b6ef0", "very-cold": "#6d28d9", "rec-cold": "#3b0764", + "rec-hot": "#8f0e20", "very-hot": "#dd6b52", "hot": "#ef9351", "warm": "#f6c18a", + "normal": "#4a9d5b", + "cool": "#92c5de", "cold": "#4393c3", "very-cold": "#2166ac", "rec-cold": "#0a2f6b", }; const PRECIP_COLORS = { - "wet-1": "#f3e8ff", "wet-2": "#e9d5ff", "wet-3": "#d8b4fe", "wet-4": "#c084fc", "wet-5": "#a855f7", - "wet-6": "#9333ea", "wet-7": "#7e22ce", "wet-8": "#6b21a8", "wet-9": "#4c1d95", + "wet-1": "#d9f0d3", "wet-2": "#b7e2b1", "wet-3": "#8fd18f", "wet-4": "#5cba9f", "wet-5": "#35a1c0", + "wet-6": "#2b8cbe", "wet-7": "#226bb3", "wet-8": "#164a97", "wet-9": "#08306b", }; -const DRY_COLOR = "#cbb26a"; +const DRY_COLOR = "#c9a24a"; // Temps go through the shared unit formatter; the rest are unit-agnostic. const fmtTemp = (v) => window.Thermograph.fmtTemp(v); diff --git a/static/mappicker.js b/static/mappicker.js index edb17ee..0cab3e5 100644 --- a/static/mappicker.js +++ b/static/mappicker.js @@ -9,7 +9,7 @@ // itself is created lazily the first time the picker opens (and reused after), so // pages that never open it pay nothing. (function () { - let overlay, mapEl, map, marker, pin = null, onPickCb = null; + let overlay, mapEl, map, marker, pin = null, onPickCb = null, pinIcon = null; let searchForm, searchInput, sugEl, confirmBtn, hintEl; const PIN_ICON = ``; @@ -93,7 +93,7 @@ function setPin(lat, lon, zoom) { pin = { lat, lon }; if (marker) marker.setLatLng([lat, lon]); - else marker = L.marker([lat, lon]).addTo(map); + else marker = L.marker([lat, lon], pinIcon ? { icon: pinIcon } : undefined).addTo(map); map.setView([lat, lon], zoom || map.getZoom()); confirmBtn.disabled = false; hintEl.textContent = "Pin set — confirm below, or tap elsewhere to move it."; @@ -117,10 +117,23 @@ overlay.hidden = false; if (!map) { - map = L.map(mapEl, { worldCopyJump: true }).setView([44.5, -95], 4); - L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { - maxZoom: 18, attribution: "© OpenStreetMap contributors", + map = L.map(mapEl, { worldCopyJump: true, zoomControl: true }).setView([44.5, -95], 4); + // Themed CARTO basemap (no key needed) instead of the plain gray OSM raster: + // sleek "dark matter" tiles under the app's dark theme, colorful "voyager" + // tiles under the light theme, so the picker matches whichever is active. + const dark = matchMedia("(prefers-color-scheme: dark)").matches; + const tiles = dark + ? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" + : "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png"; + L.tileLayer(tiles, { + subdomains: "abcd", maxZoom: 20, detectRetina: true, + attribution: '© OpenStreetMap © CARTO', }).addTo(map); + // Branded accent teardrop pin (the same glyph the Find button uses), styled + // in style.css — replaces Leaflet's default blue raster marker. + pinIcon = L.divIcon({ + className: "mp-pin", html: PIN_ICON, iconSize: [32, 32], iconAnchor: [16, 30], + }); map.on("click", (e) => setPin(e.latlng.lat, e.latlng.lng)); } // Leaflet measures the container on creation; it's zero-sized while hidden, so diff --git a/static/style.css b/static/style.css index bede74b..76cf4ae 100644 --- a/static/style.css +++ b/static/style.css @@ -5,32 +5,31 @@ --border: #2a323c; --text: #e7ecf2; --muted: #9aa6b2; - --accent: #ec4899; + --accent: #f0803c; - /* temperature grades: 9-step diverging cold -> neutral -> hot ("Orchid Sunset"). - Cool end = violet/lavender, warm end = rose/magenta; rec-* are the "Near Record" - danger tiers — deliberately dark/saturated. */ - --rec-cold: #3b0764; - --very-cold: #6d28d9; - --cold: #9b6ef0; - --cool: #cbb6f7; - --normal: #f0dce8; /* middle of the diverging temp scale = soft blush (see light-theme override) */ - --warm: #f9a8d4; - --hot: #f472b6; - --very-hot: #db2777; - --rec-hot: #7d1a49; + /* temperature grades: 9-step diverging cold -> green -> hot (colorblind-safe). + rec-* are the "Near Record" danger tiers — deliberately dark/saturated. */ + --rec-cold: #0a2f6b; + --very-cold: #2166ac; + --cold: #4393c3; + --cool: #92c5de; + --normal: #4a9d5b; /* middle of the diverging temp scale = green */ + --warm: #f6c18a; + --hot: #ef9351; + --very-hot: #dd6b52; + --rec-hot: #8f0e20; - /* precipitation: "dry" + 9 rain-intensity tiers, light lilac -> orchid -> deep violet */ - --dry: #cbb26a; - --wet-1: #f3e8ff; - --wet-2: #e9d5ff; - --wet-3: #d8b4fe; - --wet-4: #c084fc; - --wet-5: #a855f7; /* middle of the rain scale */ - --wet-6: #9333ea; - --wet-7: #7e22ce; - --wet-8: #6b21a8; - --wet-9: #4c1d95; + /* precipitation: "dry" + 9 rain-intensity tiers, light green -> teal -> dark navy */ + --dry: #c9a24a; + --wet-1: #d9f0d3; + --wet-2: #b7e2b1; + --wet-3: #8fd18f; + --wet-4: #5cba9f; + --wet-5: #35a1c0; /* middle of the rain scale */ + --wet-6: #2b8cbe; + --wet-7: #226bb3; + --wet-8: #164a97; + --wet-9: #08306b; } @media (prefers-color-scheme: light) { @@ -41,11 +40,6 @@ --border: #dde3ea; --text: #1a2029; --muted: #5b6673; - /* The soft-blush middle tiers vanish on a white surface — darken them just - enough to stay legible as swatches, cell fills, and grade text. */ - --normal: #c05a8a; - --warm: #ef7fbe; - --cool: #a98bf0; } } @@ -167,12 +161,12 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; } display: flex; align-items: center; gap: 13px; width: 100%; margin: 0 0 20px; padding: 14px 16px; min-height: 60px; border: none; border-radius: 13px; text-decoration: none; - color: #2a0716; background: linear-gradient(135deg, #f472b6, #db2777); - box-shadow: 0 4px 16px rgba(219, 39, 119, .3); + color: #1a1206; background: linear-gradient(135deg, #f79556, #ea722c); + box-shadow: 0 4px 16px rgba(240, 128, 60, .3); transition: transform .12s ease, box-shadow .12s ease, filter .12s ease; } -.cta-cal:hover { transform: translateY(-1px); box-shadow: 0 7px 22px rgba(219, 39, 119, .42); filter: brightness(1.04); } -.cta-cal:active { transform: translateY(0); box-shadow: 0 3px 10px rgba(219, 39, 119, .34); } +.cta-cal:hover { transform: translateY(-1px); box-shadow: 0 7px 22px rgba(240, 128, 60, .42); filter: brightness(1.04); } +.cta-cal:active { transform: translateY(0); box-shadow: 0 3px 10px rgba(240, 128, 60, .34); } .cta-cal:focus-visible { outline: 3px solid var(--text); outline-offset: 2px; } .cta-cal-icon { font-size: 26px; line-height: 1; flex-shrink: 0; } .cta-cal-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; } @@ -200,7 +194,7 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; } .chart-toggle button.active[data-metric="wind"] { background: #5aa9a3; color: #08201e; } .chart-toggle button.active[data-metric="gust"] { background: #8f86d8; color: #100c26; } .chart-toggle button.active[data-metric="precip"] { background: var(--wet-6); color: #fff; } -.chart-toggle button.active[data-metric="dry"] { background: #cbb26a; color: #241a04; } +.chart-toggle button.active[data-metric="dry"] { background: #c9a24a; color: #241a04; } .chart-legend { display: flex; flex-wrap: wrap; gap: 14px; font-size: 12px; color: var(--muted); margin-bottom: 8px; } @@ -705,7 +699,27 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; } .mp-map { flex: 1 1 auto; min-height: 300px; margin: 4px 16px; z-index: 0; border-radius: 12px; overflow: hidden; border: 1px solid var(--border); + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .25), 0 6px 18px rgba(0, 0, 0, .25); } +.mp-map .leaflet-container { background: var(--surface-2); } +/* Branded accent teardrop pin (an L.divIcon wrapping the Find-button glyph), + replacing Leaflet's default blue raster marker. */ +.mp-pin { background: none; border: none; filter: drop-shadow(0 3px 4px rgba(0, 0, 0, .45)); } +.mp-pin svg { width: 32px; height: 32px; display: block; } +.mp-pin path { fill: var(--accent); stroke: rgba(0, 0, 0, .35); stroke-width: 1.25; } +.mp-pin circle { fill: #fff; stroke: none; } +/* Theme the Leaflet zoom buttons + attribution to sit in the app's surfaces + instead of Leaflet's stock white chrome. */ +.mp-map .leaflet-bar { border: none; box-shadow: 0 2px 8px rgba(0, 0, 0, .3); } +.mp-map .leaflet-bar a { + background: var(--surface); color: var(--text); border-bottom-color: var(--border); +} +.mp-map .leaflet-bar a:hover { background: var(--surface-2); color: var(--accent); } +.mp-map .leaflet-control-attribution { + background: color-mix(in srgb, var(--surface) 80%, transparent); + color: var(--muted); backdrop-filter: blur(2px); border-top-left-radius: 6px; +} +.mp-map .leaflet-control-attribution a { color: var(--accent); } .mp-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px 16px; flex-wrap: wrap;