Initial commit to GitHub

This commit is contained in:
2018-03-26 15:54:25 -04:00
commit 2124b2e976
58 changed files with 7999 additions and 0 deletions

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -0,0 +1,11 @@
<style lang="scss" src="./PressureTrend.vue.scss" scoped></style>
<script lang="ts" src="./PressureTrend.vue.ts"></script>
<template>
<div class="pressure-arrow-container">
<div class="pressure-arrow">
<v-icon :style="style()">fa-long-arrow-up fa-5x</v-icon>
</div>
</div>
</template>

View File

@@ -0,0 +1,11 @@
.pressure-arrow-container {
height: 100%;
}
.pressure-arrow {
text-align: center;
position: relative;
top: 50%;
transform: translateY(-50%);
}

View File

@@ -0,0 +1,64 @@
import Vue from 'vue';
import { Component, Prop } from 'vue-property-decorator';
import moment from 'moment';
import regression from 'regression';
import { WeatherService, ValueType, HistoryEntry } from '@/services/WeatherService.ts';
@Component
export default class PressureTrend extends Vue {
pressureDifference: number | null = null;
async mounted() {
this.update();
setInterval(this.update, 60000);
}
async update() {
const end: moment.Moment = moment();
const start: moment.Moment = moment(end).subtract(3, 'hours');
const weatherData = await WeatherService.getDeviceHistory(ValueType.Pressure, start.toDate(), end.toDate());
if (!weatherData) {
return;
}
const points: Array<Array<number>> = [];
weatherData[0].Value.forEach((historyEntry: HistoryEntry) => {
if (historyEntry.Value >= 900 && historyEntry.Value <= 1050) {
const point = [moment(historyEntry.ReadTime).unix(), historyEntry.Value];
points.push(point);
}
});
const result = regression.linear(points, { precision: 10 });
const regressionPoints = result.points;
this.pressureDifference = regressionPoints[regressionPoints.length - 1][1] - regressionPoints[0][1];
}
style(): string {
let degrees: number = 0;
if (!this.pressureDifference) {
degrees = 90;
} else if (Math.abs(this.pressureDifference) <= 1.0) {
degrees = 90;
} else if (this.pressureDifference > 1.0 && this.pressureDifference <= 2.0) {
degrees = 60;
} else if (this.pressureDifference > 2.0) {
degrees = 45;
} else if (this.pressureDifference < -1.0 && this.pressureDifference >= -2.0) {
degrees = 115;
} else if (this.pressureDifference < -2.0) {
degrees = 150;
}
return `transform: rotate(${degrees}deg)`;
}
}

15
src/config/Config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { IConfig } from './IConfig';
class Config implements IConfig {
weather = {
host: null,
port: 9090
};
laundry = {
host: null,
port: 9091
};
}
export const config = new Config();

9
src/config/IConfig.ts Normal file
View File

@@ -0,0 +1,9 @@
export interface IServerConfig {
host: string | null;
port: number;
}
export interface IConfig {
weather: IServerConfig;
laundry: IServerConfig;
}

21
src/index.html Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" type="image/png" sizes="32x32" href="static/favicons/favicon-32x32.png?v=oLdWoAx4Jd">
<link rel="icon" type="image/png" sizes="16x16" href="static/favicons/favicon-16x16.png?v=oLdWoAx4Jd">
<link rel="shortcut icon" href="static/favicons/favicon.ico?v=oLdWoAx4Jd">
<link href='https://fonts.googleapis.com/css?family=Roboto:300,400,500,700|Material+Icons' rel="stylesheet">
<title>Home Status</title>
</head>
<body>
<div id="app"></div>
</body>
</html>

35
src/main.ts Normal file
View File

@@ -0,0 +1,35 @@
import Vue from 'vue';
import Vuetify from 'vuetify';
Vue.use(Vuetify);
import Highcharts from 'highcharts';
import VueHighcharts from 'vue-highcharts';
import highchartsMore from 'highcharts/highcharts-more';
import highchartsExport from 'highcharts/modules/exporting';
highchartsMore(Highcharts);
highchartsExport(Highcharts);
Vue.use(VueHighcharts, { Highcharts });
import router from './router';
import App from './views/App/App.vue';
import { WeatherService } from '@/services/WeatherService';
import { LaundryService } from '@/services/LaundryService';
import { config } from '@/config/Config';
Promise.all([
WeatherService.start(config.weather.host || localStorage['host'] || window.location.host, config.weather.port),
LaundryService.start(config.laundry.host || localStorage['host'] || window.location.host, config.laundry.port)
]).then(() => {
new Vue({
el: '#app',
router,
template: '<App/>',
components: { App }
});
});

