Compare commits
No files matched your search
@@ -0,0 +1,101 @@
|
|||||||
|
/* Shared plain-language alert helpers (used by the weather app and the station dashboard) */
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
const GLOSSARY = {
|
||||||
|
'heat advisory': 'Dangerously hot conditions expected. Limit time outdoors, drink plenty of water, and check on children, older adults and pets.',
|
||||||
|
'excessive heat watch': 'Conditions are favorable for life-threatening heat in the next day or two. Plan ahead.',
|
||||||
|
'excessive heat warning': 'Life-threatening heat is occurring or imminent. Avoid outdoor activity and stay cool.',
|
||||||
|
'extreme heat watch': 'Conditions are favorable for life-threatening heat in the next day or two. Plan ahead.',
|
||||||
|
'extreme heat warning': 'Life-threatening heat is occurring or imminent. Avoid outdoor activity and stay cool.',
|
||||||
|
'severe thunderstorm watch': 'Storms capable of damaging wind or large hail are possible in the area. Stay alert and be ready to take shelter.',
|
||||||
|
'severe thunderstorm warning': 'A severe storm with damaging wind and/or large hail is happening or imminent. Move indoors away from windows.',
|
||||||
|
'tornado watch': 'Conditions are favorable for tornadoes. Stay alert and know where you would take shelter.',
|
||||||
|
'tornado warning': 'A tornado is occurring or imminent. Take shelter immediately in the lowest, most interior room.',
|
||||||
|
'special weather statement': 'A short notice about a significant but non-warning weather event, often a strong storm, gusty winds, dense fog or sudden changes.',
|
||||||
|
'flood watch': 'Flooding is possible. Be prepared to move to higher ground and avoid low-lying areas.',
|
||||||
|
'flood advisory': 'Minor flooding is occurring or expected. Avoid flooded roads and low spots.',
|
||||||
|
'flood warning': 'Flooding is happening or imminent. Move to higher ground and never drive through flood water.',
|
||||||
|
'flash flood watch': 'Flash flooding is possible. Be ready to move to higher ground quickly.',
|
||||||
|
'flash flood warning': 'Flash flooding is occurring or imminent. Move to higher ground now.',
|
||||||
|
'wind advisory': 'Sustained or gusty winds that can blow around unsecured objects and make driving difficult for high-profile vehicles.',
|
||||||
|
'high wind watch': 'Damaging winds are possible. Secure outdoor objects.',
|
||||||
|
'high wind warning': 'Damaging winds are occurring or imminent. Secure objects and avoid travel if possible.',
|
||||||
|
'freeze watch': 'A freeze is possible. Protect sensitive plants, pets and exposed pipes.',
|
||||||
|
'freeze warning': 'Temperatures at or below freezing are expected. Protect plants, pets and exposed pipes.',
|
||||||
|
'frost advisory': 'Frost is expected and can damage sensitive plants.',
|
||||||
|
'hard freeze warning': 'Severe freezing temperatures are expected. Protect plants, pets and pipes.',
|
||||||
|
'winter storm watch': 'Heavy snow and/or ice is possible. Prepare for hazardous travel.',
|
||||||
|
'winter storm warning': 'Heavy snow and/or ice is occurring or imminent. Travel may be dangerous or impossible.',
|
||||||
|
'winter weather advisory': 'Snow, sleet or freezing rain will cause slippery roads and travel difficulty.',
|
||||||
|
'ice storm warning': 'Significant ice accumulation is expected. Power outages and dangerous travel are likely.',
|
||||||
|
'blizzard warning': 'Heavy snow with strong winds and very low visibility. Avoid travel.',
|
||||||
|
'wind chill advisory': 'Dangerously cold wind chills are expected. Dress in layers and limit time outside.',
|
||||||
|
'wind chill warning': 'Life-threatening wind chills are expected. Frostbite can occur quickly.',
|
||||||
|
'dense fog advisory': 'Fog is reducing visibility to a quarter mile or less. Slow down and use low-beam headlights.',
|
||||||
|
'dense smoke advisory': 'Smoke is reducing visibility and air quality.',
|
||||||
|
'air quality alert': 'Air quality is unhealthy for some or all people. Limit prolonged outdoor exertion.',
|
||||||
|
'red flag warning': 'Dry, windy and warm conditions create a high wildfire risk. Avoid open flames and outdoor burning.',
|
||||||
|
'fire weather watch': 'Conditions may soon create a high wildfire risk.',
|
||||||
|
'rip current statement': 'Dangerous rip currents at the beach. Stay out of the water or swim near a lifeguard.',
|
||||||
|
'coastal flood advisory': 'Minor coastal flooding is expected.',
|
||||||
|
'coastal flood warning': 'Significant coastal flooding is occurring or imminent.',
|
||||||
|
'small craft advisory': 'Winds or seas hazardous to small boats.',
|
||||||
|
'tropical storm watch': 'Tropical storm conditions are possible within about 48 hours.',
|
||||||
|
'tropical storm warning': 'Tropical storm conditions are expected within about 36 hours.',
|
||||||
|
'hurricane watch': 'Hurricane conditions are possible within about 48 hours.',
|
||||||
|
'hurricane warning': 'Hurricane conditions are expected within about 36 hours.',
|
||||||
|
'hazardous weather outlook': 'An outlook of possible hazardous weather in the next several days.',
|
||||||
|
'severe weather statement': 'An update on an ongoing severe storm warning.',
|
||||||
|
'flood statement': 'An update on an ongoing flood warning.',
|
||||||
|
'storm surge watch': 'Life-threatening storm surge flooding is possible within about 48 hours. Know your evacuation zone and be ready to leave.',
|
||||||
|
'storm surge warning': 'Life-threatening storm surge flooding is expected within about 36 hours. Follow evacuation orders from local officials immediately.',
|
||||||
|
'hurricane local statement': 'Local details on a hurricane threat: expected winds, rain, surge and tornado risk for your area, and what officials are advising.',
|
||||||
|
'tropical cyclone statement': 'Local details on a tropical storm or hurricane threat for your area.',
|
||||||
|
'tropical depression watch': 'A tropical depression may affect the area; heavy rain and gusty wind are possible.',
|
||||||
|
'extreme wind warning': 'Extreme, hurricane-force winds (115+ mph) are about to hit. Take shelter now in the strongest part of a sturdy building.',
|
||||||
|
'hurricane force wind watch': 'Hurricane-force winds are possible over marine areas. Stay in port.',
|
||||||
|
'hurricane force wind warning': 'Hurricane-force winds are expected over marine areas. Dangerous for all vessels.',
|
||||||
|
'storm warning': 'Strong marine winds (48–63 knots) expected. Dangerous for all vessels.',
|
||||||
|
'gale warning': 'Strong marine winds (34–47 knots) expected. Hazardous for small and large vessels.',
|
||||||
|
'gale watch': 'Gale-force marine winds are possible.',
|
||||||
|
'special marine warning': 'Short-lived, hazardous marine weather (strong winds, waterspouts, hail) is happening. Seek safe harbor.',
|
||||||
|
'high surf advisory': 'Large breaking waves make swimming and surfing dangerous.',
|
||||||
|
'high surf warning': 'Very large breaking waves are expected: dangerous and life-threatening at the shore.',
|
||||||
|
'beach hazards statement': 'Hazards at the beach such as rip currents or high surf. Swim near a lifeguard.',
|
||||||
|
'lake wind advisory': 'Gusty lake winds can create hazardous waves for small boats.',
|
||||||
|
'dust storm warning': 'Blowing dust is reducing visibility to near zero. Pull off the road and turn off your lights.',
|
||||||
|
'tornado emergency': 'A confirmed large and destructive tornado is moving toward a populated area. Take cover immediately.',
|
||||||
|
'flash flood emergency': 'Catastrophic, life-threatening flash flooding is happening. Move to higher ground now.',
|
||||||
|
};
|
||||||
|
|
||||||
|
function meaning(event) {
|
||||||
|
const e = String(event || '').toLowerCase().trim();
|
||||||
|
if (GLOSSARY[e]) return GLOSSARY[e];
|
||||||
|
if (e.endsWith('watch')) return 'A Watch: conditions are favorable for this hazard. Stay alert and be ready to act.';
|
||||||
|
if (e.endsWith('warning')) return 'A Warning: this hazard is happening or imminent. Take action now.';
|
||||||
|
if (e.endsWith('advisory')) return 'An Advisory: conditions may cause inconvenience or danger if you are not careful.';
|
||||||
|
if (e.endsWith('statement')) return 'An informational statement about developing or ongoing weather.';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
// NWS text is often structured "* WHAT...", "* WHERE...", "* WHEN..."; lead with the WHAT part
|
||||||
|
function firstSentence(t, max = 220) {
|
||||||
|
const what = String(t || '').match(/\*\s*WHAT\.\.\.([\s\S]*?)(?:\n\s*\n|\*\s*[A-Z ]+\.\.\.|$)/);
|
||||||
|
const c = (what ? what[1] : String(t || '')).replace(/\s+/g, ' ').trim();
|
||||||
|
const m = c.match(/^.*?[.!?](\s|$)/);
|
||||||
|
const r = (m ? m[0] : c).trim();
|
||||||
|
return r.length > max ? r.slice(0, max - 3) + '…' : r;
|
||||||
|
}
|
||||||
|
const area = (a) => String(a || '').replace(/;\s*/g, ', ');
|
||||||
|
const when = (iso) => (iso ? new Date(iso).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '');
|
||||||
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
|
// "ends in 3h 10m" / "ended" for active alerts
|
||||||
|
function remaining(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const ms = Date.parse(iso) - Date.now();
|
||||||
|
if (!Number.isFinite(ms)) return '';
|
||||||
|
if (ms <= 0) return 'expired';
|
||||||
|
const m = Math.round(ms / 60000);
|
||||||
|
return m < 60 ? `ends in ${m} min` : m < 1440 ? `ends in ${Math.floor(m / 60)}h ${m % 60}m` : `ends in ${Math.round(m / 144) / 10}d`.replace('.0d', 'd');
|
||||||
|
}
|
||||||
|
window.AlertInfo = { meaning, firstSentence, area, when, esc, remaining, GLOSSARY };
|
||||||
|
})();
|
||||||
@@ -2,27 +2,29 @@
|
|||||||
:root {
|
:root {
|
||||||
--sky-text: #ffffff;
|
--sky-text: #ffffff;
|
||||||
--blue-grad: linear-gradient(160deg, #2563eb 0%, #3b82f6 55%, #93c5fd 100%);
|
--blue-grad: linear-gradient(160deg, #2563eb 0%, #3b82f6 55%, #93c5fd 100%);
|
||||||
--topbar-h: 58px;
|
--topbar-h: calc(58px + env(safe-area-inset-top, 0px));
|
||||||
|
--gutter: 1rem;
|
||||||
}
|
}
|
||||||
html { -webkit-text-size-adjust: 100%; -webkit-tap-highlight-color: transparent; }
|
html { -webkit-text-size-adjust: 100%; -webkit-tap-highlight-color: transparent; }
|
||||||
body { overscroll-behavior-y: none; }
|
/* on <html> (the viewport): putting this on <body> can block wheel scrolling when body is a scroll container */
|
||||||
|
html { overscroll-behavior-y: none; }
|
||||||
button, a, summary { touch-action: manipulation; }
|
button, a, summary { touch-action: manipulation; }
|
||||||
|
|
||||||
/* ── Layout ── */
|
/* ── Layout ── */
|
||||||
.app { max-width: 980px; padding: 0 1rem 4rem; }
|
.app { max-width: 980px; padding: 0 var(--gutter) 4rem; }
|
||||||
@media (min-width: 900px) { .app { padding: 0 1.5rem 4rem; } }
|
@media (min-width: 900px) { :root { --gutter: 1.5rem; } }
|
||||||
|
|
||||||
/* ── Slim top bar ── */
|
/* ── Slim top bar ── */
|
||||||
.topbar {
|
.topbar {
|
||||||
position: sticky; top: 0; z-index: 200;
|
position: sticky; top: 0; z-index: 200;
|
||||||
display: flex; align-items: center; gap: 0.75rem;
|
display: flex; align-items: center; gap: 0.75rem;
|
||||||
margin: 0 -1rem 1rem; padding: calc(0.55rem + env(safe-area-inset-top, 0px)) calc(1rem + env(safe-area-inset-right, 0px)) 0.55rem calc(1rem + env(safe-area-inset-left, 0px));
|
margin: 0 calc(-1 * var(--gutter)) 1rem; padding: calc(0.55rem + env(safe-area-inset-top, 0px)) calc(var(--gutter) + env(safe-area-inset-right, 0px)) 0.55rem calc(var(--gutter) + env(safe-area-inset-left, 0px));
|
||||||
min-height: var(--topbar-h);
|
min-height: var(--topbar-h);
|
||||||
background: color-mix(in srgb, var(--paper) 86%, transparent);
|
background: color-mix(in srgb, var(--paper) 86%, transparent);
|
||||||
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
@media (min-width: 900px) { .topbar { margin: 0 -1.5rem 1.25rem; padding-left: 1.5rem; padding-right: 1.5rem; } }
|
@media (min-width: 900px) { .topbar { margin-bottom: 1.25rem; } }
|
||||||
.brand { display: inline-flex; align-items: center; gap: 0.4rem; text-decoration: none; color: var(--ink); font-weight: 800; letter-spacing: -0.01em; font-size: 1.05rem; flex-shrink: 0; }
|
.brand { display: inline-flex; align-items: center; gap: 0.4rem; text-decoration: none; color: var(--ink); font-weight: 800; letter-spacing: -0.01em; font-size: 1.05rem; flex-shrink: 0; }
|
||||||
.brand-mark { color: var(--accent); font-size: 1.3rem; line-height: 1; }
|
.brand-mark { color: var(--accent); font-size: 1.3rem; line-height: 1; }
|
||||||
.topbar .tabs { border: none; border-radius: 999px; background: var(--paper-dark); padding: 3px; gap: 2px; margin: 0 auto; overflow: visible; }
|
.topbar .tabs { border: none; border-radius: 999px; background: var(--paper-dark); padding: 3px; gap: 2px; margin: 0 auto; overflow: visible; }
|
||||||
@@ -46,8 +48,9 @@ button, a, summary { touch-action: manipulation; }
|
|||||||
.icon-btn:hover { color: var(--accent); border-color: var(--accent); }
|
.icon-btn:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
.icon-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
.icon-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.app { padding: 0 0.9rem max(5.5rem, calc(5rem + env(safe-area-inset-bottom, 0px))); }
|
:root { --gutter: 0.9rem; }
|
||||||
.topbar { margin: 0 -0.9rem 0.8rem; }
|
.app { padding: 0 calc(var(--gutter) + env(safe-area-inset-right, 0px)) max(5.5rem, calc(5rem + env(safe-area-inset-bottom, 0px))) calc(var(--gutter) + env(safe-area-inset-left, 0px)); }
|
||||||
|
.topbar { margin: 0 calc(-1 * (var(--gutter) + env(safe-area-inset-right, 0px))) 0.8rem calc(-1 * (var(--gutter) + env(safe-area-inset-left, 0px))); }
|
||||||
.brand-name { display: none; }
|
.brand-name { display: none; }
|
||||||
.topbar .tabs { display: none; }
|
.topbar .tabs { display: none; }
|
||||||
.loc-chip { flex: 1; max-width: none; justify-content: flex-start; }
|
.loc-chip { flex: 1; max-width: none; justify-content: flex-start; }
|
||||||
@@ -129,7 +132,7 @@ button, a, summary { touch-action: manipulation; }
|
|||||||
.bring-row .rec-label { font-size: 0.78rem; font-weight: 600; white-space: nowrap; }
|
.bring-row .rec-label { font-size: 0.78rem; font-weight: 600; white-space: nowrap; }
|
||||||
|
|
||||||
/* ── Sticky section headings (iPhone friendly) ── */
|
/* ── Sticky section headings (iPhone friendly) ── */
|
||||||
.sticky-head { position: sticky; top: calc(var(--topbar-h) + env(safe-area-inset-top, 0px) - 6px); z-index: 20; padding: 0.45rem 0.15rem; margin: 0 0 0.5rem;
|
.sticky-head { position: sticky; top: calc(var(--topbar-h) - 6px); z-index: 20; padding: 0.45rem 0.15rem; margin: 0 0 0.5rem;
|
||||||
background: color-mix(in srgb, var(--paper) 90%, transparent); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); }
|
background: color-mix(in srgb, var(--paper) 90%, transparent); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); }
|
||||||
.section-label { font-size: 0.78rem; letter-spacing: 0.12em; }
|
.section-label { font-size: 0.78rem; letter-spacing: 0.12em; }
|
||||||
.link-btn { min-height: 36px; font-style: normal; color: var(--accent); }
|
.link-btn { min-height: 36px; font-style: normal; color: var(--accent); }
|
||||||
@@ -207,3 +210,141 @@ body:not([data-tab="conditions"]) #loading { display: none; }
|
|||||||
.hilo-arrow { display: inline; font-size: 0.8em; opacity: .85; margin-right: 1px; }
|
.hilo-arrow { display: inline; font-size: 0.8em; opacity: .85; margin-right: 1px; }
|
||||||
.hilo-label { flex-basis: 100%; font-size: 0.62rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; opacity: .85; margin-top: 2px; }
|
.hilo-label { flex-basis: 100%; font-size: 0.62rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; opacity: .85; margin-top: 2px; }
|
||||||
@media (max-width: 640px) { .hero-hilo { flex-direction: column; gap: 0.35rem; } .hilo-divider { width: 70%; height: 1px; } }
|
@media (max-width: 640px) { .hero-hilo { flex-direction: column; gap: 0.35rem; } .hilo-divider { width: 70%; height: 1px; } }
|
||||||
|
|
||||||
|
/* ── Install guide ── */
|
||||||
|
.install-banner { display: flex; align-items: center; gap: 0.7rem; margin: 0 0 0.9rem; padding: 0.65rem 0.8rem; border-radius: 16px;
|
||||||
|
background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 18%, var(--card-bg)), var(--card-bg)); border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--border)); }
|
||||||
|
.install-banner.hidden { display: none; }
|
||||||
|
.ib-ico { font-size: 1.6rem; }
|
||||||
|
.ib-text { flex: 1; min-width: 0; display: flex; flex-direction: column; font-size: 0.85rem; line-height: 1.25; }
|
||||||
|
.ib-text span { font-size: 0.74rem; color: var(--ink-light); }
|
||||||
|
.ib-btn { font: inherit; font-weight: 700; font-size: 0.82rem; min-height: 40px; padding: 0 1rem; border-radius: 999px; border: none; background: var(--accent); color: #fff; cursor: pointer; }
|
||||||
|
.ib-x { font: inherit; width: 36px; height: 36px; border: none; background: transparent; color: var(--ink-light); font-size: 1rem; cursor: pointer; border-radius: 50%; }
|
||||||
|
.install-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 16px; padding: 1rem 1.1rem; margin-bottom: 1.4rem; }
|
||||||
|
.ins-lead { font-size: 0.88rem; margin-bottom: 0.8rem; color: var(--ink-mid); }
|
||||||
|
.ins-done { font-weight: 700; color: #22c55e; margin-bottom: 0.4rem; }
|
||||||
|
.ins-tabs { display: inline-flex; padding: 3px; gap: 2px; border-radius: 999px; background: var(--paper-darker); margin-bottom: 0.8rem; }
|
||||||
|
.ins-tab { font: inherit; font-size: 0.8rem; font-weight: 700; min-height: 36px; padding: 0 0.9rem; border: none; border-radius: 999px; background: transparent; color: var(--ink-light); cursor: pointer; }
|
||||||
|
.ins-tab.on { background: var(--accent); color: #fff; }
|
||||||
|
.ins-steps { margin: 0 0 0.6rem 1.2rem; display: grid; gap: 0.55rem; font-size: 0.9rem; line-height: 1.4; }
|
||||||
|
.ins-steps li::marker { font-weight: 800; color: var(--accent); }
|
||||||
|
.ins-ico { vertical-align: -5px; margin: 0 2px; color: var(--accent); }
|
||||||
|
.ins-note { font-size: 0.78rem; color: var(--ink-light); line-height: 1.5; margin-top: 0.5rem; }
|
||||||
|
.ins-install { font: inherit; font-weight: 700; min-height: 44px; padding: 0 1.4rem; margin-bottom: 0.8rem; border: none; border-radius: 999px; background: var(--accent); color: #fff; cursor: pointer; }
|
||||||
|
.install-dialog { border: 1px solid var(--border); border-radius: 20px; background: var(--paper-dark); color: var(--ink); padding: 1.1rem 1.2rem 1.3rem; width: min(440px, 92vw); margin: auto; }
|
||||||
|
.install-dialog::backdrop { background: rgba(0,0,0,.6); }
|
||||||
|
.install-dialog-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.7rem; }
|
||||||
|
.install-dialog-head h2 { font-size: 1.1rem; }
|
||||||
|
.install-dialog-head button { font: inherit; width: 36px; height: 36px; border: none; background: transparent; color: var(--ink-light); font-size: 1rem; cursor: pointer; border-radius: 50%; }
|
||||||
|
|
||||||
|
/* Landscape phones: side insets for the content column (the top bar already adds its own) */
|
||||||
|
.app { padding-left: calc(var(--gutter) + env(safe-area-inset-left, 0px)); padding-right: calc(var(--gutter) + env(safe-area-inset-right, 0px)); }
|
||||||
|
.topbar { margin-left: calc(-1 * (var(--gutter) + env(safe-area-inset-left, 0px))); margin-right: calc(-1 * (var(--gutter) + env(safe-area-inset-right, 0px))); }
|
||||||
|
.install-dialog { margin-top: max(1rem, env(safe-area-inset-top, 0px)); }
|
||||||
|
|
||||||
|
/* ── Map tab: full-bleed map with floating glass controls (Apple-Weather style) ── */
|
||||||
|
:root { --nav-h: 0px; --hud-top: 12px; --map-safe-b: 0px; --hud-side: 12px; }
|
||||||
|
@media (max-width: 640px) { :root { --nav-h: calc(58px + max(6px, env(safe-area-inset-bottom, 0px))); } }
|
||||||
|
body.map-fs { --hud-top: calc(12px + env(safe-area-inset-top, 0px)); --map-safe-b: env(safe-area-inset-bottom, 0px); }
|
||||||
|
|
||||||
|
.glass { background: color-mix(in srgb, var(--paper-dark) 74%, transparent); backdrop-filter: blur(18px) saturate(1.5); -webkit-backdrop-filter: blur(18px) saturate(1.5);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border) 65%, transparent); box-shadow: 0 8px 28px rgba(0, 0, 0, 0.3); }
|
||||||
|
|
||||||
|
/* The tab fills everything between the top bar and the bottom nav; no page scrolling here */
|
||||||
|
body:not(.map-fs) #tab-unified-map:not(.hidden) { position: fixed; top: var(--topbar-h); left: 0; right: 0; bottom: var(--nav-h); z-index: 60; animation: none; }
|
||||||
|
body[data-tab="unified-map"] { overflow: hidden; }
|
||||||
|
html:has(body[data-tab="unified-map"]) { overflow: hidden; }
|
||||||
|
#tab-unified-map .unified-map-wrap { position: absolute; inset: 0; padding: 0; border: 0; border-radius: 0; background: transparent; display: block; height: auto !important; min-height: 0; }
|
||||||
|
#tab-unified-map .unified-map-shell { position: absolute; inset: 0; }
|
||||||
|
#tab-unified-map .unified-map-container { position: absolute; inset: 0; height: 100% !important; min-height: 0; border: 0; border-radius: 0; }
|
||||||
|
body.map-fs #tab-unified-map { padding: 0; }
|
||||||
|
|
||||||
|
/* Floating controls, top right */
|
||||||
|
.map-hud { position: absolute; top: var(--hud-top); right: max(var(--hud-side), env(safe-area-inset-right, 0px)); display: flex; flex-direction: column; gap: 10px; z-index: 700; }
|
||||||
|
.hud-btn { width: 46px; height: 46px; padding: 0; display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; cursor: pointer; color: var(--ink); font: inherit; font-size: 1.15rem;
|
||||||
|
background: color-mix(in srgb, var(--paper-dark) 78%, transparent); backdrop-filter: blur(18px) saturate(1.5); -webkit-backdrop-filter: blur(18px) saturate(1.5);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border) 65%, transparent); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); transition: transform .12s, background .15s, color .15s; }
|
||||||
|
.hud-btn:active { transform: scale(0.94); }
|
||||||
|
.hud-btn:hover { color: var(--accent); }
|
||||||
|
.hud-btn.on { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* Readout chips, top left */
|
||||||
|
#tab-unified-map .map-overlay-readouts { position: absolute; top: var(--hud-top); left: max(var(--hud-side), env(safe-area-inset-left, 0px)); right: auto; bottom: auto; flex-direction: column; align-items: flex-start; gap: 8px; z-index: 700; }
|
||||||
|
#tab-unified-map .map-readout-chip { background: color-mix(in srgb, var(--paper-dark) 78%, transparent); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); border: 1px solid color-mix(in srgb, var(--border) 65%, transparent); color: var(--ink); font-weight: 700; padding: 0.4rem 0.8rem; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25); }
|
||||||
|
|
||||||
|
/* Layers sheet: floats beside the controls */
|
||||||
|
#tab-unified-map .map-layer-menu-panel { position: absolute; top: var(--hud-top); right: calc(max(var(--hud-side), env(safe-area-inset-right, 0px)) + 58px); z-index: 710; width: min(330px, calc(100% - 96px)); max-height: calc(100% - var(--hud-top) - 185px);
|
||||||
|
overflow: auto; margin: 0; padding: 0.8rem 0.9rem 0.9rem; border-radius: 20px; }
|
||||||
|
.lm-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.6rem; font-size: 1rem; }
|
||||||
|
.lm-close { width: 34px; height: 34px; border: none; border-radius: 50%; background: var(--paper-darker); color: var(--ink-light); font: inherit; cursor: pointer; }
|
||||||
|
.lm-style { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; font-size: 0.85rem; font-weight: 600; margin-bottom: 0.8rem; }
|
||||||
|
.lm-style select { flex: 1; max-width: 190px; min-height: 40px; font-size: 16px; }
|
||||||
|
#tab-unified-map .map-layer-menu-title { font-size: 0.66rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-light); margin-bottom: 0.4rem; }
|
||||||
|
#tab-unified-map .map-controls-toggle-grid { display: grid; grid-template-columns: 1fr; gap: 2px; }
|
||||||
|
#tab-unified-map .toggle-control { padding: 0.45rem 0.2rem; min-height: 44px; }
|
||||||
|
|
||||||
|
/* Bottom: weather summary + radar timeline float over the map */
|
||||||
|
.map-bottom { position: absolute; left: max(var(--hud-side), env(safe-area-inset-left, 0px)); right: max(var(--hud-side), env(safe-area-inset-right, 0px)); bottom: calc(12px + var(--map-safe-b)); z-index: 650; display: flex; flex-direction: column; gap: 10px; pointer-events: none; }
|
||||||
|
.map-bottom > * { pointer-events: auto; }
|
||||||
|
#tab-unified-map .map-timeline-wrap { padding: 0.55rem 0.75rem 0.6rem; border-radius: 18px; border-top: 1px solid color-mix(in srgb, var(--border) 65%, transparent); }
|
||||||
|
#tab-unified-map .map-timeline-wrap.hidden, #tab-unified-map .map-data-panel.hidden { display: none; }
|
||||||
|
#tab-unified-map .map-data-panel { align-self: flex-start; max-width: 100%; display: flex; flex-wrap: wrap; align-items: center; gap: 0.3rem 1.1rem; padding: 0.5rem 0.9rem; border-radius: 16px; }
|
||||||
|
#tab-unified-map .panel-location { font-weight: 800; font-size: 0.9rem; }
|
||||||
|
#tab-unified-map .panel-data { display: flex; flex-wrap: wrap; gap: 0.2rem 1rem; }
|
||||||
|
#tab-unified-map .data-item { display: flex; flex-direction: row; align-items: baseline; gap: 0.35rem; font-size: 0.82rem; }
|
||||||
|
@media (max-width: 640px) { #tab-unified-map .data-item:nth-child(2), #tab-unified-map .data-item:nth-child(4), #tab-unified-map .data-item:nth-child(5) { display: none; } }
|
||||||
|
|
||||||
|
/* MapLibre zoom buttons: tucked under the control stack on desktop, hidden on touch */
|
||||||
|
#unified-map .maplibregl-ctrl-top-right { margin-top: calc(var(--hud-top) + 166px); margin-right: max(var(--hud-side), env(safe-area-inset-right, 0px)); }
|
||||||
|
#unified-map .maplibregl-ctrl-top-right .maplibregl-ctrl { margin: 0; }
|
||||||
|
@media (pointer: coarse) { #unified-map .maplibregl-ctrl-top-right { display: none; } }
|
||||||
|
|
||||||
|
/* ── Alerts tab: hurricane cards + rich alert cards ── */
|
||||||
|
.hurricane-storm-card { border-radius: 16px; padding: 0.9rem 1rem; }
|
||||||
|
.hurricane-name { font-weight: 800; font-size: 1.05rem; }
|
||||||
|
.hurricane-stats b { color: var(--ink); font-size: 0.95rem; }
|
||||||
|
.hurricane-stats small { opacity: .75; }
|
||||||
|
.hurricane-meta { display: flex; flex-wrap: wrap; gap: 0.3rem 0.9rem; align-items: center; margin-top: 0.5rem; font-size: 0.74rem; color: var(--ink-light); }
|
||||||
|
.hurricane-link { color: var(--accent); font-weight: 600; text-decoration: none; }
|
||||||
|
.hurricane-link:hover { text-decoration: underline; }
|
||||||
|
.hurricane-map-hint { margin-left: auto; color: var(--accent); font-weight: 600; }
|
||||||
|
|
||||||
|
.alert-rich { border-radius: 16px; padding: 0.95rem 1.05rem; }
|
||||||
|
.alert-why { font-size: 0.95rem; font-weight: 600; line-height: 1.4; margin: 0.35rem 0 0.5rem; }
|
||||||
|
.alert-facts { display: flex; flex-wrap: wrap; gap: 0.25rem 1rem; font-size: 0.8rem; color: var(--ink-light); margin-bottom: 0.55rem; }
|
||||||
|
.alert-left { font-weight: 700; color: var(--accent); }
|
||||||
|
.alert-meaning { font-size: 0.84rem; line-height: 1.45; padding: 0.55rem 0.75rem; border-radius: 12px; background: color-mix(in srgb, var(--accent) 9%, transparent); margin-bottom: 0.5rem; }
|
||||||
|
.alert-rich .alert-action { font-size: 0.86rem; line-height: 1.45; }
|
||||||
|
.alert-more { margin-top: 0.6rem; border-top: 1px dashed var(--border); padding-top: 0.5rem; }
|
||||||
|
.alert-more summary { cursor: pointer; font-size: 0.8rem; font-weight: 700; color: var(--accent); min-height: 36px; display: flex; align-items: center; list-style: none; }
|
||||||
|
.alert-more summary::-webkit-details-marker { display: none; }
|
||||||
|
.alert-more summary::before { content: '▸'; margin-right: 0.4rem; transition: transform .15s; }
|
||||||
|
.alert-more[open] summary::before { transform: rotate(90deg); }
|
||||||
|
.alert-full { font-size: 0.84rem; line-height: 1.5; }
|
||||||
|
.alert-full p { margin: 0 0 0.6rem; }
|
||||||
|
.alert-pre { white-space: pre-wrap; }
|
||||||
|
.alert-meta { font-size: 0.74rem; color: var(--ink-light); }
|
||||||
|
|
||||||
|
/* Location permission status line */
|
||||||
|
.geo-status { font-size: 0.8rem; line-height: 1.45; color: var(--ink-light); margin: 0.5rem 0.15rem 0; padding: 0.5rem 0.75rem; border-radius: 12px; background: var(--paper-darker); }
|
||||||
|
.geo-status.hidden { display: none; }
|
||||||
|
.geo-status.ok { color: #22c55e; }
|
||||||
|
.geo-status.warn { color: var(--ink); background: color-mix(in srgb, #ef4444 14%, var(--paper-darker)); border: 1px solid color-mix(in srgb, #ef4444 45%, transparent); }
|
||||||
|
|
||||||
|
/* ── Performance on phones ──
|
||||||
|
backdrop-filter re-blurs everything behind it on every frame. Over a live WebGL map, an animated sky
|
||||||
|
or sticky headings that is what makes iPhones stutter and lock up, so those use solid translucent fills. */
|
||||||
|
.hero-hilo, .sticky-head, .glass, .hud-btn, #tab-unified-map .map-readout-chip,
|
||||||
|
.map-layer-menu-panel, .map-timeline-wrap, .map-data-panel, .install-banner {
|
||||||
|
-webkit-backdrop-filter: none !important; backdrop-filter: none !important;
|
||||||
|
}
|
||||||
|
.glass, .hud-btn, #tab-unified-map .map-readout-chip { background: color-mix(in srgb, var(--paper-dark) 92%, transparent) !important; }
|
||||||
|
.sticky-head { background: color-mix(in srgb, var(--paper) 96%, transparent) !important; }
|
||||||
|
.hero-hilo { background: rgba(0, 0, 0, 0.24) !important; }
|
||||||
|
.topbar, .bottom-nav { -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
|
||||||
|
|
||||||
|
/* readout chips follow their layer toggle (author display rules would otherwise beat [hidden]) */
|
||||||
|
#tab-unified-map .map-overlay-readouts[hidden], #tab-unified-map .map-readout-chip[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
/* Location dot: the one thing on the map by default, so make it unmistakable (static halo, no animation) */
|
||||||
|
.map-location-dot { width: 20px; height: 20px; background: #2563eb; border: 3px solid #fff; border-radius: 50%; box-shadow: 0 0 0 7px rgba(37, 99, 235, 0.28), 0 2px 8px rgba(0, 0, 0, 0.55); }
|
||||||
@@ -23,6 +23,12 @@ if (typeof baseActivate === 'function') {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep sticky section headings flush under the (safe-area aware) top bar
|
||||||
|
const topbarEl = document.querySelector('.topbar');
|
||||||
|
if (topbarEl && 'ResizeObserver' in window) {
|
||||||
|
new ResizeObserver(() => document.documentElement.style.setProperty('--topbar-h', topbarEl.offsetHeight + 'px')).observe(topbarEl);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Top bar: location chip opens search ──────────────────────────────────────
|
// ── Top bar: location chip opens search ──────────────────────────────────────
|
||||||
$('tb-search')?.addEventListener('click', () => {
|
$('tb-search')?.addEventListener('click', () => {
|
||||||
window.activateTab('settings');
|
window.activateTab('settings');
|
||||||
@@ -47,7 +53,10 @@ function skyFor(code, isDay, stationRaining) {
|
|||||||
return isDay ? 'clear-day' : 'clear-night';
|
return isDay ? 'clear-day' : 'clear-night';
|
||||||
}
|
}
|
||||||
|
|
||||||
const fx = { raf: 0, kind: null, parts: [], w: 0, h: 0, flash: 0 };
|
const fx = { raf: 0, kind: null, parts: [], w: 0, h: 0, flash: 0, last: 0, onScreen: true };
|
||||||
|
const PHONE = window.matchMedia('(max-width: 640px)').matches;
|
||||||
|
const fxShouldRun = () => fx.parts.length && !REDUCED && !document.hidden && fx.onScreen && document.body.dataset.tab === 'conditions';
|
||||||
|
function fxKick() { if (!fx.raf && fxShouldRun()) fx.raf = requestAnimationFrame(fxFrame); }
|
||||||
function fxResize(canvas) {
|
function fxResize(canvas) {
|
||||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
fx.w = canvas.clientWidth; fx.h = canvas.clientHeight;
|
fx.w = canvas.clientWidth; fx.h = canvas.clientHeight;
|
||||||
@@ -55,7 +64,8 @@ function fxResize(canvas) {
|
|||||||
canvas.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0);
|
canvas.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
}
|
}
|
||||||
function fxSeed(kind) {
|
function fxSeed(kind) {
|
||||||
const n = kind === 'storm' ? 130 : kind === 'rain' ? 85 : kind === 'snow' ? 70 : kind === 'clear-night' ? 55 : 0;
|
const base = kind === 'storm' ? 130 : kind === 'rain' ? 85 : kind === 'snow' ? 70 : kind === 'clear-night' ? 55 : 0;
|
||||||
|
const n = Math.round(base * (PHONE ? 0.55 : 1));
|
||||||
fx.parts = Array.from({ length: n }, () => ({
|
fx.parts = Array.from({ length: n }, () => ({
|
||||||
x: Math.random() * fx.w, y: Math.random() * fx.h,
|
x: Math.random() * fx.w, y: Math.random() * fx.h,
|
||||||
v: kind === 'snow' ? 0.4 + Math.random() * 0.9 : kind === 'clear-night' ? 0 : 6 + Math.random() * 7,
|
v: kind === 'snow' ? 0.4 + Math.random() * 0.9 : kind === 'clear-night' ? 0 : 6 + Math.random() * 7,
|
||||||
@@ -64,8 +74,11 @@ function fxSeed(kind) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
function fxFrame(t) {
|
function fxFrame(t) {
|
||||||
|
fx.raf = 0;
|
||||||
const canvas = $('sky-fx');
|
const canvas = $('sky-fx');
|
||||||
if (!canvas) return;
|
if (!canvas || !fxShouldRun()) return; // hidden tab / scrolled away / app in background: stop drawing entirely
|
||||||
|
if (t - fx.last < 33) { fx.raf = requestAnimationFrame(fxFrame); return; } // cap at ~30 fps
|
||||||
|
fx.last = t;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
ctx.clearRect(0, 0, fx.w, fx.h);
|
ctx.clearRect(0, 0, fx.w, fx.h);
|
||||||
const kind = fx.kind;
|
const kind = fx.kind;
|
||||||
@@ -94,21 +107,23 @@ function fxFrame(t) {
|
|||||||
ctx.beginPath(); ctx.arc(p.x, p.y * 0.8, p.r, 0, Math.PI * 2); ctx.fill();
|
ctx.beginPath(); ctx.arc(p.x, p.y * 0.8, p.r, 0, Math.PI * 2); ctx.fill();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!REDUCED && !document.hidden) fx.raf = requestAnimationFrame(fxFrame);
|
fx.raf = requestAnimationFrame(fxFrame);
|
||||||
}
|
}
|
||||||
function setSky(kind) {
|
function setSky(kind) {
|
||||||
const hero = $('hero'), canvas = $('sky-fx');
|
const hero = $('hero'), canvas = $('sky-fx');
|
||||||
if (!hero || !canvas) return;
|
if (!hero || !canvas) return;
|
||||||
hero.dataset.sky = kind;
|
hero.dataset.sky = kind;
|
||||||
cancelAnimationFrame(fx.raf);
|
cancelAnimationFrame(fx.raf);
|
||||||
|
fx.raf = 0;
|
||||||
fx.kind = kind;
|
fx.kind = kind;
|
||||||
fxResize(canvas);
|
fxResize(canvas);
|
||||||
fxSeed(kind);
|
fxSeed(kind);
|
||||||
if (fx.parts.length) fxFrame(performance.now());
|
if (fx.parts.length) fxKick();
|
||||||
else canvas.getContext('2d').clearRect(0, 0, fx.w, fx.h);
|
else canvas.getContext('2d').clearRect(0, 0, fx.w, fx.h);
|
||||||
}
|
}
|
||||||
window.addEventListener('resize', () => { const c = $('sky-fx'); if (c && fx.kind) { fxResize(c); fxSeed(fx.kind); } });
|
window.addEventListener('resize', () => { const c = $('sky-fx'); if (c && fx.kind) { fxResize(c); fxSeed(fx.kind); } });
|
||||||
document.addEventListener('visibilitychange', () => { if (!document.hidden && fx.parts.length && !REDUCED) { cancelAnimationFrame(fx.raf); fxFrame(performance.now()); } });
|
document.addEventListener('visibilitychange', fxKick);
|
||||||
|
if ('IntersectionObserver' in window && $('hero')) new IntersectionObserver((es) => { fx.onScreen = es[0].isIntersecting; fxKick(); }).observe($('hero'));
|
||||||
|
|
||||||
// ── Nowcast: next 2 hours from 15-minute precipitation ───────────────────────
|
// ── Nowcast: next 2 hours from 15-minute precipitation ───────────────────────
|
||||||
function nowcastSlots(data) {
|
function nowcastSlots(data) {
|
||||||
@@ -372,6 +387,63 @@ function updateRadar(city, outlook) {
|
|||||||
if (map.isStyleLoaded()) apply(); else map.once('idle', apply);
|
if (map.isStyleLoaded()) apply(); else map.once('idle', apply);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Map tab: independent, remembered view (not tied to the main-page radar) ──
|
||||||
|
let mapCenteredFor = null;
|
||||||
|
const mapCityKey = (c) => `${(+c.latitude).toFixed(2)},${(+c.longitude).toFixed(2)}`;
|
||||||
|
function centerMap(city, force) {
|
||||||
|
const m = typeof mapsOverview !== 'undefined' ? mapsOverview?.map : null;
|
||||||
|
if (!m || city?.latitude == null) return;
|
||||||
|
const key = mapCityKey(city);
|
||||||
|
if (!force && mapCenteredFor === key) return; // keep wherever the user panned/zoomed to
|
||||||
|
let saved = null;
|
||||||
|
try { saved = JSON.parse(localStorage.getItem('mapView') || 'null'); } catch {}
|
||||||
|
if (!force && !mapCenteredFor && saved && saved.key === key && Number.isFinite(saved.zoom)) m.jumpTo({ center: [saved.lng, saved.lat], zoom: saved.zoom });
|
||||||
|
else mapsOverview.panTo(city.latitude, city.longitude, force ? 7 : 5); // wider regional view than the main-page radar
|
||||||
|
mapCenteredFor = key;
|
||||||
|
}
|
||||||
|
(() => {
|
||||||
|
const m = typeof mapsOverview !== 'undefined' ? mapsOverview?.map : null;
|
||||||
|
if (!m) return;
|
||||||
|
let t = 0;
|
||||||
|
m.on('moveend', () => {
|
||||||
|
if (!mapCenteredFor) return;
|
||||||
|
clearTimeout(t);
|
||||||
|
t = setTimeout(() => { try { const c = m.getCenter(); localStorage.setItem('mapView', JSON.stringify({ key: mapCenteredFor, lng: c.lng, lat: c.lat, zoom: m.getZoom() })); } catch {} }, 400);
|
||||||
|
});
|
||||||
|
$('map-recenter-btn')?.addEventListener('click', () => { if (state.currentCity) centerMap(state.currentCity, true); });
|
||||||
|
})();
|
||||||
|
|
||||||
|
|
||||||
|
// ── Location permission status (so "blocked" is visible, not guessed) ────────
|
||||||
|
const GeoStatus = (() => {
|
||||||
|
const el = $('geo-status');
|
||||||
|
let status = null;
|
||||||
|
async function render() {
|
||||||
|
if (!el || !navigator.permissions || !navigator.geolocation) return;
|
||||||
|
try {
|
||||||
|
if (!status) {
|
||||||
|
status = await navigator.permissions.query({ name: 'geolocation' });
|
||||||
|
status.addEventListener('change', render);
|
||||||
|
}
|
||||||
|
} catch { return; }
|
||||||
|
const s = status.state;
|
||||||
|
const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||||
|
const standalone = window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true;
|
||||||
|
if (s === 'granted') { el.className = 'geo-status ok'; el.textContent = '📍 Location: allowed. Tap ⌖ to use it.'; }
|
||||||
|
else if (s === 'prompt') { el.className = 'geo-status'; el.textContent = '📍 Location: not asked yet. Tap ⌖ and choose Allow when your phone asks.'; }
|
||||||
|
else if (s === 'denied') {
|
||||||
|
el.className = 'geo-status warn';
|
||||||
|
el.textContent = ios
|
||||||
|
? `📍 Location: your phone has this ${standalone ? 'app' : 'site'} set to Don't Allow. Fix it in Settings → Privacy & Security → Location Services → ${standalone ? '"NeCloud Weather"' : 'Safari Websites'}. A Safari content blocker can also do this: try aA → Turn Off Content Blockers.`
|
||||||
|
: '📍 Location: blocked for this site. Click the lock icon in the address bar → Location → Allow.';
|
||||||
|
} else { el.classList.add('hidden'); return; }
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
return { refresh: render };
|
||||||
|
})();
|
||||||
|
|
||||||
// ── Public hooks ─────────────────────────────────────────────────────────────
|
// ── Public hooks ─────────────────────────────────────────────────────────────
|
||||||
let lastOutlook = null;
|
let lastOutlook = null;
|
||||||
window.AppUI = {
|
window.AppUI = {
|
||||||
@@ -393,11 +465,106 @@ window.AppUI = {
|
|||||||
if (state.currentAir) renderAqi(state.currentAir);
|
if (state.currentAir) renderAqi(state.currentAir);
|
||||||
if (stationFresh) renderStationContext();
|
if (stationFresh) renderStationContext();
|
||||||
},
|
},
|
||||||
|
centerMap,
|
||||||
|
refreshGeoStatus: () => GeoStatus.refresh(),
|
||||||
onAir(air) { renderAqi(air); },
|
onAir(air) { renderAqi(air); },
|
||||||
refreshFx() { const c = $('sky-fx'); if (c && fx.kind) { cancelAnimationFrame(fx.raf); fxResize(c); fxSeed(fx.kind); if (fx.parts.length) fxFrame(performance.now()); } },
|
refreshFx() { const c = $('sky-fx'); if (c && fx.kind) { cancelAnimationFrame(fx.raf); fx.raf = 0; fxResize(c); fxSeed(fx.kind); fxKick(); } },
|
||||||
onStation() { syncTopbarLocation(); renderStationContext(); },
|
onStation() { syncTopbarLocation(); renderStationContext(); },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// ── Install as an app (PWA): guidance for iPhone/iPad and Android ────────────
|
||||||
|
const Install = (() => {
|
||||||
|
const ua = navigator.userAgent || '';
|
||||||
|
const isIOS = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||||
|
const isAndroid = /Android/i.test(ua);
|
||||||
|
const standalone = () => window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true;
|
||||||
|
let deferred = null; // Chrome/Edge/Samsung install prompt
|
||||||
|
window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferred = e; refresh(); });
|
||||||
|
window.addEventListener('appinstalled', () => { deferred = null; localStorage.setItem('installDone', '1'); refresh(); });
|
||||||
|
|
||||||
|
const SHARE = '<svg class="ins-ico" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M12 3v12M12 3 8 7M12 3l4 4M6 11H5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-8a1 1 0 0 0-1-1h-1" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||||
|
const DOTS = '<svg class="ins-ico" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><circle cx="12" cy="5" r="2" fill="currentColor"/><circle cx="12" cy="12" r="2" fill="currentColor"/><circle cx="12" cy="19" r="2" fill="currentColor"/></svg>';
|
||||||
|
|
||||||
|
const steps = {
|
||||||
|
ios: `<ol class="ins-steps">
|
||||||
|
<li>Open this page in <b>Safari</b>. (On iOS 16.4+ Chrome and Edge can do it too.)</li>
|
||||||
|
<li>Tap the <b>Share</b> button ${SHARE} at the bottom of the screen (top right on iPad).</li>
|
||||||
|
<li>Scroll down and tap <b>Add to Home Screen</b>.</li>
|
||||||
|
<li>Tap <b>Add</b>. The app icon appears on your Home Screen.</li>
|
||||||
|
</ol>
|
||||||
|
<p class="ins-note">Open it from the Home Screen icon for the full-screen app. iPhone has no automatic install pop-up, so this manual step is the only way.</p>`,
|
||||||
|
android: `<ol class="ins-steps">
|
||||||
|
<li>Open this page in <b>Chrome</b>.</li>
|
||||||
|
<li>Tap the <b>menu</b> ${DOTS} (top right).</li>
|
||||||
|
<li>Tap <b>Install app</b> (or <b>Add to Home screen</b>).</li>
|
||||||
|
<li>Tap <b>Install</b>. The app shows up in your app drawer and on your Home screen.</li>
|
||||||
|
</ol>
|
||||||
|
<p class="ins-note"><b>Samsung Internet:</b> menu (☰) → <b>Add page to</b> → <b>Home screen</b>. <b>Firefox:</b> menu → <b>Install</b>.</p>`,
|
||||||
|
};
|
||||||
|
const other = '<p class="ins-note">Desktop Chrome/Edge: click the install icon in the address bar (or menu → <b>Install NeCloud Weather</b>).</p>';
|
||||||
|
const dashNote = '<p class="ins-note">The <b>Station Dashboard</b> installs separately: open <b>/dashboard</b> and follow the same steps to get its own icon.</p>';
|
||||||
|
|
||||||
|
function platform() { return isIOS ? 'ios' : isAndroid ? 'android' : null; }
|
||||||
|
|
||||||
|
function body(active) {
|
||||||
|
const p = active || platform() || 'ios';
|
||||||
|
const tabs = `<div class="ins-tabs" role="tablist">
|
||||||
|
<button type="button" role="tab" class="ins-tab${p === 'ios' ? ' on' : ''}" data-p="ios">iPhone / iPad</button>
|
||||||
|
<button type="button" role="tab" class="ins-tab${p === 'android' ? ' on' : ''}" data-p="android">Android</button></div>`;
|
||||||
|
const promptBtn = deferred && p === 'android' ? '<button type="button" class="ins-install" id="ins-native">Install now</button>' : '';
|
||||||
|
return `${tabs}<div class="ins-body" data-p="${p}">${promptBtn}${steps[p]}${!platform() ? other : ''}${dashNote}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wire(root) {
|
||||||
|
root.querySelectorAll('.ins-tab').forEach((b) => b.addEventListener('click', () => { root.innerHTML = body(b.dataset.p); wire(root); }));
|
||||||
|
root.querySelector('#ins-native')?.addEventListener('click', async () => {
|
||||||
|
if (!deferred) return;
|
||||||
|
deferred.prompt(); try { await deferred.userChoice; } catch {} deferred = null; refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCard() {
|
||||||
|
const card = $('install-card');
|
||||||
|
if (!card) return;
|
||||||
|
if (standalone()) {
|
||||||
|
card.innerHTML = '<p class="ins-done">✓ You\'re using the installed app.</p>' + dashNote;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
card.innerHTML = `<p class="ins-lead">Add NeCloud Weather to your phone like a normal app: full screen, its own icon, and it opens instantly.</p><div class="ins-wrap">${body()}</div>`;
|
||||||
|
wire(card.querySelector('.ins-wrap'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Banner: phones only, not installed, not dismissed within 30 days
|
||||||
|
function renderBanner() {
|
||||||
|
const el = $('install-banner');
|
||||||
|
if (!el) return;
|
||||||
|
let dismissed = 0; try { dismissed = +localStorage.getItem('installDismissed') || 0; } catch {}
|
||||||
|
const show = !standalone() && (isIOS || isAndroid) && Date.now() - dismissed > 30 * 86400000 && !localStorage.getItem('installDone');
|
||||||
|
el.classList.toggle('hidden', !show);
|
||||||
|
$('ib-how').textContent = deferred ? 'Install' : 'How';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDialog() {
|
||||||
|
const dlg = $('install-dialog'), b = $('install-dialog-body');
|
||||||
|
if (!dlg || !b) return;
|
||||||
|
b.innerHTML = body(); wire(b);
|
||||||
|
if (typeof dlg.showModal === 'function') dlg.showModal(); else dlg.setAttribute('open', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() { renderBanner(); renderCard(); }
|
||||||
|
|
||||||
|
$('ib-how')?.addEventListener('click', async () => {
|
||||||
|
if (deferred) { deferred.prompt(); try { await deferred.userChoice; } catch {} deferred = null; refresh(); }
|
||||||
|
else openDialog();
|
||||||
|
});
|
||||||
|
$('ib-close')?.addEventListener('click', () => { try { localStorage.setItem('installDismissed', String(Date.now())); } catch {} $('install-banner').classList.add('hidden'); });
|
||||||
|
$('install-dialog-close')?.addEventListener('click', () => $('install-dialog').close());
|
||||||
|
$('install-dialog')?.addEventListener('click', (e) => { if (e.target === e.currentTarget) e.currentTarget.close(); });
|
||||||
|
refresh();
|
||||||
|
return { refresh, openDialog };
|
||||||
|
})();
|
||||||
|
|
||||||
syncTopbarLocation();
|
syncTopbarLocation();
|
||||||
new MutationObserver(syncTopbarLocation).observe($('loc-name') || document.body, { childList: true, characterData: true, subtree: true });
|
new MutationObserver(syncTopbarLocation).observe($('loc-name') || document.body, { childList: true, characterData: true, subtree: true });
|
||||||
})();
|
})();
|
||||||
@@ -62,55 +62,104 @@ function parseNhcCoord(str) {
|
|||||||
|
|
||||||
let hurricaneStorms = [];
|
let hurricaneStorms = [];
|
||||||
|
|
||||||
|
// ── NHC storm model ──────────────────────────────────────────────────────────
|
||||||
|
// NHC's CurrentStorms.json reports `intensity` in KNOTS and `movementSpeed` in mph.
|
||||||
|
const KT_TO_MPH = 1.15078;
|
||||||
|
const STORM_CLASS = {
|
||||||
|
TD: 'Tropical Depression', TS: 'Tropical Storm', HU: 'Hurricane', TY: 'Typhoon',
|
||||||
|
STD: 'Subtropical Depression', STS: 'Subtropical Storm', SD: 'Subtropical Depression', SS: 'Subtropical Storm',
|
||||||
|
PTC: 'Potential Tropical Cyclone', PC: 'Post-Tropical Cyclone', EX: 'Extratropical Cyclone',
|
||||||
|
};
|
||||||
|
const SAFFIR_COLORS = { td: '#5dade2', ts: '#27ae60', 1: '#f1c40f', 2: '#f39c12', 3: '#e74c3c', 4: '#c0392b', 5: '#9b59b6', ex: '#95a5a6' };
|
||||||
|
const compass16 = deg => ['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSW','SW','WSW','W','WNW','NW','NNW'][Math.round(deg / 22.5) % 16];
|
||||||
|
function saffirCategory(kt) { return kt >= 137 ? 5 : kt >= 113 ? 4 : kt >= 96 ? 3 : kt >= 83 ? 2 : kt >= 64 ? 1 : 0; }
|
||||||
|
// Colour for a point on the forecast track, from its own forecast wind (knots)
|
||||||
|
function windColor(kt) { return kt >= 64 ? SAFFIR_COLORS[Math.max(1, saffirCategory(kt))] : kt >= 34 ? SAFFIR_COLORS.ts : SAFFIR_COLORS.td; }
|
||||||
|
function escHtml(v) { return String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
|
||||||
|
function fmtLatLon(lat, lon) {
|
||||||
|
const la = isNaN(lat) ? '--' : `${Math.abs(lat).toFixed(1)}°${lat < 0 ? 'S' : 'N'}`;
|
||||||
|
const lo = isNaN(lon) ? '--' : `${Math.abs(lon).toFixed(1)}°${lon < 0 ? 'W' : 'E'}`;
|
||||||
|
return `${la}, ${lo}`;
|
||||||
|
}
|
||||||
|
function milesBetween(lat1, lon1, lat2, lon2) {
|
||||||
|
const R = 3958.8, r = Math.PI / 180, dLat = (lat2 - lat1) * r, dLon = (lon2 - lon1) * r;
|
||||||
|
const x = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * r) * Math.cos(lat2 * r) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * R * Math.asin(Math.sqrt(x));
|
||||||
|
}
|
||||||
|
function bearingDeg(lat1, lon1, lat2, lon2) {
|
||||||
|
const r = Math.PI / 180, y = Math.sin((lon2 - lon1) * r) * Math.cos(lat2 * r);
|
||||||
|
const x = Math.cos(lat1 * r) * Math.sin(lat2 * r) - Math.sin(lat1 * r) * Math.cos(lat2 * r) * Math.cos((lon2 - lon1) * r);
|
||||||
|
return (Math.atan2(y, x) / r + 360) % 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stormInfo(s) {
|
||||||
|
const cls = String(s.classification || '').toUpperCase();
|
||||||
|
const kt = parseFloat(s.intensity);
|
||||||
|
const hasKt = Number.isFinite(kt);
|
||||||
|
const mph = hasKt ? Math.round(kt * KT_TO_MPH) : null;
|
||||||
|
const isHurr = cls === 'HU' || cls === 'TY';
|
||||||
|
const cat = isHurr && hasKt ? Math.max(1, saffirCategory(kt)) : isHurr ? 1 : 0;
|
||||||
|
const base = STORM_CLASS[cls] || 'Tropical Cyclone';
|
||||||
|
const label = cat ? `Category ${cat} ${cls === 'TY' ? 'Typhoon' : 'Hurricane'}${cat >= 3 ? ' (major)' : ''}` : base;
|
||||||
|
const badge = cat ? `cat-${cat}` : (cls === 'TS' || cls === 'STS' || cls === 'SS') ? 'cat-ts' : (cls === 'TD' || cls === 'STD' || cls === 'SD' || cls === 'PTC') ? 'cat-td' : 'cat-ex';
|
||||||
|
const color = cat ? SAFFIR_COLORS[cat] : badge === 'cat-ts' ? SAFFIR_COLORS.ts : badge === 'cat-td' ? SAFFIR_COLORS.td : SAFFIR_COLORS.ex;
|
||||||
|
const lat = Number.isFinite(s.latitudeNumeric) ? s.latitudeNumeric : parseNhcCoord(s.latitude);
|
||||||
|
const lon = Number.isFinite(s.longitudeNumeric) ? s.longitudeNumeric : parseNhcCoord(s.longitude);
|
||||||
|
const title = s.name ? `${base} ${s.name}` : base;
|
||||||
|
let movement = '';
|
||||||
|
if (Number.isFinite(s.movementSpeed)) {
|
||||||
|
movement = s.movementSpeed < 1 ? 'Nearly stationary'
|
||||||
|
: `Moving ${Number.isFinite(s.movementDir) ? compass16(s.movementDir) + ' ' : ''}at ${Math.round(s.movementSpeed)} mph`;
|
||||||
|
}
|
||||||
|
return { cls, kt: hasKt ? kt : null, mph, cat, label, base, title, badge, color, lat, lon, movement };
|
||||||
|
}
|
||||||
|
|
||||||
function renderHurricanePanel(storms) {
|
function renderHurricanePanel(storms) {
|
||||||
const panel = document.getElementById('hurricane-panel');
|
const panel = document.getElementById('hurricane-panel');
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
if (!storms || !storms.length) { panel.innerHTML = ''; return; }
|
if (!storms || !storms.length) { panel.innerHTML = ''; return; }
|
||||||
hurricaneStorms = storms;
|
hurricaneStorms = storms;
|
||||||
const catLabel = c => {
|
const here = state.currentCity;
|
||||||
if (!c) return '';
|
|
||||||
const up = c.toUpperCase();
|
|
||||||
if (up === 'HU') return 'cat-1';
|
|
||||||
if (up === 'TS') return 'cat-ts';
|
|
||||||
if (up === 'TD') return 'cat-td';
|
|
||||||
const m = c.match(/(\d)/);
|
|
||||||
if (m) return `cat-${m[1]}`;
|
|
||||||
return 'cat-td';
|
|
||||||
};
|
|
||||||
const catName = c => {
|
|
||||||
if (!c) return 'Tropical';
|
|
||||||
const up = c.toUpperCase();
|
|
||||||
if (up === 'HU') return 'Hurricane';
|
|
||||||
if (up === 'TS') return 'Tropical Storm';
|
|
||||||
if (up === 'TD') return 'Tropical Depression';
|
|
||||||
return c;
|
|
||||||
};
|
|
||||||
panel.innerHTML = storms.map(s => {
|
panel.innerHTML = storms.map(s => {
|
||||||
const lat = parseNhcCoord(s.latitude);
|
const i = stormInfo(s);
|
||||||
const lon = parseNhcCoord(s.longitude);
|
let away = '';
|
||||||
|
if (here && !isNaN(i.lat) && !isNaN(i.lon)) {
|
||||||
|
const mi = milesBetween(here.latitude, here.longitude, i.lat, i.lon);
|
||||||
|
const dir = compass16(bearingDeg(here.latitude, here.longitude, i.lat, i.lon));
|
||||||
|
away = `<span>≈ ${Math.round(state.units === 'us' ? mi : mi * 1.60934).toLocaleString()} ${state.units === 'us' ? 'mi' : 'km'} ${dir} of ${escHtml(here.name || 'you')}</span>`;
|
||||||
|
}
|
||||||
|
const updated = s.lastUpdate ? new Date(s.lastUpdate).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' }) : '';
|
||||||
|
const adv = s.publicAdvisory;
|
||||||
return `
|
return `
|
||||||
<div class="hurricane-storm-card" style="cursor:pointer" title="Click to view on map">
|
<div class="hurricane-storm-card" style="cursor:pointer" title="Click to view on map" data-storm="${escHtml(s.id || '')}">
|
||||||
<div class="hurricane-header">
|
<div class="hurricane-header">
|
||||||
<span class="hurricane-name">${s.name || 'Storm'}</span>
|
<span class="hurricane-name">${escHtml(i.title)}</span>
|
||||||
<span class="hurricane-cat-badge ${catLabel(s.classification)}">${catName(s.classification)}</span>
|
<span class="hurricane-cat-badge ${i.badge}" style="background:${i.color}">${escHtml(i.cat ? 'Category ' + i.cat : i.base)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="hurricane-stats">
|
<div class="hurricane-stats">
|
||||||
<span>Winds: ${s.intensity || '--'} mph</span>
|
<span><b>${i.mph != null ? i.mph + ' mph' : '--'}</b> winds${i.kt != null ? ` <small>(${Math.round(i.kt)} kt)</small>` : ''}</span>
|
||||||
${s.pressure ? `<span>Pressure: ${s.pressure} mb</span>` : ''}
|
${s.pressure ? `<span>${escHtml(s.pressure)} mb</span>` : ''}
|
||||||
<span>${isNaN(lat) ? s.latitude : lat.toFixed(1)+'°'}, ${isNaN(lon) ? s.longitude : Math.abs(lon).toFixed(1)+'°'+(lon < 0 ? 'W' : 'E')}</span>
|
<span>${fmtLatLon(i.lat, i.lon)}</span>
|
||||||
${s.movement ? `<span>Moving ${s.movement}</span>` : ''}
|
${i.movement ? `<span>${escHtml(i.movement)}</span>` : ''}
|
||||||
<span style="color:var(--accent,#4da6ff);font-size:0.75em">🗺 View on map</span>
|
${away}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="hurricane-meta">
|
||||||
`}).join('');
|
${updated ? `<span>Updated ${escHtml(updated)}</span>` : ''}
|
||||||
panel.querySelectorAll('.hurricane-storm-card').forEach((card, i) => {
|
${adv?.advNum ? `<span>Advisory ${escHtml(adv.advNum)}</span>` : ''}
|
||||||
card.addEventListener('click', () => flyToHurricane(hurricaneStorms[i]));
|
${adv?.url ? `<a href="${escHtml(adv.url)}" target="_blank" rel="noopener" class="hurricane-link">Official advisory ↗</a>` : ''}
|
||||||
|
<span class="hurricane-map-hint">🗺 View on map</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
panel.querySelectorAll('.hurricane-storm-card').forEach((card, idx) => {
|
||||||
|
card.addEventListener('click', () => flyToHurricane(hurricaneStorms[idx]));
|
||||||
|
card.querySelector('.hurricane-link')?.addEventListener('click', e => e.stopPropagation());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function flyToHurricane(storm) {
|
async function flyToHurricane(storm) {
|
||||||
const lat = parseNhcCoord(storm.latitude);
|
const info = stormInfo(storm);
|
||||||
const lon = parseNhcCoord(storm.longitude);
|
const { lat, lon } = info;
|
||||||
if (isNaN(lat) || isNaN(lon)) return;
|
if (isNaN(lat) || isNaN(lon)) return;
|
||||||
|
|
||||||
activateTab('unified-map');
|
activateTab('unified-map');
|
||||||
@@ -121,20 +170,30 @@ async function flyToHurricane(storm) {
|
|||||||
if (cb) cb.checked = true;
|
if (cb) cb.checked = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ground radar doesn't reach open ocean, so show infrared satellite (the storm's actual cloud
|
||||||
|
// structure). Goes through the layer checkbox so state and storage stay in sync.
|
||||||
|
const sat = document.querySelector('#tab-unified-map input[data-layer="satellite"]');
|
||||||
|
if (sat && !sat.checked) {
|
||||||
|
sat.checked = true;
|
||||||
|
sat.dispatchEvent(new Event('change'));
|
||||||
|
// a storm tap is a one-off look, not a change to the user's saved map layers
|
||||||
|
try { localStorage.removeItem(STORAGE_KEYS.showSatelliteLayer); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
await new Promise(r => setTimeout(r, 450));
|
await new Promise(r => setTimeout(r, 450));
|
||||||
|
|
||||||
const map = mapsOverview.map;
|
const map = mapsOverview.map;
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
if (!map.loaded()) await new Promise(resolve => map.once('load', resolve));
|
// 'load' fires only once per map, so waiting on it hangs forever whenever tiles are mid-load;
|
||||||
|
// flyTo works regardless, so just make sure the canvas has its real size first.
|
||||||
|
map.resize();
|
||||||
|
|
||||||
map.flyTo({ center: [lon, lat], zoom: 4, duration: 900 });
|
map.flyTo({ center: [lon, lat], zoom: 4, duration: 900, padding: { bottom: 200 } });
|
||||||
|
|
||||||
const cat = (storm.classification || '').toUpperCase();
|
|
||||||
const label = cat === 'HU' ? 'Hurricane' : cat === 'TS' ? 'Tropical Storm' : 'Tropical Depression';
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
new maplibregl.Popup({ closeButton: true, maxWidth: '240px' })
|
new maplibregl.Popup({ closeButton: true, maxWidth: '260px' })
|
||||||
.setLngLat([lon, lat])
|
.setLngLat([lon, lat])
|
||||||
.setHTML(`<b>${storm.name || 'Storm'}</b><br>${label}<br>Winds: ${storm.intensity || '--'} mph${storm.movement ? '<br>Moving ' + storm.movement : ''}`)
|
.setHTML(`<b>${escHtml(info.title)}</b><br>${escHtml(info.label)}<br>Winds: ${info.mph != null ? info.mph + ' mph' : '--'}${info.movement ? '<br>' + escHtml(info.movement) : ''}`)
|
||||||
.addTo(map);
|
.addTo(map);
|
||||||
}, 950);
|
}, 950);
|
||||||
|
|
||||||
@@ -158,20 +217,22 @@ async function flyToHurricane(storm) {
|
|||||||
if (lin?.type === 'FeatureCollection' && lin.features?.length) {
|
if (lin?.type === 'FeatureCollection' && lin.features?.length) {
|
||||||
map.addSource('hurricane-track-lin', { type: 'geojson', data: lin });
|
map.addSource('hurricane-track-lin', { type: 'geojson', data: lin });
|
||||||
map.addLayer({ id: 'hurricane-track-line', type: 'line', source: 'hurricane-track-lin',
|
map.addLayer({ id: 'hurricane-track-line', type: 'line', source: 'hurricane-track-lin',
|
||||||
paint: { 'line-color': '#ff5722', 'line-width': 2.5, 'line-dasharray': [4, 2] } });
|
paint: { 'line-color': '#ff7043', 'line-width': 2.5, 'line-dasharray': [4, 2] } });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pts?.type === 'FeatureCollection' && pts.features?.length) {
|
if (pts?.type === 'FeatureCollection' && pts.features?.length) {
|
||||||
map.addSource('hurricane-track-pts-src', { type: 'geojson', data: pts });
|
map.addSource('hurricane-track-pts-src', { type: 'geojson', data: pts });
|
||||||
map.addLayer({ id: 'hurricane-track-pts', type: 'circle', source: 'hurricane-track-pts-src',
|
// Each forecast point is coloured by its own forecast strength (not the storm's current class)
|
||||||
|
map.addLayer({
|
||||||
|
id: 'hurricane-track-pts', type: 'circle', source: 'hurricane-track-pts-src',
|
||||||
paint: {
|
paint: {
|
||||||
'circle-radius': 7,
|
'circle-radius': 7,
|
||||||
'circle-color': ['match', ['get', 'TCDVLP'],
|
'circle-color': ['step', ['to-number', ['get', 'MAXWIND'], 0],
|
||||||
'Hurricane', '#f44336', 'HU', '#f44336',
|
SAFFIR_COLORS.td, 34, SAFFIR_COLORS.ts, 64, SAFFIR_COLORS[1], 83, SAFFIR_COLORS[2],
|
||||||
'Tropical Storm', '#9c27b0', 'TS', '#9c27b0',
|
96, SAFFIR_COLORS[3], 113, SAFFIR_COLORS[4], 137, SAFFIR_COLORS[5]],
|
||||||
'#2196f3'],
|
|
||||||
'circle-stroke-width': 2, 'circle-stroke-color': '#fff',
|
'circle-stroke-width': 2, 'circle-stroke-color': '#fff',
|
||||||
} });
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const allCoords = pts.features.map(f => f.geometry.coordinates);
|
const allCoords = pts.features.map(f => f.geometry.coordinates);
|
||||||
if (allCoords.length > 1) {
|
if (allCoords.length > 1) {
|
||||||
@@ -179,28 +240,29 @@ async function flyToHurricane(storm) {
|
|||||||
const lats = allCoords.map(c => c[1]);
|
const lats = allCoords.map(c => c[1]);
|
||||||
map.fitBounds(
|
map.fitBounds(
|
||||||
[[Math.min(...lons), Math.min(...lats)], [Math.max(...lons), Math.max(...lats)]],
|
[[Math.min(...lons), Math.min(...lats)], [Math.max(...lons), Math.max(...lats)]],
|
||||||
{ padding: 60, duration: 600, maxZoom: 5 }
|
{ padding: { top: 70, left: 50, right: 50, bottom: 260 }, duration: 600, maxZoom: 5 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
map.on('click', 'hurricane-track-pts', e => {
|
// Register the handlers once per map: re-adding them on every call stacked up duplicate popups
|
||||||
const p = e.features[0].properties;
|
if (!map.__trackHandlers) {
|
||||||
const tau = p.TAU != null ? `+${p.TAU}h` : '';
|
map.__trackHandlers = true;
|
||||||
const knots = p.MAXWIND || p.INTENSITY;
|
map.on('click', 'hurricane-track-pts', e => {
|
||||||
const wind = knots ? `${Math.round(knots * 1.15)} mph` : '--';
|
const p = e.features[0].properties;
|
||||||
const type = p.TCDVLP || p.STORMTYPE || '';
|
const when = p.TAU != null ? (p.TAU === 0 ? 'Now' : `+${p.TAU}h`) : '';
|
||||||
new maplibregl.Popup().setLngLat(e.lngLat)
|
const kt = Number(p.MAXWIND) || 0;
|
||||||
.setHTML(`<b>${tau}</b> ${type}<br>Winds: ${wind}${p.MSLP ? '<br>Pressure: ' + p.MSLP + ' mb' : ''}`)
|
const cat = saffirCategory(kt);
|
||||||
.addTo(map);
|
const kind = cat ? `Category ${cat} hurricane` : kt >= 34 ? 'Tropical storm' : 'Tropical depression';
|
||||||
});
|
new maplibregl.Popup().setLngLat(e.lngLat)
|
||||||
map.on('mouseenter', 'hurricane-track-pts', () => { map.getCanvas().style.cursor = 'pointer'; });
|
.setHTML(`<b>${escHtml(when)}</b> ${escHtml(kind)}<br>Winds: ${kt ? Math.round(kt * KT_TO_MPH) + ' mph (' + kt + ' kt)' : '--'}`)
|
||||||
map.on('mouseleave', 'hurricane-track-pts', () => { map.getCanvas().style.cursor = ''; });
|
.addTo(map);
|
||||||
|
});
|
||||||
|
map.on('mouseenter', 'hurricane-track-pts', () => { map.getCanvas().style.cursor = 'pointer'; });
|
||||||
|
map.on('mouseleave', 'hurricane-track-pts', () => { map.getCanvas().style.cursor = ''; });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render overlay last so storm circles sit on top of the track layers
|
|
||||||
await renderHurricaneOverlay(map);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPrecipChart(h, startIdx) {
|
function renderPrecipChart(h, startIdx) {
|
||||||
@@ -264,8 +326,50 @@ async function subscribePush() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Overlays used to bail out silently whenever tiles were still loading (most of the time while radar animates).
|
||||||
|
// Wait for the map to settle instead (but never hang: give up after a few seconds and try anyway).
|
||||||
|
function mapReady(map, timeoutMs = 4000) {
|
||||||
|
if (!map || map.loaded()) return Promise.resolve();
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const t = setTimeout(resolve, timeoutMs);
|
||||||
|
map.once('idle', () => { clearTimeout(t); resolve(); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infrared satellite from NASA GIBS (GOES-West + GOES-East, newest image, no key). Unlike ground radar it
|
||||||
|
// reaches open ocean, so a hurricane's cloud shield is visible. East draws over West where they overlap.
|
||||||
|
const GOES_LAYERS = [['goes-west', 'GOES-West_ABI_Band13_Clean_Infrared'], ['goes-east', 'GOES-East_ABI_Band13_Clean_Infrared']];
|
||||||
|
async function renderCloudOverlay(map) {
|
||||||
|
if (!map) return;
|
||||||
|
await mapReady(map);
|
||||||
|
const on = !!state.unifiedMapLayers.satellite;
|
||||||
|
const stamp = Math.floor(Date.now() / 600000); // GIBS updates ~every 10 min; bust the tile cache then
|
||||||
|
try {
|
||||||
|
GOES_LAYERS.forEach(([id, layer]) => {
|
||||||
|
if (!on) { if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', 'none'); return; }
|
||||||
|
if (map.getLayer(id)) { map.setLayoutProperty(id, 'visibility', 'visible'); }
|
||||||
|
else {
|
||||||
|
map.addSource(id, { type: 'raster', tileSize: 256, maxzoom: 6, attribution: 'NASA GIBS',
|
||||||
|
tiles: [`https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/${layer}/default/GoogleMapsCompatible_Level6/{z}/{y}/{x}.png?t=${stamp}`] });
|
||||||
|
// sit under the animated radar frames so rain still reads on top of the clouds
|
||||||
|
const beforeId = map.getStyle().layers.find(l => l.id.startsWith('rv-radar-'))?.id;
|
||||||
|
map.addLayer({ id, type: 'raster', source: id, paint: { 'raster-opacity': 0.8, 'raster-fade-duration': 0 } }, beforeId);
|
||||||
|
}
|
||||||
|
if (map.__cloudStamp !== stamp && map.getSource(id)?.setTiles) {
|
||||||
|
map.getSource(id).setTiles([`https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/${layer}/default/GoogleMapsCompatible_Level6/{z}/{y}/{x}.png?t=${stamp}`]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (on) map.__cloudStamp = stamp;
|
||||||
|
// Radar frames must always draw above the clouds (they may have been added after this layer), or rain disappears
|
||||||
|
map.getStyle().layers.filter(l => l.id.startsWith('rv-radar-')).forEach(l => map.moveLayer(l.id));
|
||||||
|
} catch { /* style swapped mid-update; next refresh retries */ }
|
||||||
|
}
|
||||||
|
|
||||||
async function renderHurricaneOverlay(map) {
|
async function renderHurricaneOverlay(map) {
|
||||||
if (!map || !map.loaded()) return;
|
if (!map) return;
|
||||||
|
const tok = (map.__hurrTok = (map.__hurrTok || 0) + 1); // a newer refresh supersedes this one
|
||||||
|
await mapReady(map);
|
||||||
|
if (tok !== map.__hurrTok) return;
|
||||||
['hurricane-overlay'].forEach(id => {
|
['hurricane-overlay'].forEach(id => {
|
||||||
if (map.getLayer(id)) map.removeLayer(id);
|
if (map.getLayer(id)) map.removeLayer(id);
|
||||||
if (map.getSource(id)) map.removeSource(id);
|
if (map.getSource(id)) map.removeSource(id);
|
||||||
@@ -273,17 +377,16 @@ async function renderHurricaneOverlay(map) {
|
|||||||
if (!state.unifiedMapLayers.hurricane) return;
|
if (!state.unifiedMapLayers.hurricane) return;
|
||||||
try {
|
try {
|
||||||
const data = await fetch('/api/hurricanes').then(r => r.json());
|
const data = await fetch('/api/hurricanes').then(r => r.json());
|
||||||
|
if (tok !== map.__hurrTok) return;
|
||||||
const storms = data.storms || [];
|
const storms = data.storms || [];
|
||||||
if (!storms.length) return;
|
if (!storms.length) return;
|
||||||
const features = storms.map(s => {
|
const features = storms.map(s => {
|
||||||
const lat = parseNhcCoord(s.latitude);
|
const i = stormInfo(s);
|
||||||
const lon = parseNhcCoord(s.longitude);
|
if (isNaN(i.lat) || isNaN(i.lon)) return null;
|
||||||
if (isNaN(lat) || isNaN(lon)) return null;
|
|
||||||
const cat = (s.classification || '').toUpperCase();
|
|
||||||
return {
|
return {
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
geometry: { type: 'Point', coordinates: [lon, lat] },
|
geometry: { type: 'Point', coordinates: [i.lon, i.lat] },
|
||||||
properties: { name: s.name || 'Storm', cat, intensity: s.intensity || '--' },
|
properties: { title: i.title, label: i.label, mph: i.mph, kt: i.kt, color: i.color, movement: i.movement },
|
||||||
};
|
};
|
||||||
}).filter(Boolean);
|
}).filter(Boolean);
|
||||||
map.addSource('hurricane-overlay', { type: 'geojson', data: { type: 'FeatureCollection', features } });
|
map.addSource('hurricane-overlay', { type: 'geojson', data: { type: 'FeatureCollection', features } });
|
||||||
@@ -291,25 +394,29 @@ async function renderHurricaneOverlay(map) {
|
|||||||
id: 'hurricane-overlay', type: 'circle', source: 'hurricane-overlay',
|
id: 'hurricane-overlay', type: 'circle', source: 'hurricane-overlay',
|
||||||
paint: {
|
paint: {
|
||||||
'circle-radius': 14,
|
'circle-radius': 14,
|
||||||
'circle-color': ['match', ['get', 'cat'], 'HU', '#f44336', 'TS', '#9c27b0', '#2196f3'],
|
'circle-color': ['get', 'color'],
|
||||||
'circle-opacity': 0.55,
|
'circle-opacity': 0.55,
|
||||||
'circle-stroke-width': 2.5,
|
'circle-stroke-width': 2.5,
|
||||||
'circle-stroke-color': ['match', ['get', 'cat'], 'HU', '#f44336', 'TS', '#9c27b0', '#2196f3'],
|
'circle-stroke-color': ['get', 'color'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
map.on('click', 'hurricane-overlay', e => {
|
// Handlers are registered once; this overlay is rebuilt on every map refresh
|
||||||
const p = e.features[0].properties;
|
if (!map.__stormOverlayHandlers) {
|
||||||
const label = p.cat === 'HU' ? 'Hurricane' : p.cat === 'TS' ? 'Tropical Storm' : 'Tropical Depression';
|
map.__stormOverlayHandlers = true;
|
||||||
new maplibregl.Popup().setLngLat(e.lngLat)
|
map.on('click', 'hurricane-overlay', e => {
|
||||||
.setHTML(`<b>${p.name}</b><br>${label}<br>Winds: ${p.intensity} mph`).addTo(map);
|
const p = e.features[0].properties;
|
||||||
});
|
new maplibregl.Popup().setLngLat(e.lngLat)
|
||||||
map.on('mouseenter', 'hurricane-overlay', () => { map.getCanvas().style.cursor = 'pointer'; });
|
.setHTML(`<b>${escHtml(p.title)}</b><br>${escHtml(p.label)}<br>Winds: ${p.mph != null && p.mph !== 'null' ? p.mph + ' mph' : '--'}${p.movement ? '<br>' + escHtml(p.movement) : ''}`).addTo(map);
|
||||||
map.on('mouseleave', 'hurricane-overlay', () => { map.getCanvas().style.cursor = ''; });
|
});
|
||||||
|
map.on('mouseenter', 'hurricane-overlay', () => { map.getCanvas().style.cursor = 'pointer'; });
|
||||||
|
map.on('mouseleave', 'hurricane-overlay', () => { map.getCanvas().style.cursor = ''; });
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderWildfireOverlay(map) {
|
async function renderWildfireOverlay(map) {
|
||||||
if (!map || !map.loaded()) return;
|
if (!map) return;
|
||||||
|
await mapReady(map);
|
||||||
['wildfire-fill', 'wildfire-outline'].forEach(id => {
|
['wildfire-fill', 'wildfire-outline'].forEach(id => {
|
||||||
if (map.getLayer(id)) map.removeLayer(id);
|
if (map.getLayer(id)) map.removeLayer(id);
|
||||||
});
|
});
|
||||||
@@ -334,7 +441,8 @@ async function renderWildfireOverlay(map) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function renderSPCOverlay(map) {
|
async function renderSPCOverlay(map) {
|
||||||
if (!map || !map.loaded()) return;
|
if (!map) return;
|
||||||
|
await mapReady(map);
|
||||||
['spc-overlay'].forEach(id => {
|
['spc-overlay'].forEach(id => {
|
||||||
if (map.getLayer(id)) map.removeLayer(id);
|
if (map.getLayer(id)) map.removeLayer(id);
|
||||||
if (map.getSource(id)) map.removeSource(id);
|
if (map.getSource(id)) map.removeSource(id);
|
||||||
@@ -386,7 +494,7 @@ const STORAGE_KEYS = {
|
|||||||
showLightning: 'weather.showLightning',
|
showLightning: 'weather.showLightning',
|
||||||
showWind: 'weather.showWind',
|
showWind: 'weather.showWind',
|
||||||
showRadarLayer: 'weather.map.radar',
|
showRadarLayer: 'weather.map.radar',
|
||||||
showSatelliteLayer: 'weather.map.satellite',
|
showSatelliteLayer: 'weather.map.goesIR', // new key: the old 'satellite' toggle never drew anything, ignore its stale value
|
||||||
showLightningLayer: 'weather.map.lightning',
|
showLightningLayer: 'weather.map.lightning',
|
||||||
showWindLayer: 'weather.map.wind',
|
showWindLayer: 'weather.map.wind',
|
||||||
showAqiLayer: 'weather.map.aqi',
|
showAqiLayer: 'weather.map.aqi',
|
||||||
@@ -432,13 +540,13 @@ const state = {
|
|||||||
showExtendedForecast: false,
|
showExtendedForecast: false,
|
||||||
unifiedMapLayers: {
|
unifiedMapLayers: {
|
||||||
radar: localStorage.getItem(STORAGE_KEYS.showRadarLayer) !== 'false',
|
radar: localStorage.getItem(STORAGE_KEYS.showRadarLayer) !== 'false',
|
||||||
satellite: localStorage.getItem(STORAGE_KEYS.showSatelliteLayer) !== 'false',
|
satellite: localStorage.getItem(STORAGE_KEYS.showSatelliteLayer) === 'true',
|
||||||
lightning: localStorage.getItem(STORAGE_KEYS.showLightningLayer) !== 'false',
|
lightning: localStorage.getItem(STORAGE_KEYS.showLightningLayer) === 'true',
|
||||||
wind: localStorage.getItem(STORAGE_KEYS.showWindLayer) !== 'false',
|
wind: localStorage.getItem(STORAGE_KEYS.showWindLayer) === 'true',
|
||||||
aqi: localStorage.getItem(STORAGE_KEYS.showAqiLayer) !== 'false',
|
aqi: localStorage.getItem(STORAGE_KEYS.showAqiLayer) === 'true',
|
||||||
alerts: localStorage.getItem(STORAGE_KEYS.showAlertsLayer) !== 'false',
|
alerts: localStorage.getItem(STORAGE_KEYS.showAlertsLayer) === 'true',
|
||||||
temp: localStorage.getItem(STORAGE_KEYS.showTempMarker) !== 'false',
|
temp: localStorage.getItem(STORAGE_KEYS.showTempMarker) === 'true',
|
||||||
sunMoon: localStorage.getItem(STORAGE_KEYS.showSunMoonMarkers) !== 'false',
|
sunMoon: localStorage.getItem(STORAGE_KEYS.showSunMoonMarkers) === 'true',
|
||||||
hurricane: localStorage.getItem(STORAGE_KEYS.showHurricaneLayer) === 'true',
|
hurricane: localStorage.getItem(STORAGE_KEYS.showHurricaneLayer) === 'true',
|
||||||
wildfire: localStorage.getItem(STORAGE_KEYS.showWildfireLayer) === 'true',
|
wildfire: localStorage.getItem(STORAGE_KEYS.showWildfireLayer) === 'true',
|
||||||
spc: localStorage.getItem(STORAGE_KEYS.showSpcLayer) === 'true',
|
spc: localStorage.getItem(STORAGE_KEYS.showSpcLayer) === 'true',
|
||||||
@@ -892,6 +1000,9 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
|
|||||||
center: [-98.35, 39.5],
|
center: [-98.35, 39.5],
|
||||||
zoom: 4,
|
zoom: 4,
|
||||||
attributionControl: false,
|
attributionControl: false,
|
||||||
|
// The embedded radar sits in a scrolling page: plain wheel / one-finger drag scroll the page,
|
||||||
|
// Ctrl+wheel / two fingers move the map.
|
||||||
|
cooperativeGestures: mapEl?.id === 'radar-embed',
|
||||||
});
|
});
|
||||||
|
|
||||||
let radarFrames = [];
|
let radarFrames = [];
|
||||||
@@ -941,7 +1052,7 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
|
|||||||
function addFrameLayer(frame, forMode) {
|
function addFrameLayer(frame, forMode) {
|
||||||
const id = `rv-${forMode}-${frame.time}`;
|
const id = `rv-${forMode}-${frame.time}`;
|
||||||
if (!map.getSource(id)) {
|
if (!map.getSource(id)) {
|
||||||
map.addSource(id, { type: 'raster', tiles: [frameTileUrl(frame, forMode)], tileSize: 256 });
|
map.addSource(id, { type: 'raster', tiles: [frameTileUrl(frame, forMode)], tileSize: 256, ...(frame.maxNativeZoom ? { maxzoom: frame.maxNativeZoom } : {}) });
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id, type: 'raster', source: id,
|
id, type: 'raster', source: id,
|
||||||
paint: {
|
paint: {
|
||||||
@@ -1278,8 +1389,9 @@ function refreshMapsOverview() {
|
|||||||
|
|
||||||
if (!state.currentCity) return;
|
if (!state.currentCity) return;
|
||||||
|
|
||||||
const zoom = mapsOverview.map.getZoom ? mapsOverview.map.getZoom() : 6;
|
// Keep the user's own pan/zoom; only (re)centre when the location changes
|
||||||
mapsOverview.panTo(state.currentCity.latitude, state.currentCity.longitude, zoom || 6);
|
if (window.AppUI && window.AppUI.centerMap) window.AppUI.centerMap(state.currentCity);
|
||||||
|
else mapsOverview.panTo(state.currentCity.latitude, state.currentCity.longitude, mapsOverview.map.getZoom ? mapsOverview.map.getZoom() : 6);
|
||||||
|
|
||||||
clearLightningMarkers();
|
clearLightningMarkers();
|
||||||
clearWindArrows();
|
clearWindArrows();
|
||||||
@@ -1294,6 +1406,7 @@ function refreshMapsOverview() {
|
|||||||
renderAlertMarker(state.currentCity);
|
renderAlertMarker(state.currentCity);
|
||||||
renderTempMarker(state.currentCity);
|
renderTempMarker(state.currentCity);
|
||||||
renderSunMoonMarker(state.currentCity);
|
renderSunMoonMarker(state.currentCity);
|
||||||
|
renderCloudOverlay(mapsOverview.map);
|
||||||
renderHurricaneOverlay(mapsOverview.map);
|
renderHurricaneOverlay(mapsOverview.map);
|
||||||
renderWildfireOverlay(mapsOverview.map);
|
renderWildfireOverlay(mapsOverview.map);
|
||||||
renderSPCOverlay(mapsOverview.map);
|
renderSPCOverlay(mapsOverview.map);
|
||||||
@@ -1596,8 +1709,8 @@ function renderWeather(data, city) {
|
|||||||
|
|
||||||
radarEmbed.panTo(city.latitude, city.longitude, 6);
|
radarEmbed.panTo(city.latitude, city.longitude, 6);
|
||||||
radarFull.panTo(city.latitude, city.longitude, 6);
|
radarFull.panTo(city.latitude, city.longitude, 6);
|
||||||
const mapZoom = mapsOverview?.map?.getZoom ? mapsOverview.map.getZoom() : 6;
|
if (window.AppUI && window.AppUI.centerMap) window.AppUI.centerMap(city);
|
||||||
mapsOverview.panTo(city.latitude, city.longitude, mapZoom || 6);
|
else mapsOverview.panTo(city.latitude, city.longitude, (mapsOverview?.map?.getZoom ? mapsOverview.map.getZoom() : 6) || 6);
|
||||||
if (mapsWindEl) mapsWindEl.textContent = `${speedDisplay(c.wind_speed_10m)} ${speedUnit()} ${compassDir(c.wind_direction_10m)}`;
|
if (mapsWindEl) mapsWindEl.textContent = `${speedDisplay(c.wind_speed_10m)} ${speedUnit()} ${compassDir(c.wind_direction_10m)}`;
|
||||||
updateSourceLocation(city);
|
updateSourceLocation(city);
|
||||||
updateShareUrl(city);
|
updateShareUrl(city);
|
||||||
@@ -1689,6 +1802,10 @@ function severityRank(severity) {
|
|||||||
function plainAlertAction(eventName, severity) {
|
function plainAlertAction(eventName, severity) {
|
||||||
const eventText = String(eventName || '').toLowerCase();
|
const eventText = String(eventName || '').toLowerCase();
|
||||||
const sev = normalizeSeverity(severity);
|
const sev = normalizeSeverity(severity);
|
||||||
|
if (eventText.includes('storm surge')) return 'Know your evacuation zone. If officials order an evacuation, leave early: surge is the deadliest part of a tropical storm.';
|
||||||
|
if (eventText.includes('hurricane') || eventText.includes('typhoon')) return eventText.includes('warning') ? 'Finish storm preparations now, follow local evacuation orders, and plan to shelter until the storm has fully passed.' : 'Review your plan, stock water, food and medicine, secure outdoor items, and know your evacuation zone.';
|
||||||
|
if (eventText.includes('tropical')) return eventText.includes('warning') ? 'Expect damaging winds and heavy rain. Secure outdoor items, charge devices, and stay off the roads once conditions deteriorate.' : 'Tropical storm conditions are possible. Prepare now: secure outdoor items and keep supplies ready.';
|
||||||
|
if (eventText.includes('extreme wind')) return 'Take shelter immediately in the strongest part of a sturdy building, away from windows.';
|
||||||
if (eventText.includes('tornado')) return 'Move to an interior room on the lowest level away from windows immediately.';
|
if (eventText.includes('tornado')) return 'Move to an interior room on the lowest level away from windows immediately.';
|
||||||
if (eventText.includes('thunderstorm')) return 'Pause outdoor activity, secure loose items, and be ready to shelter indoors quickly.';
|
if (eventText.includes('thunderstorm')) return 'Pause outdoor activity, secure loose items, and be ready to shelter indoors quickly.';
|
||||||
if (eventText.includes('flood')) return 'Avoid low-water crossings and reroute away from flood-prone roads.';
|
if (eventText.includes('flood')) return 'Avoid low-water crossings and reroute away from flood-prone roads.';
|
||||||
@@ -1703,10 +1820,9 @@ function plainAlertAction(eventName, severity) {
|
|||||||
|
|
||||||
function buildAlertTiming(p) {
|
function buildAlertTiming(p) {
|
||||||
const start = p?.onset || p?.effective || p?.sent;
|
const start = p?.onset || p?.effective || p?.sent;
|
||||||
const end = p?.expires;
|
const end = p?.expires || p?.ends;
|
||||||
const startTxt = start ? formatClock(start) : 'now';
|
const fmt = iso => new Date(iso).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||||
const endTxt = end ? formatClock(end) : 'until further notice';
|
return `${start ? fmt(start) : 'now'} → ${end ? fmt(end) : 'until further notice'}`;
|
||||||
return `${startTxt} - ${endTxt}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildStormEtaText() {
|
function buildStormEtaText() {
|
||||||
@@ -1898,13 +2014,60 @@ function showShareWeatherStatus(msg) {
|
|||||||
}, 2200);
|
}, 2200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One active alert: what it's for, where, when, what to do, and the full text on demand
|
||||||
|
function buildAlertCard(f) {
|
||||||
|
const p = f.properties || {};
|
||||||
|
const AI = window.AlertInfo;
|
||||||
|
const sev = normalizeSeverity(p.severity);
|
||||||
|
const meaning = AI.meaning(p.event);
|
||||||
|
const why = p.description ? AI.firstSentence(p.description) : (p.headline || 'See official sources for details.');
|
||||||
|
const areaAll = AI.area(p.areaDesc);
|
||||||
|
const parts = String(p.areaDesc || '').split(';').map(x => x.trim()).filter(Boolean);
|
||||||
|
const areaShort = parts.length > 4 ? `${parts.slice(0, 4).join(', ')} +${parts.length - 4} more` : areaAll;
|
||||||
|
const left = AI.remaining(p.expires || p.ends);
|
||||||
|
const todo = p.instruction ? AI.firstSentence(p.instruction, 260) : plainAlertAction(p.event, p.severity);
|
||||||
|
const meta = [p.severity && p.severity !== 'Unknown' && `Severity: ${p.severity}`, p.urgency && p.urgency !== 'Unknown' && `Urgency: ${p.urgency}`,
|
||||||
|
p.certainty && p.certainty !== 'Unknown' && `Certainty: ${p.certainty}`, p.senderName && `Issued by ${p.senderName}`].filter(Boolean);
|
||||||
|
|
||||||
|
const card = document.createElement('article');
|
||||||
|
card.className = 'alert-item alert-rich';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="alert-top">
|
||||||
|
<strong>${AI.esc(p.event || 'Alert')}</strong>
|
||||||
|
<span class="alert-severity severity-${sev.toLowerCase()}">${sev}</span>
|
||||||
|
</div>
|
||||||
|
<div class="alert-why">${AI.esc(why)}</div>
|
||||||
|
<div class="alert-facts">
|
||||||
|
${areaShort ? `<span>📍 ${AI.esc(areaShort)}</span>` : ''}
|
||||||
|
<span>🕑 ${AI.esc(buildAlertTiming(p))}</span>
|
||||||
|
${left ? `<span class="alert-left">⏳ ${AI.esc(left)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
${meaning ? `<div class="alert-meaning"><b>What this means:</b> ${AI.esc(meaning)}</div>` : ''}
|
||||||
|
<div class="alert-action"><b>What to do:</b> ${AI.esc(todo)}</div>
|
||||||
|
<details class="alert-more">
|
||||||
|
<summary>Full details</summary>
|
||||||
|
<div class="alert-full">
|
||||||
|
${p.headline ? `<p><b>Headline:</b> ${AI.esc(p.headline)}</p>` : ''}
|
||||||
|
${areaAll ? `<p><b>Area:</b> ${AI.esc(areaAll)}</p>` : ''}
|
||||||
|
${p.description ? `<p class="alert-pre"><b>Details:</b>\n${AI.esc(p.description.trim())}</p>` : ''}
|
||||||
|
${p.instruction ? `<p class="alert-pre"><b>Instructions:</b>\n${AI.esc(p.instruction.trim())}</p>` : ''}
|
||||||
|
${meta.length ? `<p class="alert-meta">${meta.map(AI.esc).join(' · ')}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
</details>`;
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
function renderAlerts(data) {
|
function renderAlerts(data) {
|
||||||
// Populate hurricane panel asynchronously
|
// Populate hurricane panel asynchronously
|
||||||
fetch('/api/hurricanes').then(r => r.json()).then(d => renderHurricanePanel(d.storms || [])).catch(() => {});
|
fetch('/api/hurricanes').then(r => r.json()).then(d => renderHurricanePanel(d.storms || [])).catch(() => {});
|
||||||
|
|
||||||
const wrap = document.getElementById('alerts-wrap');
|
const wrap = document.getElementById('alerts-wrap');
|
||||||
const list = document.getElementById('alerts-list');
|
const list = document.getElementById('alerts-list');
|
||||||
const features = data?.features || [];
|
// Only alerts that are still active (a cached response can include ones that have just expired)
|
||||||
|
const features = (data?.features || []).filter(f => {
|
||||||
|
const end = Date.parse(f.properties?.expires || f.properties?.ends || '');
|
||||||
|
return !Number.isFinite(end) || end > Date.now();
|
||||||
|
});
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
|
|
||||||
if (!features.length) {
|
if (!features.length) {
|
||||||
@@ -1943,39 +2106,7 @@ function renderAlerts(data) {
|
|||||||
head.appendChild(hint);
|
head.appendChild(hint);
|
||||||
groupWrap.appendChild(head);
|
groupWrap.appendChild(head);
|
||||||
|
|
||||||
grouped.get(severity).slice(0, 6).forEach(f => {
|
grouped.get(severity).forEach(f => groupWrap.appendChild(buildAlertCard(f)));
|
||||||
const p = f.properties || {};
|
|
||||||
const card = document.createElement('article');
|
|
||||||
card.className = 'alert-item';
|
|
||||||
|
|
||||||
const top = document.createElement('div');
|
|
||||||
top.className = 'alert-top';
|
|
||||||
const event = document.createElement('strong');
|
|
||||||
event.textContent = p.event || 'Alert';
|
|
||||||
const sev = document.createElement('span');
|
|
||||||
sev.className = `alert-severity severity-${normalizeSeverity(p.severity).toLowerCase()}`;
|
|
||||||
sev.textContent = normalizeSeverity(p.severity);
|
|
||||||
top.appendChild(event);
|
|
||||||
top.appendChild(sev);
|
|
||||||
|
|
||||||
const timing = document.createElement('div');
|
|
||||||
timing.className = 'alert-timing';
|
|
||||||
timing.textContent = `Window: ${buildAlertTiming(p)}`;
|
|
||||||
|
|
||||||
const msg = document.createElement('div');
|
|
||||||
msg.className = 'alert-message';
|
|
||||||
msg.textContent = p.headline || p.description || 'See official sources for details.';
|
|
||||||
|
|
||||||
const action = document.createElement('div');
|
|
||||||
action.className = 'alert-action';
|
|
||||||
action.textContent = `What this means for me: ${plainAlertAction(p.event, p.severity)}`;
|
|
||||||
|
|
||||||
card.appendChild(top);
|
|
||||||
card.appendChild(timing);
|
|
||||||
card.appendChild(msg);
|
|
||||||
card.appendChild(action);
|
|
||||||
groupWrap.appendChild(card);
|
|
||||||
});
|
|
||||||
|
|
||||||
list.appendChild(groupWrap);
|
list.appendChild(groupWrap);
|
||||||
});
|
});
|
||||||
@@ -1994,10 +2125,10 @@ function renderAlerts(data) {
|
|||||||
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
||||||
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
||||||
navigator.serviceWorker.ready.then(reg =>
|
navigator.serviceWorker.ready.then(reg =>
|
||||||
reg.showNotification(title, { body, icon: '/icons/icon-192.png', badge: '/icons/icon-192.png', tag: id, data: { url: '/?tab=alerts' } })
|
reg.showNotification(title, { body, icon: '/icons/v2/icon-192.png', badge: '/icons/v2/icon-192.png', tag: id, data: { url: '/?tab=alerts' } })
|
||||||
).catch(() => new Notification(title, { body, icon: '/icons/icon-192.png' }));
|
).catch(() => new Notification(title, { body, icon: '/icons/v2/icon-192.png' }));
|
||||||
} else {
|
} else {
|
||||||
new Notification(title, { body, icon: '/icons/icon-192.png', badge: '/icons/icon-192.png' });
|
new Notification(title, { body, icon: '/icons/v2/icon-192.png', badge: '/icons/v2/icon-192.png' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (state.ntfyTopic) {
|
if (state.ntfyTopic) {
|
||||||
@@ -2184,26 +2315,63 @@ searchBtn.addEventListener('click', doSearch);
|
|||||||
searchInput.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); });
|
searchInput.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); });
|
||||||
|
|
||||||
// ── Geolocation ───────────────────────────────────────────────────────────────
|
// ── Geolocation ───────────────────────────────────────────────────────────────
|
||||||
locateBtn.addEventListener('click', () => {
|
// Why a location request failed, in plain language (the browser tells us: 1 denied, 2 unavailable, 3 timeout)
|
||||||
|
const IS_IOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||||
|
const IS_STANDALONE = window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true;
|
||||||
|
async function geoPermissionState() {
|
||||||
|
try { return (await navigator.permissions.query({ name: 'geolocation' })).state; } catch { return 'unknown'; }
|
||||||
|
}
|
||||||
|
function geoErrorHelp(err, perm) {
|
||||||
|
const code = err && err.code;
|
||||||
|
const tag = ` (error ${code ?? '?'}${perm && perm !== 'unknown' ? ', permission: ' + perm : ''})`;
|
||||||
|
if (code === 1) {
|
||||||
|
if (IS_IOS) {
|
||||||
|
const where = IS_STANDALONE
|
||||||
|
? 'Settings → Privacy & Security → Location Services → "NeCloud Weather" → While Using the App'
|
||||||
|
: 'Settings → Privacy & Security → Location Services → Safari Websites → While Using, then in Safari tap aA → Website Settings → Location → Allow';
|
||||||
|
return `Your phone says location is off for ${IS_STANDALONE ? 'this app' : 'this site'}. Turn it on in ${where}. If you use a Safari content blocker, also try aA → Turn Off Content Blockers for this site.${tag}`;
|
||||||
|
}
|
||||||
|
return `Location is blocked for this site. Click the lock icon in the address bar → Location → Allow, then try again.${tag}`;
|
||||||
|
}
|
||||||
|
if (code === 2) return `Your device couldn't find your position yet (no GPS or Wi-Fi fix). Make sure Location Services is on, turn off Low Power Mode, and try again, ideally on Wi-Fi or outdoors.${tag}`;
|
||||||
|
if (code === 3) return `Timed out waiting for your location. Try again in a moment.${tag}`;
|
||||||
|
return `Could not get your location.${tag}`;
|
||||||
|
}
|
||||||
|
function geoOnce(opts) {
|
||||||
|
return new Promise((resolve, reject) => navigator.geolocation.getCurrentPosition(resolve, reject, opts));
|
||||||
|
}
|
||||||
|
// A slow or cold GPS often times out the first time: retry once with a patient, high-accuracy request.
|
||||||
|
// A real "denied" is final, so don't retry that.
|
||||||
|
async function getPositionRobust() {
|
||||||
|
try {
|
||||||
|
return await geoOnce({ enableHighAccuracy: false, timeout: 12000, maximumAge: 5 * 60 * 1000 });
|
||||||
|
} catch (e) {
|
||||||
|
if (e && e.code === 1) throw e;
|
||||||
|
return await geoOnce({ enableHighAccuracy: true, timeout: 20000, maximumAge: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
locateBtn.addEventListener('click', async () => {
|
||||||
if (!navigator.geolocation) { showErr('Geolocation not supported.'); return; }
|
if (!navigator.geolocation) { showErr('Geolocation not supported.'); return; }
|
||||||
clearErr();
|
clearErr();
|
||||||
hide(weatherDiv);
|
hide(weatherDiv);
|
||||||
show(loadingEl);
|
show(loadingEl);
|
||||||
|
try {
|
||||||
navigator.geolocation.getCurrentPosition(async pos => {
|
const pos = await getPositionRobust();
|
||||||
const { latitude: lat, longitude: lon } = pos.coords;
|
const { latitude: lat, longitude: lon } = pos.coords;
|
||||||
try {
|
try {
|
||||||
const city = await buildDetectedCity(lat, lon);
|
const city = await buildDetectedCity(lat, lon);
|
||||||
await loadAll(city, 'gps-button');
|
await loadAll(city, 'gps-button');
|
||||||
} catch {
|
} catch {
|
||||||
showErr('Could not fetch weather for your location.');
|
showErr('Could not fetch weather for your location.');
|
||||||
} finally {
|
|
||||||
hide(loadingEl);
|
|
||||||
}
|
}
|
||||||
}, () => {
|
} catch (err) {
|
||||||
|
if (state.currentWeather) show(weatherDiv);
|
||||||
|
showErr(geoErrorHelp(err, await geoPermissionState()));
|
||||||
|
} finally {
|
||||||
hide(loadingEl);
|
hide(loadingEl);
|
||||||
showErr('Location access denied.');
|
if (window.AppUI && window.AppUI.refreshGeoStatus) window.AppUI.refreshGeoStatus();
|
||||||
});
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
unitsBtn.addEventListener('click', () => {
|
unitsBtn.addEventListener('click', () => {
|
||||||
@@ -2446,20 +2614,26 @@ document.querySelectorAll('#tab-unified-map input[type="checkbox"][data-layer]')
|
|||||||
if (!enabled && unifiedMap?.stopPlay) unifiedMap.stopPlay();
|
if (!enabled && unifiedMap?.stopPlay) unifiedMap.stopPlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (layerName === 'satellite') {
|
if (layerName === 'satellite') renderCloudOverlay(mapsOverview.map);
|
||||||
if (enabled) {
|
syncReadoutChips();
|
||||||
if (unifiedMapMode !== 'satellite') unifiedMapModeButton.click();
|
|
||||||
setCloudLayerVisible(true);
|
|
||||||
} else {
|
|
||||||
setCloudLayerVisible(false);
|
|
||||||
if (unifiedMapMode !== 'radar') unifiedMapModeButton.click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.currentCity) refreshMapsOverview();
|
if (state.currentCity) refreshMapsOverview();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// The top-left readout chips only show for layers the user switched on
|
||||||
|
function syncReadoutChips() {
|
||||||
|
const L = state.unifiedMapLayers;
|
||||||
|
[['overlay-temp', L.temp], ['overlay-aqi', L.aqi], ['overlay-sunmoon', L.sunMoon]].forEach(([id, on]) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.hidden = !on;
|
||||||
|
});
|
||||||
|
const box = document.getElementById('map-overlay-readouts');
|
||||||
|
if (box) box.hidden = !(L.temp || L.aqi || L.sunMoon);
|
||||||
|
}
|
||||||
|
syncReadoutChips();
|
||||||
|
|
||||||
const baseMapSelect = document.getElementById('base-map-select');
|
const baseMapSelect = document.getElementById('base-map-select');
|
||||||
if (baseMapSelect) {
|
if (baseMapSelect) {
|
||||||
baseMapSelect.value = state.unifiedMapBaseLayer;
|
baseMapSelect.value = state.unifiedMapBaseLayer;
|
||||||
@@ -2485,12 +2659,14 @@ if (baseMapSelect) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const layerMenuToggleBtn = document.getElementById('layer-menu-toggle');
|
const layerMenuToggleBtn = document.getElementById('layer-menu-toggle');
|
||||||
|
document.getElementById('layer-menu-close')?.addEventListener('click', () => { if (!document.getElementById('layer-menu-panel').classList.contains('hidden')) document.getElementById('layer-menu-toggle').click(); });
|
||||||
const layerMenuPanel = document.getElementById('layer-menu-panel');
|
const layerMenuPanel = document.getElementById('layer-menu-panel');
|
||||||
if (layerMenuToggleBtn && layerMenuPanel) {
|
if (layerMenuToggleBtn && layerMenuPanel) {
|
||||||
layerMenuToggleBtn.addEventListener('click', () => {
|
layerMenuToggleBtn.addEventListener('click', () => {
|
||||||
const willShow = layerMenuPanel.classList.contains('hidden');
|
const willShow = layerMenuPanel.classList.contains('hidden');
|
||||||
layerMenuPanel.classList.toggle('hidden', !willShow);
|
layerMenuPanel.classList.toggle('hidden', !willShow);
|
||||||
layerMenuToggleBtn.textContent = willShow ? 'Hide Layer Menu' : 'Show Layer Menu';
|
if (!layerMenuToggleBtn.classList.contains('hud-btn')) layerMenuToggleBtn.textContent = willShow ? 'Hide Layer Menu' : 'Show Layer Menu';
|
||||||
|
layerMenuToggleBtn.classList.toggle('on', willShow);
|
||||||
layerMenuToggleBtn.setAttribute('aria-expanded', willShow ? 'true' : 'false');
|
layerMenuToggleBtn.setAttribute('aria-expanded', willShow ? 'true' : 'false');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,16 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#070c14">
|
<meta name="theme-color" content="#070c14">
|
||||||
<link rel="manifest" href="/dashboard.webmanifest">
|
<link rel="manifest" href="/dashboard.webmanifest">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/icons/v2/dashboard-icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/icons/v2/dashboard-apple-touch-icon-180.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="167x167" href="/icons/v2/dashboard-apple-touch-icon-167.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="152x152" href="/icons/v2/dashboard-apple-touch-icon-152.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="120x120" href="/icons/v2/dashboard-apple-touch-icon-120.png">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Station">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/icons/v2/dashboard-icon-192.png">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<link rel="stylesheet" href="/pwa-safe.css">
|
||||||
<title>Account</title>
|
<title>Account</title>
|
||||||
<style>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
@@ -62,6 +72,7 @@ dialog .row { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top:
|
|||||||
<div><h1>Account</h1><div class="sub" id="whoami">Loading…</div></div>
|
<div><h1>Account</h1><div class="sub" id="whoami">Loading…</div></div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<a href="/dashboard" class="nav-link">← Dashboard</a>
|
<a href="/dashboard" class="nav-link">← Dashboard</a>
|
||||||
|
<a href="/dashboard/settings" class="nav-link">Settings</a>
|
||||||
<a href="/dashboard/logout" class="nav-link">Sign out</a>
|
<a href="/dashboard/logout" class="nav-link">Sign out</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,6 +88,16 @@ dialog .row { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top:
|
|||||||
<p class="hint">Changing your password signs you out everywhere else.</p>
|
<p class="hint">Changing your password signs you out everywhere else.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="section" id="installSection">
|
||||||
|
<div class="section-hdr">Install this dashboard as an app</div>
|
||||||
|
<p class="hint" style="margin:0 0 0.7rem" id="installDone" hidden>✓ You're using the installed app.</p>
|
||||||
|
<div id="installSteps">
|
||||||
|
<p class="hint" style="margin:0 0 0.4rem"><b>iPhone / iPad (Safari):</b> tap the <b>Share</b> button, then <b>Add to Home Screen</b>, then <b>Add</b>.</p>
|
||||||
|
<p class="hint" style="margin:0"><b>Android (Chrome):</b> tap the <b>⋮ menu</b>, then <b>Install app</b> (or <b>Add to Home screen</b>), then <b>Install</b>.</p>
|
||||||
|
<p class="hint">Open the app from its Home Screen icon for full screen. The weather app at <b>/</b> installs separately the same way.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="adminArea" class="hidden">
|
<div id="adminArea" class="hidden">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-hdr"><span>Users</span><span id="userCount" class="hint" style="margin:0"></span></div>
|
<div class="section-hdr"><span>Users</span><span id="userCount" class="hint" style="margin:0"></span></div>
|
||||||
@@ -188,6 +209,9 @@ $('nuBtn').addEventListener('click', async () => {
|
|||||||
} catch (e) { flash($('nuMsg'), e.message, true); }
|
} catch (e) { flash($('nuMsg'), e.message, true); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true) {
|
||||||
|
$('installDone').hidden = false; $('installSteps').hidden = true;
|
||||||
|
}
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
me = await api('/api/dashboard/me');
|
me = await api('/api/dashboard/me');
|
||||||
|
|||||||
@@ -8,7 +8,12 @@
|
|||||||
<meta name="apple-mobile-web-app-title" content="Station">
|
<meta name="apple-mobile-web-app-title" content="Station">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<link rel="manifest" href="/dashboard.webmanifest">
|
<link rel="manifest" href="/dashboard.webmanifest">
|
||||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
|
<link rel="icon" type="image/png" sizes="192x192" href="/icons/v2/dashboard-icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/icons/v2/dashboard-apple-touch-icon-180.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="167x167" href="/icons/v2/dashboard-apple-touch-icon-167.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="152x152" href="/icons/v2/dashboard-apple-touch-icon-152.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="120x120" href="/icons/v2/dashboard-apple-touch-icon-120.png">
|
||||||
|
<link rel="stylesheet" href="/pwa-safe.css">
|
||||||
<title>Station Dashboard — Sign In</title>
|
<title>Station Dashboard — Sign In</title>
|
||||||
<style>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
/* Records tab: plain-language station records. Reads /api/station/records and /api/station/daily. */
|
||||||
|
(() => {
|
||||||
|
const root = document.getElementById('recordsRoot');
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
|
const DAY_MS = 86400000;
|
||||||
|
const dayMs = (d) => Date.parse(d + 'T12:00:00Z');
|
||||||
|
const fmtDay = (d) => new Date(dayMs(d)).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
|
||||||
|
const fmtTime = (ts) => new Date(ts).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||||
|
const localDayKey = (ts) => new Date(ts).toLocaleDateString('en-CA');
|
||||||
|
const today = () => localDayKey(Date.now());
|
||||||
|
const daysBetween = (d) => Math.round((dayMs(today()) - dayMs(d)) / DAY_MS);
|
||||||
|
const agoText = (d) => {
|
||||||
|
const n = daysBetween(d);
|
||||||
|
if (n <= 0) return 'today';
|
||||||
|
if (n === 1) return 'yesterday';
|
||||||
|
if (n < 14) return n + ' days ago';
|
||||||
|
if (n < 60) return Math.round(n / 7) + ' weeks ago';
|
||||||
|
return Math.round(n / 30.4) + ' months ago';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Plain-English labels for numbers people can't judge on their own
|
||||||
|
const gustLabel = (v) => v < 19 ? 'Breezy' : v < 32 ? 'Windy' : v < 47 ? 'Strong wind' : v < 64 ? 'Gale' : v < 74 ? 'Storm force' : 'Hurricane force';
|
||||||
|
const uvLabel = (v) => v < 3 ? 'Low' : v < 6 ? 'Moderate' : v < 8 ? 'High' : v < 11 ? 'Very high' : 'Extreme';
|
||||||
|
const rateLabel = (v) => v < 0.1 ? 'Light rain' : v < 0.3 ? 'Moderate rain' : v < 2 ? 'Heavy rain' : 'Violent downpour';
|
||||||
|
|
||||||
|
const deg = (v) => v.toFixed(1) + '°F';
|
||||||
|
const inch = (v) => v.toFixed(2) + '"';
|
||||||
|
const mph = (v) => v.toFixed(1) + ' mph';
|
||||||
|
|
||||||
|
// Each record: where it comes from, how to say it, and what it means to someone new to weather data.
|
||||||
|
// field/kind drive the "runner-up" line and "today vs record" line from the per-day table.
|
||||||
|
const RECORDS = [
|
||||||
|
{ theme: 'Temperature', icon: '🔥', title: 'Hottest', why: 'The highest air temperature your station has measured.', field: 'hi', kind: 'max', fmt: deg, today: 'high' },
|
||||||
|
{ theme: 'Temperature', icon: '🥶', title: 'Coldest', why: 'The lowest air temperature your station has measured.', field: 'lo', kind: 'min', fmt: deg, today: 'low' },
|
||||||
|
{ theme: 'Temperature', icon: '🌤', title: 'Coolest daytime high', why: 'The day that never warmed up: its high was the lowest of any day.', field: 'hi', kind: 'min', fmt: deg, day: (r) => r.notable?.coolestHigh },
|
||||||
|
{ theme: 'Temperature', icon: '🌙', title: 'Warmest night', why: 'The night that never cooled off: its low was the highest of any day.', field: 'lo', kind: 'max', fmt: deg, day: (r) => r.notable?.warmestLow },
|
||||||
|
|
||||||
|
{ theme: 'Rain', icon: '🌧', title: 'Wettest day', why: 'The most rain that fell in a single day (midnight to midnight).', field: 'rain', kind: 'max', fmt: inch, day: (r) => r.notable?.wettestDay, today: 'rain' },
|
||||||
|
{ theme: 'Rain', icon: '⛈', title: 'Heaviest downpour', why: 'How fast rain fell at its hardest moment, in inches per hour.', field: 'rate', kind: 'max', fmt: (v) => v.toFixed(2) + ' in/hr', tag: rateLabel, val: (r) => r.highRainRate, ts: (r) => r.highRainRateDate },
|
||||||
|
|
||||||
|
{ theme: 'Wind', icon: '💨', title: 'Strongest gust', why: 'The fastest short burst of wind. Gusts last only a few seconds.', field: 'gust', kind: 'max', fmt: mph, tag: gustLabel, val: (r) => r.highGust, ts: (r) => r.highGustDate, today: 'gust' },
|
||||||
|
|
||||||
|
{ theme: 'Sun', icon: '☀️', title: 'Strongest sunshine', why: 'Peak sunlight power. Around 1,000 W/m² is about the most you get on a clear summer day.', field: 'solar', kind: 'max', fmt: (v) => Math.round(v).toLocaleString() + ' W/m²', val: (r) => r.highSolar, ts: (r) => r.highSolarDate },
|
||||||
|
{ theme: 'Sun', icon: '🧴', title: 'Highest UV index', why: 'How strong the sun\'s burning rays were. 3 and up is when sunscreen starts to matter.', field: 'uv', kind: 'max', fmt: (v) => String(Math.round(v)), tag: uvLabel, val: (r) => r.highUV, ts: (r) => r.highUVDate },
|
||||||
|
|
||||||
|
{ theme: 'Air pressure', icon: '📈', title: 'Highest pressure', why: 'High pressure usually means calm, clear weather.', field: 'pmax', kind: 'max', fmt: (v) => v.toFixed(2) + ' inHg', val: (r) => r.highPressure, ts: (r) => r.highPressureDate },
|
||||||
|
{ theme: 'Air pressure', icon: '📉', title: 'Lowest pressure', why: 'Low pressure usually comes with storms and rain. The lower it goes, the stronger the storm.', field: 'pmin', kind: 'min', fmt: (v) => v.toFixed(2) + ' inHg', val: (r) => r.lowPressure, ts: (r) => r.lowPressureDate },
|
||||||
|
|
||||||
|
{ theme: 'Humidity', icon: '💧', title: 'Most humid', why: 'How much moisture was in the air. 100% means the air can\'t hold any more (fog, dew, or a muggy day).', field: 'hmax', kind: 'max', fmt: (v) => Math.round(v) + '%', val: (r) => r.highHumidity, ts: (r) => r.highHumidityDate },
|
||||||
|
{ theme: 'Humidity', icon: '🏜', title: 'Driest air', why: 'The lowest moisture in the air. Under 30% feels dry: static shocks and dry skin.', field: 'hmin', kind: 'min', fmt: (v) => Math.round(v) + '%', val: (r) => r.lowHumidity, ts: (r) => r.lowHumidityDate },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Resolve a record definition to concrete numbers/dates (or null when the station hasn't measured it yet)
|
||||||
|
function resolve(def, r) {
|
||||||
|
if (def.day) {
|
||||||
|
const o = def.day(r);
|
||||||
|
return o ? { value: o.value, day: o.day, ts: null } : null;
|
||||||
|
}
|
||||||
|
const value = def.val ? def.val(r) : (def.field === 'hi' ? r.highTemp : def.field === 'lo' ? r.lowTemp : null);
|
||||||
|
const ts = def.ts ? def.ts(r) : (def.field === 'hi' ? r.highTempDate : r.lowTempDate);
|
||||||
|
if (value == null || !ts) return null;
|
||||||
|
return { value, ts, day: localDayKey(ts) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function runnersUp(def, daily, bestDay) {
|
||||||
|
const rows = daily.filter((d) => d[def.field] != null && d.d !== bestDay);
|
||||||
|
rows.sort((a, b) => (def.kind === 'max' ? b[def.field] - a[def.field] : a[def.field] - b[def.field]));
|
||||||
|
return rows.slice(0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayLine(def, rec, daily) {
|
||||||
|
if (!def.today || !daily.length) return '';
|
||||||
|
const row = daily[daily.length - 1];
|
||||||
|
if (row.d !== today()) return '';
|
||||||
|
const key = { high: 'hi', low: 'lo', rain: 'rain', gust: 'gust' }[def.today];
|
||||||
|
const v = row[key];
|
||||||
|
if (v == null) return '';
|
||||||
|
const diff = Math.abs(rec.value - v);
|
||||||
|
const gap = def.fmt === deg ? diff.toFixed(1) + '°' : def.fmt === inch ? diff.toFixed(2) + '"' : diff.toFixed(1) + ' mph';
|
||||||
|
const beyond = def.kind === 'max' ? v >= rec.value : v <= rec.value;
|
||||||
|
const where = beyond ? 'at the record' : (def.kind === 'max' ? gap + ' below the record' : gap + ' above the record');
|
||||||
|
const label = { high: 'Today\'s high so far', low: 'Today\'s low so far', rain: 'Rain so far today', gust: 'Today\'s strongest gust' }[def.today];
|
||||||
|
return `<div class="rc-today">${label}: <b>${def.fmt(v)}</b> · ${where}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function card(def, r, daily) {
|
||||||
|
const rec = resolve(def, r);
|
||||||
|
if (!rec) return null;
|
||||||
|
const isNew = daysBetween(rec.day) <= 14;
|
||||||
|
const when = rec.ts && def.field !== 'rain'
|
||||||
|
? `${fmtDay(rec.day)} at ${fmtTime(rec.ts)}`
|
||||||
|
: fmtDay(rec.day);
|
||||||
|
const ru = runnersUp(def, daily, rec.day);
|
||||||
|
const ruLine = ru.length
|
||||||
|
? `<div class="rc-next">Next closest: ${ru.map((x) => `${def.fmt(x[def.field])} <span>(${new Date(dayMs(x.d)).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })})</span>`).join(' · ')}</div>`
|
||||||
|
: '';
|
||||||
|
return `<div class="rc${isNew ? ' rc-new' : ''}">
|
||||||
|
<div class="rc-top"><span class="rc-ico" aria-hidden="true">${def.icon}</span><span class="rc-title">${def.title}</span>${isNew ? '<span class="rc-badge">NEW</span>' : ''}</div>
|
||||||
|
<div class="rc-val">${def.fmt(rec.value)}${def.tag ? `<span class="rc-tag">${def.tag(rec.value)}</span>` : ''}</div>
|
||||||
|
<div class="rc-when">${when}<span> · ${agoText(rec.day)}</span></div>
|
||||||
|
<div class="rc-why">${def.why}</div>
|
||||||
|
${todayLine(def, rec, daily)}${ruLine}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthStrip(r) {
|
||||||
|
const have = new Set((r.months || []).map((m) => +m.month.slice(5, 7)));
|
||||||
|
const names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
return `<div class="rc-months" role="img" aria-label="Months with recorded data">${names.map((n, i) => `<span class="${have.has(i + 1) ? 'on' : ''}">${n}</span>`).join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function intro(r) {
|
||||||
|
const missing = 12 - new Set((r.months || []).map((m) => +m.month.slice(5, 7))).size;
|
||||||
|
return `<div class="rec-intro">
|
||||||
|
<h2>Your station's records</h2>
|
||||||
|
<p><b>A record is the most extreme weather your station has measured.</b> Your station has been recording for <b>${r.daysRecorded} days</b> (since ${fmtDay(r.firstDay)}), so these are records <i>so far</i>. They will change as the seasons go by.</p>
|
||||||
|
${monthStrip(r)}
|
||||||
|
<p class="rec-note">${missing > 0 ? `Highlighted months have data. The other ${missing} will fill in over time, so winter cold and summer heat records are not final until your station has been through those seasons. ` : ''}A <span class="rc-badge">NEW</span> badge means the record was set in the last 14 days.</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthTable(r) {
|
||||||
|
const months = r.months || [];
|
||||||
|
if (!months.length) return '';
|
||||||
|
const P = r.periods?.months || {};
|
||||||
|
const chips = {};
|
||||||
|
if (months.length >= 2) {
|
||||||
|
const add = (rec, chip) => { if (rec) (chips[rec.key] = chips[rec.key] || []).push(chip); };
|
||||||
|
add(P.hottest, '🔥 hottest month'); add(P.coldest, '❄️ coldest month');
|
||||||
|
add(P.wettest, '🌧 wettest month'); add(P.driest, '🏜 driest month');
|
||||||
|
}
|
||||||
|
const lo = Math.min(...months.map((m) => m.lo).filter((v) => v != null));
|
||||||
|
const hi = Math.max(...months.map((m) => m.hi).filter((v) => v != null));
|
||||||
|
const maxRain = Math.max(0.01, ...months.map((m) => m.rain));
|
||||||
|
const span = Math.max(1, hi - lo);
|
||||||
|
const dim = (ym) => new Date(+ym.slice(0, 4), +ym.slice(5, 7), 0).getDate();
|
||||||
|
const longMonth = (m) => new Date(m + '-15T12:00:00Z').toLocaleDateString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' });
|
||||||
|
const LIMIT = 12;
|
||||||
|
const rows = [...months].reverse().map((m, idx) => {
|
||||||
|
const total = dim(m.month), partial = m.days < total * 0.9;
|
||||||
|
const left = m.lo != null ? ((m.lo - lo) / span) * 100 : 0;
|
||||||
|
const width = m.lo != null && m.hi != null ? Math.max(3, ((m.hi - m.lo) / span) * 100) : 0;
|
||||||
|
return `<tr${idx >= LIMIT ? ' class="rec-extra" hidden' : ''}>
|
||||||
|
<td><b>${longMonth(m.month)}</b>${(chips[m.month] || []).map((c) => `<span class="rec-chip">${c}</span>`).join('')}${partial ? `<span class="rec-yr">${m.days} of ${total} days so far</span>` : ''}</td>
|
||||||
|
<td>${m.avg != null ? m.avg.toFixed(1) + '°F' : '—'}</td>
|
||||||
|
<td class="rec-rng"><span>${m.lo != null ? Math.round(m.lo) : '—'}° to ${m.hi != null ? Math.round(m.hi) : '—'}°</span><i class="rec-track"><i style="left:${left.toFixed(1)}%;width:${width.toFixed(1)}%"></i></i></td>
|
||||||
|
<td class="rec-rain"><span>${m.rain.toFixed(2)}"</span><i class="rec-track r"><i style="width:${((m.rain / maxRain) * 100).toFixed(1)}%"></i></i></td>
|
||||||
|
<td>${m.gust != null ? Math.round(m.gust) + ' mph' : '—'}</td></tr>`;
|
||||||
|
}).join('');
|
||||||
|
return `<h3 class="rec-h2">Month by month</h3>
|
||||||
|
<p class="rec-sub2">How each month compared. The temperature bar shows the coldest to hottest reading that month, on one shared scale.</p>
|
||||||
|
<div class="rec-scroll"><table class="rec-table2"><thead><tr><th>Month</th><th>Average temp</th><th>Coldest to hottest</th><th>Total rain</th><th>Strongest gust</th></tr></thead><tbody>${rows}</tbody></table></div>${months.length > LIMIT ? `<button type="button" class="rec-more" data-more="month">Show all ${months.length} months</button>` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function yearBlock(r) {
|
||||||
|
const years = r.years || [];
|
||||||
|
if (!years.length) return '';
|
||||||
|
if (years.length === 1) {
|
||||||
|
const y = years[0];
|
||||||
|
return `<h3 class="rec-h2">${y.year} so far</h3>
|
||||||
|
<p class="rec-sub2">${y.days} days recorded: hottest <b>${y.hi != null ? deg(y.hi) : '—'}</b>, coldest <b>${y.lo != null ? deg(y.lo) : '—'}</b>, <b>${y.rain.toFixed(2)}"</b> of rain, strongest gust <b>${y.gust != null ? Math.round(y.gust) + ' mph' : '—'}</b>. Year-against-year comparisons appear once there is a second year of data.</p>`;
|
||||||
|
}
|
||||||
|
const rows = [...years].reverse().map((y) => `<tr><td><b>${y.year}</b><span class="rec-yr">${y.days} days</span></td><td>${y.avg != null ? y.avg.toFixed(1) + '°F' : '—'}</td><td>${y.hi != null ? deg(y.hi) : '—'}</td><td>${y.lo != null ? deg(y.lo) : '—'}</td><td>${y.rain.toFixed(2)}"</td><td>${y.gust != null ? Math.round(y.gust) + ' mph' : '—'}</td></tr>`).join('');
|
||||||
|
return `<h3 class="rec-h2">Year by year</h3><div class="rec-scroll"><table class="rec-table2"><thead><tr><th>Year</th><th>Average temp</th><th>Hottest</th><th>Coldest</th><th>Total rain</th><th>Strongest gust</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only meaningful once a calendar month has been seen in more than one year
|
||||||
|
function sameMonthTable(r) {
|
||||||
|
const cm = (r.calendarMonths || []).map((c, i) => ({ c, i })).filter((x) => x.c.years > 1);
|
||||||
|
if (!cm.length) return '';
|
||||||
|
const mn = (i) => new Date(Date.UTC(2001, i, 15)).toLocaleDateString('en-US', { month: 'long', timeZone: 'UTC' });
|
||||||
|
const cell = (x, f) => x ? `<b>${f(x.value)}</b><span class="rec-yr">${fmtDay(x.day)}</span>` : '—';
|
||||||
|
const rows = cm.map(({ c, i }) => `<tr><td><b>${mn(i)}</b><span class="rec-yr">${c.years} years of data</span></td><td>${cell(c.hi, deg)}</td><td>${cell(c.lo, deg)}</td><td>${cell(c.wettestDay, inch)}</td><td>${cell(c.gust, mph)}</td></tr>`).join('');
|
||||||
|
return `<h3 class="rec-h2">Records for each month of the year</h3>
|
||||||
|
<p class="rec-sub2">The most extreme day for each calendar month, from any year. For example, the hottest July ever recorded.</p>
|
||||||
|
<div class="rec-scroll"><table class="rec-table2"><thead><tr><th>Month</th><th>Hottest day</th><th>Coldest</th><th>Wettest day</th><th>Strongest gust</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [rr, dr] = await Promise.all([fetch('/api/station/records'), fetch('/api/station/daily').catch(() => null)]);
|
||||||
|
if (!rr.ok) { root.innerHTML = '<div class="status">No records yet.</div>'; return; }
|
||||||
|
const r = await rr.json();
|
||||||
|
if (!r) { root.innerHTML = '<div class="status">No data recorded yet.</div>'; return; }
|
||||||
|
let daily = [];
|
||||||
|
try { if (dr && dr.ok) daily = await dr.json(); } catch {}
|
||||||
|
|
||||||
|
const themes = [...new Set(RECORDS.map((d) => d.theme))];
|
||||||
|
const empty = [];
|
||||||
|
const groups = themes.map((t) => {
|
||||||
|
const cards = RECORDS.filter((d) => d.theme === t).map((d) => {
|
||||||
|
const html = card(d, r, daily);
|
||||||
|
if (!html) empty.push(d.title);
|
||||||
|
return html;
|
||||||
|
}).filter(Boolean);
|
||||||
|
return cards.length ? `<h3 class="rec-h2">${t}</h3><div class="rc-grid">${cards.join('')}</div>` : '';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
root.innerHTML = intro(r) + groups
|
||||||
|
+ (empty.length ? `<p class="rec-note">Not measured yet: ${empty.join(', ')}. These show up once the station records something for them.</p>` : '')
|
||||||
|
+ monthTable(r) + yearBlock(r) + sameMonthTable(r)
|
||||||
|
+ `<div class="rec-actions"><a href="/api/station/backup.jsonl" download>⬇ Download all readings</a><a href="/api/station/records-backup.json" download>⬇ Download records</a></div>`;
|
||||||
|
root.querySelectorAll('.rec-more').forEach((b) => b.addEventListener('click', () => {
|
||||||
|
const open = b.dataset.open !== '1';
|
||||||
|
b.dataset.open = open ? '1' : '';
|
||||||
|
root.querySelectorAll('.rec-extra').forEach((tr) => { tr.hidden = !open; });
|
||||||
|
b.textContent = open ? 'Show fewer months' : b.dataset.label;
|
||||||
|
}));
|
||||||
|
root.querySelectorAll('.rec-more').forEach((b) => { b.dataset.label = b.textContent; });
|
||||||
|
} catch {
|
||||||
|
root.innerHTML = '<div class="status error">Failed to load records.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
})();
|
||||||
@@ -2,7 +2,17 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<link rel="manifest" href="/dashboard.webmanifest">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/icons/v2/dashboard-icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/icons/v2/dashboard-apple-touch-icon-180.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="167x167" href="/icons/v2/dashboard-apple-touch-icon-167.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="152x152" href="/icons/v2/dashboard-apple-touch-icon-152.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="120x120" href="/icons/v2/dashboard-apple-touch-icon-120.png">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Station">
|
||||||
|
<link rel="stylesheet" href="/pwa-safe.css">
|
||||||
<title>Station Dashboard — Recover Access</title>
|
<title>Station Dashboard — Recover Access</title>
|
||||||
<style>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
@@ -27,6 +37,7 @@ button:hover { opacity: 0.88; }
|
|||||||
.error { background: rgba(194,90,42,0.15); border: 1px solid var(--accent); border-radius: 8px; padding: 0.6rem 0.8rem; margin-bottom: 1rem; font-size: 0.9rem; color: var(--accent); }
|
.error { background: rgba(194,90,42,0.15); border: 1px solid var(--accent); border-radius: 8px; padding: 0.6rem 0.8rem; margin-bottom: 1rem; font-size: 0.9rem; color: var(--accent); }
|
||||||
.back { display: block; text-align: center; margin-top: 1.25rem; font-size: 0.85rem; color: var(--border); text-decoration: none; }
|
.back { display: block; text-align: center; margin-top: 1.25rem; font-size: 0.85rem; color: var(--border); text-decoration: none; }
|
||||||
.back:hover { color: var(--ink); }
|
.back:hover { color: var(--ink); }
|
||||||
|
html, body { padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -2,7 +2,17 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<link rel="manifest" href="/dashboard.webmanifest">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/icons/v2/dashboard-icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/icons/v2/dashboard-apple-touch-icon-180.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="167x167" href="/icons/v2/dashboard-apple-touch-icon-167.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="152x152" href="/icons/v2/dashboard-apple-touch-icon-152.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="120x120" href="/icons/v2/dashboard-apple-touch-icon-120.png">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Station">
|
||||||
|
<link rel="stylesheet" href="/pwa-safe.css">
|
||||||
<title>Station Settings</title>
|
<title>Station Settings</title>
|
||||||
<style>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
@@ -21,7 +31,7 @@
|
|||||||
}
|
}
|
||||||
html, body { min-height: 100%; background: var(--paper); color: var(--ink); font-family: var(--mono); font-size: 15px; }
|
html, body { min-height: 100%; background: var(--paper); color: var(--ink); font-family: var(--mono); font-size: 15px; }
|
||||||
|
|
||||||
.header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.5rem; border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--paper-dark) 82%, transparent); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); position: sticky; top: 0; z-index: 10; transition: box-shadow .25s; }
|
.header { display: flex; align-items: center; justify-content: space-between; padding: calc(1rem + env(safe-area-inset-top, 0px)) calc(1.5rem + env(safe-area-inset-right, 0px)) 1rem calc(1.5rem + env(safe-area-inset-left, 0px)); border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--paper-dark) 82%, transparent); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); position: sticky; top: 0; z-index: 10; transition: box-shadow .25s; }
|
||||||
.header.scrolled { box-shadow: 0 4px 24px rgba(0,0,0,0.18); }
|
.header.scrolled { box-shadow: 0 4px 24px rgba(0,0,0,0.18); }
|
||||||
.header h1 { font-size: 1.15rem; }
|
.header h1 { font-size: 1.15rem; }
|
||||||
.header-right { display: flex; align-items: center; gap: 1rem; font-size: 0.85rem; }
|
.header-right { display: flex; align-items: center; gap: 1rem; font-size: 0.85rem; }
|
||||||
@@ -159,6 +169,18 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- NWS reference station -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-hdr">NWS Reference Station</div>
|
||||||
|
<p class="hint" style="margin:0 0 0.85rem">Logs every official observation from the National Weather Service station nearest this ZIP code (plus hourly forecast snapshots and alerts) and overlays it on your charts.</p>
|
||||||
|
<div class="threshold-row">
|
||||||
|
<div><label>ZIP code</label><input id="nwsZip" type="text" inputmode="numeric" maxlength="5" placeholder="24550" autocomplete="off"></div>
|
||||||
|
<div style="align-self:end"><button class="primary" id="saveNwsBtn">Save & look up</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-actions"><span id="nwsMsg" class="save-msg"></span></div>
|
||||||
|
<div id="nwsInfo" class="hint"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Export / Import -->
|
<!-- Export / Import -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-hdr">Data</div>
|
<div class="section-hdr">Data</div>
|
||||||
@@ -420,6 +442,35 @@ checkKeyStatus();
|
|||||||
loadThresholds();
|
loadThresholds();
|
||||||
loadStationName();
|
loadStationName();
|
||||||
loadLocation();
|
loadLocation();
|
||||||
|
|
||||||
|
// ── NWS reference station ──────────────────────────────────────────────
|
||||||
|
async function loadNwsInfo() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/nws/config');
|
||||||
|
if (!r.ok) return;
|
||||||
|
const c = await r.json();
|
||||||
|
const zipEl = document.getElementById('nwsZip');
|
||||||
|
if (zipEl && !zipEl.value) zipEl.value = c.zip || '';
|
||||||
|
const res = c.resolved;
|
||||||
|
const when = (t) => t ? new Date(t).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '—';
|
||||||
|
document.getElementById('nwsInfo').innerHTML = res
|
||||||
|
? `Station <b>${res.stationId}</b> — ${res.stationName} · ${res.distanceMi ?? '?'} mi from ZIP ${res.zip}${res.place ? ' (' + res.place + ')' : ''}${res.approx ? ' · <i>approximate ZIP location</i>' : ''}<br>${c.observations.toLocaleString()} observations logged · first ${when(c.firstObservation)} · latest ${when(c.lastObservation)}${c.lastError ? '<br><span style="color:var(--accent)">Last error: ' + c.lastError + '</span>' : ''}`
|
||||||
|
: (c.lastError ? `<span style="color:var(--accent)">${c.lastError}</span>` : 'Looking up the station for this ZIP…');
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
document.getElementById('saveNwsBtn')?.addEventListener('click', async () => {
|
||||||
|
const msg = document.getElementById('nwsMsg'), zip = document.getElementById('nwsZip').value.trim();
|
||||||
|
msg.textContent = 'Looking up…'; msg.className = 'save-msg';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/nws/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ zip }) });
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) throw new Error(d.error || 'Failed');
|
||||||
|
msg.textContent = 'Saved — logging has started'; await loadNwsInfo();
|
||||||
|
setTimeout(loadNwsInfo, 4000);
|
||||||
|
} catch (e) { msg.textContent = e.message; msg.className = 'save-msg err'; }
|
||||||
|
setTimeout(() => { msg.textContent = ''; }, 6000);
|
||||||
|
});
|
||||||
|
loadNwsInfo();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -2,7 +2,17 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<link rel="manifest" href="/dashboard.webmanifest">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/icons/v2/dashboard-icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/icons/v2/dashboard-apple-touch-icon-180.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="167x167" href="/icons/v2/dashboard-apple-touch-icon-167.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="152x152" href="/icons/v2/dashboard-apple-touch-icon-152.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="120x120" href="/icons/v2/dashboard-apple-touch-icon-120.png">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Station">
|
||||||
|
<link rel="stylesheet" href="/pwa-safe.css">
|
||||||
<title>Station Dashboard — Create Account</title>
|
<title>Station Dashboard — Create Account</title>
|
||||||
<style>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
@@ -24,6 +34,7 @@ button { width: 100%; padding: 0.7rem; background: var(--accent); color: #fff; b
|
|||||||
button:hover { opacity: 0.88; }
|
button:hover { opacity: 0.88; }
|
||||||
.error { background: rgba(194,90,42,0.15); border: 1px solid var(--accent); border-radius: 8px; padding: 0.6rem 0.8rem; margin-bottom: 1rem; font-size: 0.9rem; color: var(--accent); }
|
.error { background: rgba(194,90,42,0.15); border: 1px solid var(--accent); border-radius: 8px; padding: 0.6rem 0.8rem; margin-bottom: 1rem; font-size: 0.9rem; color: var(--accent); }
|
||||||
.note { margin-top: 1rem; font-size: 0.8rem; opacity: 0.55; text-align: center; }
|
.note { margin-top: 1rem; font-size: 0.8rem; opacity: 0.55; text-align: center; }
|
||||||
|
html, body { padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -115,3 +115,94 @@ canvas.sparkline { max-width: 100%; }
|
|||||||
.tab:hover, .logout:hover { border-color: var(--ui); color: var(--ui); }
|
.tab:hover, .logout:hover { border-color: var(--ui); color: var(--ui); }
|
||||||
.chart-reset:hover { border-color: var(--ui); }
|
.chart-reset:hover { border-color: var(--ui); }
|
||||||
a { color: var(--ui); }
|
a { color: var(--ui); }
|
||||||
|
|
||||||
|
/* Per-chart controls */
|
||||||
|
.chart-ctl { display: inline-flex; flex-wrap: wrap; gap: 0.4rem 0.8rem; align-items: center; text-transform: none; letter-spacing: 0; font-weight: 500; font-size: 0.74rem; color: var(--ink-light); }
|
||||||
|
.chart-ctl label { display: inline-flex; align-items: center; gap: 0.35rem; }
|
||||||
|
.chart-ctl select { font: inherit; font-size: 0.76rem; padding: 0.25rem 0.4rem; min-height: 30px; border-radius: 8px; border: 1px solid var(--border); background: var(--paper); color: var(--ink); }
|
||||||
|
@media (pointer: coarse) { .chart-ctl select { font-size: 16px; min-height: 40px; } }
|
||||||
|
.chart-card .chart-title { flex-wrap: wrap; row-gap: 0.4rem; }
|
||||||
|
|
||||||
|
/* Month / year record cards: the period is the headline */
|
||||||
|
.record-period { font-size: 0.92rem; font-weight: 800; color: var(--ink); letter-spacing: -0.005em; margin-top: 0.1rem; }
|
||||||
|
.rec-sub { text-transform: none; letter-spacing: 0; font-weight: 400; opacity: .75; }
|
||||||
|
.rec-cal td { vertical-align: top; line-height: 1.25; }
|
||||||
|
.rec-cal td b { display: block; font-variant-numeric: tabular-nums; }
|
||||||
|
.rec-yr { display: block; font-size: 0.68rem; color: var(--ink-light); font-weight: 600; white-space: nowrap; }
|
||||||
|
|
||||||
|
.rec-cal tr.empty { opacity: .4; }
|
||||||
|
|
||||||
|
/* Alert history: what / where / when */
|
||||||
|
.ah-what { font-size: 0.86rem; line-height: 1.4; }
|
||||||
|
.ah-meta { margin-top: 0.25rem; font-size: 0.74rem; color: var(--ink-light); line-height: 1.5; }
|
||||||
|
.ah-sev { display: inline-block; padding: 0 0.45rem; border-radius: 99px; font-weight: 700; font-size: 0.68rem; border: 1px solid var(--border); }
|
||||||
|
.ah-sev.sev-extreme { color: #ef4444; border-color: #ef4444; } .ah-sev.sev-severe { color: #f97316; border-color: #f97316; }
|
||||||
|
.ah-sev.sev-moderate { color: #eab308; border-color: #eab308; } .ah-sev.sev-minor { color: var(--cool); border-color: var(--cool); }
|
||||||
|
.ah-row { cursor: pointer; }
|
||||||
|
.ah-row.open td { background: color-mix(in srgb, var(--ui) 6%, transparent); }
|
||||||
|
.ah-full { margin-top: 0.6rem; padding-top: 0.6rem; border-top: 1px dashed var(--border); font-size: 0.82rem; line-height: 1.5; cursor: default; }
|
||||||
|
.ah-full p { margin: 0 0 0.5rem; }
|
||||||
|
.ah-desc { white-space: pre-wrap; }
|
||||||
|
.ah-meaning { padding: 0.5rem 0.7rem; border-radius: 10px; background: color-mix(in srgb, var(--ui) 10%, transparent); }
|
||||||
|
.ah-note { font-size: 0.74rem; color: var(--ink-light); }
|
||||||
|
|
||||||
|
/* NWS overlay + card */
|
||||||
|
.nws-toggle.active { background: #7c5cff !important; border-color: #7c5cff !important; }
|
||||||
|
.nws-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #a78bfa; margin-left: 0.35rem; vertical-align: middle; }
|
||||||
|
.nws-card .kv b { font-size: 1.05rem; }
|
||||||
|
.nws-raw { display: block; margin-top: 0.4rem; font-size: 0.7rem; overflow-wrap: anywhere; color: var(--ink-light); background: var(--paper); border: 1px solid var(--border); border-radius: 8px; padding: 0.35rem 0.55rem; }
|
||||||
|
|
||||||
|
/* Performance on phones: blur only the two big fixed bars, lightly */
|
||||||
|
.logout, .range-btn, .refresh-btn, .chart-card, .stat, .today-bar { -webkit-backdrop-filter: none !important; backdrop-filter: none !important; }
|
||||||
|
.header, .tabbar { -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
|
||||||
|
|
||||||
|
/* ── Records tab (plain-language) ── */
|
||||||
|
.rec2 { max-width: 1100px; margin: 0 auto; }
|
||||||
|
.rec-intro { background: var(--paper-dark); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.1rem 1.2rem; margin-top: 1rem; }
|
||||||
|
.rec-intro h2 { font-size: 1.15rem; margin-bottom: 0.45rem; }
|
||||||
|
.rec-intro p { font-size: 0.9rem; line-height: 1.55; color: var(--ink-mid); }
|
||||||
|
.rec-note { font-size: 0.8rem !important; color: var(--ink-light) !important; margin-top: 0.7rem; line-height: 1.5; }
|
||||||
|
.rec2 > .rec-note { margin: 0.9rem 0.2rem 0; }
|
||||||
|
.rc-months { display: grid; grid-template-columns: repeat(12, 1fr); gap: 4px; margin-top: 0.85rem; }
|
||||||
|
.rc-months span { text-align: center; font-size: 0.68rem; font-weight: 600; padding: 0.3rem 0; border-radius: 6px; background: var(--paper-darker); color: var(--ink-light); opacity: 0.55; }
|
||||||
|
.rc-months span.on { background: var(--accent); color: #fff; opacity: 1; }
|
||||||
|
.rec-h2 { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-light); margin: 1.6rem 0.2rem 0.6rem; }
|
||||||
|
.rec-sub2 { font-size: 0.85rem; line-height: 1.55; color: var(--ink-mid); margin: -0.2rem 0.2rem 0.7rem; }
|
||||||
|
.rc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; }
|
||||||
|
.rc { background: var(--paper-dark); border: 1px solid var(--border); border-radius: var(--radius); padding: 0.95rem 1.05rem; display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }
|
||||||
|
.rc-new { border-color: #d4a017; box-shadow: 0 0 0 1px #d4a01766; }
|
||||||
|
.rc-top { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
|
.rc-ico { font-size: 1.15rem; line-height: 1; }
|
||||||
|
.rc-title { font-size: 0.8rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-mid); }
|
||||||
|
.rc-badge { font-size: 0.62rem; font-weight: 800; letter-spacing: 0.08em; padding: 0.12rem 0.4rem; border-radius: 999px; background: #d4a017; color: #1b1400; }
|
||||||
|
.rc-top .rc-badge { margin-left: auto; }
|
||||||
|
.rc-val { font-size: 1.9rem; font-weight: 800; line-height: 1.1; font-variant-numeric: tabular-nums; display: flex; align-items: baseline; flex-wrap: wrap; gap: 0.2rem 0.6rem; }
|
||||||
|
.rc-tag { font-size: 0.74rem; font-weight: 700; padding: 0.15rem 0.5rem; border-radius: 999px; background: var(--paper-darker); color: var(--ink-mid); }
|
||||||
|
.rc-when { font-size: 0.82rem; font-weight: 600; color: var(--ink); }
|
||||||
|
.rc-when span { font-weight: 400; color: var(--ink-light); }
|
||||||
|
.rc-why { font-size: 0.8rem; line-height: 1.5; color: var(--ink-light); }
|
||||||
|
.rc-today, .rc-next { font-size: 0.78rem; color: var(--ink-mid); padding-top: 0.45rem; margin-top: 0.15rem; border-top: 1px dashed var(--border); }
|
||||||
|
.rc-next { border-top: none; padding-top: 0; margin-top: 0; color: var(--ink-light); }
|
||||||
|
.rc-next span { opacity: 0.8; }
|
||||||
|
.rec-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; background: var(--paper-dark); border: 1px solid var(--border); border-radius: var(--radius); }
|
||||||
|
.rec-table2 { width: 100%; border-collapse: collapse; font-size: 0.86rem; }
|
||||||
|
.rec-table2 th, .rec-table2 td { text-align: left; padding: 0.7rem 0.9rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||||
|
.rec-table2 tr:last-child td { border-bottom: none; }
|
||||||
|
.rec-table2 th { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-light); font-weight: 700; white-space: nowrap; }
|
||||||
|
.rec-table2 td { font-variant-numeric: tabular-nums; }
|
||||||
|
.rec-table2 td:first-child { min-width: 9.5rem; }
|
||||||
|
.rec-table2 .rec-yr { display: block; font-size: 0.72rem; color: var(--ink-light); font-weight: 400; margin-top: 0.15rem; }
|
||||||
|
.rec-chip { display: inline-block; margin: 0.25rem 0.3rem 0 0; font-size: 0.68rem; font-weight: 600; padding: 0.12rem 0.5rem; border-radius: 999px; background: var(--paper-darker); color: var(--ink-mid); white-space: nowrap; }
|
||||||
|
.rec-rng, .rec-rain { min-width: 9rem; }
|
||||||
|
.rec-rng > span, .rec-rain > span { display: block; margin-bottom: 0.35rem; }
|
||||||
|
.rec-track { display: block; position: relative; height: 7px; border-radius: 99px; background: var(--paper-darker); }
|
||||||
|
.rec-track > i { position: absolute; top: 0; bottom: 0; border-radius: 99px; background: linear-gradient(90deg, var(--cool), var(--accent)); }
|
||||||
|
.rec-track.r > i { left: 0; background: var(--cool); }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.rc-grid { grid-template-columns: 1fr; }
|
||||||
|
.rc-val { font-size: 1.7rem; }
|
||||||
|
.rec-table2 th, .rec-table2 td { padding: 0.6rem 0.65rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-extra[hidden] { display: none; }
|
||||||
|
.rec-more { display: block; margin: 0.7rem auto 0; min-height: 40px; padding: 0 1.1rem; border: 1px solid var(--border); border-radius: 999px; background: var(--paper-dark); color: var(--ink); font: inherit; font-size: 0.82rem; font-weight: 600; cursor: pointer; }
|
||||||
@@ -51,6 +51,7 @@ function setTab(name, updateHash = true) {
|
|||||||
}
|
}
|
||||||
function activeTab() { return document.querySelector('.tab.active')?.dataset.tab || 'now'; }
|
function activeTab() { return document.querySelector('.tab.active')?.dataset.tab || 'now'; }
|
||||||
function renderTab(name) {
|
function renderTab(name) {
|
||||||
|
if (name === 'charts' && typeof chartsDirty !== 'undefined' && chartsDirty && typeof loadHistory === 'function') loadHistory(currentRange);
|
||||||
if (name === 'rain' && !rendered.rain) { renderRain(); rendered.rain = true; }
|
if (name === 'rain' && !rendered.rain) { renderRain(); rendered.rain = true; }
|
||||||
if (name === 'records' && !rendered.cal) { renderCalendar(); rendered.cal = true; }
|
if (name === 'records' && !rendered.cal) { renderCalendar(); rendered.cal = true; }
|
||||||
if (name === 'insights' && !rendered.insights) { renderInsights(); rendered.insights = true; }
|
if (name === 'insights' && !rendered.insights) { renderInsights(); rendered.insights = true; }
|
||||||
@@ -106,7 +107,9 @@ function sunTimes(noonMs, lat, lon) {
|
|||||||
if (cosW > 1 || cosW < -1) return null; // polar day/night
|
if (cosW > 1 || cosW < -1) return null; // polar day/night
|
||||||
const w = Math.acos(cosW) / RAD;
|
const w = Math.acos(cosW) / RAD;
|
||||||
const toMs = (j) => (j - 2440587.5) * MS_DAY;
|
const toMs = (j) => (j - 2440587.5) * MS_DAY;
|
||||||
return { rise: toMs(Jt - w / 360), set: toMs(Jt + w / 360), noon: toMs(Jt) };
|
// The julian-day rounding above can land on the neighbouring day; align to the requested day
|
||||||
|
const shift = Math.round((noonMs - toMs(Jt)) / MS_DAY) * MS_DAY;
|
||||||
|
return { rise: toMs(Jt - w / 360) + shift, set: toMs(Jt + w / 360) + shift, noon: toMs(Jt) + shift };
|
||||||
}
|
}
|
||||||
const SYNODIC = 29.530588853;
|
const SYNODIC = 29.530588853;
|
||||||
const NEW_MOON = Date.UTC(2000, 0, 6, 18, 14);
|
const NEW_MOON = Date.UTC(2000, 0, 6, 18, 14);
|
||||||
@@ -247,6 +250,7 @@ function renderNowExtras() {
|
|||||||
updateCompass(LATEST);
|
updateCompass(LATEST);
|
||||||
renderSunCard();
|
renderSunCard();
|
||||||
renderHealthCard();
|
renderHealthCard();
|
||||||
|
renderNwsCard();
|
||||||
renderHighlights();
|
renderHighlights();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,7 +301,13 @@ function medianIntervalMin() {
|
|||||||
d.sort((a, b) => a - b);
|
d.sort((a, b) => a - b);
|
||||||
return d[d.length >> 1] / 60000;
|
return d[d.length >> 1] / 60000;
|
||||||
}
|
}
|
||||||
function coverageFor(row, perDay) { return row && perDay ? Math.min(100, (row.n / perDay) * 100) : null; }
|
// Share of the day's 288 five-minute slots that have at least one reading, so it doesn't matter whether the
|
||||||
|
// station reports every 60, 100 or 120 s. Days saved before slots were tracked fall back to reading count.
|
||||||
|
function coverageFor(row, perDay) {
|
||||||
|
if (!row) return null;
|
||||||
|
if (row.slots != null) return Math.min(100, (row.slots / 288) * 100);
|
||||||
|
return perDay ? Math.min(100, (row.n / perDay) * 100) : null;
|
||||||
|
}
|
||||||
|
|
||||||
function renderHealthCard() {
|
function renderHealthCard() {
|
||||||
const host = $('healthCard');
|
const host = $('healthCard');
|
||||||
@@ -307,7 +317,8 @@ function renderHealthCard() {
|
|||||||
const interval = medianIntervalMin();
|
const interval = medianIntervalMin();
|
||||||
const perDay = interval ? 1440 / interval : null;
|
const perDay = interval ? 1440 / interval : null;
|
||||||
const past = DAILY.filter((d) => d.d !== todayStr()).slice(-30);
|
const past = DAILY.filter((d) => d.d !== todayStr()).slice(-30);
|
||||||
const cov = perDay && past.length ? mean(past.map((d) => coverageFor(d, perDay))) : null;
|
const covVals = past.map((d) => coverageFor(d, perDay)).filter((v) => v != null);
|
||||||
|
const cov = covVals.length ? mean(covVals) : null;
|
||||||
const t = todayRow();
|
const t = todayRow();
|
||||||
host.innerHTML = `<div class="ui-card"><h3>Station Health</h3><div class="kv">
|
host.innerHTML = `<div class="ui-card"><h3>Station Health</h3><div class="kv">
|
||||||
<div><b><span class="pill ${ok ? 'ok' : 'warn'}">${ok ? 'Online' : 'Delayed'}</span></b><span>Last report</span><small>${ageMin <= 0 ? 'just now' : ageMin + ' min ago'}</small></div>
|
<div><b><span class="pill ${ok ? 'ok' : 'warn'}">${ok ? 'Online' : 'Delayed'}</span></b><span>Last report</span><small>${ageMin <= 0 ? 'just now' : ageMin + ' min ago'}</small></div>
|
||||||
@@ -526,8 +537,8 @@ function renderInsights() {
|
|||||||
const interval = medianIntervalMin();
|
const interval = medianIntervalMin();
|
||||||
const perDay = interval ? 1440 / interval : null;
|
const perDay = interval ? 1440 / interval : null;
|
||||||
const cov = past.slice(-45);
|
const cov = past.slice(-45);
|
||||||
if (perDay) {
|
if (cov.length) {
|
||||||
const vals = cov.map((d) => +coverageFor(d, perDay).toFixed(0));
|
const vals = cov.map((d) => +(coverageFor(d, perDay) ?? 0).toFixed(0));
|
||||||
const ds = barDataset('Coverage %', vals, c.cool);
|
const ds = barDataset('Coverage %', vals, c.cool);
|
||||||
ds.backgroundColor = vals.map((v) => (v < 90 ? c.accent + 'bb' : c.cool + 'bb'));
|
ds.backgroundColor = vals.map((v) => (v < 90 ? c.accent + 'bb' : c.cool + 'bb'));
|
||||||
makeChart('inCoverage', 'bar', [ds], cov.map((d) => prettyDay(d.d)), '%', { scales: { y: { min: 0, max: 100 } } });
|
makeChart('inCoverage', 'bar', [ds], cov.map((d) => prettyDay(d.d)), '%', { scales: { y: { min: 0, max: 100 } } });
|
||||||
@@ -556,26 +567,68 @@ function renderInsights() {
|
|||||||
const right = document.querySelector('.header-right');
|
const right = document.querySelector('.header-right');
|
||||||
if (!right) return;
|
if (!right) return;
|
||||||
const settings = right.querySelector('a[href="/dashboard/settings"]');
|
const settings = right.querySelector('a[href="/dashboard/settings"]');
|
||||||
if (me.role !== 'admin' && settings) settings.remove();
|
|
||||||
const acct = document.createElement('a');
|
const acct = document.createElement('a');
|
||||||
acct.href = '/dashboard/account'; acct.className = 'logout'; acct.textContent = me.role === 'admin' ? 'Account & Users' : 'Account';
|
acct.href = '/dashboard/account'; acct.className = 'logout'; acct.textContent = me.role === 'admin' ? 'Account & Users' : 'Account';
|
||||||
acct.title = `Signed in as ${me.username} (${me.role})`;
|
acct.title = `Signed in as ${me.username} (${me.role})`;
|
||||||
(settings && me.role === 'admin' ? settings : right.querySelector('a[href="/dashboard/logout"]'))?.before(acct);
|
(settings || right.querySelector('a[href="/dashboard/logout"]'))?.before(acct);
|
||||||
const nm = $('stationName');
|
const nm = $('stationName');
|
||||||
if (nm && !nm.textContent) nm.textContent = me.username;
|
if (nm && !nm.textContent) nm.textContent = me.username;
|
||||||
window.DASH_USER = me;
|
window.DASH_USER = me;
|
||||||
} catch {}
|
} catch {}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
||||||
|
// ── Official NWS observation vs your station ─────────────────────────────────
|
||||||
|
let NWS = null;
|
||||||
|
function renderNwsCard() {
|
||||||
|
const host = $('nwsCard');
|
||||||
|
if (!host) return;
|
||||||
|
const n = NWS, l = n?.latest, r = n?.resolved;
|
||||||
|
if (!n) { host.innerHTML = ''; return; }
|
||||||
|
if (!l) {
|
||||||
|
host.innerHTML = `<div class="ui-card"><h3>Official NWS observation</h3><p class="note">${n.lastError ? 'Could not reach the National Weather Service yet: ' + n.lastError : 'Waiting for the first observation from the National Weather Service…'}${r ? ` Station <b>${r.stationId}</b> for ZIP ${r.zip}.` : ''}</p></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const f = (v, d = 0, u = '') => (num(v) == null ? '—' : v.toFixed(d) + u);
|
||||||
|
const ageMin = Math.round((Date.now() - l.ts) / 60000);
|
||||||
|
const mine = LATEST;
|
||||||
|
const diffs = [];
|
||||||
|
if (mine && num(mine.tempf) != null && num(l.tempf) != null && Math.abs(mine.dateutc - l.ts) < 90 * 60000) {
|
||||||
|
const d = mine.tempf - l.tempf;
|
||||||
|
diffs.push(Math.abs(d) < 0.5 ? 'Your station matches NWS on temperature' : `Your station reads <b>${Math.abs(d).toFixed(1)}°F ${d > 0 ? 'warmer' : 'cooler'}</b>`);
|
||||||
|
if (num(mine.humidity) != null && num(l.humidity) != null && Math.abs(mine.humidity - l.humidity) >= 3) diffs.push(`${Math.abs(mine.humidity - l.humidity).toFixed(0)}% ${mine.humidity > l.humidity ? 'more humid' : 'drier'}`);
|
||||||
|
}
|
||||||
|
const wind = num(l.windspeedmph) != null ? `${l.winddir != null ? cardinal(l.winddir) + ' ' : ''}${l.windspeedmph.toFixed(0)} mph${num(l.windgustmph) != null ? ' (g ' + l.windgustmph.toFixed(0) + ')' : ''}` : '—';
|
||||||
|
host.innerHTML = `<div class="ui-card nws-card"><h3>Official NWS observation <span class="nws-dot"></span></h3>
|
||||||
|
<p class="note" style="margin:-0.4rem 0 0.8rem"><b>${esc2(r?.stationId || l.station)}</b> ${esc2(r?.stationName || '')} · ${r?.distanceMi != null ? r.distanceMi + ' mi from ' : 'for '}ZIP ${esc2(r?.zip || '')}${r?.place ? ' (' + esc2(r.place) + ')' : ''}</p>
|
||||||
|
<div class="kv">
|
||||||
|
<div><b>${f(l.tempf, 1, '°F')}</b><span>Temperature</span></div>
|
||||||
|
<div><b>${f(l.dewPoint, 0, '°F')}</b><span>Dew point</span></div>
|
||||||
|
<div><b>${f(l.humidity, 0, '%')}</b><span>Humidity</span></div>
|
||||||
|
<div><b>${wind}</b><span>Wind</span></div>
|
||||||
|
<div><b>${f(l.baromrelin, 2, ' inHg')}</b><span>Pressure</span></div>
|
||||||
|
<div><b>${f(l.visibilityMi, 0, ' mi')}</b><span>Visibility</span></div>
|
||||||
|
<div><b>${esc2(l.text || '—')}</b><span>Conditions</span></div>
|
||||||
|
</div>
|
||||||
|
<p class="note" style="margin-top:0.8rem">Observed ${new Date(l.ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })} (${ageMin} min ago)${diffs.length ? ' · ' + diffs.join(' · ') : ''} · ${n.observations.toLocaleString()} observations logged${n.firstObservation ? ' since ' + prettyDay(dayStr(n.firstObservation)) : ''}${l.raw ? `<br><code class="nws-raw">${esc2(l.raw)}</code>` : ''}</p></div>`;
|
||||||
|
}
|
||||||
|
const esc2 = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
|
|
||||||
// ── Data refresh ─────────────────────────────────────────────────────────────
|
// ── Data refresh ─────────────────────────────────────────────────────────────
|
||||||
async function refreshData() {
|
async function refreshData() {
|
||||||
try {
|
try {
|
||||||
const [daily, recent] = await Promise.all([
|
const [daily, recent, nws] = await Promise.all([
|
||||||
getJSON('/api/station/daily').catch(() => []),
|
getJSON('/api/station/daily').catch(() => []),
|
||||||
getJSON('/api/station/history?range=2d').catch(() => []),
|
getJSON('/api/station/history?range=2d').catch(() => []),
|
||||||
|
getJSON('/api/nws/latest').catch(() => null),
|
||||||
]);
|
]);
|
||||||
DAILY = daily; RECENT = recent;
|
DAILY = daily; RECENT = recent; NWS = nws;
|
||||||
LATEST = RECENT[RECENT.length - 1] || null;
|
LATEST = RECENT[RECENT.length - 1] || null;
|
||||||
|
const allBtn = document.querySelector('.range-btn[data-range="all"]');
|
||||||
|
if (allBtn && DAILY[0]) {
|
||||||
|
const first = new Date(dayMs(DAILY[0].d)).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
|
||||||
|
allBtn.title = `Everything recorded since ${first}`;
|
||||||
|
}
|
||||||
if (LATEST?.tz) TZ = LATEST.tz;
|
if (LATEST?.tz) TZ = LATEST.tz;
|
||||||
if (!LOC) {
|
if (!LOC) {
|
||||||
const loc = await getJSON('/api/dashboard/location').catch(() => null);
|
const loc = await getJSON('/api/dashboard/location').catch(() => null);
|
||||||
|
|||||||
@@ -10,12 +10,18 @@
|
|||||||
<meta name="apple-mobile-web-app-title" content="Station">
|
<meta name="apple-mobile-web-app-title" content="Station">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<link rel="manifest" href="/dashboard.webmanifest">
|
<link rel="manifest" href="/dashboard.webmanifest">
|
||||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
|
<link rel="icon" type="image/png" sizes="192x192" href="/icons/v2/dashboard-icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/icons/v2/dashboard-apple-touch-icon-180.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="167x167" href="/icons/v2/dashboard-apple-touch-icon-167.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="152x152" href="/icons/v2/dashboard-apple-touch-icon-152.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="120x120" href="/icons/v2/dashboard-apple-touch-icon-120.png">
|
||||||
|
<link rel="stylesheet" href="/pwa-safe.css">
|
||||||
<title>Station Dashboard</title>
|
<title>Station Dashboard</title>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
|
||||||
<link rel="stylesheet" href="/dashboard-ui.css">
|
<link rel="stylesheet" href="/dashboard-ui.css">
|
||||||
|
<script src="/alert-info.js"></script>
|
||||||
<style>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
:root {
|
:root {
|
||||||
@@ -335,7 +341,8 @@ canvas.sparkline { display: block; width: 100%; height: 22px; margin-top: 0.4rem
|
|||||||
|
|
||||||
/* ── iPhone / touch ── */
|
/* ── iPhone / touch ── */
|
||||||
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; -webkit-tap-highlight-color: transparent; }
|
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; -webkit-tap-highlight-color: transparent; }
|
||||||
body { overscroll-behavior-y: none; min-height: 100vh; min-height: 100dvh; }
|
html { overscroll-behavior-y: none; }
|
||||||
|
body { min-height: 100vh; min-height: 100dvh; }
|
||||||
.header { padding-top: calc(1rem + env(safe-area-inset-top)); padding-left: calc(1.5rem + env(safe-area-inset-left)); padding-right: calc(1.5rem + env(safe-area-inset-right)); }
|
.header { padding-top: calc(1rem + env(safe-area-inset-top)); padding-left: calc(1.5rem + env(safe-area-inset-left)); padding-right: calc(1.5rem + env(safe-area-inset-right)); }
|
||||||
.page { padding-left: calc(1rem + env(safe-area-inset-left)); padding-right: calc(1rem + env(safe-area-inset-right)); padding-bottom: calc(4rem + env(safe-area-inset-bottom)); }
|
.page { padding-left: calc(1rem + env(safe-area-inset-left)); padding-right: calc(1rem + env(safe-area-inset-right)); padding-bottom: calc(4rem + env(safe-area-inset-bottom)); }
|
||||||
/* Let the page scroll when a finger starts on a chart; taps/long-press still show tooltips */
|
/* Let the page scroll when a finger starts on a chart; taps/long-press still show tooltips */
|
||||||
@@ -534,7 +541,7 @@ input, select, textarea { font-size: 16px; } /* stops iOS zooming in on focus */
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="sunHealthRow" class="two-col"><div id="sunCard"></div><div id="healthCard"></div></div>
|
<div id="sunHealthRow" class="two-col"><div id="sunCard"></div><div id="healthCard"></div><div id="nwsCard" style="grid-column:1/-1"></div></div>
|
||||||
</div><!-- /now -->
|
</div><!-- /now -->
|
||||||
|
|
||||||
<div class="tab-pane" data-pane="charts">
|
<div class="tab-pane" data-pane="charts">
|
||||||
@@ -550,8 +557,10 @@ input, select, textarea { font-size: 16px; } /* stops iOS zooming in on focus */
|
|||||||
<button class="range-btn" data-range="1y">1y</button>
|
<button class="range-btn" data-range="1y">1y</button>
|
||||||
<button class="range-btn" data-range="2y">2y</button>
|
<button class="range-btn" data-range="2y">2y</button>
|
||||||
<button class="range-btn" data-range="3y">3y</button>
|
<button class="range-btn" data-range="3y">3y</button>
|
||||||
|
<button class="range-btn" data-range="all" title="Everything recorded so far">All</button>
|
||||||
<button class="refresh-btn" id="refreshBtn">↻ Refresh</button>
|
<button class="refresh-btn" id="refreshBtn">↻ Refresh</button>
|
||||||
<button class="range-btn" id="yoyBtn" style="display:none" title="Overlay same period from 1 year ago">YoY</button>
|
<button class="range-btn" id="yoyBtn" style="display:none" title="Overlay same period from 1 year ago">YoY</button>
|
||||||
|
<button class="range-btn nws-toggle" id="nwsBtn" title="Overlay official NWS observations for your reference ZIP">NWS overlay</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="updated" id="updatedLine"></div>
|
<div class="updated" id="updatedLine"></div>
|
||||||
|
|
||||||
@@ -565,21 +574,8 @@ input, select, textarea { font-size: 16px; } /* stops iOS zooming in on focus */
|
|||||||
<div class="tab-pane" data-pane="rain" id="rainTab"><div class="status">Loading rain…</div></div>
|
<div class="tab-pane" data-pane="rain" id="rainTab"><div class="status">Loading rain…</div></div>
|
||||||
|
|
||||||
<div class="tab-pane" data-pane="records">
|
<div class="tab-pane" data-pane="records">
|
||||||
<!-- Records -->
|
<!-- Records (rendered by dashboard-records.js) -->
|
||||||
<details class="settings-panel" id="recordsPanel" style="margin-top:1.5rem">
|
<div id="recordsRoot" class="rec2"><div class="status">Loading records…</div></div>
|
||||||
<summary>All-Time Station Records</summary>
|
|
||||||
<div class="settings-body" id="recordsBody">
|
|
||||||
<div class="status">Loading records…</div>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<!-- Monthly Summary -->
|
|
||||||
<details class="settings-panel" id="monthlyPanel" style="margin-top:1rem">
|
|
||||||
<summary>Monthly Summary</summary>
|
|
||||||
<div class="settings-body" id="monthlyBody">
|
|
||||||
<div class="status">Loading…</div>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<div id="calendarCard" class="section-card" style="margin-top:1rem"><div class="status">Loading calendar…</div></div>
|
<div id="calendarCard" class="section-card" style="margin-top:1rem"><div class="status">Loading calendar…</div></div>
|
||||||
|
|
||||||
@@ -605,12 +601,28 @@ const RANGE_LABELS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let currentRange = '24h';
|
let currentRange = '24h';
|
||||||
|
|
||||||
|
// ── Phone-friendly charts ──
|
||||||
|
// Fewer points, no entry animation and a capped pixel ratio keep 11 charts cheap on an iPhone, and charts
|
||||||
|
// are only built while the Charts tab is actually on screen.
|
||||||
|
const NARROW_SCREEN = window.innerWidth < 700;
|
||||||
|
const MAX_PTS = NARROW_SCREEN ? 360 : 720;
|
||||||
|
let chartsDirty = true;
|
||||||
|
const chartsPaneActive = () => !!document.querySelector('.tab-pane[data-pane="charts"].active');
|
||||||
|
if (typeof Chart !== 'undefined') {
|
||||||
|
if (window.matchMedia('(pointer: coarse)').matches) Chart.defaults.animation = false;
|
||||||
|
Chart.defaults.devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
}
|
||||||
let charts = {};
|
let charts = {};
|
||||||
|
|
||||||
// ── Formatting helpers ──────────────────────────────────────────────────────
|
// ── Formatting helpers ──────────────────────────────────────────────────────
|
||||||
function fmtTime(ts, range) {
|
function fmtTime(ts, range) {
|
||||||
const d = new Date(ts);
|
const d = new Date(ts);
|
||||||
if (['7d','30d','90d','1y','2y','3y'].includes(range)) {
|
if (range === '2d') return d.toLocaleDateString('en-US', { weekday: 'short' }) + ' ' + d.toLocaleTimeString('en-US', { hour: 'numeric', hour12: true });
|
||||||
|
if (['1y','2y','3y','all'].includes(range)) {
|
||||||
|
return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
|
||||||
|
}
|
||||||
|
if (['7d','30d','90d'].includes(range)) {
|
||||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
}
|
}
|
||||||
return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
|
return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||||
@@ -648,7 +660,7 @@ function makeChart(canvasId, type, datasets, labels, yLabel, opts = {}) {
|
|||||||
interaction: { mode: 'index', intersect: false },
|
interaction: { mode: 'index', intersect: false },
|
||||||
plugins: {
|
plugins: {
|
||||||
zoom: {
|
zoom: {
|
||||||
zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x', onZoomComplete: ({ chart }) => markZoomed(chart) },
|
zoom: { wheel: { enabled: true, modifierKey: 'ctrl' }, pinch: { enabled: true }, mode: 'x', onZoomComplete: ({ chart }) => markZoomed(chart) },
|
||||||
pan: { enabled: true, mode: 'x', threshold: 10, onPanComplete: ({ chart }) => markZoomed(chart) },
|
pan: { enabled: true, mode: 'x', threshold: 10, onPanComplete: ({ chart }) => markZoomed(chart) },
|
||||||
},
|
},
|
||||||
legend: { display: datasets.length > 1, labels: { color: c.inkLight, font: { size: 11 }, boxWidth: 10 } },
|
legend: { display: datasets.length > 1, labels: { color: c.inkLight, font: { size: 11 }, boxWidth: 10 } },
|
||||||
@@ -778,8 +790,96 @@ function drawWindRose(canvas, readings) {
|
|||||||
})(t0);
|
})(t0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Pressure tendency (own range + change window) ────────────────────────────
|
||||||
|
let presTendRange = 'follow', presTendWin = 3;
|
||||||
|
try { presTendRange = localStorage.getItem('ptRange') || 'follow'; presTendWin = parseInt(localStorage.getItem('ptWin')) || 3; } catch {}
|
||||||
|
const presTendCache = {};
|
||||||
|
let presTendLast = { pts: null, range: null };
|
||||||
|
|
||||||
|
async function drawPresTend(pagePts, pageRange) {
|
||||||
|
const canvas = document.getElementById('cPresTend');
|
||||||
|
if (!canvas) return;
|
||||||
|
let pts = pagePts, range = pageRange;
|
||||||
|
if (presTendRange !== 'follow') {
|
||||||
|
range = presTendRange;
|
||||||
|
const hit = presTendCache[range];
|
||||||
|
if (hit && Date.now() - hit.at < 120000) pts = hit.data;
|
||||||
|
else {
|
||||||
|
try {
|
||||||
|
const bucket = ['7d', '30d', 'all'].includes(range) ? '&points=' + MAX_PTS : '';
|
||||||
|
const r = await fetch(`/api/station/history?range=${range}${bucket}`);
|
||||||
|
pts = r.ok ? await r.json() : [];
|
||||||
|
} catch { pts = []; }
|
||||||
|
presTendCache[range] = { at: Date.now(), data: pts };
|
||||||
|
}
|
||||||
|
if (document.getElementById('cPresTend') !== canvas) return; // charts were re-rendered meanwhile
|
||||||
|
}
|
||||||
|
if (pts.length > MAX_PTS * 1.25) { const step = Math.ceil(pts.length / MAX_PTS); pts = pts.filter((_, i) => i % step === 0); }
|
||||||
|
// Bucketed (long) ranges can't resolve changes shorter than one bucket: widen the window to fit
|
||||||
|
const bucketMs = pts.length > 1 ? (pts[pts.length - 1].dateutc - pts[0].dateutc) / (pts.length - 1) : 0;
|
||||||
|
const effHours = Math.max(presTendWin, Math.ceil(bucketMs / 3600000));
|
||||||
|
const winMs = effHours * 3600000;
|
||||||
|
const c = chartColors();
|
||||||
|
const tend = pts.map(r => {
|
||||||
|
const t = r.dateutc - winMs;
|
||||||
|
if (!pts.length || t < pts[0].dateutc) return null;
|
||||||
|
let lo = 0, hi = pts.length - 1;
|
||||||
|
while (lo < hi) { const m = (lo + hi) >> 1; if (pts[m].dateutc < t) lo = m + 1; else hi = m; }
|
||||||
|
const base = pts[lo].baromrelin;
|
||||||
|
return r.baromrelin != null && base != null ? +(r.baromrelin - base).toFixed(3) : null;
|
||||||
|
});
|
||||||
|
const ds = barDataset(`${effHours}h change`, tend, c.cool);
|
||||||
|
ds.backgroundColor = tend.map(v => v == null ? 'transparent' : (v >= 0 ? c.cool + 'bb' : c.accent + 'bb'));
|
||||||
|
ds.borderWidth = 0;
|
||||||
|
makeChart('cPresTend', 'bar', [ds], pts.map(r => fmtTime(r.dateutc, range)), `inHg / ${effHours}h`);
|
||||||
|
const title = document.getElementById('ptTitle');
|
||||||
|
if (title) title.textContent = `Pressure Tendency · ${effHours}-hour change (inHg)${effHours !== presTendWin ? ' — widened to fit this range' : ''}`;
|
||||||
|
if (!tend.some(v => v != null) && title) title.textContent += ' — not enough data for this window';
|
||||||
|
}
|
||||||
|
|
||||||
|
function initPresTend(pagePts, pageRange) {
|
||||||
|
presTendLast = { pts: pagePts, range: pageRange };
|
||||||
|
const selRange = document.getElementById('ptRange'), selWin = document.getElementById('ptWin');
|
||||||
|
if (selRange) {
|
||||||
|
selRange.value = presTendRange;
|
||||||
|
selRange.addEventListener('change', () => { presTendRange = selRange.value; try { localStorage.setItem('ptRange', presTendRange); } catch {} drawPresTend(presTendLast.pts, presTendLast.range); });
|
||||||
|
}
|
||||||
|
if (selWin) {
|
||||||
|
selWin.value = String(presTendWin);
|
||||||
|
selWin.addEventListener('change', () => { presTendWin = parseInt(selWin.value); try { localStorage.setItem('ptWin', presTendWin); } catch {} drawPresTend(presTendLast.pts, presTendLast.range); });
|
||||||
|
}
|
||||||
|
drawPresTend(pagePts, pageRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NWS overlay (official observations for the reference ZIP) ─────────────────
|
||||||
|
let nwsOverlay = true;
|
||||||
|
try { nwsOverlay = localStorage.getItem('nwsOverlay') !== '0'; } catch {}
|
||||||
|
const NWS_COLOR = '#a78bfa';
|
||||||
|
|
||||||
|
// Map sparse NWS observations onto the chart's points (averaging when several land on one point)
|
||||||
|
function nwsAlign(pts, nws, key) {
|
||||||
|
if (!nws || !nws.length || !pts.length) return null;
|
||||||
|
const sum = new Array(pts.length).fill(0), cnt = new Array(pts.length).fill(0);
|
||||||
|
const span = pts.length > 1 ? (pts[pts.length - 1].dateutc - pts[0].dateutc) / (pts.length - 1) : 3600000;
|
||||||
|
const tol = Math.max(span / 2, 35 * 60000);
|
||||||
|
for (const o of nws) {
|
||||||
|
const v = o[key]; if (v == null) continue;
|
||||||
|
let lo = 0, hi = pts.length - 1;
|
||||||
|
while (lo < hi) { const m = (lo + hi) >> 1; if (pts[m].dateutc < o.ts) lo = m + 1; else hi = m; }
|
||||||
|
let idx = lo;
|
||||||
|
if (idx > 0 && Math.abs(pts[idx - 1].dateutc - o.ts) < Math.abs(pts[idx].dateutc - o.ts)) idx--;
|
||||||
|
if (Math.abs(pts[idx].dateutc - o.ts) > tol) continue;
|
||||||
|
sum[idx] += v; cnt[idx]++;
|
||||||
|
}
|
||||||
|
const out = sum.map((t, i) => (cnt[i] ? +(t / cnt[i]).toFixed(2) : null));
|
||||||
|
return out.some(v => v != null) ? out : null;
|
||||||
|
}
|
||||||
|
function nwsDataset(label, data, dashed) {
|
||||||
|
return { label, data, borderColor: NWS_COLOR, backgroundColor: NWS_COLOR, borderWidth: 1.6, borderDash: dashed ? [4, 3] : [], pointRadius: 3, pointHoverRadius: 5, tension: 0, spanGaps: true, fill: false };
|
||||||
|
}
|
||||||
|
|
||||||
// ── Render charts ────────────────────────────────────────────────────────────
|
// ── Render charts ────────────────────────────────────────────────────────────
|
||||||
function renderCharts(readings, range, yoy = null) {
|
function renderCharts(readings, range, yoy = null, nws = null) {
|
||||||
const area = document.getElementById('chartsArea');
|
const area = document.getElementById('chartsArea');
|
||||||
if (!readings.length) {
|
if (!readings.length) {
|
||||||
area.innerHTML = '<div class="status">No recorded data for this range yet. Data is collected every minute while the station is active.</div>';
|
area.innerHTML = '<div class="status">No recorded data for this range yet. Data is collected every minute while the station is active.</div>';
|
||||||
@@ -791,8 +891,8 @@ function renderCharts(readings, range, yoy = null) {
|
|||||||
|
|
||||||
// downsample for 30d to keep chart manageable
|
// downsample for 30d to keep chart manageable
|
||||||
let pts = readings;
|
let pts = readings;
|
||||||
if (readings.length > 720) {
|
if (readings.length > MAX_PTS) {
|
||||||
const step = Math.ceil(readings.length / 720);
|
const step = Math.ceil(readings.length / MAX_PTS);
|
||||||
pts = readings.filter((_, i) => i % step === 0);
|
pts = readings.filter((_, i) => i % step === 0);
|
||||||
}
|
}
|
||||||
const lbls = pts.map(r => fmtTime(r.dateutc, range));
|
const lbls = pts.map(r => fmtTime(r.dateutc, range));
|
||||||
@@ -834,7 +934,15 @@ function renderCharts(readings, range, yoy = null) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chart-card wide" id="cardPresTend">
|
<div class="chart-card wide" id="cardPresTend">
|
||||||
<div class="chart-title">Pressure Tendency · 3-hour change (inHg)</div>
|
<div class="chart-title"><span id="ptTitle">Pressure Tendency (inHg)</span>
|
||||||
|
<span class="chart-ctl">
|
||||||
|
<label>Range <select id="ptRange" aria-label="Pressure tendency range">
|
||||||
|
<option value="follow">Same as page</option><option value="6h">6 hours</option><option value="24h">24 hours</option>
|
||||||
|
<option value="2d">2 days</option><option value="7d">7 days</option><option value="30d">30 days</option><option value="all">All time</option></select></label>
|
||||||
|
<label>Change over <select id="ptWin" aria-label="Pressure change window">
|
||||||
|
<option value="1">1 hour</option><option value="3">3 hours</option><option value="6">6 hours</option>
|
||||||
|
<option value="12">12 hours</option><option value="24">24 hours</option></select></label>
|
||||||
|
</span></div>
|
||||||
<div class="chart-wrap" style="height:140px"><canvas id="cPresTend"></canvas></div>
|
<div class="chart-wrap" style="height:140px"><canvas id="cPresTend"></canvas></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -877,28 +985,28 @@ function renderCharts(readings, range, yoy = null) {
|
|||||||
}
|
}
|
||||||
tempDatasets.push(lineDataset('Temp °F', field(pts, 'tempf'), c.accent));
|
tempDatasets.push(lineDataset('Temp °F', field(pts, 'tempf'), c.accent));
|
||||||
tempDatasets.push(lineDataset('Feels Like', field(pts, 'feelsLike'), c.cool, true));
|
tempDatasets.push(lineDataset('Feels Like', field(pts, 'feelsLike'), c.cool, true));
|
||||||
|
const nwsLab = nws && nws.length ? 'NWS ' + (nws[nws.length - 1].station || '') : 'NWS';
|
||||||
|
const addNws = (sets, key, suffix, dashed) => { const a = nwsAlign(pts, nws, key); if (a) sets.push(nwsDataset(`${nwsLab} ${suffix}`, a, dashed)); };
|
||||||
|
addNws(tempDatasets, 'tempf', 'temp');
|
||||||
if (yoy?.prior?.length) {
|
if (yoy?.prior?.length) {
|
||||||
const yoyPts = yoy.prior.length > 720 ? yoy.prior.filter((_, i) => i % Math.ceil(yoy.prior.length / 720) === 0) : yoy.prior;
|
const yoyPts = yoy.prior.length > MAX_PTS ? yoy.prior.filter((_, i) => i % Math.ceil(yoy.prior.length / MAX_PTS) === 0) : yoy.prior;
|
||||||
tempDatasets.push({ ...lineDataset('Last Year', yoyPts.map(r => r.tempf), c.accent + '55', true), borderWidth: 1 });
|
tempDatasets.push({ ...lineDataset('Last Year', yoyPts.map(r => r.tempf), c.accent + '55', true), borderWidth: 1 });
|
||||||
}
|
}
|
||||||
makeChart('cTemp', 'line', tempDatasets, lbls, '°F', { plugins: { legend: { labels: { filter: i => !['Max', 'Min'].includes(i.text) } } } });
|
makeChart('cTemp', 'line', tempDatasets, lbls, '°F', { plugins: { legend: { labels: { filter: i => !['Max', 'Min'].includes(i.text) } } } });
|
||||||
|
|
||||||
makeChart('cHum', 'line',
|
const humSets = [lineDataset('Humidity %', field(pts, 'humidity'), c.cool), lineDataset('Dew Point °F', field(pts, 'dewPoint'), c.accent, true)];
|
||||||
[lineDataset('Humidity %', field(pts, 'humidity'), c.cool),
|
addNws(humSets, 'humidity', 'humidity'); addNws(humSets, 'dewPoint', 'dew pt', true);
|
||||||
lineDataset('Dew Point °F', field(pts, 'dewPoint'), c.accent, true)],
|
makeChart('cHum', 'line', humSets, lbls, null);
|
||||||
lbls, null);
|
|
||||||
|
|
||||||
makeChart('cWind', 'line',
|
const windSets = [lineDataset('Wind mph', field(pts, 'windspeedmph'), c.cool), lineDataset('Gust mph', field(pts, 'windgustmph'), c.accent, true)];
|
||||||
[lineDataset('Wind mph', field(pts, 'windspeedmph'), c.cool),
|
addNws(windSets, 'windspeedmph', 'wind'); addNws(windSets, 'windgustmph', 'gust', true);
|
||||||
lineDataset('Gust mph', field(pts, 'windgustmph'), c.accent, true)],
|
makeChart('cWind', 'line', windSets, lbls, 'mph');
|
||||||
lbls, 'mph');
|
|
||||||
|
|
||||||
const hasPres = pts.some(r => r.baromrelin != null);
|
const hasPres = pts.some(r => r.baromrelin != null);
|
||||||
if (hasPres) {
|
if (hasPres) {
|
||||||
makeChart('cPres', 'line',
|
const presSets = [lineDataset('Rel. Pressure', field(pts, 'baromrelin'), c.accent), lineDataset('Abs. Pressure', field(pts, 'baromabsin'), c.cool, true)];
|
||||||
[lineDataset('Rel. Pressure', field(pts, 'baromrelin'), c.accent),
|
addNws(presSets, 'baromrelin', 'pressure');
|
||||||
lineDataset('Abs. Pressure', field(pts, 'baromabsin'), c.cool, true)],
|
makeChart('cPres', 'line', presSets, lbls, 'inHg');
|
||||||
lbls, 'inHg');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
makeChart('cRainRate', 'bar',
|
makeChart('cRainRate', 'bar',
|
||||||
@@ -920,21 +1028,8 @@ function renderCharts(readings, range, yoy = null) {
|
|||||||
}
|
}
|
||||||
makeChart('cSolar', 'line', solarSets, lbls, 'W/m²', { scales: { y: { min: 0 } } });
|
makeChart('cSolar', 'line', solarSets, lbls, 'W/m²', { scales: { y: { min: 0 } } });
|
||||||
|
|
||||||
// Pressure tendency: change vs 3 hours earlier (what forecasters watch)
|
// Pressure tendency: change vs N hours earlier (what forecasters watch); has its own range + window
|
||||||
if (hasPres) {
|
if (hasPres) initPresTend(pts, range);
|
||||||
const tend = pts.map(r => {
|
|
||||||
const t = r.dateutc - 3 * 3600000;
|
|
||||||
let lo = 0, hi = pts.length - 1;
|
|
||||||
if (t < pts[0].dateutc) return null;
|
|
||||||
while (lo < hi) { const m = (lo + hi) >> 1; if (pts[m].dateutc < t) lo = m + 1; else hi = m; }
|
|
||||||
const base = pts[lo].baromrelin;
|
|
||||||
return r.baromrelin != null && base != null ? +(r.baromrelin - base).toFixed(3) : null;
|
|
||||||
});
|
|
||||||
const ds = barDataset('3h change', tend, c.cool);
|
|
||||||
ds.backgroundColor = tend.map(v => v == null ? 'transparent' : (v >= 0 ? c.cool + 'bb' : c.accent + 'bb'));
|
|
||||||
ds.borderWidth = 0;
|
|
||||||
makeChart('cPresTend', 'bar', [ds], lbls, 'inHg / 3h');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Indoor vs outdoor
|
// Indoor vs outdoor
|
||||||
if (pts.some(r => r.tempinf != null)) {
|
if (pts.some(r => r.tempinf != null)) {
|
||||||
@@ -1068,19 +1163,22 @@ async function updateLatest() {
|
|||||||
|
|
||||||
// ── Load history ─────────────────────────────────────────────────────────────
|
// ── Load history ─────────────────────────────────────────────────────────────
|
||||||
async function loadHistory(range) {
|
async function loadHistory(range) {
|
||||||
|
if (!chartsPaneActive()) { chartsDirty = true; return; } // build charts when the Charts tab is opened
|
||||||
|
chartsDirty = false;
|
||||||
const area = document.getElementById('chartsArea');
|
const area = document.getElementById('chartsArea');
|
||||||
area.innerHTML = '<div class="status" id="loadingMsg">Loading…</div>';
|
area.innerHTML = '<div class="status" id="loadingMsg">Loading…</div>';
|
||||||
try {
|
try {
|
||||||
const pts = ['7d','30d','90d','1y','2y','3y'].includes(range) ? '&points=720' : '';
|
const pts = ['7d','30d','90d','1y','2y','3y','all'].includes(range) ? '&points=' + MAX_PTS : '';
|
||||||
|
const nwsP = nwsOverlay ? fetch(`/api/nws/history?range=${range}`).then(x => (x.ok ? x.json() : [])).catch(() => []) : Promise.resolve([]);
|
||||||
const r = await fetch(`/api/station/history?range=${range}${pts}`);
|
const r = await fetch(`/api/station/history?range=${range}${pts}`);
|
||||||
if (r.status === 401) { location.href = '/dashboard/login'; return; }
|
if (r.status === 401) { location.href = '/dashboard/login'; return; }
|
||||||
if (!r.ok) throw new Error('Failed');
|
if (!r.ok) throw new Error('Failed');
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
renderCharts(data, range, yoyEnabled ? yoyData : null);
|
renderCharts(data, range, yoyEnabled ? yoyData : null, await nwsP);
|
||||||
const line = document.getElementById('updatedLine');
|
const line = document.getElementById('updatedLine');
|
||||||
if (line) {
|
if (line) {
|
||||||
const next = new Date(Date.now() + 120000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
const next = new Date(Date.now() + 120000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
line.textContent = `${data.length}${data.some(d => d.n) ? ' time buckets' : ' readings'} · pinch / scroll to zoom · double-tap to reset · updated ${new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} · next refresh ${next}`;
|
line.textContent = `${data.length}${data.some(d => d.n) ? ' time buckets' : ' readings'} · pinch or Ctrl+scroll to zoom · double-click to reset · updated ${new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} · next refresh ${next}`;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
area.innerHTML = `<div class="status error">Failed to load station history. ${e.message}</div>`;
|
area.innerHTML = `<div class="status error">Failed to load station history. ${e.message}</div>`;
|
||||||
@@ -1176,11 +1274,41 @@ function renderRecordPeriods(d, ri) {
|
|||||||
['Windiest Day', nv(n.windiestDay, v => v.toFixed(1) + ' mph'), n.windiestDay],
|
['Windiest Day', nv(n.windiestDay, v => v.toFixed(1) + ' mph'), n.windiestDay],
|
||||||
['Wettest Day', nv(n.wettestDay, v => v.toFixed(2) + '"'), n.wettestDay],
|
['Wettest Day', nv(n.wettestDay, v => v.toFixed(2) + '"'), n.wettestDay],
|
||||||
].map(([l, v, o]) => ri(l, v, o ? dayDate(o.day) : null)).join('') + '</div>';
|
].map(([l, v, o]) => ri(l, v, o ? dayDate(o.day) : null)).join('') + '</div>';
|
||||||
if (d.wettestMonth || d.wettestYear) {
|
// Months & years: the period itself (e.g. "October 2026") is the headline of each card
|
||||||
html += '<div class="rec-h">Wettest periods</div><div class="records-grid">'
|
const now = new Date();
|
||||||
+ (d.wettestMonth ? ri('Wettest Month', d.wettestMonth.rain.toFixed(2) + '"', dayDate(d.wettestMonth.month + '-15')) : '')
|
const curMonth = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0'), curYear = String(now.getFullYear());
|
||||||
+ (d.wettestYear ? ri('Wettest Year', d.wettestYear.rain.toFixed(2) + '"', dayDate(d.wettestYear.year + '-07-01')) : '')
|
const longMonth = (m) => new Date(m + '-15T12:00:00Z').toLocaleDateString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' });
|
||||||
+ '</div>';
|
const rp = (label, rec, fmtVal, isMonth, waitMsg) => {
|
||||||
|
if (!rec) return `<div class="record-item"><div class="record-label">${label}</div><div class="record-val">—</div><div class="record-period">${waitMsg || 'Not enough data yet'}</div></div>`;
|
||||||
|
const current = rec.key === (isMonth ? curMonth : curYear);
|
||||||
|
return `<div class="record-item${current ? ' record-best' : ''}"><div class="record-label">${label}</div><div class="record-val">${fmtVal(rec.value)}</div>
|
||||||
|
<div class="record-period">${isMonth ? longMonth(rec.key) : rec.key}</div><div class="record-date">${rec.days} day${rec.days === 1 ? '' : 's'} recorded${rec.partial ? ' · partial' : ''}</div></div>`;
|
||||||
|
};
|
||||||
|
const P = JSON.parse(JSON.stringify(d.periods || { months: {}, years: {} }));
|
||||||
|
// An extreme and its opposite landing on the same period just means there's nothing to compare yet
|
||||||
|
const same = (a, b) => a && b && a.key === b.key;
|
||||||
|
const waitM = 'Needs another full month', waitY = 'Needs another year of data';
|
||||||
|
let wM = {}, wY = {};
|
||||||
|
if (same(P.months.hottest, P.months.coldest)) { P.months.coldest = null; wM.coldest = waitM; }
|
||||||
|
if (same(P.months.wettest, P.months.driest)) { P.months.driest = null; wM.driest = waitM; }
|
||||||
|
if (same(P.years.hottest, P.years.coldest)) { P.years.coldest = null; wY.coldest = waitY; }
|
||||||
|
const deg = (v) => v.toFixed(1) + '°F', inch = (v) => v.toFixed(2) + '"', mph = (v) => v.toFixed(0) + ' mph';
|
||||||
|
html += '<div class="rec-h">Months <span class="rec-sub">(avg temp = mean of all readings; partial months are flagged)</span></div><div class="records-grid">'
|
||||||
|
+ rp('Wettest Month', P.months.wettest, inch, true) + rp('Driest Month', P.months.driest, inch, true, wM.driest)
|
||||||
|
+ rp('Hottest Month', P.months.hottest, deg, true) + rp('Coldest Month', P.months.coldest, deg, true, wM.coldest)
|
||||||
|
+ rp('Windiest Month', P.months.windiest, mph, true) + '</div>';
|
||||||
|
html += '<div class="rec-h">Years</div><div class="records-grid">'
|
||||||
|
+ rp('Wettest Year', P.years.wettest, inch, false) + rp('Hottest Year', P.years.hottest, deg, false)
|
||||||
|
+ rp('Coldest Year', P.years.coldest, deg, false, wY.coldest) + rp('Windiest Year', P.years.windiest, mph, false) + '</div>';
|
||||||
|
|
||||||
|
// Records for each calendar month, whichever year they happened in
|
||||||
|
if (d.calendarMonths && d.calendarMonths.some(c => c.years)) {
|
||||||
|
const mn = (i) => new Date(Date.UTC(2001, i, 15)).toLocaleDateString('en-US', { month: 'long', timeZone: 'UTC' });
|
||||||
|
const cell = (x, fmtV) => x ? `<b>${fmtV(x.value)}</b><span class="rec-yr">${x.day ? new Date(x.day + 'T12:00:00Z').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }) : ''}</span>` : '<span class="rec-yr">—</span>';
|
||||||
|
const rows = d.calendarMonths.map((c, i) => `<tr${c.years ? '' : ' class="empty"'}><td><b>${mn(i)}</b><span class="rec-yr">${c.years ? c.years + ' year' + (c.years > 1 ? 's' : '') : 'no data'}</span></td>
|
||||||
|
<td>${cell(c.hi, deg)}</td><td>${cell(c.lo, deg)}</td><td>${cell(c.wettestDay, inch)}</td>
|
||||||
|
<td>${c.wettestTotal ? `<b>${inch(c.wettestTotal.value)}</b><span class="rec-yr">${longMonth(c.wettestTotal.month)}</span>` : '<span class="rec-yr">—</span>'}</td><td>${cell(c.gust, mph)}</td></tr>`).join('');
|
||||||
|
html += `<div class="rec-h">Records by calendar month <span class="rec-sub">(any year)</span></div><div class="rec-scroll"><table class="rec-table rec-cal"><tr><th>Month</th><th>Record high</th><th>Record low</th><th>Wettest day</th><th>Wettest month</th><th>Peak gust</th></tr>${rows}</table></div>`;
|
||||||
}
|
}
|
||||||
const mname = (m) => new Date(m + '-15T12:00:00Z').toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' });
|
const mname = (m) => new Date(m + '-15T12:00:00Z').toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' });
|
||||||
const row = (label, a) => `<tr><td>${label}</td><td>${a.hi != null ? a.hi.toFixed(1) : '—'}</td><td>${a.lo != null ? a.lo.toFixed(1) : '—'}</td><td>${a.gust != null ? a.gust.toFixed(0) : '—'}</td><td>${a.rain.toFixed(2)}</td><td>${a.days}</td></tr>`;
|
const row = (label, a) => `<tr><td>${label}</td><td>${a.hi != null ? a.hi.toFixed(1) : '—'}</td><td>${a.lo != null ? a.lo.toFixed(1) : '—'}</td><td>${a.gust != null ? a.gust.toFixed(0) : '—'}</td><td>${a.rain.toFixed(2)}</td><td>${a.days}</td></tr>`;
|
||||||
@@ -1240,6 +1368,17 @@ async function loadRecords() {
|
|||||||
let yoyEnabled = false;
|
let yoyEnabled = false;
|
||||||
let yoyData = null;
|
let yoyData = null;
|
||||||
|
|
||||||
|
const nwsBtn = document.getElementById('nwsBtn');
|
||||||
|
if (nwsBtn) {
|
||||||
|
nwsBtn.classList.toggle('active', nwsOverlay);
|
||||||
|
nwsBtn.addEventListener('click', () => {
|
||||||
|
nwsOverlay = !nwsOverlay;
|
||||||
|
try { localStorage.setItem('nwsOverlay', nwsOverlay ? '1' : '0'); } catch {}
|
||||||
|
nwsBtn.classList.toggle('active', nwsOverlay);
|
||||||
|
loadHistory(currentRange);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const yoyBtn = document.getElementById('yoyBtn');
|
const yoyBtn = document.getElementById('yoyBtn');
|
||||||
yoyBtn.addEventListener('click', async () => {
|
yoyBtn.addEventListener('click', async () => {
|
||||||
yoyEnabled = !yoyEnabled;
|
yoyEnabled = !yoyEnabled;
|
||||||
@@ -1511,6 +1650,91 @@ const TAG_LABELS = {
|
|||||||
|
|
||||||
let alertHistoryDays = 30;
|
let alertHistoryDays = 30;
|
||||||
|
|
||||||
|
// Plain-language helpers are shared with the weather app (alert-info.js)
|
||||||
|
const { meaning: alertMeaning, firstSentence: ahFirstSentence, area: ahArea, when: ahWhen } = window.AlertInfo;
|
||||||
|
const ahState = { items: [], open: new Set(), loading: new Set(), gone: new Set(), queue: [], running: false };
|
||||||
|
const SEV_ORDER = { Extreme: 'sev-extreme', Severe: 'sev-severe', Moderate: 'sev-moderate', Minor: 'sev-minor' };
|
||||||
|
|
||||||
|
function ahRow(it, i) {
|
||||||
|
const isNws = it.type === 'nws';
|
||||||
|
const time = new Date(it.ts).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
const badge = `<span class="ah-badge ${isNws ? 'nws' : 'threshold'}">${isNws ? 'NWS' : 'Threshold'}</span>`;
|
||||||
|
const label = isNws ? (it.event || 'Alert') : (TAG_LABELS[it.tag] || it.tag || '—');
|
||||||
|
const open = ahState.open.has(i);
|
||||||
|
let what, meta = [];
|
||||||
|
if (isNws) {
|
||||||
|
what = it.description ? ahFirstSentence(it.description) : alertMeaning(it.event) || it.headline || '';
|
||||||
|
if (it.areaDesc) {
|
||||||
|
const parts = String(it.areaDesc).split(';').map(x => x.trim()).filter(Boolean);
|
||||||
|
meta.push(`📍 ${esc(parts.length > 4 ? parts.slice(0, 4).join(', ') + ' +' + (parts.length - 4) + ' more' : ahArea(it.areaDesc))}`);
|
||||||
|
}
|
||||||
|
else if (ahState.loading.has(it.alertId)) meta.push('📍 looking up area…');
|
||||||
|
if (it.severity && it.severity !== 'Unknown') meta.push(`<span class="ah-sev ${SEV_ORDER[it.severity] || ''}">${esc(it.severity)}</span>`);
|
||||||
|
if (it.effective || it.expires) meta.push(`🕑 ${esc(ahWhen(it.effective))}${it.expires ? ' → ' + esc(ahWhen(it.expires)) : ''}`);
|
||||||
|
} else {
|
||||||
|
what = it.body || '';
|
||||||
|
}
|
||||||
|
let full = '';
|
||||||
|
if (open) {
|
||||||
|
if (isNws) {
|
||||||
|
const meaning = alertMeaning(it.event);
|
||||||
|
const loading = ahState.loading.has(it.alertId);
|
||||||
|
full = `<div class="ah-full">
|
||||||
|
${meaning ? `<p class="ah-meaning"><b>What this means:</b> ${esc(meaning)}</p>` : ''}
|
||||||
|
${it.headline ? `<p><b>Headline:</b> ${esc(it.headline)}</p>` : ''}
|
||||||
|
${it.areaDesc ? `<p><b>Area:</b> ${esc(ahArea(it.areaDesc))}</p>` : ''}
|
||||||
|
${it.description ? `<p class="ah-desc"><b>Details:</b>\n${esc(it.description)}</p>` : loading ? '<p class="ah-note">Fetching the full text from the National Weather Service…</p>' : ahState.gone.has(it.alertId) ? '<p class="ah-note">The National Weather Service no longer provides the full text for this older alert.</p>' : ''}
|
||||||
|
${it.instruction ? `<p class="ah-desc"><b>What to do:</b>\n${esc(it.instruction)}</p>` : ''}
|
||||||
|
<p class="ah-note">${[it.severity && 'Severity: ' + it.severity, it.urgency && 'Urgency: ' + it.urgency, it.certainty && 'Certainty: ' + it.certainty, it.senderName && 'Issued by ' + it.senderName].filter(Boolean).map(esc).join(' · ')}</p>
|
||||||
|
</div>`;
|
||||||
|
} else full = `<div class="ah-full"><p>${esc(it.body || '')}</p></div>`;
|
||||||
|
}
|
||||||
|
return `<tr class="ah-row${open ? ' open' : ''}" data-i="${i}" title="Click for the full alert">
|
||||||
|
<td style="white-space:nowrap;color:var(--ink-light);font-size:0.78rem">${time}</td>
|
||||||
|
<td>${badge}</td>
|
||||||
|
<td style="font-weight:600">${esc(label)}</td>
|
||||||
|
<td><div class="ah-what">${esc(what)}</div>${meta.length ? `<div class="ah-meta">${meta.join(' · ')}</div>` : ''}${full}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ahRender() {
|
||||||
|
const tbody = document.getElementById('ahBody');
|
||||||
|
if (!tbody) return;
|
||||||
|
tbody.innerHTML = ahState.items.map(ahRow).join('');
|
||||||
|
tbody.querySelectorAll('.ah-full').forEach((f) => f.addEventListener('click', (e) => e.stopPropagation())); // allow selecting/copying text
|
||||||
|
tbody.querySelectorAll('.ah-row').forEach((row) => row.addEventListener('click', () => {
|
||||||
|
const i = +row.dataset.i, it = ahState.items[i];
|
||||||
|
if (ahState.open.has(i)) ahState.open.delete(i); else { ahState.open.add(i); ahLookup(it, true); }
|
||||||
|
ahRender();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch missing text/area for older NWS entries (cached on the server), a few at a time
|
||||||
|
function ahApply(id, detail) {
|
||||||
|
for (const it of ahState.items) if (it.alertId === id) for (const [k, v] of Object.entries(detail || {})) if (v != null && it[k] == null) it[k] = v;
|
||||||
|
}
|
||||||
|
function ahLookup(it, urgent) {
|
||||||
|
if (!it || it.type !== 'nws' || !it.alertId || (it.description && it.areaDesc) || ahState.gone.has(it.alertId) || ahState.loading.has(it.alertId)) return;
|
||||||
|
if (urgent) ahState.queue.unshift(it.alertId); else ahState.queue.push(it.alertId);
|
||||||
|
ahState.loading.add(it.alertId);
|
||||||
|
ahPump();
|
||||||
|
}
|
||||||
|
async function ahPump() {
|
||||||
|
if (ahState.running) return;
|
||||||
|
ahState.running = true;
|
||||||
|
while (ahState.queue.length) {
|
||||||
|
const id = ahState.queue.shift();
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/dashboard/alert-detail?id=' + encodeURIComponent(id));
|
||||||
|
if (r.ok) ahApply(id, await r.json()); else ahState.gone.add(id);
|
||||||
|
} catch { ahState.gone.add(id); }
|
||||||
|
ahState.loading.delete(id);
|
||||||
|
ahRender();
|
||||||
|
await new Promise((res) => setTimeout(res, 120));
|
||||||
|
}
|
||||||
|
ahState.running = false;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAlertHistory(days) {
|
async function loadAlertHistory(days) {
|
||||||
alertHistoryDays = days;
|
alertHistoryDays = days;
|
||||||
const body = document.getElementById('alertHistoryBody');
|
const body = document.getElementById('alertHistoryBody');
|
||||||
@@ -1519,7 +1743,7 @@ async function loadAlertHistory(days) {
|
|||||||
const r = await fetch(`/api/dashboard/alert-history?days=${days}`);
|
const r = await fetch(`/api/dashboard/alert-history?days=${days}`);
|
||||||
if (!r.ok) throw new Error();
|
if (!r.ok) throw new Error();
|
||||||
const items = await r.json();
|
const items = await r.json();
|
||||||
const dayBtns = body.querySelectorAll?.('.range-btn') || [];
|
ahState.items = items; ahState.open = new Set(); ahState.queue = []; ahState.loading = new Set(); ahState.gone = new Set();
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<div class="alert-history-bar">
|
<div class="alert-history-bar">
|
||||||
<span style="font-size:0.82rem;color:var(--ink-light)">Range:</span>
|
<span style="font-size:0.82rem;color:var(--ink-light)">Range:</span>
|
||||||
@@ -1529,35 +1753,16 @@ async function loadAlertHistory(days) {
|
|||||||
${items.length === 0
|
${items.length === 0
|
||||||
? `<div class="ah-empty">No alerts logged in the last ${days} days.</div>`
|
? `<div class="ah-empty">No alerts logged in the last ${days} days.</div>`
|
||||||
: `<div class="alert-history-table-wrap"><table class="alert-history-table">
|
: `<div class="alert-history-table-wrap"><table class="alert-history-table">
|
||||||
<thead><tr><th>Time</th><th>Type</th><th>Event</th><th>Details</th></tr></thead>
|
<thead><tr><th>Time</th><th>Type</th><th>Alert</th><th>What it's for · where · when</th></tr></thead>
|
||||||
<tbody>${items.map(it => {
|
<tbody id="ahBody"></tbody>
|
||||||
const isNws = it.type === 'nws';
|
</table></div>`}`;
|
||||||
const badge = `<span class="ah-badge ${isNws ? 'nws' : 'threshold'}">${isNws ? 'NWS' : 'Threshold'}</span>`;
|
body.querySelectorAll('[data-ahdays]').forEach(btn => btn.addEventListener('click', () => loadAlertHistory(parseInt(btn.dataset.ahdays))));
|
||||||
const label = isNws ? (it.event || 'Alert') : (TAG_LABELS[it.tag] || it.tag || '—');
|
if (items.length) {
|
||||||
const detail = isNws ? (it.headline || '') : (it.body || '');
|
ahRender();
|
||||||
const full = isNws ? [it.headline, it.description, it.instruction && ('What to do: ' + it.instruction)].filter(Boolean).join('\n\n') : detail;
|
// Look up missing details in the background, newest first
|
||||||
const time = new Date(it.ts).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
const seen = new Set();
|
||||||
return `<tr class="ah-row" data-full="${esc(full)}" title="Click to expand">
|
for (const it of items) if (it.type === 'nws' && it.alertId && !(it.description && it.areaDesc) && !seen.has(it.alertId)) { seen.add(it.alertId); ahLookup(it, false); }
|
||||||
<td style="white-space:nowrap;color:var(--ink-light);font-size:0.78rem">${time}</td>
|
}
|
||||||
<td>${badge}</td>
|
|
||||||
<td style="font-weight:600">${label}</td>
|
|
||||||
<td><div class="ah-body">${esc(detail)}</div></td>
|
|
||||||
</tr>`;
|
|
||||||
}).join('')}</tbody>
|
|
||||||
</table></div>`
|
|
||||||
}`;
|
|
||||||
body.querySelectorAll('.ah-row').forEach(row => {
|
|
||||||
row.addEventListener('click', () => {
|
|
||||||
const el = row.querySelector('.ah-body');
|
|
||||||
const open = row.classList.toggle('open');
|
|
||||||
el.textContent = open ? row.dataset.full : el.dataset.short;
|
|
||||||
});
|
|
||||||
const el = row.querySelector('.ah-body');
|
|
||||||
el.dataset.short = el.textContent;
|
|
||||||
});
|
|
||||||
body.querySelectorAll('[data-ahdays]').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => loadAlertHistory(parseInt(btn.dataset.ahdays)));
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
document.getElementById('alertHistoryBody').innerHTML = '<div class="status error">Failed to load alert history.</div>';
|
document.getElementById('alertHistoryBody').innerHTML = '<div class="status error">Failed to load alert history.</div>';
|
||||||
}
|
}
|
||||||
@@ -1596,5 +1801,6 @@ setInterval(updateTrends, 120000);
|
|||||||
setInterval(loadAlerts, 5 * 60 * 1000);
|
setInterval(loadAlerts, 5 * 60 * 1000);
|
||||||
</script>
|
</script>
|
||||||
<script src="/dashboard-ui.js"></script>
|
<script src="/dashboard-ui.js"></script>
|
||||||
|
<script src="/dashboard-records.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -8,9 +8,34 @@
|
|||||||
"background_color": "#070c14",
|
"background_color": "#070c14",
|
||||||
"theme_color": "#070c14",
|
"theme_color": "#070c14",
|
||||||
"orientation": "any",
|
"orientation": "any",
|
||||||
"categories": ["weather", "utilities"],
|
"categories": [
|
||||||
|
"weather",
|
||||||
|
"utilities"
|
||||||
|
],
|
||||||
"icons": [
|
"icons": [
|
||||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
{
|
||||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
"src": "/icons/v2/dashboard-icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/v2/dashboard-apple-touch-icon-180.png",
|
||||||
|
"sizes": "180x180",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/v2/dashboard-icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/v2/dashboard-icon-maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 63 KiB |
@@ -1,12 +1,22 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
<!-- Solid background (fills corners for maskable safe zone) -->
|
<defs>
|
||||||
<rect width="512" height="512" fill="#1a1a1a"/>
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#3b82f6"/><stop offset="1" stop-color="#0a1a40"/></linearGradient>
|
||||||
<!-- Sun -->
|
<radialGradient id="glow" cx="0.18" cy="0.1" r="0.7"><stop offset="0" stop-color="#fff" stop-opacity="0.28"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient>
|
||||||
<circle cx="205" cy="205" r="70" fill="#e07848"/>
|
<radialGradient id="sun" cx="0.4" cy="0.4" r="0.7"><stop offset="0" stop-color="#ffe9a8"/><stop offset="1" stop-color="#f97316"/></radialGradient>
|
||||||
<!-- Cloud bumps (front, overlapping sun) -->
|
<radialGradient id="sunglow"><stop offset="0" stop-color="#fbbf24" stop-opacity="0.5"/><stop offset="1" stop-color="#fbbf24" stop-opacity="0"/></radialGradient>
|
||||||
<circle cx="215" cy="325" r="72" fill="#ece7dc"/>
|
<linearGradient id="cloud" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#c9dcf5"/></linearGradient>
|
||||||
<circle cx="305" cy="297" r="88" fill="#ece7dc"/>
|
<filter id="soft" x="-20%" y="-20%" width="140%" height="160%"><feGaussianBlur stdDeviation="22"/></filter>
|
||||||
<circle cx="390" cy="328" r="62" fill="#ece7dc"/>
|
</defs>
|
||||||
<!-- Cloud base (flat bottom) -->
|
<rect width="1024" height="1024" fill="url(#bg)"/>
|
||||||
<rect x="147" y="335" width="305" height="68" fill="#ece7dc"/>
|
<rect width="1024" height="1024" fill="url(#glow)"/>
|
||||||
|
<circle cx="400" cy="430" r="230" fill="url(#sunglow)"/>
|
||||||
|
<circle cx="400" cy="430" r="132" fill="url(#sun)"/>
|
||||||
|
<g filter="url(#soft)" opacity="0.35" transform="translate(0 26)" fill="#06122e">
|
||||||
|
<circle cx="392" cy="572" r="96"/><circle cx="522" cy="522" r="126"/><circle cx="648" cy="582" r="86"/>
|
||||||
|
<rect x="300" y="560" width="432" height="132" rx="66"/>
|
||||||
|
</g>
|
||||||
|
<g fill="url(#cloud)">
|
||||||
|
<circle cx="392" cy="572" r="96"/><circle cx="522" cy="522" r="126"/><circle cx="648" cy="582" r="86"/>
|
||||||
|
<rect x="300" y="560" width="432" height="132" rx="66"/>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 559 B After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 63 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#3b82f6"/><stop offset="1" stop-color="#0a1a40"/></linearGradient>
|
||||||
|
<radialGradient id="glow" cx="0.18" cy="0.1" r="0.7"><stop offset="0" stop-color="#fff" stop-opacity="0.28"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient>
|
||||||
|
<radialGradient id="sun" cx="0.4" cy="0.4" r="0.7"><stop offset="0" stop-color="#ffe9a8"/><stop offset="1" stop-color="#f97316"/></radialGradient>
|
||||||
|
<radialGradient id="sunglow"><stop offset="0" stop-color="#fbbf24" stop-opacity="0.5"/><stop offset="1" stop-color="#fbbf24" stop-opacity="0"/></radialGradient>
|
||||||
|
<linearGradient id="cloud" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#c9dcf5"/></linearGradient>
|
||||||
|
<filter id="soft" x="-20%" y="-20%" width="140%" height="160%"><feGaussianBlur stdDeviation="22"/></filter>
|
||||||
|
</defs>
|
||||||
|
<rect width="1024" height="1024" fill="url(#bg)"/>
|
||||||
|
<rect width="1024" height="1024" fill="url(#glow)"/>
|
||||||
|
<circle cx="400" cy="430" r="230" fill="url(#sunglow)"/>
|
||||||
|
<circle cx="400" cy="430" r="132" fill="url(#sun)"/>
|
||||||
|
<g filter="url(#soft)" opacity="0.35" transform="translate(0 26)" fill="#06122e">
|
||||||
|
<circle cx="392" cy="572" r="96"/><circle cx="522" cy="522" r="126"/><circle cx="648" cy="582" r="86"/>
|
||||||
|
<rect x="300" y="560" width="432" height="132" rx="66"/>
|
||||||
|
</g>
|
||||||
|
<g fill="url(#cloud)">
|
||||||
|
<circle cx="392" cy="572" r="96"/><circle cx="522" cy="522" r="126"/><circle cx="648" cy="582" r="86"/>
|
||||||
|
<rect x="300" y="560" width="432" height="132" rx="66"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -3,9 +3,15 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
|
<link rel="stylesheet" href="/pwa-safe.css">
|
||||||
<title>NeCloud Weather</title>
|
<title>NeCloud Weather</title>
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
<link rel="apple-touch-icon" href="/icons/icon.svg">
|
<link rel="icon" type="image/svg+xml" href="/icons/v2/icon.svg">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/icons/v2/icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/icons/v2/apple-touch-icon-180.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="167x167" href="/icons/v2/apple-touch-icon-167.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="152x152" href="/icons/v2/apple-touch-icon-152.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="120x120" href="/icons/v2/apple-touch-icon-120.png">
|
||||||
<meta name="theme-color" content="#edf2f7" media="(prefers-color-scheme: light)">
|
<meta name="theme-color" content="#edf2f7" media="(prefers-color-scheme: light)">
|
||||||
<meta name="theme-color" content="#070c14" media="(prefers-color-scheme: dark)">
|
<meta name="theme-color" content="#070c14" media="(prefers-color-scheme: dark)">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
@@ -29,7 +35,7 @@
|
|||||||
<a class="brand" href="/" aria-label="NeCloud Weather home"><span class="brand-mark">☁︎</span><span class="brand-name">NeCloud</span></a>
|
<a class="brand" href="/" aria-label="NeCloud Weather home"><span class="brand-mark">☁︎</span><span class="brand-name">NeCloud</span></a>
|
||||||
<nav class="tabs" aria-label="Sections">
|
<nav class="tabs" aria-label="Sections">
|
||||||
<button class="tab active" data-tab="conditions">Now</button>
|
<button class="tab active" data-tab="conditions">Now</button>
|
||||||
<button class="tab" data-tab="station">Station</button>
|
<button class="tab" data-tab="station" title="Live readings from a personal weather station">Station</button>
|
||||||
<button class="tab" data-tab="unified-map">Map</button>
|
<button class="tab" data-tab="unified-map">Map</button>
|
||||||
<button class="tab" data-tab="alerts">Alerts<span class="alert-badge hidden" id="alert-badge-nav"></span></button>
|
<button class="tab" data-tab="alerts">Alerts<span class="alert-badge hidden" id="alert-badge-nav"></span></button>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -41,6 +47,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- Install-as-app prompt (phones, not yet installed, dismissible) -->
|
||||||
|
<div id="install-banner" class="install-banner hidden" role="region" aria-label="Install the app">
|
||||||
|
<span class="ib-ico" aria-hidden="true">📲</span>
|
||||||
|
<div class="ib-text"><b>Install NeCloud Weather</b><span>Add it to your Home Screen for a full-screen app.</span></div>
|
||||||
|
<button type="button" class="ib-btn" id="ib-how">How</button>
|
||||||
|
<button type="button" class="ib-x" id="ib-close" aria-label="Dismiss">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Loading: skeleton of the Now tab -->
|
<!-- Loading: skeleton of the Now tab -->
|
||||||
<div id="loading" class="loading hidden" aria-busy="true" aria-label="Loading weather">
|
<div id="loading" class="loading hidden" aria-busy="true" aria-label="Loading weather">
|
||||||
<div class="sk sk-hero"></div>
|
<div class="sk sk-hero"></div>
|
||||||
@@ -72,7 +86,7 @@
|
|||||||
<div id="cur-icon" class="hero-icon"></div>
|
<div id="cur-icon" class="hero-icon"></div>
|
||||||
<div id="cur-desc" class="hero-desc"></div>
|
<div id="cur-desc" class="hero-desc"></div>
|
||||||
<div id="cur-precip" class="hero-sub"></div>
|
<div id="cur-precip" class="hero-sub"></div>
|
||||||
<div id="station-badge" class="station-badge hidden">📡 <span id="station-badge-name">My Station</span></div>
|
<div id="station-badge" class="station-badge hidden" title="These readings come from a personal weather station on site, not the forecast model. See the Station tab.">📡 <span id="station-badge-name">My Station</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-center">
|
<div class="hero-center">
|
||||||
<div class="hero-temp"><span id="cur-temp">--</span><span class="deg">°F</span></div>
|
<div class="hero-temp"><span id="cur-temp">--</span><span class="deg">°F</span></div>
|
||||||
@@ -239,25 +253,37 @@
|
|||||||
<div id="station-live" class="sl"></div>
|
<div id="station-live" class="sl"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TAB: MAP (full-bleed map, floating glass controls) ── -->
|
||||||
<div id="tab-unified-map" class="tab-panel hidden">
|
<div id="tab-unified-map" class="tab-panel hidden">
|
||||||
<div class="unified-map-wrap">
|
<div class="unified-map-wrap">
|
||||||
<!-- Map Controls -->
|
<div class="unified-map-shell">
|
||||||
<div class="map-controls-header">
|
<div id="unified-map" class="unified-map-container"></div>
|
||||||
<div class="map-controls-row">
|
|
||||||
<label for="base-map-select" class="control-label">Base Map:</label>
|
<div class="map-overlay-readouts" id="map-overlay-readouts">
|
||||||
<select id="base-map-select" class="map-select">
|
<span class="map-readout-chip" id="overlay-temp">Temp --</span>
|
||||||
|
<span class="map-readout-chip" id="overlay-aqi">AQI --</span>
|
||||||
|
<span class="map-readout-chip" id="overlay-sunmoon">Sun -- • Moon --</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="map-hud" role="toolbar" aria-label="Map controls">
|
||||||
|
<button id="layer-menu-toggle" class="hud-btn" type="button" aria-expanded="false" aria-label="Map layers" title="Layers">
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M12 3 3 8l9 5 9-5-9-5Z" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/><path d="m3 12 9 5 9-5M3 16l9 5 9-5" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round" stroke-linecap="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<button id="map-recenter-btn" class="hud-btn" type="button" aria-label="My location" title="My location">
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M21 3 3 10.5l7 2.5 2.5 7L21 3Z" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<button id="map-fullscreen-btn" class="hud-btn radar-fs-btn" type="button" aria-label="Full screen" title="Full screen">⛶</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="layer-menu-panel" class="map-layer-menu-panel glass hidden" role="dialog" aria-label="Map layers">
|
||||||
|
<div class="lm-head"><b>Map</b><button type="button" id="layer-menu-close" class="lm-close" aria-label="Close layers">✕</button></div>
|
||||||
|
<label class="lm-style" for="base-map-select"><span>Style</span> <select id="base-map-select" class="map-select">
|
||||||
<option value="dark">Dark</option>
|
<option value="dark">Dark</option>
|
||||||
<option value="light">Light</option>
|
<option value="light">Light</option>
|
||||||
<option value="satellite">Satellite</option>
|
<option value="satellite">Satellite</option>
|
||||||
</select>
|
</select></label>
|
||||||
<button id="map-fullscreen-btn" class="radar-btn radar-fs-btn" title="Fullscreen">⛶</button>
|
<div class="map-layer-menu-title">Layers</div>
|
||||||
</div>
|
<div class="map-controls-toggle-grid">
|
||||||
|
|
||||||
<div class="map-layer-menu">
|
|
||||||
<button id="layer-menu-toggle" class="map-layer-menu-toggle" type="button" aria-expanded="false">Show Layer Menu</button>
|
|
||||||
<div id="layer-menu-panel" class="map-layer-menu-panel hidden">
|
|
||||||
<div class="map-layer-menu-title">Map Layers</div>
|
|
||||||
<div class="map-controls-toggle-grid">
|
|
||||||
<label class="toggle-control">
|
<label class="toggle-control">
|
||||||
<span class="toggle-label">📡 Radar</span>
|
<span class="toggle-label">📡 Radar</span>
|
||||||
<span class="switch-toggle">
|
<span class="switch-toggle">
|
||||||
@@ -266,7 +292,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="toggle-control">
|
<label class="toggle-control">
|
||||||
<span class="toggle-label">☁️ Clouds</span>
|
<span class="toggle-label">🛰 Satellite (IR)</span>
|
||||||
<span class="switch-toggle">
|
<span class="switch-toggle">
|
||||||
<input type="checkbox" id="toggle-satellite" data-layer="satellite">
|
<input type="checkbox" id="toggle-satellite" data-layer="satellite">
|
||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
@@ -336,36 +362,10 @@
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Map -->
|
<div class="map-bottom">
|
||||||
<div class="unified-map-shell">
|
<div class="map-data-panel glass hidden" id="data-panel">
|
||||||
<div id="unified-map" class="unified-map-container" style="height:56vh; min-height:420px;"></div>
|
|
||||||
<div class="map-overlay-readouts" id="map-overlay-readouts">
|
|
||||||
<span class="map-readout-chip" id="overlay-temp">Temp --</span>
|
|
||||||
<span class="map-readout-chip" id="overlay-aqi">AQI --</span>
|
|
||||||
<span class="map-readout-chip" id="overlay-sunmoon">Sun -- • Moon --</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Radar/Satellite Timeline Controls -->
|
|
||||||
<div class="map-timeline-wrap hidden" id="timeline-controls">
|
|
||||||
<div class="radar-controls">
|
|
||||||
<button id="timeline-play" class="radar-btn play-btn" title="Play/Pause">▶</button>
|
|
||||||
<div class="radar-timeline" id="timeline-slider"></div>
|
|
||||||
<span id="timeline-timestamp" class="radar-ts">--</span>
|
|
||||||
</div>
|
|
||||||
<div class="radar-legend">
|
|
||||||
<span class="legend-label">Light</span>
|
|
||||||
<div class="legend-bar"></div>
|
|
||||||
<span class="legend-label">Heavy</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Data Panel -->
|
|
||||||
<div class="map-data-panel hidden" id="data-panel">
|
|
||||||
<div class="panel-location">
|
<div class="panel-location">
|
||||||
<span id="panel-location-name">--</span>
|
<span id="panel-location-name">--</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -391,6 +391,20 @@
|
|||||||
<span id="panel-cloud">--</span>
|
<span id="panel-cloud">--</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="map-timeline-wrap glass hidden" id="timeline-controls">
|
||||||
|
<div class="radar-controls">
|
||||||
|
<button id="timeline-play" class="radar-btn play-btn" title="Play/Pause">▶</button>
|
||||||
|
<div class="radar-timeline" id="timeline-slider"></div>
|
||||||
|
<span id="timeline-timestamp" class="radar-ts">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="radar-legend">
|
||||||
|
<span class="legend-label">Light</span>
|
||||||
|
<div class="legend-bar"></div>
|
||||||
|
<span class="legend-label">Heavy</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -416,6 +430,9 @@
|
|||||||
<div id="tab-settings" class="tab-panel hidden">
|
<div id="tab-settings" class="tab-panel hidden">
|
||||||
<div class="settings-wrap">
|
<div class="settings-wrap">
|
||||||
|
|
||||||
|
<div class="settings-section-label">Install the app</div>
|
||||||
|
<div id="install-card" class="install-card"></div>
|
||||||
|
|
||||||
<div class="settings-section-label">Station</div>
|
<div class="settings-section-label">Station</div>
|
||||||
<div class="settings-nav-links">
|
<div class="settings-nav-links">
|
||||||
<a href="/?tab=station" class="settings-nav-link">Live Station</a>
|
<a href="/?tab=station" class="settings-nav-link">Live Station</a>
|
||||||
@@ -432,6 +449,7 @@
|
|||||||
<button id="units-btn" title="Toggle units">US</button>
|
<button id="units-btn" title="Toggle units">US</button>
|
||||||
</div>
|
</div>
|
||||||
<ul id="suggestions" class="suggestions hidden"></ul>
|
<ul id="suggestions" class="suggestions hidden"></ul>
|
||||||
|
<p id="geo-status" class="geo-status hidden" role="status"></p>
|
||||||
<p id="error-msg" class="error hidden"></p>
|
<p id="error-msg" class="error hidden"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1277,7 +1295,13 @@
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<dialog id="install-dialog" class="install-dialog" aria-label="How to install the app">
|
||||||
|
<div class="install-dialog-head"><h2>Install the app</h2><button type="button" id="install-dialog-close" aria-label="Close">✕</button></div>
|
||||||
|
<div id="install-dialog-body"></div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
|
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
|
||||||
|
<script src="alert-info.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
<script src="station-live.js"></script>
|
<script src="station-live.js"></script>
|
||||||
<script src="app-ui.js"></script>
|
<script src="app-ui.js"></script>
|
||||||
|
|||||||
@@ -5,26 +5,44 @@
|
|||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"display_override": ["window-controls-overlay", "standalone"],
|
"display_override": [
|
||||||
|
"window-controls-overlay",
|
||||||
|
"standalone"
|
||||||
|
],
|
||||||
"background_color": "#070c14",
|
"background_color": "#070c14",
|
||||||
"theme_color": "#070c14",
|
"theme_color": "#070c14",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"categories": ["weather", "utilities"],
|
"categories": [
|
||||||
|
"weather",
|
||||||
|
"utilities"
|
||||||
|
],
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/icons/icon-192.png",
|
"src": "/icons/v2/icon-192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/icon-512.png",
|
"src": "/icons/v2/apple-touch-icon-180.png",
|
||||||
"sizes": "512x512",
|
"sizes": "180x180",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/icon.svg",
|
"src": "/icons/v2/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/v2/icon-maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/v2/icon.svg",
|
||||||
"sizes": "any",
|
"sizes": "any",
|
||||||
"type": "image/svg+xml",
|
"type": "image/svg+xml",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
@@ -33,7 +51,11 @@
|
|||||||
"share_target": {
|
"share_target": {
|
||||||
"action": "/?share",
|
"action": "/?share",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"params": { "title": "title", "text": "text", "url": "url" }
|
"params": {
|
||||||
|
"title": "title",
|
||||||
|
"text": "text",
|
||||||
|
"url": "url"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"shortcuts": [
|
"shortcuts": [
|
||||||
{
|
{
|
||||||
@@ -41,28 +63,48 @@
|
|||||||
"short_name": "Now",
|
"short_name": "Now",
|
||||||
"description": "View current weather and forecast",
|
"description": "View current weather and forecast",
|
||||||
"url": "/?tab=conditions",
|
"url": "/?tab=conditions",
|
||||||
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/v2/icon-192.png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "My Station",
|
"name": "My Station",
|
||||||
"short_name": "Station",
|
"short_name": "Station",
|
||||||
"description": "Live readings from your weather station",
|
"description": "Live readings from your weather station",
|
||||||
"url": "/?tab=station",
|
"url": "/?tab=station",
|
||||||
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/v2/icon-192.png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Radar Map",
|
"name": "Radar Map",
|
||||||
"short_name": "Map",
|
"short_name": "Map",
|
||||||
"description": "Live radar and weather map",
|
"description": "Live radar and weather map",
|
||||||
"url": "/?tab=unified-map",
|
"url": "/?tab=unified-map",
|
||||||
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/v2/icon-192.png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Active Alerts",
|
"name": "Active Alerts",
|
||||||
"short_name": "Alerts",
|
"short_name": "Alerts",
|
||||||
"description": "Weather warnings and alerts",
|
"description": "Weather warnings and alerts",
|
||||||
"url": "/?tab=alerts",
|
"url": "/?tab=alerts",
|
||||||
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/v2/icon-192.png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/* Shared PWA safe-area rules: notch / Dynamic Island, rounded corners, home indicator (iPhone 14–17 etc.).
|
||||||
|
Needs <meta name="viewport" content="..., viewport-fit=cover"> on the page. */
|
||||||
|
|
||||||
|
/* Installed iPhone apps draw under the status bar with white text (black-translucent).
|
||||||
|
A dark cap behind that zone keeps the clock and battery readable on light themes too. */
|
||||||
|
@media (display-mode: standalone) {
|
||||||
|
body::before {
|
||||||
|
content: ''; position: fixed; top: 0; left: 0; right: 0; z-index: 100000; pointer-events: none;
|
||||||
|
height: env(safe-area-inset-top, 0px); background: #070c14;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Never let page content slide under the sensor housing or rounded corners in landscape */
|
||||||
|
/* (main app and dashboard add side insets to their own containers; simple pages use .safe-sides) */
|
||||||
|
.safe-sides { padding-left: env(safe-area-inset-left, 0px); padding-right: env(safe-area-inset-right, 0px); }
|
||||||
|
|
||||||
|
/* Fixed overlays that hug the top / sides / bottom */
|
||||||
|
.offline-banner { padding-top: calc(0.4rem + env(safe-area-inset-top, 0px)); }
|
||||||
|
.ptr-indicator { top: env(safe-area-inset-top, 0px); }
|
||||||
|
.learn-back-top { right: max(1rem, calc(0.6rem + env(safe-area-inset-right, 0px))); }
|
||||||
|
|
||||||
|
/* Bottom bars: clear the home indicator and the rounded bottom corners */
|
||||||
|
.bottom-nav { padding-left: max(8px, env(safe-area-inset-left, 0px)); padding-right: max(8px, env(safe-area-inset-right, 0px)); padding-bottom: max(6px, env(safe-area-inset-bottom, 0px)); }
|
||||||
|
|
||||||
|
/* (Full-screen map: the map runs edge to edge; its floating controls add the insets themselves) */
|
||||||
|
|
||||||
|
/* Modal dialogs: stay inside the safe area on short (landscape) screens */
|
||||||
|
dialog { max-height: calc(100dvh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) - 1.5rem); overflow: auto; }
|
||||||
@@ -75,3 +75,18 @@
|
|||||||
.sl-empty button { margin-top: 1rem; font: inherit; font-weight: 600; padding: .6rem 1.2rem; min-height: 44px; border-radius: 10px; border: 1px solid var(--sl-accent); background: transparent; color: var(--sl-accent); cursor: pointer; }
|
.sl-empty button { margin-top: 1rem; font: inherit; font-weight: 600; padding: .6rem 1.2rem; min-height: 44px; border-radius: 10px; border: 1px solid var(--sl-accent); background: transparent; color: var(--sl-accent); cursor: pointer; }
|
||||||
.sl-link { display: inline-flex; align-items: center; gap: .3rem; font-size: .78rem; font-weight: 600; color: var(--sl-muted); text-decoration: none; padding: .32rem .75rem; border: 1px solid var(--sl-border); border-radius: 8px; }
|
.sl-link { display: inline-flex; align-items: center; gap: .3rem; font-size: .78rem; font-weight: 600; color: var(--sl-muted); text-decoration: none; padding: .32rem .75rem; border: 1px solid var(--sl-border); border-radius: 8px; }
|
||||||
.sl-link:hover { color: var(--sl-accent); border-color: var(--sl-accent); }
|
.sl-link:hover { color: var(--sl-accent); border-color: var(--sl-accent); }
|
||||||
|
|
||||||
|
/* What is this station? */
|
||||||
|
.sl-about { background: var(--sl-surface); border: 1px solid var(--sl-border); border-radius: 14px; margin-bottom: 0.9rem; overflow: hidden; }
|
||||||
|
.sl-about summary { display: flex; align-items: center; gap: 0.6rem; min-height: 48px; padding: 0 1rem; cursor: pointer; list-style: none; font-size: 0.9rem; font-weight: 600; color: var(--sl-text); }
|
||||||
|
.sl-about summary::-webkit-details-marker { display: none; }
|
||||||
|
.sl-about-ico { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 50%; background: color-mix(in srgb, var(--sl-accent) 18%, transparent); color: var(--sl-accent); font-size: 0.95rem; }
|
||||||
|
.sl-about-chev { margin-left: auto; color: var(--sl-muted); font-size: 1.2rem; transition: transform .2s; }
|
||||||
|
.sl-about[open] .sl-about-chev { transform: rotate(90deg); }
|
||||||
|
.sl-about-body { padding: 0 1rem 1rem; font-size: 0.88rem; line-height: 1.55; color: var(--sl-text); }
|
||||||
|
.sl-about-body p { margin: 0 0 0.6rem; }
|
||||||
|
.sl-about-h { font-size: 0.62rem; font-weight: 700; letter-spacing: .11em; text-transform: uppercase; color: var(--sl-muted); margin: 0.9rem 0 0.35rem !important; }
|
||||||
|
.sl-sensors { margin: 0 0 0.3rem; padding: 0; list-style: none; display: grid; gap: 0.4rem; }
|
||||||
|
.sl-sensors li { padding-left: 0; color: var(--sl-text); }
|
||||||
|
.sl-about-foot { margin-top: 0.8rem !important; padding-top: 0.7rem; border-top: 1px solid var(--sl-border); font-size: 0.8rem; color: var(--sl-muted); }
|
||||||
|
.sl-about-foot a { color: var(--sl-accent); font-weight: 600; }
|
||||||
@@ -52,6 +52,22 @@ const SHELL = `
|
|||||||
<span class="sl-live" id="sl-live"><span class="sl-dot"></span><span id="sl-live-text">Live</span></span>
|
<span class="sl-live" id="sl-live"><span class="sl-dot"></span><span id="sl-live-text">Live</span></span>
|
||||||
<span><span class="sl-time" id="sl-time"></span></span>
|
<span><span class="sl-time" id="sl-time"></span></span>
|
||||||
</div>
|
</div>
|
||||||
|
<details class="sl-about" id="sl-about">
|
||||||
|
<summary><span class="sl-about-ico">ⓘ</span><span>What is this station?</span><span class="sl-about-chev">›</span></summary>
|
||||||
|
<div class="sl-about-body">
|
||||||
|
<p><b id="sl-about-name">This station</b> is a <b>personal weather station</b>: real instruments on the owner's property that measure the weather <i>right here, right now</i>. These numbers are measurements, not a forecast or a computer model.</p>
|
||||||
|
<p class="sl-about-h">What it measures</p>
|
||||||
|
<ul id="sl-sensors" class="sl-sensors"><li>Waiting for the first reading…</li></ul>
|
||||||
|
<p class="sl-about-h">How it fits with the rest of the app</p>
|
||||||
|
<ul class="sl-sensors">
|
||||||
|
<li><b>Now</b> shows a forecast model for the city you picked. <b>Station</b> shows what one spot is actually measuring.</li>
|
||||||
|
<li>When you see <b>📡 Your station</b> on the Now tab, its temperature, wind and rain come from this station.</li>
|
||||||
|
<li>It reports about every minute or two. <b>Live</b> means the last reading is under 15 minutes old; <b>Offline</b> means older.</li>
|
||||||
|
<li>Because it's one spot, it can differ a little from the airport or the forecast, and that's normal.</li>
|
||||||
|
</ul>
|
||||||
|
<p class="sl-about-foot">History, records and charts, plus a side-by-side with the official National Weather Service station, are on the <a href="/dashboard">Station Dashboard</a> (sign-in required).</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
<section class="sl-hero">
|
<section class="sl-hero">
|
||||||
<div class="sl-hero-dots"></div><div class="sl-hero-sweep"></div><div class="sl-hero-ring"></div><div class="sl-hero-ring2"></div>
|
<div class="sl-hero-dots"></div><div class="sl-hero-sweep"></div><div class="sl-hero-ring"></div><div class="sl-hero-ring2"></div>
|
||||||
<div class="sl-hero-body">
|
<div class="sl-hero-body">
|
||||||
@@ -109,10 +125,21 @@ const setAll = (sel, v) => root.querySelectorAll(sel).forEach((el) => { el.textC
|
|||||||
let shellReady = false;
|
let shellReady = false;
|
||||||
let lastDir = null;
|
let lastDir = null;
|
||||||
|
|
||||||
|
|
||||||
|
// "What is this station?" starts open for new visitors and stays closed once dismissed
|
||||||
|
function initAbout() {
|
||||||
|
const d = $('sl-about');
|
||||||
|
if (!d) return;
|
||||||
|
let closed = false; try { closed = localStorage.getItem('stationAboutClosed') === '1'; } catch {}
|
||||||
|
d.open = !closed;
|
||||||
|
d.addEventListener('toggle', () => { try { localStorage.setItem('stationAboutClosed', d.open ? '0' : '1'); } catch {} });
|
||||||
|
}
|
||||||
|
|
||||||
function ensureShell() {
|
function ensureShell() {
|
||||||
if (shellReady) return;
|
if (shellReady) return;
|
||||||
root.innerHTML = SHELL;
|
root.innerHTML = SHELL;
|
||||||
shellReady = true;
|
shellReady = true;
|
||||||
|
initAbout();
|
||||||
drawCompass(null, null);
|
drawCompass(null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +205,19 @@ window.renderStationTab = function renderStationTab(device) {
|
|||||||
const name = (typeof state !== 'undefined' && state.stationName) || device.info?.name || 'My Station';
|
const name = (typeof state !== 'undefined' && state.stationName) || device.info?.name || 'My Station';
|
||||||
set('sl-name', name);
|
set('sl-name', name);
|
||||||
|
|
||||||
|
|
||||||
|
// Describe the instruments this particular station really has
|
||||||
|
set('sl-about-name', name);
|
||||||
|
const has = (...k) => k.some((f) => d[f] != null);
|
||||||
|
const sensors = [
|
||||||
|
has('tempf', 'humidity') && '🌡 <b>Temperature & humidity</b>: a shaded outdoor sensor',
|
||||||
|
has('windspeedmph', 'winddir', 'windgustmph') && '💨 <b>Wind</b>: speed, gusts and direction (anemometer and wind vane)',
|
||||||
|
has('dailyrainin', 'rainratein') && '🌧 <b>Rain</b>: a rain gauge that tips with each bit of rain',
|
||||||
|
has('baromrelin') && '🧭 <b>Pressure</b>: a barometer (falling pressure often means weather is coming)',
|
||||||
|
has('uv', 'solarradiation') && '☀️ <b>Sun</b>: UV and solar-radiation sensors',
|
||||||
|
has('tempinf', 'humidityin') && '🏠 <b>Indoor</b>: a sensor inside the house',
|
||||||
|
].filter(Boolean);
|
||||||
|
if (sensors.length) $('sl-sensors').innerHTML = sensors.map((x) => `<li>${x}</li>`).join('');
|
||||||
const fresh = d.dateutc && Date.now() - d.dateutc < 15 * 60 * 1000;
|
const fresh = d.dateutc && Date.now() - d.dateutc < 15 * 60 * 1000;
|
||||||
const live = $('sl-live');
|
const live = $('sl-live');
|
||||||
live.classList.toggle('stale', !fresh);
|
live.classList.toggle('stale', !fresh);
|
||||||
@@ -230,7 +270,7 @@ window.stationTabMessage = function (kind, detail) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (kind === 'unconfigured') {
|
if (kind === 'unconfigured') {
|
||||||
showMessage('No station connected', 'Add your Ambient Weather API keys in Settings to see live readings from your own station here.');
|
showMessage('No station connected', 'This tab shows live readings from a <b>personal weather station</b> (a real device on someone\'s property that measures conditions on the spot, not a forecast). Add your Ambient Weather API keys in Settings to connect yours.');
|
||||||
} else {
|
} else {
|
||||||
showMessage('Station unavailable', detail || 'Could not reach your station right now. It will keep retrying.');
|
showMessage('Station unavailable', detail || 'Could not reach your station right now. It will keep retrying.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2429,13 +2429,14 @@ body.map-fs #unified-map {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.cat-td { background: #2196f3; }
|
.cat-td { background: #5dade2; }
|
||||||
.cat-ts { background: #9c27b0; }
|
.cat-ts { background: #27ae60; }
|
||||||
.cat-1 { background: #ff9800; }
|
.cat-1 { background: #f1c40f; color: #1a1a1a; }
|
||||||
.cat-2 { background: #f44336; }
|
.cat-2 { background: #f39c12; }
|
||||||
.cat-3 { background: #b71c1c; }
|
.cat-3 { background: #e74c3c; }
|
||||||
.cat-4 { background: #7b1fa2; }
|
.cat-4 { background: #c0392b; }
|
||||||
.cat-5 { background: #1a237e; }
|
.cat-5 { background: #9b59b6; }
|
||||||
|
.cat-ex { background: #95a5a6; }
|
||||||
|
|
||||||
.hurricane-stats {
|
.hurricane-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ self.addEventListener('message', event => {
|
|||||||
if (event.data === 'skipWaiting') self.skipWaiting();
|
if (event.data === 'skipWaiting') self.skipWaiting();
|
||||||
});
|
});
|
||||||
|
|
||||||
const CACHE = 'weather-v9';
|
const CACHE = 'weather-v15';
|
||||||
|
|
||||||
// App shell cached on install for offline resilience.
|
// App shell cached on install for offline resilience.
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
@@ -14,9 +14,11 @@ const PRECACHE = [
|
|||||||
'/station-live.css',
|
'/station-live.css',
|
||||||
'/station-live.js',
|
'/station-live.js',
|
||||||
'/app-ui.css',
|
'/app-ui.css',
|
||||||
|
'/pwa-safe.css',
|
||||||
|
'/alert-info.js',
|
||||||
'/app-ui.js',
|
'/app-ui.js',
|
||||||
'/manifest.json',
|
'/manifest.json',
|
||||||
'/icons/icon.svg'
|
'/icons/v2/icon.svg'
|
||||||
];
|
];
|
||||||
|
|
||||||
self.addEventListener('install', event => {
|
self.addEventListener('install', event => {
|
||||||
@@ -42,8 +44,8 @@ self.addEventListener('push', event => {
|
|||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
self.registration.showNotification(payload.title || 'NeCloud Weather', {
|
self.registration.showNotification(payload.title || 'NeCloud Weather', {
|
||||||
body: payload.body || '',
|
body: payload.body || '',
|
||||||
icon: '/icons/icon-192.png',
|
icon: '/icons/v2/icon-192.png',
|
||||||
badge: '/icons/icon-192.png',
|
badge: '/icons/v2/icon-192.png',
|
||||||
tag: payload.tag || 'weather-alert',
|
tag: payload.tag || 'weather-alert',
|
||||||
data: { url: payload.url || '/' },
|
data: { url: payload.url || '/' },
|
||||||
})
|
})
|
||||||
@@ -99,14 +101,14 @@ self.addEventListener('fetch', event => {
|
|||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
})
|
})
|
||||||
.catch(() => caches.match(request).then(cached => cached || caches.match('/')))
|
.catch(() => caches.match(request, { ignoreSearch: true }).then(cached => cached || caches.match('/', { ignoreSearch: true })))
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache-first for static assets.
|
// Cache-first for static assets.
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(request).then(cached => {
|
caches.match(request, { ignoreSearch: true }).then(cached => {
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
return fetch(request).then(response => {
|
return fetch(request).then(response => {
|
||||||
if (response.ok && (
|
if (response.ok && (
|
||||||
|
|||||||
@@ -190,6 +190,13 @@ function recordAlertLog(entry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fields worth keeping from an NWS alert so the history can say what it was for and where
|
||||||
|
function nwsLogFields(p) {
|
||||||
|
return { event: p.event, headline: p.headline, description: p.description, instruction: p.instruction,
|
||||||
|
areaDesc: p.areaDesc, severity: p.severity, urgency: p.urgency, certainty: p.certainty, senderName: p.senderName,
|
||||||
|
effective: p.effective, expires: p.expires };
|
||||||
|
}
|
||||||
|
|
||||||
// Tracks NWS alert IDs already written this process lifetime to avoid duplicate log lines
|
// Tracks NWS alert IDs already written this process lifetime to avoid duplicate log lines
|
||||||
// across multiple subscribers covering the same geographic alert.
|
// across multiple subscribers covering the same geographic alert.
|
||||||
const loggedAlertIds = new Set();
|
const loggedAlertIds = new Set();
|
||||||
@@ -213,6 +220,27 @@ function stationLocalDay(r) {
|
|||||||
return f.format(new Date(r.dateutc));
|
return f.format(new Date(r.dateutc));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Local 5-minute slot (0-287) of a reading, for interval-independent coverage.
|
||||||
|
const slotFmtCache = new Map();
|
||||||
|
function stationLocalSlot(r) {
|
||||||
|
const tz = r.tz || stationTzFallback;
|
||||||
|
let f = slotFmtCache.get(tz);
|
||||||
|
if (!f) {
|
||||||
|
try { f = new Intl.DateTimeFormat('en-GB', { timeZone: tz, hourCycle: 'h23', hour: '2-digit', minute: '2-digit' }); }
|
||||||
|
catch { f = new Intl.DateTimeFormat('en-GB', { timeZone: stationTzFallback, hourCycle: 'h23', hour: '2-digit', minute: '2-digit' }); }
|
||||||
|
slotFmtCache.set(tz, f);
|
||||||
|
}
|
||||||
|
const p = Object.fromEntries(f.formatToParts(new Date(r.dateutc)).map(x => [x.type, x.value]));
|
||||||
|
return Math.min(287, Math.floor((Number(p.hour) * 60 + Number(p.minute)) / 5));
|
||||||
|
}
|
||||||
|
// day.sl is a 288-char string of 0/1: which 5-minute slots of the local day have at least one reading.
|
||||||
|
function markSlot(day, slot) {
|
||||||
|
const sl = day.sl && day.sl.length === 288 ? day.sl : '0'.repeat(288);
|
||||||
|
if (sl[slot] === '1') { day.sl = sl; return false; }
|
||||||
|
day.sl = sl.slice(0, slot) + '1' + sl.slice(slot + 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Per-day set of dateutc values already on disk. Shared by the live recorder and
|
// Per-day set of dateutc values already on disk. Shared by the live recorder and
|
||||||
// the backfill so neither can write a reading the other (or a prior run) saved.
|
// the backfill so neither can write a reading the other (or a prior run) saved.
|
||||||
const stationSeen = new Map();
|
const stationSeen = new Map();
|
||||||
@@ -358,6 +386,7 @@ function updateRecordsFromReading(obs) {
|
|||||||
applyReadingToRecords(obs);
|
applyReadingToRecords(obs);
|
||||||
const day = recordsStore.days[stationLocalDay(obs)];
|
const day = recordsStore.days[stationLocalDay(obs)];
|
||||||
day.n = (day.n || 0) + 1;
|
day.n = (day.n || 0) + 1;
|
||||||
|
markSlot(day, stationLocalSlot(obs));
|
||||||
addAggregates(day, obs);
|
addAggregates(day, obs);
|
||||||
recordsDirty = true;
|
recordsDirty = true;
|
||||||
}
|
}
|
||||||
@@ -368,6 +397,7 @@ function rebuildRecordsFromFiles() {
|
|||||||
try { files = fs.readdirSync(STATION_DIR).filter(f => f.endsWith('.jsonl')).sort(); } catch { return 0; }
|
try { files = fs.readdirSync(STATION_DIR).filter(f => f.endsWith('.jsonl')).sort(); } catch { return 0; }
|
||||||
const counts = {};
|
const counts = {};
|
||||||
const fresh = {};
|
const fresh = {};
|
||||||
|
const slotsFresh = {};
|
||||||
let scanned = 0;
|
let scanned = 0;
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
try {
|
try {
|
||||||
@@ -380,6 +410,7 @@ function rebuildRecordsFromFiles() {
|
|||||||
applyReadingToRecords(r);
|
applyReadingToRecords(r);
|
||||||
const k = stationLocalDay(r);
|
const k = stationLocalDay(r);
|
||||||
counts[k] = (counts[k] || 0) + 1;
|
counts[k] = (counts[k] || 0) + 1;
|
||||||
|
markSlot(slotsFresh[k] || (slotsFresh[k] = {}), stationLocalSlot(r));
|
||||||
addAggregates(fresh[k] || (fresh[k] = {}), r);
|
addAggregates(fresh[k] || (fresh[k] = {}), r);
|
||||||
scanned++;
|
scanned++;
|
||||||
}
|
}
|
||||||
@@ -389,6 +420,9 @@ function rebuildRecordsFromFiles() {
|
|||||||
for (const [k, n] of Object.entries(counts)) {
|
for (const [k, n] of Object.entries(counts)) {
|
||||||
const day = recordsStore.days[k];
|
const day = recordsStore.days[k];
|
||||||
day.n = Math.max(day.n || 0, n);
|
day.n = Math.max(day.n || 0, n);
|
||||||
|
// union with what live recording already marked (pruned raw files must not lose coverage)
|
||||||
|
const fs_ = slotsFresh[k]?.sl;
|
||||||
|
if (fs_) { const cur = day.sl && day.sl.length === 288 ? day.sl : '0'.repeat(288); day.sl = [...fs_].map((c, i) => (c === '1' || cur[i] === '1') ? '1' : '0').join(''); }
|
||||||
// Replace aggregates only when the files hold at least as many samples as the store did
|
// Replace aggregates only when the files hold at least as many samples as the store did
|
||||||
const f = fresh[k];
|
const f = fresh[k];
|
||||||
if (f.tn != null && f.tn >= (day.tn || 0)) { day.tsum = f.tsum; day.tn = f.tn; }
|
if (f.tn != null && f.tn >= (day.tn || 0)) { day.tsum = f.tsum; day.tn = f.tn; }
|
||||||
@@ -452,13 +486,18 @@ function deriveRecords() {
|
|||||||
const agg = (keyFn) => {
|
const agg = (keyFn) => {
|
||||||
const g = {};
|
const g = {};
|
||||||
for (const [d, v] of days) {
|
for (const [d, v] of days) {
|
||||||
const k = keyFn(d); const a = g[k] || (g[k] = { days: 0, rain: 0, hi: null, lo: null, gust: null });
|
const k = keyFn(d); const a = g[k] || (g[k] = { days: 0, rain: 0, hi: null, lo: null, gust: null, tsum: 0, tn: 0 });
|
||||||
a.days++; a.rain += v.rain || 0;
|
a.days++; a.rain += v.rain || 0;
|
||||||
|
if (v.tn) { a.tsum += v.tsum; a.tn += v.tn; }
|
||||||
if (v.hi && (a.hi == null || v.hi[0] > a.hi)) a.hi = v.hi[0];
|
if (v.hi && (a.hi == null || v.hi[0] > a.hi)) a.hi = v.hi[0];
|
||||||
if (v.lo && (a.lo == null || v.lo[0] < a.lo)) a.lo = v.lo[0];
|
if (v.lo && (a.lo == null || v.lo[0] < a.lo)) a.lo = v.lo[0];
|
||||||
if (v.gust && (a.gust == null || v.gust[0] > a.gust)) a.gust = v.gust[0];
|
if (v.gust && (a.gust == null || v.gust[0] > a.gust)) a.gust = v.gust[0];
|
||||||
}
|
}
|
||||||
for (const a of Object.values(g)) a.rain = +a.rain.toFixed(2);
|
for (const a of Object.values(g)) {
|
||||||
|
a.rain = +a.rain.toFixed(2);
|
||||||
|
a.avg = a.tn ? +(a.tsum / a.tn).toFixed(1) : null;
|
||||||
|
delete a.tsum; delete a.tn;
|
||||||
|
}
|
||||||
return g;
|
return g;
|
||||||
};
|
};
|
||||||
const byYear = agg(d => d.slice(0, 4));
|
const byYear = agg(d => d.slice(0, 4));
|
||||||
@@ -468,6 +507,55 @@ function deriveRecords() {
|
|||||||
out.months = ym;
|
out.months = ym;
|
||||||
out.wettestMonth = ym.reduce((b, m) => (!b || m.rain > b.rain ? m : b), null);
|
out.wettestMonth = ym.reduce((b, m) => (!b || m.rain > b.rain ? m : b), null);
|
||||||
out.wettestYear = out.years.reduce((b, y) => (!b || y.rain > b.rain ? y : b), null);
|
out.wettestYear = out.years.reduce((b, y) => (!b || y.rain > b.rain ? y : b), null);
|
||||||
|
|
||||||
|
// Month / year records (partial periods are flagged so "coldest month" can't be a 2-week stub unnoticed)
|
||||||
|
const dim = (ym) => new Date(+ym.slice(0, 4), +ym.slice(5, 7), 0).getDate();
|
||||||
|
const mk = (p, key, val, total) => ({ key, value: val, days: p.days, partial: p.days < total * 0.9 });
|
||||||
|
// preferComplete: averages / "driest" are only fair over whole periods, so rank complete ones first and
|
||||||
|
// fall back to partial ones only when no complete period exists yet. Totals and extremes stay valid when partial.
|
||||||
|
const pick = (list, keyName, valFn, dir, totalFn, preferComplete) => {
|
||||||
|
const rank = (onlyComplete) => {
|
||||||
|
let best = null;
|
||||||
|
for (const p of list) {
|
||||||
|
const v = valFn(p); if (v == null) continue;
|
||||||
|
const total = totalFn(p[keyName]);
|
||||||
|
if (onlyComplete && p.days < total * 0.9) continue;
|
||||||
|
if (!best || (dir === 'max' ? v > best.value : v < best.value)) best = mk(p, p[keyName], v, total);
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
return (preferComplete && rank(true)) || rank(false);
|
||||||
|
};
|
||||||
|
const mPick = (valFn, dir, preferComplete) => pick(ym, 'month', valFn, dir, dim, preferComplete);
|
||||||
|
const yPick = (valFn, dir, preferComplete) => pick(out.years, 'year', valFn, dir, () => 365, preferComplete);
|
||||||
|
out.periods = {
|
||||||
|
months: {
|
||||||
|
wettest: mPick(p => p.rain, 'max'), driest: mPick(p => p.rain, 'min', true),
|
||||||
|
hottest: mPick(p => p.avg, 'max', true), coldest: mPick(p => p.avg, 'min', true), windiest: mPick(p => p.gust, 'max'),
|
||||||
|
},
|
||||||
|
years: {
|
||||||
|
wettest: yPick(p => p.rain, 'max'), hottest: yPick(p => p.avg, 'max', true),
|
||||||
|
coldest: yPick(p => p.avg, 'min', true), windiest: yPick(p => p.gust, 'max'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Records for each calendar month across every year ("record July", "record January", …)
|
||||||
|
const cal = Array.from({ length: 12 }, () => ({ hi: null, lo: null, wettestDay: null, gust: null, wettestTotal: null, years: new Set() }));
|
||||||
|
const monthRain = {};
|
||||||
|
for (const [d, v] of days) {
|
||||||
|
const m = +d.slice(5, 7) - 1, c = cal[m];
|
||||||
|
c.years.add(d.slice(0, 4));
|
||||||
|
if (v.hi && (!c.hi || v.hi[0] > c.hi.value)) c.hi = { value: v.hi[0], day: d };
|
||||||
|
if (v.lo && (!c.lo || v.lo[0] < c.lo.value)) c.lo = { value: v.lo[0], day: d };
|
||||||
|
if (v.rain != null && v.rain > 0 && (!c.wettestDay || v.rain > c.wettestDay.value)) c.wettestDay = { value: v.rain, day: d };
|
||||||
|
if (v.gust && (!c.gust || v.gust[0] > c.gust.value)) c.gust = { value: v.gust[0], day: d };
|
||||||
|
monthRain[d.slice(0, 7)] = (monthRain[d.slice(0, 7)] || 0) + (v.rain || 0);
|
||||||
|
}
|
||||||
|
for (const [ymKey, total] of Object.entries(monthRain)) {
|
||||||
|
const c = cal[+ymKey.slice(5, 7) - 1];
|
||||||
|
if (total > 0 && (!c.wettestTotal || total > c.wettestTotal.value)) c.wettestTotal = { value: +total.toFixed(2), month: ymKey };
|
||||||
|
}
|
||||||
|
out.calendarMonths = cal.map((c, i) => ({ month: i + 1, years: c.years.size, hi: c.hi, lo: c.lo, wettestDay: c.wettestDay, gust: c.gust, wettestTotal: c.wettestTotal }));
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,7 +564,7 @@ try {
|
|||||||
else rebuildRecordsFromFiles();
|
else rebuildRecordsFromFiles();
|
||||||
} catch (e) { console.error('[records] startup rebuild error:', e.message); }
|
} catch (e) { console.error('[records] startup rebuild error:', e.message); }
|
||||||
setInterval(saveRecordsStore, 30 * 1000).unref();
|
setInterval(saveRecordsStore, 30 * 1000).unref();
|
||||||
for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => { saveRecordsStore(); process.exit(0); });
|
for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => { saveRecordsStore(); try { saveAlertDetails(); } catch {} process.exit(0); });
|
||||||
|
|
||||||
function loadStationReadings(rangeMs) {
|
function loadStationReadings(rangeMs) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -558,6 +646,63 @@ function isValidCoord(value, min, max) {
|
|||||||
return Number.isFinite(n) && n >= min && n <= max;
|
return Number.isFinite(n) && n >= min && n <= max;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Cache-proof asset URLs ───────────────────────────────────────────────────
|
||||||
|
// A reverse proxy in front of the app was serving stale copies of static files (old app.js / sw.js)
|
||||||
|
// next to new HTML. HTML is always fetched fresh, so every local script, stylesheet, icon and manifest
|
||||||
|
// it references gets a content-hash version (app.js?v=ab12cd34): any change is a brand-new URL that no
|
||||||
|
// proxy or phone has cached.
|
||||||
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
||||||
|
const assetHashes = new Map(); // absolute path -> { mtimeMs, hash }
|
||||||
|
function assetVersion(url) {
|
||||||
|
const rel = url.replace(/^\//, '');
|
||||||
|
const abs = path.join(PUBLIC_DIR, rel);
|
||||||
|
if (!abs.startsWith(PUBLIC_DIR)) return null;
|
||||||
|
try {
|
||||||
|
const st = fs.statSync(abs);
|
||||||
|
const hit = assetHashes.get(abs);
|
||||||
|
if (hit && hit.mtimeMs === st.mtimeMs) return hit.hash;
|
||||||
|
const hash = crypto.createHash('sha1').update(fs.readFileSync(abs)).digest('hex').slice(0, 10);
|
||||||
|
assetHashes.set(abs, { mtimeMs: st.mtimeMs, hash });
|
||||||
|
return hash;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
const inlineIcons = new Map();
|
||||||
|
function inlineIcon(url) {
|
||||||
|
const abs = path.join(PUBLIC_DIR, url.replace(/^\//, ''));
|
||||||
|
if (!abs.startsWith(PUBLIC_DIR)) return null;
|
||||||
|
try {
|
||||||
|
const st = fs.statSync(abs);
|
||||||
|
const hit = inlineIcons.get(abs);
|
||||||
|
if (hit && hit.mtimeMs === st.mtimeMs) return hit.uri;
|
||||||
|
const uri = 'data:image/png;base64,' + fs.readFileSync(abs).toString('base64');
|
||||||
|
inlineIcons.set(abs, { mtimeMs: st.mtimeMs, uri });
|
||||||
|
return uri;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
function versionedHtml(file) {
|
||||||
|
let html = fs.readFileSync(path.join(PUBLIC_DIR, file), 'utf8');
|
||||||
|
html = html.replace(/\b(src|href)="([^"#?]+\.(?:js|css|png|svg|webmanifest|json))"/g, (m, attr, url) => {
|
||||||
|
if (/^(?:[a-z]+:)?\/\//i.test(url)) return m; // external (https://, //cdn)
|
||||||
|
const v = assetVersion(url);
|
||||||
|
return v ? `${attr}="${url}?v=${v}"` : m;
|
||||||
|
});
|
||||||
|
// iOS draws a letter tile if its separate icon download fails (proxy, network path, cached failure).
|
||||||
|
// Embed the 180px touch icon in the page itself so the image arrives with the HTML, no second request.
|
||||||
|
html = html.replace(/<link rel="apple-touch-icon" sizes="180x180" href="(\/icons\/v2\/[^"?]+\.png)(?:\?v=[0-9a-f]+)?">/, (m, url) => {
|
||||||
|
const uri = inlineIcon(url);
|
||||||
|
return uri ? `<link rel="apple-touch-icon" sizes="180x180" href="${uri}">` : m;
|
||||||
|
});
|
||||||
|
// the service worker script is cached by the same proxy, so version its URL too
|
||||||
|
const swV = assetVersion('/sw.js');
|
||||||
|
if (swV) html = html.replace(/register\('\/sw\.js'\)/g, `register('/sw.js?v=${swV}')`);
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
function sendHtml(res, file) {
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.send(versionedHtml(file));
|
||||||
|
}
|
||||||
|
|
||||||
// Keep shell assets fresh so SW/app versions do not drift.
|
// Keep shell assets fresh so SW/app versions do not drift.
|
||||||
app.use('/', (req, res, next) => {
|
app.use('/', (req, res, next) => {
|
||||||
if (req.path === '/' || req.path.endsWith('.html')) {
|
if (req.path === '/' || req.path.endsWith('.html')) {
|
||||||
@@ -573,12 +718,14 @@ app.use('/', (req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get(['/', '/index.html'], (_req, res) => sendHtml(res, 'index.html'));
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
// Prevent browser favicon requests from showing noisy 404s.
|
// Prevent browser favicon requests from showing noisy 404s.
|
||||||
app.get('/favicon.ico', (_req, res) => {
|
// Icons iOS and browsers request from the site root when a page doesn't declare one
|
||||||
res.status(204).end();
|
const ICON_DIR = path.join(__dirname, 'public', 'icons', 'v2');
|
||||||
});
|
app.get(['/apple-touch-icon.png', '/apple-touch-icon-precomposed.png'], (_req, res) => res.sendFile(path.join(ICON_DIR, 'apple-touch-icon.png')));
|
||||||
|
app.get('/favicon.ico', (_req, res) => res.sendFile(path.join(ICON_DIR, 'icon-192.png')));
|
||||||
|
|
||||||
// Geocoding proxy
|
// Geocoding proxy
|
||||||
app.get('/api/geocode', async (req, res) => {
|
app.get('/api/geocode', async (req, res) => {
|
||||||
@@ -770,7 +917,7 @@ app.get('/api/alerts', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Invalid lat/lon' });
|
return res.status(400).json({ error: 'Invalid lat/lon' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const r = await fetchWithRetry(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, {
|
const r = await fetchWithRetry(`${NWS_BASE}/alerts/active?point=${lat},${lon}`, {
|
||||||
attempts: 1,
|
attempts: 1,
|
||||||
timeoutMs: 12000,
|
timeoutMs: 12000,
|
||||||
fetchOptions: {
|
fetchOptions: {
|
||||||
@@ -789,12 +936,38 @@ app.get('/api/alerts', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Radar/satellite tile configuration
|
// Radar tile configuration.
|
||||||
|
// Primary: RainViewer (global composite, 10-min frames, free, tiles load straight from their CDN).
|
||||||
|
// Fallback: IEM NEXRAD (US only). IEM blocks IPs that pull too many tiles and answers with a "sorry"
|
||||||
|
// redirect, so it must not be the only source. Satellite imagery is separate (NASA GIBS, client-side).
|
||||||
|
let rainviewerCache = { at: 0, data: null };
|
||||||
|
async function rainviewerFrames() {
|
||||||
|
if (rainviewerCache.data && Date.now() - rainviewerCache.at < 120000) return rainviewerCache.data;
|
||||||
|
const r = await fetchWithRetry('https://api.rainviewer.com/public/weather-maps.json', { attempts: 2, timeoutMs: 8000 });
|
||||||
|
if (!r.ok) throw new Error(`RainViewer ${r.status}`);
|
||||||
|
const j = await r.json();
|
||||||
|
const host = j.host || 'https://tilecache.rainviewer.com';
|
||||||
|
const frames = (j.radar?.past || []).slice(-12).map(f => ({
|
||||||
|
time: f.time,
|
||||||
|
url: `${host}${f.path}/256/{z}/{x}/{y}/2/1_1.png`, // colour scheme 2, smoothed, snow shown
|
||||||
|
isForecast: false,
|
||||||
|
maxNativeZoom: 7, // RainViewer's free tiles stop at z7; MapLibre overzooms beyond that
|
||||||
|
}));
|
||||||
|
if (!frames.length) throw new Error('RainViewer returned no frames');
|
||||||
|
rainviewerCache = { at: Date.now(), data: frames };
|
||||||
|
return frames;
|
||||||
|
}
|
||||||
|
|
||||||
app.get('/api/radar/times', async (req, res) => {
|
app.get('/api/radar/times', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
try {
|
||||||
|
const past = await rainviewerFrames();
|
||||||
|
return res.json({ provider: 'rainviewer', radar: { past }, satellite: { infrared: [] } });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('RainViewer unavailable, falling back to IEM:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
// IEM NEXRAD tile service (US radar, public/free) with historical offsets for animation.
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const radarLayerBase = 'https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0';
|
const radarLayerBase = 'https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0';
|
||||||
const radarOffsetsMin = [55, 50, 45, 40, 35, 30, 25, 20, 15, 10, 5, 0];
|
const radarOffsetsMin = [55, 50, 45, 40, 35, 30, 25, 20, 15, 10, 5, 0];
|
||||||
const radarPast = radarOffsetsMin.map(offset => {
|
const radarPast = radarOffsetsMin.map(offset => {
|
||||||
@@ -806,28 +979,7 @@ app.get('/api/radar/times', async (req, res) => {
|
|||||||
maxNativeZoom: 12,
|
maxNativeZoom: 12,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
res.json({ provider: 'iem-radar', radar: { past: radarPast }, satellite: { infrared: [] } });
|
||||||
// IEM GOES East cloud/satellite frames for animation.
|
|
||||||
const satOffsetsMin = [30, 25, 20, 15, 10, 5, 0];
|
|
||||||
const satelliteInfrared = satOffsetsMin.map(offset => {
|
|
||||||
const layer = offset === 0 ? 'goes_east' : `goes_east_m${String(offset).padStart(2, '0')}m`;
|
|
||||||
return {
|
|
||||||
time: now - (offset * 60),
|
|
||||||
url: `https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/${layer}/{z}/{x}/{y}.png`,
|
|
||||||
isForecast: false,
|
|
||||||
maxNativeZoom: 10,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
provider: 'iem-radar-sat',
|
|
||||||
radar: {
|
|
||||||
past: radarPast,
|
|
||||||
},
|
|
||||||
satellite: {
|
|
||||||
infrared: satelliteInfrared,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Radar config error:', e.message);
|
console.error('Radar config error:', e.message);
|
||||||
res.status(500).json({ error: 'Radar times failed' });
|
res.status(500).json({ error: 'Radar times failed' });
|
||||||
@@ -867,10 +1019,11 @@ app.post('/api/push-unsubscribe', (req, res) => {
|
|||||||
|
|
||||||
// ── Hurricane tracking proxy (NHC) ────────────────────────────────────────────
|
// ── Hurricane tracking proxy (NHC) ────────────────────────────────────────────
|
||||||
const hCache = { data: null, at: 0 };
|
const hCache = { data: null, at: 0 };
|
||||||
|
const NHC_BASE = process.env.NHC_BASE || 'https://www.nhc.noaa.gov';
|
||||||
app.get('/api/hurricanes', async (_req, res) => {
|
app.get('/api/hurricanes', async (_req, res) => {
|
||||||
if (hCache.data && Date.now() - hCache.at < 5 * 60 * 1000) return res.json(hCache.data);
|
if (hCache.data && Date.now() - hCache.at < 5 * 60 * 1000) return res.json(hCache.data);
|
||||||
try {
|
try {
|
||||||
const r = await fetchWithRetry('https://www.nhc.noaa.gov/CurrentStorms.json', {
|
const r = await fetchWithRetry(`${NHC_BASE}/CurrentStorms.json`, {
|
||||||
attempts: 2, timeoutMs: 10000,
|
attempts: 2, timeoutMs: 10000,
|
||||||
fetchOptions: { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/json' } },
|
fetchOptions: { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/json' } },
|
||||||
});
|
});
|
||||||
@@ -884,6 +1037,11 @@ app.get('/api/hurricanes', async (_req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Strength class for a point on the forecast track (falls back to the storm's own class when wind is unknown)
|
||||||
|
function typeForKt(kt, fallback) {
|
||||||
|
if (!kt) return fallback || 'TS';
|
||||||
|
return kt >= 64 ? 'HU' : kt >= 34 ? 'TS' : 'TD';
|
||||||
|
}
|
||||||
app.get('/api/hurricanes/track', async (req, res) => {
|
app.get('/api/hurricanes/track', async (req, res) => {
|
||||||
const { stormId } = req.query;
|
const { stormId } = req.query;
|
||||||
if (!stormId || !/^[a-z]{2}\d{6}$/i.test(stormId)) {
|
if (!stormId || !/^[a-z]{2}\d{6}$/i.test(stormId)) {
|
||||||
@@ -893,7 +1051,7 @@ app.get('/api/hurricanes/track', async (req, res) => {
|
|||||||
const sid = stormId.toLowerCase();
|
const sid = stormId.toLowerCase();
|
||||||
let storm = (hCache.data?.storms || []).find(s => s.id?.toLowerCase() === sid);
|
let storm = (hCache.data?.storms || []).find(s => s.id?.toLowerCase() === sid);
|
||||||
if (!storm) {
|
if (!storm) {
|
||||||
const r = await fetchWithRetry('https://www.nhc.noaa.gov/CurrentStorms.json', {
|
const r = await fetchWithRetry(`${NHC_BASE}/CurrentStorms.json`, {
|
||||||
attempts: 2, timeoutMs: 10000, fetchOptions: { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)' } }
|
attempts: 2, timeoutMs: 10000, fetchOptions: { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)' } }
|
||||||
});
|
});
|
||||||
storm = ((await r.json()).activeStorms || []).find(s => s.id?.toLowerCase() === sid);
|
storm = ((await r.json()).activeStorms || []).find(s => s.id?.toLowerCase() === sid);
|
||||||
@@ -931,7 +1089,7 @@ app.get('/api/hurricanes/track', async (req, res) => {
|
|||||||
lat: parseFloat(initM[1]) * (initM[2] === 'S' ? -1 : 1),
|
lat: parseFloat(initM[1]) * (initM[2] === 'S' ? -1 : 1),
|
||||||
lon: parseFloat(initM[3]) * (initM[4] === 'W' ? -1 : 1),
|
lon: parseFloat(initM[3]) * (initM[4] === 'W' ? -1 : 1),
|
||||||
windKt: curWindM ? parseInt(curWindM[1]) : 0,
|
windKt: curWindM ? parseInt(curWindM[1]) : 0,
|
||||||
type: storm?.classification || 'TS',
|
type: typeForKt(curWindM ? parseInt(curWindM[1]) : 0, storm?.classification),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -943,7 +1101,8 @@ app.get('/api/hurricanes/track', async (req, res) => {
|
|||||||
const lat = parseFloat(m[3]) * (m[4] === 'S' ? -1 : 1);
|
const lat = parseFloat(m[3]) * (m[4] === 'S' ? -1 : 1);
|
||||||
const lon = parseFloat(m[5]) * (m[6] === 'W' ? -1 : 1);
|
const lon = parseFloat(m[5]) * (m[6] === 'W' ? -1 : 1);
|
||||||
const windM = text.substring(m.index).match(/MAX WIND\s+(\d+)\s+KT/);
|
const windM = text.substring(m.index).match(/MAX WIND\s+(\d+)\s+KT/);
|
||||||
positions.push({ tau, lat, lon, windKt: windM ? parseInt(windM[1]) : 0, type: storm?.classification || 'TS' });
|
const kt = windM ? parseInt(windM[1]) : 0;
|
||||||
|
positions.push({ tau, lat, lon, windKt: kt, type: typeForKt(kt, storm?.classification) });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!positions.length) return res.json({ lin: null, pts: null, pgn: null });
|
if (!positions.length) return res.json({ lin: null, pts: null, pgn: null });
|
||||||
@@ -1192,15 +1351,19 @@ async function alertWatcherCron() {
|
|||||||
// ── Log NWS alerts for the dashboard location regardless of subscribers ───────
|
// ── Log NWS alerts for the dashboard location regardless of subscribers ───────
|
||||||
const dashLat = dashAuth?.stationLat;
|
const dashLat = dashAuth?.stationLat;
|
||||||
const dashLon = dashAuth?.stationLon;
|
const dashLon = dashAuth?.stationLon;
|
||||||
if (dashLat != null) {
|
const nwsRes = nwsSaved().resolved;
|
||||||
const dashAlertData = await fetch(`${base}/api/alerts?lat=${dashLat}&lon=${dashLon}`)
|
const alertSpots = [];
|
||||||
|
if (dashLat != null) alertSpots.push({ lat: dashLat, lon: dashLon });
|
||||||
|
if (nwsRes && !(nwsRes.lat === dashLat && nwsRes.lon === dashLon)) alertSpots.push({ lat: nwsRes.lat, lon: nwsRes.lon, zip: nwsRes.zip });
|
||||||
|
for (const spot of alertSpots) {
|
||||||
|
const dashAlertData = await fetch(`${base}/api/alerts?lat=${spot.lat}&lon=${spot.lon}`)
|
||||||
.then(r => r.json()).catch(() => ({ features: [] }));
|
.then(r => r.json()).catch(() => ({ features: [] }));
|
||||||
for (const a of dashAlertData.features || []) {
|
for (const a of dashAlertData.features || []) {
|
||||||
const alertId = a.id || a.properties?.id;
|
const alertId = a.id || a.properties?.id;
|
||||||
if (alertId && !loggedAlertIds.has(alertId)) {
|
if (alertId && !loggedAlertIds.has(alertId)) {
|
||||||
loggedAlertIds.add(alertId);
|
loggedAlertIds.add(alertId);
|
||||||
const p = a.properties || {};
|
const p = a.properties || {};
|
||||||
recordAlertLog({ type: 'nws', alertId, event: p.event, headline: p.headline, description: p.description, instruction: p.instruction, effective: p.effective, expires: p.expires, lat: dashLat, lon: dashLon });
|
recordAlertLog({ type: 'nws', alertId, ...nwsLogFields(p), lat: spot.lat, lon: spot.lon, ...(spot.zip ? { zip: spot.zip } : {}) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1226,7 +1389,7 @@ async function alertWatcherCron() {
|
|||||||
const alertId = a.id || p.id;
|
const alertId = a.id || p.id;
|
||||||
if (alertId && !loggedAlertIds.has(alertId)) {
|
if (alertId && !loggedAlertIds.has(alertId)) {
|
||||||
loggedAlertIds.add(alertId);
|
loggedAlertIds.add(alertId);
|
||||||
recordAlertLog({ type: 'nws', alertId, event: p.event, headline: p.headline, description: p.description, instruction: p.instruction, effective: p.effective, expires: p.expires, lat: sub.lat, lon: sub.lon });
|
recordAlertLog({ type: 'nws', alertId, ...nwsLogFields(p), lat: sub.lat, lon: sub.lon });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sub.seenAlertIds = [...new Set([...sub.seenAlertIds, ...newAlerts.map(f => f.id || f.properties?.id)])].slice(-200);
|
sub.seenAlertIds = [...new Set([...sub.seenAlertIds, ...newAlerts.map(f => f.id || f.properties?.id)])].slice(-200);
|
||||||
@@ -1357,7 +1520,7 @@ app.use('/dashboard', express.urlencoded({ extended: false }));
|
|||||||
// Setup (first-time account creation)
|
// Setup (first-time account creation)
|
||||||
app.get('/dashboard/setup', (req, res) => {
|
app.get('/dashboard/setup', (req, res) => {
|
||||||
if (hasUsers()) return res.redirect('/dashboard');
|
if (hasUsers()) return res.redirect('/dashboard');
|
||||||
res.sendFile(path.join(__dirname, 'public', 'dashboard-setup.html'));
|
sendHtml(res, 'dashboard-setup.html');
|
||||||
});
|
});
|
||||||
|
|
||||||
// First-run setup creates the initial admin account
|
// First-run setup creates the initial admin account
|
||||||
@@ -1380,7 +1543,7 @@ app.post('/dashboard/setup', (req, res) => {
|
|||||||
app.get('/dashboard/login', (req, res) => {
|
app.get('/dashboard/login', (req, res) => {
|
||||||
if (!hasUsers()) return res.redirect('/dashboard/setup');
|
if (!hasUsers()) return res.redirect('/dashboard/setup');
|
||||||
if (currentUser(req)) return res.redirect('/dashboard');
|
if (currentUser(req)) return res.redirect('/dashboard');
|
||||||
res.sendFile(path.join(__dirname, 'public', 'dashboard-login.html'));
|
sendHtml(res, 'dashboard-login.html');
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/dashboard/login', (req, res) => {
|
app.post('/dashboard/login', (req, res) => {
|
||||||
@@ -1408,7 +1571,7 @@ app.get('/dashboard/logout', (_req, res) => {
|
|||||||
// Password recovery: the daily token (printed in the server log) resets the primary admin account
|
// Password recovery: the daily token (printed in the server log) resets the primary admin account
|
||||||
app.get('/dashboard/recover', (_req, res) => {
|
app.get('/dashboard/recover', (_req, res) => {
|
||||||
if (!hasUsers()) return res.redirect('/dashboard/setup');
|
if (!hasUsers()) return res.redirect('/dashboard/setup');
|
||||||
res.sendFile(path.join(__dirname, 'public', 'dashboard-recover.html'));
|
sendHtml(res, 'dashboard-recover.html');
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/dashboard/recover', (req, res) => {
|
app.post('/dashboard/recover', (req, res) => {
|
||||||
@@ -1431,16 +1594,20 @@ app.post('/dashboard/recover', (req, res) => {
|
|||||||
|
|
||||||
// Main dashboard
|
// Main dashboard
|
||||||
app.get('/dashboard', requireDashboardAuth, (_req, res) => {
|
app.get('/dashboard', requireDashboardAuth, (_req, res) => {
|
||||||
res.sendFile(path.join(__dirname, 'public', 'dashboard.html'));
|
sendHtml(res, 'dashboard.html');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Settings page
|
// Settings page
|
||||||
// Account page: everyone can change their own password; admins also manage users here
|
// Account page: everyone can change their own password; admins also manage users here
|
||||||
app.get('/dashboard/account', requireDashboardAuth, (_req, res) => {
|
app.get('/dashboard/account', requireDashboardAuth, (_req, res) => {
|
||||||
res.sendFile(path.join(__dirname, 'public', 'dashboard-account.html'));
|
sendHtml(res, 'dashboard-account.html');
|
||||||
});
|
});
|
||||||
app.get('/dashboard/settings', requireDashboardAuth, requireAdmin, (_req, res) => {
|
app.get('/dashboard/settings', requireDashboardAuth, (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, 'public', 'dashboard-settings.html'));
|
if (req.dashUser.role !== 'admin') {
|
||||||
|
const esc = (t) => String(t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
|
return res.status(403).send(`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><body style="font-family:system-ui;max-width:32rem;margin:3rem auto;padding:0 1rem"><h2>Settings are admin-only</h2><p>You are signed in as <b>${esc(req.dashUser.username)}</b> (role: <b>${esc(req.dashUser.role || 'none')}</b>).</p><p>Sign out and sign in with your admin account, or ask an admin to change your role on the Account page.</p><p><a href="/dashboard/account">Account</a> · <a href="/dashboard/logout">Sign out</a> · <a href="/dashboard">Dashboard</a></p>`);
|
||||||
|
}
|
||||||
|
sendHtml(res, 'dashboard-settings.html');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Downsample into ~`points` time buckets: averages for smooth series, true max/min for
|
// Downsample into ~`points` time buckets: averages for smooth series, true max/min for
|
||||||
@@ -1471,17 +1638,255 @@ function bucketReadings(readings, points) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── NWS reference station (official observations for a ZIP code) ──────────────
|
||||||
|
// Resolves the ZIP to its nearest NWS observation station, logs every observation it publishes
|
||||||
|
// (normalised to imperial units + the raw METAR), keeps hourly-forecast snapshots, and serves the
|
||||||
|
// data for the dashboard's overlay. Base URLs are overridable so it can be tested offline.
|
||||||
|
const NWS_BASE = process.env.NWS_BASE || 'https://api.weather.gov';
|
||||||
|
const ZIP_API = process.env.ZIP_API || 'https://api.zippopotam.us/us';
|
||||||
|
const NWS_DIR = path.join(DATA_DIR, 'nws');
|
||||||
|
const NWS_FC_DIR = path.join(NWS_DIR, 'forecast');
|
||||||
|
fs.mkdirSync(NWS_FC_DIR, { recursive: true });
|
||||||
|
const NWS_HEADERS = { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/geo+json' } };
|
||||||
|
const NWS_ZIP_FALLBACK = { '24550': { lat: 37.22, lon: -79.34, place: 'Evington, VA' } }; // used only if the ZIP lookup is unreachable
|
||||||
|
const nwsState = { running: false, lastError: null, lastPollAt: 0, lastForecastAt: 0, lastObsTs: 0, obsCount: 0, firstObsTs: null };
|
||||||
|
|
||||||
|
const nwsSaved = () => dashAuth?.nws || {};
|
||||||
|
function nwsSave(patch) {
|
||||||
|
dashAuth = { ...(dashAuth || {}), nws: { ...nwsSaved(), ...patch } };
|
||||||
|
try { fs.writeFileSync(DASH_AUTH_FILE, JSON.stringify(dashAuth)); } catch {}
|
||||||
|
}
|
||||||
|
const haversineKm = (a, b, c, d) => { const R = 6371, r = Math.PI / 180, dLat = (c - a) * r, dLon = (d - b) * r; const x = Math.sin(dLat / 2) ** 2 + Math.cos(a * r) * Math.cos(c * r) * Math.sin(dLon / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(x)); };
|
||||||
|
|
||||||
|
async function nwsResolve(force = false) {
|
||||||
|
const cfg = nwsSaved();
|
||||||
|
const zip = cfg.zip || '24550';
|
||||||
|
const res = cfg.resolved;
|
||||||
|
if (!force && res && res.zip === zip && Date.now() - res.resolvedAt < 30 * 86400000) return res;
|
||||||
|
let lat = cfg.lat, lon = cfg.lon, place = null, approx = false;
|
||||||
|
if (lat == null || lon == null) {
|
||||||
|
try {
|
||||||
|
const r = await fetchWithRetry(`${ZIP_API}/${zip}`, { attempts: 2, timeoutMs: 10000 });
|
||||||
|
const z = await r.json();
|
||||||
|
const pl = z.places?.[0];
|
||||||
|
lat = parseFloat(pl.latitude); lon = parseFloat(pl.longitude);
|
||||||
|
place = `${pl['place name']}, ${pl['state abbreviation']}`;
|
||||||
|
} catch (e) {
|
||||||
|
const fb = NWS_ZIP_FALLBACK[zip];
|
||||||
|
if (!fb) throw new Error(`Could not look up ZIP ${zip}`);
|
||||||
|
lat = fb.lat; lon = fb.lon; place = fb.place; approx = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pr = await fetchWithRetry(`${NWS_BASE}/points/${lat.toFixed(4)},${lon.toFixed(4)}`, { attempts: 2, timeoutMs: 12000, fetchOptions: NWS_HEADERS });
|
||||||
|
const pt = (await pr.json()).properties;
|
||||||
|
const sr = await fetchWithRetry(pt.observationStations, { attempts: 2, timeoutMs: 12000, fetchOptions: NWS_HEADERS });
|
||||||
|
const st = (await sr.json()).features?.[0];
|
||||||
|
if (!st) throw new Error('No observation station near this ZIP');
|
||||||
|
const [sLon, sLat] = st.geometry?.coordinates || [null, null];
|
||||||
|
const rl = pt.relativeLocation?.properties;
|
||||||
|
const resolved = {
|
||||||
|
zip, lat, lon, approx, place: place || (rl ? `${rl.city}, ${rl.state}` : zip),
|
||||||
|
stationId: st.properties.stationIdentifier, stationName: st.properties.name, stationLat: sLat, stationLon: sLon,
|
||||||
|
distanceMi: sLat != null ? +(haversineKm(lat, lon, sLat, sLon) * 0.621371).toFixed(1) : null,
|
||||||
|
forecastHourly: pt.forecastHourly, gridId: pt.gridId, timeZone: pt.timeZone, resolvedAt: Date.now(),
|
||||||
|
};
|
||||||
|
nwsSave({ zip, resolved });
|
||||||
|
console.log(`[nws] ZIP ${zip} → ${resolved.stationId} (${resolved.stationName}), ${resolved.distanceMi} mi`);
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nwsQ = (q, kind) => {
|
||||||
|
if (!q || q.value == null || !Number.isFinite(q.value)) return null;
|
||||||
|
const unit = String(q.unitCode || '').split(':').pop(), v = q.value; // e.g. "wmoUnit:km_h-1" -> "km_h-1"
|
||||||
|
switch (kind) {
|
||||||
|
case 'temp': return unit === 'degC' ? v * 9 / 5 + 32 : unit === 'K' ? (v - 273.15) * 9 / 5 + 32 : v;
|
||||||
|
case 'speed': return unit === 'km_h-1' ? v * 0.621371 : unit === 'm_s-1' ? v * 2.23694 : unit === 'kt' ? v * 1.15078 : v;
|
||||||
|
case 'press': return unit === 'Pa' ? v * 0.00029530 : unit === 'hPa' ? v * 0.02953 : v;
|
||||||
|
case 'dist': return unit === 'm' ? v * 0.000621371 : unit === 'km' ? v * 0.621371 : v;
|
||||||
|
case 'len': return unit === 'mm' ? v / 25.4 : unit === 'm' ? v * 39.3701 : v;
|
||||||
|
default: return v;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const r1 = (v, d = 1) => (v == null ? null : +v.toFixed(d));
|
||||||
|
|
||||||
|
function normalizeNwsObs(f) {
|
||||||
|
const p = f.properties || {};
|
||||||
|
const ts = Date.parse(p.timestamp);
|
||||||
|
if (!Number.isFinite(ts)) return null;
|
||||||
|
return {
|
||||||
|
dateutc: ts, ts, station: (p.station || '').split('/').pop() || null,
|
||||||
|
tempf: r1(nwsQ(p.temperature, 'temp')), dewPoint: r1(nwsQ(p.dewpoint, 'temp')), humidity: r1(nwsQ(p.relativeHumidity)),
|
||||||
|
windspeedmph: r1(nwsQ(p.windSpeed, 'speed')), windgustmph: r1(nwsQ(p.windGust, 'speed')), winddir: r1(nwsQ(p.windDirection), 0),
|
||||||
|
baromrelin: r1(nwsQ(p.seaLevelPressure, 'press'), 3), baromabsin: r1(nwsQ(p.barometricPressure, 'press'), 3),
|
||||||
|
visibilityMi: r1(nwsQ(p.visibility, 'dist'), 1),
|
||||||
|
rain1hIn: r1(nwsQ(p.precipitationLastHour, 'len'), 2), rain3hIn: r1(nwsQ(p.precipitationLast3Hours, 'len'), 2), rain6hIn: r1(nwsQ(p.precipitationLast6Hours, 'len'), 2),
|
||||||
|
heatIndexF: r1(nwsQ(p.heatIndex, 'temp')), windChillF: r1(nwsQ(p.windChill, 'temp')),
|
||||||
|
max24F: r1(nwsQ(p.maxTemperatureLast24Hours, 'temp')), min24F: r1(nwsQ(p.minTemperatureLast24Hours, 'temp')),
|
||||||
|
text: p.textDescription || null,
|
||||||
|
clouds: (p.cloudLayers || []).map(c => ({ amount: c.amount, baseFt: c.base?.value != null ? Math.round(c.base.value * 3.28084) : null })),
|
||||||
|
presentWeather: (p.presentWeather || []).map(w => w.weather || w.rawString).filter(Boolean),
|
||||||
|
raw: p.rawMessage || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nwsSeen = new Map(); // day -> Set(ts)
|
||||||
|
function nwsDaySet(day) {
|
||||||
|
let set = nwsSeen.get(day);
|
||||||
|
if (set) return set;
|
||||||
|
set = new Set();
|
||||||
|
try { for (const l of fs.readFileSync(path.join(NWS_DIR, `${day}.jsonl`), 'utf8').split('\n')) if (l) { try { set.add(JSON.parse(l).ts); } catch {} } } catch {}
|
||||||
|
nwsSeen.set(day, set);
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
function nwsSaveObs(rec) {
|
||||||
|
const day = new Date(rec.ts).toISOString().slice(0, 10);
|
||||||
|
const set = nwsDaySet(day);
|
||||||
|
if (set.has(rec.ts)) return false;
|
||||||
|
fs.appendFileSync(path.join(NWS_DIR, `${day}.jsonl`), JSON.stringify(rec) + '\n');
|
||||||
|
set.add(rec.ts);
|
||||||
|
nwsState.obsCount++;
|
||||||
|
if (rec.ts > nwsState.lastObsTs) nwsState.lastObsTs = rec.ts;
|
||||||
|
if (!nwsState.firstObsTs || rec.ts < nwsState.firstObsTs) nwsState.firstObsTs = rec.ts;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nwsFetchObservations(res, limit) {
|
||||||
|
const r = await fetchWithRetry(`${NWS_BASE}/stations/${res.stationId}/observations?limit=${limit}`, { attempts: 2, timeoutMs: 15000, fetchOptions: NWS_HEADERS });
|
||||||
|
const feats = (await r.json()).features || [];
|
||||||
|
let added = 0;
|
||||||
|
for (const f of feats.reverse()) { const rec = normalizeNwsObs(f); if (rec && nwsSaveObs(rec)) added++; }
|
||||||
|
return { seen: feats.length, added };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nwsSnapshotForecast(res) {
|
||||||
|
if (!res.forecastHourly) return;
|
||||||
|
const r = await fetchWithRetry(res.forecastHourly, { attempts: 2, timeoutMs: 15000, fetchOptions: NWS_HEADERS });
|
||||||
|
const p = (await r.json()).properties || {};
|
||||||
|
const issued = Date.parse(p.updateTime || p.generatedAt) || Date.now();
|
||||||
|
const day = new Date(issued).toISOString().slice(0, 10);
|
||||||
|
const file = path.join(NWS_FC_DIR, `${day}.jsonl`);
|
||||||
|
try { if (fs.readFileSync(file, 'utf8').split('\n').some(l => l && JSON.parse(l).issued === issued)) return; } catch {}
|
||||||
|
const periods = (p.periods || []).slice(0, 48).map(x => ({
|
||||||
|
start: Date.parse(x.startTime), tempf: x.temperatureUnit === 'C' ? x.temperature * 9 / 5 + 32 : x.temperature,
|
||||||
|
pop: x.probabilityOfPrecipitation?.value ?? null, wind: x.windSpeed, windDir: x.windDirection,
|
||||||
|
humidity: x.relativeHumidity?.value ?? null, dewC: x.dewpoint?.value ?? null, short: x.shortForecast,
|
||||||
|
}));
|
||||||
|
fs.appendFileSync(file, JSON.stringify({ ts: Date.now(), issued, station: res.stationId, zip: res.zip, periods }) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nwsPoll() {
|
||||||
|
if (nwsState.running) return;
|
||||||
|
nwsState.running = true;
|
||||||
|
try {
|
||||||
|
const first = !nwsSaved().backfilled;
|
||||||
|
const res = await nwsResolve();
|
||||||
|
const out = await nwsFetchObservations(res, first ? 500 : 15); // first run: everything NWS still keeps (~7 days)
|
||||||
|
if (first && out.seen) nwsSave({ backfilled: Date.now() });
|
||||||
|
if (Date.now() - nwsState.lastForecastAt > 3 * 3600000) { await nwsSnapshotForecast(res); nwsState.lastForecastAt = Date.now(); }
|
||||||
|
nwsState.lastError = null;
|
||||||
|
nwsState.lastPollAt = Date.now();
|
||||||
|
if (out.added) console.log(`[nws] logged ${out.added} observation(s) from ${res.stationId}`);
|
||||||
|
} catch (e) {
|
||||||
|
nwsState.lastError = e.message;
|
||||||
|
console.error('[nws] poll error:', e.message);
|
||||||
|
}
|
||||||
|
nwsState.running = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadNwsReadings(rangeMs) {
|
||||||
|
const now = Date.now(), cutoff = now - rangeMs;
|
||||||
|
const days = Math.min(Math.ceil(rangeMs / 86400000) + 1, 4000);
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < days; i++) {
|
||||||
|
const day = stationDateKey(now - i * 86400000);
|
||||||
|
try {
|
||||||
|
for (const l of fs.readFileSync(path.join(NWS_DIR, `${day}.jsonl`), 'utf8').split('\n')) {
|
||||||
|
if (!l) continue;
|
||||||
|
try { const o = JSON.parse(l); if (o.ts >= cutoff) { delete o.raw; out.push(o); } } catch {}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => a.ts - b.ts);
|
||||||
|
}
|
||||||
|
// Index what's already on disk so the status shows the right totals after a restart
|
||||||
|
(function nwsIndex() {
|
||||||
|
try {
|
||||||
|
for (const f of fs.readdirSync(NWS_DIR).filter(f => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f)).sort()) {
|
||||||
|
for (const l of fs.readFileSync(path.join(NWS_DIR, f), 'utf8').split('\n')) {
|
||||||
|
if (!l) continue;
|
||||||
|
try { const t = JSON.parse(l).ts; nwsState.obsCount++; if (t > nwsState.lastObsTs) nwsState.lastObsTs = t; if (!nwsState.firstObsTs || t < nwsState.firstObsTs) nwsState.firstObsTs = t; } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
})();
|
||||||
|
setTimeout(nwsPoll, 20 * 1000).unref();
|
||||||
|
setInterval(nwsPoll, 10 * 60 * 1000).unref();
|
||||||
|
|
||||||
// Station history API
|
// Station history API
|
||||||
app.get('/api/station/history', requireDashboardAuth, (req, res) => {
|
app.get('/api/station/history', requireDashboardAuth, (req, res) => {
|
||||||
const rangeMap = { '1h': 3600000, '6h': 21600000, '24h': 86400000, '2d': 172800000, '7d': 604800000, '30d': 2592000000, '90d': 7776000000, '1y': 31536000000, '2y': 63072000000, '3y': 94608000000 };
|
const rangeMap = { '1h': 3600000, '6h': 21600000, '24h': 86400000, '2d': 172800000, '7d': 604800000, '30d': 2592000000, '90d': 7776000000, '1y': 31536000000, '2y': 63072000000, '3y': 94608000000 };
|
||||||
const range = req.query.range || '24h';
|
const range = req.query.range || '24h';
|
||||||
|
const points = parseInt(req.query.points);
|
||||||
|
|
||||||
|
if (range === 'all') {
|
||||||
|
// Everything on disk, always bucketed (default 720 points) so the payload stays small
|
||||||
|
const first = firstStationDay();
|
||||||
|
if (!first) return res.json([]);
|
||||||
|
const rangeAll = Date.now() - Date.parse(first + 'T00:00:00Z') + 2 * 86400000;
|
||||||
|
const pts = points >= 50 ? points : 720;
|
||||||
|
const key = `${first}|${pts}`;
|
||||||
|
if (allHistoryCache.key === key && Date.now() - allHistoryCache.at < 60000) return res.json(allHistoryCache.data);
|
||||||
|
const readingsAll = loadStationReadings(rangeAll);
|
||||||
|
const data = readingsAll.length > pts ? bucketReadings(readingsAll, pts) : readingsAll;
|
||||||
|
allHistoryCache = { key, at: Date.now(), data };
|
||||||
|
return res.json(data);
|
||||||
|
}
|
||||||
|
|
||||||
const rangeMs = rangeMap[range] || rangeMap['24h'];
|
const rangeMs = rangeMap[range] || rangeMap['24h'];
|
||||||
const readings = loadStationReadings(rangeMs);
|
const readings = loadStationReadings(rangeMs);
|
||||||
const points = parseInt(req.query.points);
|
|
||||||
if (points >= 50 && readings.length > points) return res.json(bucketReadings(readings, points));
|
if (points >= 50 && readings.length > points) return res.json(bucketReadings(readings, points));
|
||||||
res.json(readings);
|
res.json(readings);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Earliest day with saved readings (YYYY-MM-DD, UTC file name)
|
||||||
|
let allHistoryCache = { key: null, at: 0, data: null };
|
||||||
|
function firstStationDay() {
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(STATION_DIR).filter(f => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f)).sort();
|
||||||
|
return files.length ? files[0].slice(0, 10) : null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NWS reference data API ───────────────────────────────────────────────────
|
||||||
|
function nwsStatus() {
|
||||||
|
const c = nwsSaved();
|
||||||
|
return { zip: c.zip || '24550', resolved: c.resolved || null, observations: nwsState.obsCount, firstObservation: nwsState.firstObsTs, lastObservation: nwsState.lastObsTs || null, lastPoll: nwsState.lastPollAt || null, lastError: nwsState.lastError };
|
||||||
|
}
|
||||||
|
app.get('/api/nws/config', requireDashboardAuth, (_req, res) => res.json(nwsStatus()));
|
||||||
|
app.post('/api/nws/config', requireDashboardAuth, requireAdmin, async (req, res) => {
|
||||||
|
const zip = String(req.body?.zip || '').trim();
|
||||||
|
if (!/^\d{5}$/.test(zip)) return res.status(400).json({ error: 'Enter a 5-digit US ZIP code' });
|
||||||
|
nwsSave({ zip, resolved: null, lat: undefined, lon: undefined, backfilled: undefined });
|
||||||
|
nwsState.lastObsTs = 0; nwsState.lastForecastAt = 0;
|
||||||
|
try { await nwsResolve(true); } catch (e) { return res.status(502).json({ error: `Could not set up that ZIP: ${e.message}` }); }
|
||||||
|
nwsPoll();
|
||||||
|
res.json(nwsStatus());
|
||||||
|
});
|
||||||
|
app.get('/api/nws/latest', requireDashboardAuth, (_req, res) => {
|
||||||
|
const recent = loadNwsReadings(6 * 3600000);
|
||||||
|
const latest = recent[recent.length - 1] || null;
|
||||||
|
let raw = null;
|
||||||
|
if (latest) { try { const day = new Date(latest.ts).toISOString().slice(0, 10); for (const l of fs.readFileSync(path.join(NWS_DIR, `${day}.jsonl`), 'utf8').split('\n')) { if (l && JSON.parse(l).ts === latest.ts) raw = JSON.parse(l).raw; } } catch {} }
|
||||||
|
res.json({ ...nwsStatus(), latest: latest ? { ...latest, raw } : null });
|
||||||
|
});
|
||||||
|
app.get('/api/nws/history', requireDashboardAuth, (req, res) => {
|
||||||
|
const range = String(req.query.range || '24h');
|
||||||
|
const map = { '1h': 3600000, '6h': 21600000, '24h': 86400000, '2d': 172800000, '7d': 604800000, '30d': 2592000000, '90d': 7776000000, '1y': 31536000000, '2y': 63072000000, '3y': 94608000000 };
|
||||||
|
let ms = map[range];
|
||||||
|
if (range === 'all') ms = 4000 * 86400000;
|
||||||
|
res.json(loadNwsReadings(ms || map['24h']));
|
||||||
|
});
|
||||||
|
|
||||||
// Latest station reading
|
// Latest station reading
|
||||||
app.get('/api/station/latest', requireDashboardAuth, async (req, res) => {
|
app.get('/api/station/latest', requireDashboardAuth, async (req, res) => {
|
||||||
if (!ambientServerConfig.apiKey || !ambientServerConfig.appKey) {
|
if (!ambientServerConfig.apiKey || !ambientServerConfig.appKey) {
|
||||||
@@ -1614,6 +2019,7 @@ app.get('/api/station/daily', requireDashboardAuth, (req, res) => {
|
|||||||
if (limit) entries = entries.slice(-limit);
|
if (limit) entries = entries.slice(-limit);
|
||||||
res.json(entries.map(([d, v]) => ({
|
res.json(entries.map(([d, v]) => ({
|
||||||
d, n: v.n || 0,
|
d, n: v.n || 0,
|
||||||
|
slots: v.sl ? (v.sl.match(/1/g) || []).length : null, // 5-min slots with data (of 288)
|
||||||
hi: v.hi?.[0] ?? null, hiT: v.hi?.[1] ?? null, lo: v.lo?.[0] ?? null, loT: v.lo?.[1] ?? null,
|
hi: v.hi?.[0] ?? null, hiT: v.hi?.[1] ?? null, lo: v.lo?.[0] ?? null, loT: v.lo?.[1] ?? null,
|
||||||
avg: v.tn ? +(v.tsum / v.tn).toFixed(1) : null,
|
avg: v.tn ? +(v.tsum / v.tn).toFixed(1) : null,
|
||||||
gust: v.gust?.[0] ?? null, gustT: v.gust?.[1] ?? null,
|
gust: v.gust?.[0] ?? null, gustT: v.gust?.[1] ?? null,
|
||||||
@@ -1978,6 +2384,51 @@ app.get('/api/station/trend', requireDashboardAuth, (_req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Alert history log
|
// Alert history log
|
||||||
|
// ── NWS alert details (looked up by alert id, cached on disk) ────────────────
|
||||||
|
const ALERT_DETAIL_FILE = path.join(DATA_DIR, 'alert-details.json');
|
||||||
|
let alertDetails = {};
|
||||||
|
try { alertDetails = JSON.parse(fs.readFileSync(ALERT_DETAIL_FILE, 'utf8')); } catch {}
|
||||||
|
let alertDetailsDirty = false;
|
||||||
|
function saveAlertDetails() {
|
||||||
|
if (!alertDetailsDirty) return;
|
||||||
|
try { fs.writeFileSync(ALERT_DETAIL_FILE + '.tmp', JSON.stringify(alertDetails)); fs.renameSync(ALERT_DETAIL_FILE + '.tmp', ALERT_DETAIL_FILE); alertDetailsDirty = false; }
|
||||||
|
catch (e) { console.error('[alerts] detail cache save error:', e.message); }
|
||||||
|
}
|
||||||
|
setInterval(saveAlertDetails, 30 * 1000).unref();
|
||||||
|
|
||||||
|
const ALERT_ID_RE = /^https:\/\/api\.weather\.gov\/alerts\/[A-Za-z0-9:._-]+$/;
|
||||||
|
const alertLookupsInFlight = new Map();
|
||||||
|
async function lookupAlertDetail(id) {
|
||||||
|
const hit = alertDetails[id];
|
||||||
|
if (hit && !hit.unavailable) return hit;
|
||||||
|
if (hit && hit.unavailable && Date.now() - hit.at < 6 * 3600000) return hit; // don't hammer NWS for gone alerts
|
||||||
|
if (alertLookupsInFlight.has(id)) return alertLookupsInFlight.get(id);
|
||||||
|
const job = (async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetchWithRetry(id, { attempts: 1, timeoutMs: 10000, fetchOptions: { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/geo+json' } } });
|
||||||
|
const f = await r.json();
|
||||||
|
const p = f.properties || f;
|
||||||
|
if (!p.event) throw new Error('no event');
|
||||||
|
alertDetails[id] = { ...nwsLogFields(p), fetchedAt: Date.now() };
|
||||||
|
} catch (e) {
|
||||||
|
alertDetails[id] = { unavailable: true, at: Date.now() };
|
||||||
|
}
|
||||||
|
alertDetailsDirty = true;
|
||||||
|
alertLookupsInFlight.delete(id);
|
||||||
|
return alertDetails[id];
|
||||||
|
})();
|
||||||
|
alertLookupsInFlight.set(id, job);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/dashboard/alert-detail', requireDashboardAuth, async (req, res) => {
|
||||||
|
const id = String(req.query.id || '');
|
||||||
|
if (!ALERT_ID_RE.test(id)) return res.status(400).json({ error: 'Invalid alert id' });
|
||||||
|
const d = await lookupAlertDetail(id);
|
||||||
|
if (d.unavailable) return res.status(404).json({ error: 'NWS no longer provides this alert' });
|
||||||
|
res.json(d);
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/dashboard/alert-history', requireDashboardAuth, (req, res) => {
|
app.get('/api/dashboard/alert-history', requireDashboardAuth, (req, res) => {
|
||||||
const days = Math.min(90, Math.max(1, parseInt(req.query.days) || 30));
|
const days = Math.min(90, Math.max(1, parseInt(req.query.days) || 30));
|
||||||
const results = [];
|
const results = [];
|
||||||
@@ -1993,6 +2444,12 @@ app.get('/api/dashboard/alert-history', requireDashboardAuth, (req, res) => {
|
|||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
// Fill in text/area for older entries from the detail cache when we have it
|
||||||
|
for (const e of results) {
|
||||||
|
if (e.type !== 'nws' || e.description || !e.alertId) continue;
|
||||||
|
const d = alertDetails[e.alertId];
|
||||||
|
if (d && !d.unavailable) Object.assign(e, Object.fromEntries(Object.entries(d).filter(([k, v]) => v != null && e[k] == null)));
|
||||||
|
}
|
||||||
results.sort((a, b) => b.ts - a.ts);
|
results.sort((a, b) => b.ts - a.ts);
|
||||||
res.json(results);
|
res.json(results);
|
||||||
});
|
});
|
||||||
|
|||||||