Please see the template. Appreciate your input.
<template>
<v-card v-if="state?.currentStorm" class="mx-auto lightning-card bg-grey-darken-4"
style="max-width: 300px; min-width:300px">
<v-card v-if="state?.currentStorm" class="mx-auto lightning-card bg-grey-darken-4" style="max-width: 300px; min-width:300px">
<div class="d-flex justify-space-between align-center header-bg" style="position: relative; z-index: 10;">
<div class="d-flex align-center">
<v-icon icon="mdi-flash" color="amber" size="large" class="mr-1"
:class="{ 'pulsing-icon': (state?.currentStorm?.frequency > 0) }">
</v-icon>
<div class="d-flex flex-column align-start">
<span class="text-subtitle-1 font-weight-bold text-brown-lighten-4" style="line-height: 1.2rem;">Lightning</span>
<span class="text-grey-darken-1" style="font-size: 0.55rem;">Blitzortung.org</span>
</div>
</div>
<v-chip size="x-small" color="orange-darken-4" variant="flat" style="font-weight: bold;">
Freq: {{ state?.currentStorm?.frequency || 0 }}/min
</v-chip>
<div class="d-flex align-center">
<v-btn icon variant="plain" size="small" @click="toggleMute" class="ma-0">
<v-icon :icon="state?.config?.mute ? 'mdi-volume-off' : 'mdi-volume-high'"
:color="state?.config?.mute ? 'red' : 'green'" size="small"></v-icon>
</v-btn>
<v-btn icon variant="plain" size="small" @click.stop="openSettings" class="ma-0">
<v-icon icon="mdi-cog" color="grey-lighten-1" size="small"></v-icon>
</v-btn>
</div>
</div>
<template v-if="state?.config?.alertThreshold">
<div v-if="state.currentStorm.distance !== null && state.currentStorm.distance <= state.config.alertThreshold"
class="danger-banner text-center py-1 font-weight-bold text-white" style="background-color: #d32f2f;">
⚠️ DANGER: {{ state.currentStorm.distance }}{{ state.config.unit }}
</div>
<div v-else-if="state.currentStorm.distance !== null && state.currentStorm.distance <= state.config.searchRadius"
class="monitoring-banner text-center py-1 font-weight-bold text-white" style="background-color: #2e7d32;">
🪟 MONITORING: {{ state.currentStorm.distance }}{{ state.config.unit }}
</div>
</template>
<v-card-text v-if="state?.history && state.history.length > 0" class="pa-4">
<div class="d-flex justify-space-between align-end mb-4">
<div class="d-flex align-baseline">
<div class="display-value text-brown-lighten-4">{{ state.currentStorm?.distance }}</div>
<div class="unit-text ml-1">{{ state.config?.unit }}</div>
</div>
<div class="text-right d-flex flex-column align-center" style="min-width: 100px;">
<v-icon icon="mdi-navigation"
:style="{ transform: `rotate(${state.currentStorm?.bearing || 0}deg)`, transition: 'transform 0.5s' }"
size="34" color="brown-lighten-4"></v-icon>
<div :class="['trend-text font-weight-black mt-1 text-capitalize', trendColor ]">
{{ state.currentStorm?.trend }}
</div>
<div class="unit-text ml-1">
{{ getDir(state.currentStorm?.bearing) }}
</div>
</div>
</div>
<v-sparkline :model-value="sparklineValues" :gradient="['#4caf50', '#ffeb3b', '#f44336']" smooth="10"
line-width="3" height="50" fill padding="4"></v-sparkline>
<v-expansion-panels v-model="panel" variant="accordion" class="mt-4 custom-panels">
<v-expansion-panel class="bg-transparent" value="strikes">
<v-expansion-panel-title class="pa-0 text-capitalize text-caption font-weight-bold white--text">
Recent Strikes
</v-expansion-panel-title>
<v-expansion-panel-text>
<div v-for="(strike, i) in recentStrikes" :key="i"
class="d-flex justify-space-between py-1 text-caption border-bottom-dim">
<span class="text-grey-lighten-1">{{ formatTime(strike.time) }}</span>
<span class="font-weight-bold text-orange-lighten-2">{{ strike.dist }}{{ state?.config?.unit }}</span>
<span class="white--text">{{ getDir(strike.bearing) }}</span>
</div>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
<v-card-text v-else class="pa-10 text-center text-grey-darken-1">
<v-icon icon="mdi-shield-check-outline" size="48" class="mb-2"></v-icon>
<div class="text-caption font-weight-bold text-uppercase">Quiet</div>
</v-card-text>
<div class="px-5 py-1 footer-bg border-top-dim rounded-b-lg">
<div class="d-flex justify-space-between align-center text-caption font-weight-bold">
<span class="text-orange" style="font-size: 0.7rem;">Area: {{ state?.config?.searchRadius }}{{ state?.config?.unit }}</span>
<div class="text-center">
<div class="text-green-accent-2 d-flex align-center justify-center" style="font-size: 0.65rem;">
<v-icon icon="mdi-pulse" size="10" class="mr-1 pulse-icon"></v-icon>
{{ lastUpdated }}
</div>
</div>
<span class="text-orange" style="font-size: 0.7rem;">Alert: {{ state?.config?.alertThreshold }}{{ state?.config?.unit }}</span>
</div>
</div>
<v-dialog v-model="showModal" max-width="325px" persistent>
<v-card class="bg-grey-darken-4 text-white">
<v-card-title class="text-h6 font-weight-bold">
<v-icon icon="mdi-cog" color="grey-lighten-1" size="small"></v-icon> Settings
</v-card-title>
<v-card-text>
<div class="text-caption text-grey-lighten-1 mb-1">📏 Unit</div>
<v-btn-toggle v-model="localConfig.unit" mandatory color="orange" density="compact" class="mb-4">
<v-btn value="mi" size="small">Mi</v-btn>
<v-btn value="km" size="small">Km</v-btn>
</v-btn-toggle>
<div class="pa-3 bg-grey-darken-4 rounded mb-4">
<div class="text-overline mb-2">🏠 Location</div>
<v-row dense>
<v-col cols="6">
<v-text-field v-model.number="localConfig.homeLocation.lat" label="Lat" density="compact" variant="outlined"
hide-details type="number" step="0.0001"></v-text-field>
</v-col>
<v-col cols="6">
<v-text-field v-model.number="localConfig.homeLocation.lon" label="Lon" density="compact" variant="outlined"
hide-details type="number" step="0.0001"></v-text-field>
</v-col>
</v-row>
</div>
<div class="pa-3 bg-grey-darken-4 rounded mb-3">
<div class="text-overline mb-1 text-capitalize">🕑 Reset Time ({{ localConfig.resetTime }})</div>
<div class="d-flex align-center ga-2">
<v-btn-toggle v-model="localConfig.resetTime" color="primary" mandatory density="compact"
class="flex-grow-1">
<v-btn :value="5" size="x-small">5</v-btn>
<v-btn :value="10" size="x-small">10</v-btn>
<v-btn :value="30" size="x-small">30</v-btn>
<v-btn :value="60" size="x-small">60</v-btn>
</v-btn-toggle>
</div>
</div>
<div class="pa-3 bg-grey-darken-4 rounded mb-3">
<div class="text-overline mb-1 text-capitalize">🌎 Search Radius ({{ localConfig.unit }})</div>
<v-text-field v-model.number="localConfig.searchRadius" type="number" density="compact" variant="solo-filled"
hide-details @input="localConfig.searchRadius = Math.min(localConfig.searchRadius, 12450)"
:rules="[v => v <= 12450 || 'Max Earth radius is 12450mi']"></v-text-field>
</div>
<div class="pa-3 bg-grey-darken-4 rounded mb-3">
<div class="text-overline mb-1 text-capitalize">⚡ Alert Threshold ({{ localConfig.unit }})</div>
<v-text-field v-model.number="localConfig.alertThreshold" type="number" density="compact" variant="solo-filled"
hide-details @input="localConfig.alertThreshold = Math.min(localConfig.alertThreshold, 12450)"
:rules="[v => v <= 12450 || 'Max Earth radius is 12450mi']"></v-text-field>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn variant="text" @click="showModal = false">Cancel</v-btn>
<v-btn color="orange" variant="flat" @click="saveSettings">Save</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>
<script>
export default {
data() {
return {
state: {
history: [],
config: { searchRadius: 50, alertThreshold: 25, resetTime: 30, unit: 'mi', mute: false },
currentStorm: { distance: 0, bearing: 0, trend: 'Stationary', status: 'Clear' }
},
showModal: false,
panel: [],
localConfig: { searchRadius: 50, alertThreshold: 25, unit: 'mi' },
expiryTimer: null
};
},
watch: {
'localConfig.unit': function(newUnit, oldUnit) {
if (!oldUnit || newUnit === oldUnit) return;
const factor = 1.60934;
if (newUnit === 'km') {
this.localConfig.alertThreshold = Math.round(this.localConfig.alertThreshold * factor);
this.localConfig.searchRadius = Math.round(this.localConfig.searchRadius * factor);
} else {
this.localConfig.alertThreshold = Math.round(this.localConfig.alertThreshold / factor);
this.localConfig.searchRadius = Math.round(this.localConfig.searchRadius / factor);
}
}
},
mounted() {
this.$socket.on('msg-input:' + this.id, (msg) => {
if (msg?.payload) {
this.state = JSON.parse(JSON.stringify(msg.payload));
}
});
this.startExpiryTimer();
},
unmounted() {
if (this.expiryTimer) clearInterval(this.expiryTimer);
},
methods: {
startExpiryTimer() {
this.expiryTimer = setInterval(() => {
if (this.state?.history?.length > 0) {
const thirtyMinsAgo = Date.now() - (30 * 60 * 1000);
this.state.history = this.state.history.filter(s => s && s.time > thirtyMinsAgo);
}
}, 60000);
},
toggleMute() {
if (!this.state?.config) return;
this.state.config.mute = !this.state.config.mute;
this.send({
topic: "update_config",
payload: { ...this.state.config }
});
},
openSettings() {
const baseConfig = this.state?.config || { searchRadius: 50, alertThreshold: 25, unit: 'mi', mute: false };
this.localConfig = JSON.parse(JSON.stringify(baseConfig));
this.showModal = true;
},
saveSettings() {
this.state.config = { ...this.state.config, ...this.localConfig };
this.send({
topic: "update_config",
payload: { ...this.state.config }
});
this.showModal = false;
},
formatTime(ts) {
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
},
getDir(b) {
const sectors = ["N", "NE", "E", "SE", "S", "SW", "W", "NW", "N"];
return sectors[Math.round((b || 0) / 45) % 8];
},
getTimeAgo(timestamp) {
if (!timestamp) return 'Never';
const seconds = Math.floor((new Date() - new Date(timestamp)) / 1000);
if (seconds < 60) return "Just now";
const minutes = Math.floor(seconds / 60);
return minutes < 60 ? minutes + "m ago" : Math.floor(minutes / 60) + "h ago";
}
},
computed: {
sparklineValues() {
if (!this.state?.history?.length) return [0, 0];
return this.state.history.map(h => 100 - (h.dist || 0));
},
recentStrikes() {
if (!this.state?.history?.length) return [];
const thirtyMinsAgo = Date.now() - (30 * 60 * 1000);
return [...this.state.history]
.filter(s => s && s.time > thirtyMinsAgo)
.reverse()
.slice(0, 5);
},
lastUpdated() {
if (!this.state?.history?.length) return 'No Data';
const latest = this.state.history[this.state.history.length - 1];
return latest?.time ? this.getTimeAgo(latest.time) : 'No Data';
},
trendColor() {
const trend = this.state?.currentStorm?.trend;
if (trend === 'Approaching') return 'text-red-lighten-2';
if (trend === 'Receding') return 'text-green-accent-2';
return 'text-blue-lighten-3';
}
}
}
</script>
<style scoped>
.display-container {display: flex;align-items: baseline;}
.lightning-card { border-radius: 12px; border: 1px solid rgba(255,255,255,0.1); }
.header-bg { background: rgba(0,0,0,0.3); }
.footer-bg { background: rgba(0,0,0,0.5); }
.danger-banner { background: #d32f2f; letter-spacing: 1px; font-size: 0.75rem; }
.border-top-dim { border-top: 1px solid rgba(255,255,255,0.1); }
.border-bottom-dim { border-bottom: 1px solid rgba(255,255,255,0.05); }
.danger-banner { background: #d32f2f; letter-spacing: 0.5px; font-size: 0.75rem; text-transform: uppercase; }
.display-value { font-size: 4.5rem; font-weight: 900; line-height: 1; letter-spacing: -2px; }
.unit-text { font-size: 1.2rem; color: rgba(255,255,255,0.4); margin-left: 2px; }
.trend-text { font-size: 1.1rem; line-height: 1.2; text-transform: uppercase; margin-top: 2px; }
.custom-panels .v-expansion-panel-title { min-height: 32px !important; }
.v-expansion-panel-text__wrapper { padding: 4px 0 !important; }
.pulse-icon {animation: pulse-animation 2s infinite;}
@keyframes pulse-animation {0% { opacity: 1; }50% { opacity: 0.3; }100% { opacity: 1; }}
@keyframes pulse-lightning {
0% { transform: scale(1); filter: drop-shadow(0 0 0px #FFD54F); }
50% { transform: scale(1.2); filter: drop-shadow(0 0 8px #FFD54F); }
100% { transform: scale(1); filter: drop-shadow(0 0 0px #FFD54F); }
}
.pulsing-icon {animation: pulse-lightning 1.5s infinite ease-in-out;}
</style>