35
src/router/index.ts Normal file
View File

@@ -0,0 +1,35 @@
import Vue from 'vue';
import Router from 'vue-router';
Vue.use(Router);
import Dashboard from '@/views/Dashboard/Dashboard.vue';
import Laundry from '@/views/Laundry/Laundry.vue';
import Weather from '@/views/Weather/Weather.vue';
import WeatherHistory from '@/views/WeatherHistory/WeatherHistory.vue';
export default new Router({
routes: [
{
path: '/',
name: 'Dashboard',
component: Dashboard
},
{
path: '/laundry',
name: 'Laundry',
component: Laundry
},
{
path: '/weather',
name: 'Weather',
component: Weather
},
{
path: '/weather-history/:type',
name: 'WeatherHistory',
component: WeatherHistory
}
]
});

View File

@@ -0,0 +1,33 @@
import io from 'socket.io-client';
export class LaundryStatus {
washer: boolean = false;
dryer: boolean = false;
}
export class LaundryService {
static socket: SocketIOClient.Socket | null;
static status: LaundryStatus = new LaundryStatus();
static start(server: string, port: number) {
if (this.socket) {
return;
}
this.socket = io(`http://${server}:${port}`);
this.socket.on('status', (statusString: string) => {
const newStatus = JSON.parse(statusString);
if (newStatus.washer !== undefined) {
this.status.washer = newStatus.washer;
}
if (newStatus.dryer !== undefined) {
this.status.dryer = newStatus.dryer;
}
});
this.socket.emit('getStatus');
}
}

View File

@@ -0,0 +1,100 @@
import Vue from 'vue';
import { hubConnection, Connection, Proxy } from 'signalr-no-jquery';
import moment from 'moment';
export class WeatherDeviceReading {
Value: number;
ReadTime: string;
}
export class WindDirectionReading extends WeatherDeviceReading {
WindDirectionString: string;
}
export class RainReading extends WeatherDeviceReading {
Inches: number;
}
export class TemperatureReading extends WeatherDeviceReading {
DegreesF: number;
}
export class WeatherDeviceValue {
ValueType: ValueType;
Current: WeatherDeviceReading;
}
export class WeatherDevice {
Address: string;
DisplayName: string;
Errors: number;
Id: number;
Indoor: boolean;
LastRead: string;
Operations: number;
RefreshFrequency: number;
SupportedValues: Array<ValueType>;
Type: number;
Values: { [valueName: string]: WeatherDeviceValue };
}
export enum ValueType {
Temperature,
Pressure,
Humidity,
WindSpeed,
WindDirection,
Rain
}
export type HistoryEntry = {
ValueType: ValueType;
Value: number;
ReadTime: string;
};
export type HistoryResult = { Key: WeatherDevice, Value: Array<HistoryEntry> };
export class WeatherService {
static deviceMap: { [deviceId: number]: WeatherDevice } = {};
private static connection: Connection;
private static proxy: Proxy;
static async start(server: string, port: number) {
this.connection = hubConnection(`http://${server}:${port}/signalr/`);
this.proxy = this.connection.createHubProxy('weatherHub');
await this.connection.start();
this.proxy.on('deviceRefreshed', (updatedDevice: WeatherDevice) => {
Vue.set(this.deviceMap, updatedDevice.Id.toString(), updatedDevice);
});
const devices = await this.proxy.invoke('getDevices');
devices.forEach((device: WeatherDevice) => {
Vue.set(this.deviceMap, device.Id.toString(), device);
});
}
static async getDeviceHistory(valueType: ValueType, start: Date, end: Date): Promise<any[] | null> {
const startString = moment(start).toISOString();
const endString = moment(end).toISOString();
if (valueType === ValueType.WindDirection) {
const data = await this.proxy.invoke('getWindDirectionHistory', startString, endString);
return data;
} else if (valueType === ValueType.WindSpeed) {
const data = await this.proxy.invoke('getWindSpeedHistory', 5, startString, endString);
return data;
} else {
const data = await this.proxy.invoke('getGenericHistory', valueType, startString, endString);
return data;
}
}
}

