Fix alert dedup across reloads, add radar fullscreen, fix swipe conflicts
- Persist alertIdsSeen to localStorage so already-seen alerts don't re-notify every time the PWA is opened; cap stored IDs at 200 entries - Use serviceWorker.showNotification() for alert notifications so they work when the app is backgrounded, with fallback to new Notification() - Add ⛶ fullscreen button to radar embed; uses Fullscreen API and resizes the MapLibre canvas after toggle - Fix swipe-to-change-tab: block gesture when touch starts on a map, hourly scroll row, or radar timeline; also require the gesture to be more horizontal than vertical (dx > dy*1.5) to avoid conflict with vertical scrolling
This commit is contained in:
3 files changed
+87
-4
No files matched your search
+64
-3
@@ -300,6 +300,7 @@ const STORAGE_KEYS = {
|
|||||||
alertThresholds: 'weather.alertThresholds',
|
alertThresholds: 'weather.alertThresholds',
|
||||||
digestHour: 'weather.digestHour',
|
digestHour: 'weather.digestHour',
|
||||||
pwsUrl: 'weather.pwsUrl',
|
pwsUrl: 'weather.pwsUrl',
|
||||||
|
alertIdsSeen: 'weather.alertIdsSeen',
|
||||||
};
|
};
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
@@ -314,7 +315,7 @@ const state = {
|
|||||||
currentAlerts: null,
|
currentAlerts: null,
|
||||||
currentSource: 'startup',
|
currentSource: 'startup',
|
||||||
savedCities: JSON.parse(localStorage.getItem(STORAGE_KEYS.savedCities) || '[]'),
|
savedCities: JSON.parse(localStorage.getItem(STORAGE_KEYS.savedCities) || '[]'),
|
||||||
alertIdsSeen: new Set(),
|
alertIdsSeen: new Set(JSON.parse(localStorage.getItem('weather.alertIdsSeen') || '[]')),
|
||||||
autoRefreshTimer: null,
|
autoRefreshTimer: null,
|
||||||
lastRefreshAt: 0,
|
lastRefreshAt: 0,
|
||||||
lightningMarkers: [],
|
lightningMarkers: [],
|
||||||
@@ -1053,6 +1054,30 @@ const radarEmbed = radarEmbedEl
|
|||||||
)
|
)
|
||||||
: { map: null, panTo: () => {}, startPlay: () => {}, stopPlay: () => {}, refresh: () => {} };
|
: { map: null, panTo: () => {}, startPlay: () => {}, stopPlay: () => {}, refresh: () => {} };
|
||||||
|
|
||||||
|
// ── Radar embed fullscreen toggle ─────────────────────────────────────────────
|
||||||
|
(function initRadarFullscreen() {
|
||||||
|
const btn = document.getElementById('embed-fullscreen');
|
||||||
|
const wrap = document.getElementById('radar-embed-wrap');
|
||||||
|
if (!btn || !wrap) return;
|
||||||
|
|
||||||
|
function updateIcon() {
|
||||||
|
btn.textContent = document.fullscreenElement === wrap ? '✕' : '⛶';
|
||||||
|
btn.title = document.fullscreenElement === wrap ? 'Exit fullscreen' : 'Toggle fullscreen';
|
||||||
|
// MapLibre needs a resize signal after the container size changes.
|
||||||
|
setTimeout(() => radarEmbed.map && radarEmbed.map.resize(), 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if (document.fullscreenElement === wrap) {
|
||||||
|
document.exitFullscreen().catch(() => {});
|
||||||
|
} else {
|
||||||
|
wrap.requestFullscreen().catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('fullscreenchange', updateIcon);
|
||||||
|
})();
|
||||||
|
|
||||||
const radarFullEl = document.getElementById('radar-full');
|
const radarFullEl = document.getElementById('radar-full');
|
||||||
const radarFull = radarFullEl
|
const radarFull = radarFullEl
|
||||||
? createRadar(
|
? createRadar(
|
||||||
@@ -1806,21 +1831,35 @@ function renderAlerts(data) {
|
|||||||
});
|
});
|
||||||
show(wrap);
|
show(wrap);
|
||||||
|
|
||||||
|
const newlySeen = [];
|
||||||
features.forEach(f => {
|
features.forEach(f => {
|
||||||
const id = f.id || f.properties?.id;
|
const id = f.id || f.properties?.id;
|
||||||
if (!id || state.alertIdsSeen.has(id)) return;
|
if (!id || state.alertIdsSeen.has(id)) return;
|
||||||
state.alertIdsSeen.add(id);
|
state.alertIdsSeen.add(id);
|
||||||
|
newlySeen.push(id);
|
||||||
const p = f.properties || {};
|
const p = f.properties || {};
|
||||||
const title = `Weather alert: ${p.event || 'Severe weather'}`;
|
const title = `Weather alert: ${p.event || 'Severe weather'}`;
|
||||||
const body = p.headline || p.description || 'An alert was issued for your selected location.';
|
const body = p.headline || p.description || 'An alert was issued for your selected location.';
|
||||||
|
|
||||||
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
||||||
|
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
||||||
|
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' } })
|
||||||
|
).catch(() => new Notification(title, { body, icon: '/icons/icon-192.png' }));
|
||||||
|
} else {
|
||||||
new Notification(title, { body, icon: '/icons/icon-192.png', badge: '/icons/icon-192.png' });
|
new Notification(title, { body, icon: '/icons/icon-192.png', badge: '/icons/icon-192.png' });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (state.ntfyTopic) {
|
if (state.ntfyTopic) {
|
||||||
sendNtfyNotification(title, body, { priority: 'high', tags: ['warning,weather'] });
|
sendNtfyNotification(title, body, { priority: 'high', tags: ['warning,weather'] });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (newlySeen.length) {
|
||||||
|
// Persist seen IDs; keep only the most recent 200 to bound localStorage size.
|
||||||
|
const stored = Array.from(state.alertIdsSeen).slice(-200);
|
||||||
|
localStorage.setItem('weather.alertIdsSeen', JSON.stringify(stored));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAll(city, source) {
|
async function loadAll(city, source) {
|
||||||
@@ -2477,10 +2516,32 @@ syncOfflineState();
|
|||||||
(function initSwipe() {
|
(function initSwipe() {
|
||||||
const TABS = ['conditions', 'unified-map', 'alerts', 'learn', 'settings'];
|
const TABS = ['conditions', 'unified-map', 'alerts', 'learn', 'settings'];
|
||||||
let touchX = 0;
|
let touchX = 0;
|
||||||
document.addEventListener('touchstart', e => { touchX = e.touches[0].clientX; }, { passive: true });
|
let touchY = 0;
|
||||||
|
let blocked = false;
|
||||||
|
|
||||||
|
function isSwipeBlocked(target) {
|
||||||
|
// Don't intercept touches on maps (panning) or horizontal scroll containers.
|
||||||
|
return !!(
|
||||||
|
target.closest('.maplibregl-map') ||
|
||||||
|
target.closest('#hourly-scroll') ||
|
||||||
|
target.closest('.hourly-track') ||
|
||||||
|
target.closest('.precip-chart-wrap') ||
|
||||||
|
target.closest('.radar-timeline')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('touchstart', e => {
|
||||||
|
touchX = e.touches[0].clientX;
|
||||||
|
touchY = e.touches[0].clientY;
|
||||||
|
blocked = isSwipeBlocked(e.target);
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
document.addEventListener('touchend', e => {
|
document.addEventListener('touchend', e => {
|
||||||
|
if (blocked) return;
|
||||||
const dx = e.changedTouches[0].clientX - touchX;
|
const dx = e.changedTouches[0].clientX - touchX;
|
||||||
if (Math.abs(dx) < 60) return;
|
const dy = e.changedTouches[0].clientY - touchY;
|
||||||
|
// Require the gesture to be more horizontal than vertical, and meet the distance threshold.
|
||||||
|
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
|
||||||
const cur = TABS.findIndex(t => document.querySelector(`.tab[data-tab="${t}"]`)?.classList.contains('active'));
|
const cur = TABS.findIndex(t => document.querySelector(`.tab[data-tab="${t}"]`)?.classList.contains('active'));
|
||||||
if (cur < 0) return;
|
if (cur < 0) return;
|
||||||
const next = dx < 0 ? Math.min(cur + 1, TABS.length - 1) : Math.max(cur - 1, 0);
|
const next = dx < 0 ? Math.min(cur + 1, TABS.length - 1) : Math.max(cur - 1, 0);
|
||||||
|
|||||||
@@ -194,13 +194,14 @@
|
|||||||
<button class="link-btn" id="open-map-tab">Open full map →</button>
|
<button class="link-btn" id="open-map-tab">Open full map →</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="radar-embed-wrap">
|
<div class="radar-embed-wrap" id="radar-embed-wrap">
|
||||||
<div id="radar-embed" class="radar-map"></div>
|
<div id="radar-embed" class="radar-map"></div>
|
||||||
<div class="radar-controls">
|
<div class="radar-controls">
|
||||||
<button id="embed-play" class="radar-btn play-btn" title="Play/Pause">▶</button>
|
<button id="embed-play" class="radar-btn play-btn" title="Play/Pause">▶</button>
|
||||||
<button id="embed-layer" class="radar-btn layer-btn" title="Toggle radar/satellite">R</button>
|
<button id="embed-layer" class="radar-btn layer-btn" title="Toggle radar/satellite">R</button>
|
||||||
<div class="radar-timeline" id="embed-timeline"></div>
|
<div class="radar-timeline" id="embed-timeline"></div>
|
||||||
<span id="embed-timestamp" class="radar-ts">--</span>
|
<span id="embed-timestamp" class="radar-ts">--</span>
|
||||||
|
<button id="embed-fullscreen" class="radar-btn fullscreen-btn" title="Toggle fullscreen">⛶</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1680,6 +1680,27 @@ details[open] .drawer-chevron { transform: rotate(90deg); }
|
|||||||
|
|
||||||
.radar-btn:hover { opacity: 0.72; }
|
.radar-btn:hover { opacity: 0.72; }
|
||||||
.layer-btn { background: var(--accent); }
|
.layer-btn { background: var(--accent); }
|
||||||
|
.fullscreen-btn { font-size: 1rem; }
|
||||||
|
|
||||||
|
/* Fullscreen state for the radar embed */
|
||||||
|
.radar-embed-wrap:fullscreen,
|
||||||
|
.radar-embed-wrap:-webkit-full-screen {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 0;
|
||||||
|
margin: 0;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
.radar-embed-wrap:fullscreen #radar-embed,
|
||||||
|
.radar-embed-wrap:-webkit-full-screen #radar-embed {
|
||||||
|
flex: 1;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
.radar-embed-wrap:fullscreen .radar-controls,
|
||||||
|
.radar-embed-wrap:-webkit-full-screen .radar-controls {
|
||||||
|
background: rgba(0,0,0,0.8);
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
.radar-timeline {
|
.radar-timeline {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
Reference in new issue
Block a user