51
src/views/App/App.vue Normal file
View File

@@ -0,0 +1,51 @@
<style lang="scss" src="./App.vue.scss"></style>
<script lang="ts" src="./App.vue.ts"></script>
<template>
<v-app id="inspire" light>
<v-navigation-drawer clipped fixed v-model="drawer" app width="250">
<v-list dense>
<span v-for="item in items" v-bind:key="item.title">
<v-list-group v-if="item.items" :value="item.active" v-model="item.expanded">
<v-list-tile slot="item" :to="item.to">
<v-list-tile-action>
<v-icon>{{ item.action }}</v-icon>
</v-list-tile-action>
<v-list-tile-content>
<v-list-tile-title>{{ item.title }}</v-list-tile-title>
</v-list-tile-content>
<v-list-tile-action>
<v-icon>keyboard_arrow_down</v-icon>
</v-list-tile-action>
</v-list-tile>
<v-list-tile v-for="subItem in item.items" v-bind:key="subItem.title" :to="subItem.to">
<v-list-tile-content>
<v-list-tile-title>{{ subItem.title }}</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
</v-list-group>
<v-list-tile v-else slot="item" :to="item.to" exact>
<v-list-tile-action>
<v-icon>{{ item.action }}</v-icon>
</v-list-tile-action>
<v-list-tile-content>
<v-list-tile-title>{{ item.title }}</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
</span>
</v-list>
</v-navigation-drawer>
<v-toolbar app fixed clipped-left color="indigo" dark dense flat>
<v-toolbar-side-icon @click.stop="drawer = !drawer"></v-toolbar-side-icon>
<v-toolbar-title>Home Status</v-toolbar-title>
</v-toolbar>
<v-content>
<v-container fluid fill-height>
<v-layout>
<router-view></router-view>
</v-layout>
</v-container>
</v-content>
</v-app>
</template>

View File

@@ -0,0 +1,43 @@
$fa-font-path: "~font-awesome/fonts";
@import "~font-awesome/scss/font-awesome";
@import '@/../vuetify/dist/vuetify.min.css';
html {
overflow: auto;
}
#app {
font-family: "Avenir", Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.view-container {
overflow-x: hidden;
overflow-y: auto;
width: 100%;
height: 100%;
}
.view-loading-overlay {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
z-index: 998;
background-color: rgba(0, 0, 0, 0.25);
}
.view-loading-progress {
left: 50%;
transform: translate(-50%, -50%);
top: 35%;
z-index: 999;
position: absolute;
}
.container {
position: relative;
}

67
src/views/App/App.vue.ts Normal file
View File

@@ -0,0 +1,67 @@
import Vue from 'vue';
import { Component, Prop } from 'vue-property-decorator';
import { ValueType, WeatherService } from '@/services/WeatherService';
import { LaundryService } from '@/services/LaundryService';
@Component
export default class App extends Vue {
drawer: boolean | null = null;
items: any = [
{
action: 'home',
title: 'Dashboard',
to: '/'
},
{
action: 'local_laundry_service',
title: 'Laundry',
to: '/laundry'
},
{
action: 'cloud',
title: 'Weather',
to: '/weather'
},
{
action: 'multiline_chart',
title: 'Weather Charts',
expanded: false,
route: 'WeatherHistory',
items: [
{
title: 'Temperature',
to: '/weather-history/' + ValueType.Temperature
},
{
title: 'Pressure',
to: '/weather-history/' + ValueType.Pressure
},
{
title: 'Humidity',
to: '/weather-history/' + ValueType.Humidity
},
{
title: 'Wind direction',
to: '/weather-history/' + ValueType.WindDirection
},
{
title: 'Wind speed',
to: '/weather-history/' + ValueType.WindSpeed
},
{
title: 'Rain',
to: '/weather-history/' + ValueType.Rain
}
]
}
];
async mounted() {
this.items.forEach((item: any) => {
if (item.route) {
item.expanded = this.$route.name === item.route;
}
});
}
}

View File

@@ -0,0 +1,69 @@
<style lang="scss" src="./Dashboard.vue.scss" scoped></style>
<script lang="ts" src="./Dashboard.vue.ts"></script>
<style lang="scss" src="./Grid.scss"></style>
<template>
<div id="dashboard-container" v-if="ready">
<v-toolbar height="42" flat>
<v-spacer></v-spacer>
<v-btn small outline color="grey darken-1" @click="startEdit()">
<v-icon left class="button-icon">edit</v-icon>
Edit
</v-btn>
<v-btn small outline color="grey darken-1" @click="toggleLocked()">
<v-icon left class="button-icon">lock</v-icon>
{{ locked ? 'Unlock' : 'Lock' }}
</v-btn>
</v-toolbar>
<v-dialog class="dashboard-panels-dialog" v-model="editing" persistent width="50%" scrollable>
<v-card>
<v-card-title class="indigo dashboard-panels-header">
<span class="title white--text">
Dashboard Panels
</span>
<v-spacer></v-spacer>
<v-btn icon="icon" v-on:click="editing = false" class="white--text">
<v-icon>close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="dashboard-panels-text">
<div class="dashboard-panels-item" v-for="panel in allPanels" :key="panel.componentName">
<div class="dashboard-panels-item-name">
{{ panel.name }}
</div>
<div>
{{ panel.description }}
</div>
<v-btn class="dashboard-panels-item-button" color="primary" small white--text @click="addPanel(panel)" v-if="!isPanelAdded(panel)">
Add
</v-btn>
<v-btn class="dashboard-panels-item-button" color="primary" small white--text @click="removePanel(panel)" v-if="isPanelAdded(panel)">
Remove
</v-btn>
</div>
</v-card-text>
</v-card>
</v-dialog>
<grid-layout :layout="panels" :col-num="100" :row-height="10" :is-draggable="!locked" :is-resizable="!locked" :vertical-compact="false" :margin="[10, 10]" :use-css-transforms="true" @layout-updated="savePanels">
<grid-item v-for="panel in panels" :key="panel.i" :x="panel.x" :y="panel.y" :w="panel.w" :h="panel.h" :i="panel.i">
<v-card flat class="dashboard-panel">
<v-card-title class="indigo dashboard-panel-header">
<span class="title white--text">
{{ panel.name }}
</span>
<v-spacer></v-spacer>
<v-btn v-show="!locked" small icon title="Remove" @click="removePanel(panel)" dark>
<v-icon>close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="dashboard-panel-text">
<component :is="panel.componentName"></component>
</v-card-text>
</v-card>
</grid-item>
</grid-layout>
</div>
</template>

View File

@@ -0,0 +1,79 @@
.vue-grid-item {
border-radius: 6px;
}
.vue-grid-item:not(.vue-grid-placeholder) {
border: 1px solid #dddddd;
}
#dashboard-container {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
.button-icon {
font-size: 20px;
margin-right: 8px;
}
.dashboard-panel {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
overflow: hidden;
}
.dashboard-panel-header {
height: 26px;
min-height: 26px;
padding-right: 0;
& .title {
font-size: 10pt !important;
}
& button .icon {
font-size: 12pt;
}
}
.dashboard-panel-text {
padding: 6px 14px;
height: calc(100% - 34px);
}
.dashboard-panels-header {
height: 48px;
min-height: 48px;
padding-right: 0;
}
.dashboard-panels-text {
padding: 0;
height: 50vh;
}
.dashboard-panels-item {
padding: 10px 15px;
position: relative;
&:nth-child(2) {
background-color: #eee;
}
}
.dashboard-panels-item-name {
font-size: 13pt;
font-weight: bold;
}
.dashboard-panels-item-button {
position: absolute;
top: 12px;
right: 5px;
}

View File

@@ -0,0 +1,102 @@
import Vue from 'vue';
import { Component, Prop } from 'vue-property-decorator';
import VueGridLayout from 'vue-grid-layout';
import Laundry from '@/views/Laundry/Laundry.vue';
import Weather from '@/views/Weather/Weather.vue';
import PressureTrend from '@/components/PressureTrend/PressureTrend.vue';
Vue.component('GridLayout', VueGridLayout.GridLayout);
Vue.component('GridItem', VueGridLayout.GridItem);
Vue.component('Laundry', Laundry);
Vue.component('Weather', Weather);
Vue.component('PressureTrend', PressureTrend);
class DashboardPanel {
name: string;
componentName: string;
description: string;
defaultSize: { height: number, width: number };
}
class DashboardPanelLayout {
x: number;
y: number;
w: number;
h: number;
i: string;
name: string;
componentName: string;
}
@Component
export default class Dashboard extends Vue {
ready: boolean = false;
locked: boolean = true;
editing: boolean = false;
panels: Array<DashboardPanelLayout> = [];
allPanels: Array<DashboardPanel> = [
{ name: 'Weather', componentName: 'Weather', description: 'Text summary of current weather conditions', defaultSize: { height: 10, width: 30 } },
{ name: 'Laundry', componentName: 'Laundry', description: 'Current washer and dryer status', defaultSize: { height: 6, width: 15 } },
{ name: 'Pressure Trend', componentName: 'PressureTrend', description: 'An arrow showing the barometric pressure trend for the last three hours.', defaultSize: { height: 8, width: 20 } }
];
mounted() {
const savedPanels = localStorage.getItem('panels');
if (savedPanels) {
this.panels = JSON.parse(savedPanels);
} else {
this.allPanels.forEach((panel) => this.addPanel(panel));
}
this.ready = true;
}
toggleLocked() {
this.locked = !this.locked;
}
startEdit() {
this.editing = true;
}
savePanels() {
const savedPanels = JSON.stringify(this.panels);
localStorage.setItem('panels', savedPanels);
}
isPanelAdded(panel: DashboardPanel) {
return this.panels.find((currentPanel) => currentPanel.componentName === panel.componentName);
}
addPanel(panel: DashboardPanel) {
this.panels.push({
x: 0,
y: 0,
h: panel.defaultSize.height,
w: panel.defaultSize.width,
i: this.panels.length.toString(),
name: panel.name,
componentName: panel.componentName,
});
this.savePanels();
}
removePanel(panel: DashboardPanel) {
const index = this.panels.findIndex((currentPanel) => currentPanel.componentName === panel.componentName);
this.panels.splice(index, 1);
this.savePanels();
}
}

View File

@@ -0,0 +1,3 @@
.vue-grid-item.vue-grid-placeholder {
background-color: #3f51b5;
}

View File

@@ -0,0 +1,26 @@
<style lang="scss" src="./Laundry.vue.scss" scoped></style>
<script lang="ts" src="./Laundry.vue.ts"></script>
<template>
<div>
<table>
<tr>
<td class="device-name">
Washer
</td>
<td :class="laundryStatus.washer.toString()">
{{ laundryStatus.washer ? 'On' : 'Off' }}
</td>
</tr>
<tr>
<td class="device-name">
Dryer
</td>
<td :class="laundryStatus.dryer.toString()">
{{ laundryStatus.dryer ? 'On' : 'Off' }}
</td>
</tr>
</table>
</div>
</template>

View File

@@ -0,0 +1,13 @@
.device-name {
font-weight: bold;
padding-right: 10px;
text-align: right;
}
.true {
color: darkgoldenrod;
}
.false {
color: darkgreen;
}

View File

@@ -0,0 +1,9 @@
import Vue from 'vue';
import { Component, Prop } from 'vue-property-decorator';
import { LaundryService, LaundryStatus } from '@/services/LaundryService.ts';
@Component
export default class Laundry extends Vue {
laundryStatus: LaundryStatus = LaundryService.status;
}

View File

@@ -0,0 +1,18 @@
<style lang="scss" src="./Weather.vue.scss" scoped></style>
<script lang="ts" src="./Weather.vue.ts"></script>
<template>
<div>
<table>
<tr v-for="device in devices" v-bind:key="device.Id">
<td class="device-name">
{{ device.DisplayName }}
</td>
<td>
{{ formatDevice(device) }}
</td>
</tr>
</table>
</div>
</template>

View File

@@ -0,0 +1,5 @@
.device-name {
font-weight: bold;
padding-right: 10px;
text-align: right;
}

View File

@@ -0,0 +1,60 @@
import Vue from 'vue';
import { Component, Prop } from 'vue-property-decorator';
import { WeatherService, WeatherDevice, ValueType, TemperatureReading, RainReading, WindDirectionReading, WeatherDeviceValue } from '@/services/WeatherService.ts';
@Component
export default class Weather extends Vue {
private deviceMap = WeatherService.deviceMap;
formatDevice(device: WeatherDevice): string {
let valueDisplay: string = '';
for (const value of Object.values(device.Values)) {
switch (value.ValueType) {
case ValueType.Temperature:
const tempReading = value.Current as TemperatureReading;
valueDisplay += ' ' + tempReading.DegreesF.toFixed(2) + '°F';
break;
case ValueType.Humidity:
valueDisplay += ' ' + value.Current.Value.toFixed(2) + '%';
break;
case ValueType.Pressure:
valueDisplay += ' ' + value.Current.Value.toFixed(2) + ' hPa';
break;
case ValueType.Rain:
const rainReading = value.Current as RainReading;
valueDisplay += ' ' + rainReading.Inches.toFixed(2) + '"';
break;
case ValueType.WindSpeed:
valueDisplay += ' ' + value.Current.Value.toFixed(2) + ' MPH';
break;
case ValueType.WindDirection:
const windReading = value.Current as WindDirectionReading;
valueDisplay += ' ' + windReading.WindDirectionString;
break;
}
}
return valueDisplay;
}
get devices(): Array<WeatherDevice> {
return Object.values(this.deviceMap).sort((a, b) => a.Type - b.Type);
}
}

View File

@@ -0,0 +1,35 @@
#chart-subtitle {
cursor: pointer;
color: #1976d2;
&:hover {
text-decoration: underline;
}
}
.chart-day-arrow {
position: relative;
top: -1px;
cursor: not-allowed;
&:hover:not(.disabled) {
text-decoration: underline;
}
&:not(.disabled) {
cursor: pointer;
color: #1976d2;
}
}
#chart-day-previous {
padding-right: 10px;
}
#chart-day-next {
padding-left: 10px;
}
.chart-settings {
margin-left: 250px;
}

View File

@@ -0,0 +1,48 @@
<style lang="scss" src="./WeatherHistory.vue.scss" scoped></style>
<script lang="ts" src="./WeatherHistory.vue.ts"></script>
<style lang="scss" src="./Chart.scss"></style>
<template>
<div class="view-container">
<div class="view-loading-overlay" v-if="loading">
<v-progress-circular indeterminate class="view-loading-progress indigo--text" size="64" />
</div>
<div v-if="ready" id="chart-container">
<v-toolbar height="42" flat>
<v-menu offset-y>
<v-btn small outline slot="activator" color="grey darken-1">
{{ timeSpanItems[selectedTimeSpan] }}
</v-btn>
<v-list dense>
<v-list-tile v-for="(text, value) in timeSpanItems" :key="value" @click="selectedTimeSpan = Number(value)">
<v-list-tile-title>{{ text }}</v-list-tile-title>
</v-list-tile>
</v-list>
</v-menu>
<v-btn v-show="selectedTimeSpan === timeSpans.Day" small outline color="grey darken-1" @click="handleDateArrowClick(-1)">
<v-icon>skip_previous</v-icon>
</v-btn>
<v-menu v-show="selectedTimeSpan === timeSpans.Day" lazy :close-on-content-click="false" v-model="showDateMenu" offset-y full-width>
<v-btn id="date-button" small outline slot="activator" color="grey darken-1">
{{ getSelectedDateDisplayString() }}
</v-btn>
<v-date-picker v-model="selectedDateIsoString" no-title autosave></v-date-picker>
</v-menu>
<v-btn v-show="selectedTimeSpan === timeSpans.Day && !isSelectedDateToday()" small outline color="grey darken-1" @click="handleDateArrowClick(1)">
<v-icon>skip_next</v-icon>
</v-btn>
<v-btn v-show="selectedTimeSpan === timeSpans.Day && !isSelectedDateToday()" small outline color="grey darken-1" @click="resetToToday">
Today
</v-btn>
</v-toolbar>
<highcharts id="chart" :options="chartConfig" ref="highcharts"></highcharts>
</div>
</div>
</template>

View File

@@ -0,0 +1,20 @@
#chart-container {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
#chart {
position: absolute;
top: 42px;
bottom: 0;
left: 0;
right: 0;
}
#date-button {
margin-left: 0;
margin-right: 0;
}

View File

@@ -0,0 +1,360 @@
import Vue from 'vue';
import { Component, Prop, Watch } from 'vue-property-decorator';
import moment from 'moment';
import { WeatherService, ValueType } from '@/services/WeatherService.ts';
import * as Highcharts from 'highcharts';
import { AxisOptions } from 'highcharts';
enum TimeSpan {
Last24Hours,
Day,
Custom
}
@Component
export default class Weather extends Vue {
loading: boolean = true;
ready: boolean = false;
selectedValueType: ValueType | null = null;
selectedTimeSpan: TimeSpan = TimeSpan.Last24Hours;
selectedDate: moment.Moment = moment().startOf('day');
timeSpans: typeof TimeSpan = TimeSpan;
timeSpanItems: { [value: number]: string } = {};
chartConfig: Highcharts.Options | null = null;
showDateMenu: boolean = false;
async mounted() {
Highcharts.setOptions({
global: {
useUTC: false
}
});
this.timeSpanItems[TimeSpan.Last24Hours] = 'Last 24 hours';
this.timeSpanItems[TimeSpan.Day] = 'Day';
this.selectedValueType = Number(this.$route.params['type']);
}
prepareData(deviceList: any, displayName: string = '', valueName: string, minValue?: number, additive?: boolean) {
const chartData: any[] = [];
deviceList.forEach((device: any) => {
let deviceName;
if (typeof device.Key === 'string') {
deviceName = device.Key;
} else {
deviceName = displayName === undefined ? device.Key : device.Key[displayName];
}
const deviceData = {
name: deviceName,
data: [] as any
};
let previousValue: number | null = null;
device.Value.forEach((value: any) => {
let currentValue: number | null = value[valueName];
const readTime = moment(value.ReadTime);
if (minValue && currentValue) {
if (currentValue < minValue) {
currentValue = null;
}
}
if (currentValue != null) {
if (additive && previousValue !== null) {
currentValue += previousValue;
}
}
deviceData.data.push([readTime.valueOf(), currentValue] as any);
previousValue = currentValue;
});
chartData.push(deviceData);
});
return chartData;
}
prepareDataByValueType(valueType: ValueType, deviceData: any): any {
let chartData: any;
switch (valueType) {
case ValueType.Temperature:
chartData = this.prepareData(deviceData, 'DisplayName', 'DegreesF', -40);
return { chartData: chartData, categoryData: undefined };
case ValueType.Pressure:
chartData = this.prepareData(deviceData, 'DisplayName', 'Value', 850);
return { chartData: chartData, categoryData: undefined };
case ValueType.Humidity:
chartData = this.prepareData(deviceData, 'DisplayName', 'Value', 0);
return { chartData: chartData, categoryData: undefined };
case ValueType.WindDirection:
const categoryData: any[] = [];
chartData = [];
deviceData.forEach((device: any) => {
categoryData.push(device.Key);
chartData.push(device.Value);
});
return { chartData: chartData, categoryData: categoryData };
case ValueType.WindSpeed:
chartData = this.prepareData(deviceData, undefined, 'Value');
return { chartData: chartData, categoryData: undefined };
case ValueType.Rain:
chartData = this.prepareData(deviceData, 'DisplayName', 'Inches', undefined, true);
return { chartData: chartData, categoryData: undefined };
default:
return null;
}
}
loadChart(chartData: any, categoryData: any) {
switch (this.selectedValueType) {
case ValueType.Temperature:
this.chartConfig = this.createChartConfig(chartData, 'Temperature', 'Degrees F', '°F');
break;
case ValueType.Pressure:
this.chartConfig = this.createChartConfig(chartData, 'Pressure', 'hPa', ' hPa');
break;
case ValueType.Humidity:
this.chartConfig = this.createChartConfig(chartData, 'Humidity', '%', '%');
break;
case ValueType.WindDirection:
this.chartConfig = {
chart: {
polar: true,
type: 'column'
},
legend: {
enabled: false
},
xAxis: {
categories: categoryData,
tickmarkPlacement: 'on'
},
yAxis: {
labels: {
enabled: false
}
},
plotOptions: {
series: {
shadow: false,
pointPlacement: 'on',
animation: false
},
column: {
groupPadding: 0
}
},
title: {
text: 'Wind Direction'
},
series: [{
type: 'column',
name: 'Samples',
data: chartData
}]
};
break;
case ValueType.WindSpeed:
this.chartConfig = this.createChartConfig(chartData, 'Wind Speed', 'MPH', ' MPH');
if (this.chartConfig.yAxis) {
(this.chartConfig.yAxis as AxisOptions).min = 0;
}
break;
case ValueType.Rain:
this.chartConfig = this.createChartConfig(chartData, 'Rain', '', '"');
if (this.chartConfig.yAxis) {
(this.chartConfig.yAxis as AxisOptions).min = 0;
}
break;
default:
this.chartConfig = this.createChartConfig(null, '', '', '');
break;
}
}
createChartConfig(chartData: any, title: string, yAxisTitle: string, tooltipSuffix: string): Highcharts.Options {
const chartConfig = {
chart: {
type: 'line',
zoomType: 'x'
},
tooltip: {
xDateFormat: '%A %B %e: %I:%M:%S %p',
valueDecimals: 3,
valueSuffix: tooltipSuffix
},
xAxis: {
type: 'datetime',
dateTimeLabelFormats: {
minute: '%I:%M %p',
hour: '%I:%M %p',
second: '%I:%M:%S %p',
day: '%I:%M %p'
}
},
yAxis: {
title: {
text: null
},
labels: {
formatter(): string {
return (this as any).value + tooltipSuffix;
}
}
},
plotOptions: {
series: {
marker: {
enabled: false
},
animation: false
}
},
title: {
text: title,
y: 18
},
series: chartData
};
return chartConfig;
}
@Watch('$route')
onRouteChange() {
this.selectedValueType = Number(this.$route.params['type']);
}
@Watch('selectedValueType')
@Watch('selectedTimeSpan')
@Watch('selectedDate')
async refreshChart() {
if (this.selectedValueType === null) {
return;
}
this.loading = true;
let start: Date;
let end: Date;
if (this.selectedTimeSpan === TimeSpan.Custom) {
// start = moment('2014-01-01 00:00:00 -05:00').toDate();
// end = moment('2015-01-01 00:00:00 -05:00').toDate();
// weatherService.getDailySummary($scope.selectedValueType.id, $scope.selectedDevice.id, start, end).done(data => {
// var preparedData = this.prepareDataByValueType($scope.selectedValueType.id, data);
// this.loadChart($scope, preparedData.chartData, preparedData.categoryData);
// $scope.chartConfig.loading = false;
// $scope.$apply();
// });
} else {
switch (this.selectedTimeSpan) {
case TimeSpan.Last24Hours: {
start = moment().subtract(24, 'h').toDate();
end = moment().toDate();
break;
}
case TimeSpan.Day: {
start = moment(this.selectedDate).startOf('d').toDate();
end = moment(this.selectedDate).endOf('d').toDate();
break;
}
default: {
return;
}
}
const deviceData = await WeatherService.getDeviceHistory(this.selectedValueType, start, end);
const preparedData = this.prepareDataByValueType(this.selectedValueType, deviceData);
this.loadChart(preparedData.chartData, preparedData.categoryData);
this.loading = false;
this.ready = true;
}
}
handleDateArrowClick(value: number) {
this.selectedDate.add(value, 'day');
this.refreshChart();
}
isSelectedDateToday(): boolean {
const isToday = this.selectedDate.startOf('day').isSame(moment().startOf('day'));
return isToday;
}
get selectedDateIsoString(): string {
return this.selectedDate.format('YYYY-MM-DD');
}
set selectedDateIsoString(value: string) {
this.selectedDate = moment(value);
}
getSelectedDateDisplayString(): string {
return this.selectedDate.format('LL');
}
resetToToday() {
this.selectedDate = moment().startOf('day');
}
}