evcc-io/assets/js/components/Energyflow/Visualization.vue
2024-10-16 14:31:26 +02:00

322 lines
8.9 KiB
Vue

<template>
<div
data-testid="visualization"
class="visualization"
:class="{ 'visualization--ready': visualizationReady }"
>
<div class="label-scale d-flex">
<div class="d-flex justify-content-start flex-grow-1">
<LabelBar v-bind="labelBarProps('top', 'pvProduction')">
<shopicon-regular-sun></shopicon-regular-sun>
</LabelBar>
<LabelBar v-bind="labelBarProps('top', 'batteryDischarge')">
<BatteryIcon :soc="batterySoc" />
</LabelBar>
<LabelBar v-bind="labelBarProps('top', 'gridImport')">
<shopicon-regular-powersupply></shopicon-regular-powersupply>
</LabelBar>
<LabelBar v-bind="labelBarProps('top', 'unknownImport')">
<QuestionIcon />
</LabelBar>
</div>
<div class="label-scale-name">In</div>
</div>
<div ref="site_progress" class="site-progress">
<div class="site-progress-bar self-pv" :style="{ width: widthTotal(selfPvAdjusted) }">
<AnimatedNumber
v-if="selfPv && visualizationReady"
class="power"
:to="selfPv"
:format="fmtBarValue"
/>
</div>
<div
class="site-progress-bar self-battery"
:style="{ width: widthTotal(selfBatteryAdjusted) }"
>
<AnimatedNumber
v-if="selfBattery && visualizationReady"
class="power"
:to="selfBattery"
:format="fmtBarValue"
/>
</div>
<div
class="site-progress-bar grid-import"
:style="{ width: widthTotal(gridImportAdjusted) }"
>
<AnimatedNumber
v-if="gridImport && visualizationReady"
class="power"
:to="gridImport"
:format="fmtBarValue"
/>
</div>
<div
class="site-progress-bar pv-export"
:style="{ width: widthTotal(pvExportAdjusted) }"
>
<AnimatedNumber
v-if="pvExport && visualizationReady"
class="power"
:to="pvExport"
:format="fmtBarValue"
/>
</div>
<div
class="site-progress-bar unknown-output"
:style="{ width: widthTotal(unknownOutput) }"
>
<AnimatedNumber
v-if="unknownOutput && visualizationReady"
class="power"
:to="unknownOutput"
:format="fmtBarValue"
/>
</div>
<div v-if="totalAdjusted <= 0" class="site-progress-bar w-100 grid-import">
<span>{{ fmtW(0, POWER_UNIT.AUTO, true) }}</span>
</div>
</div>
<div class="label-scale d-flex">
<div class="d-flex justify-content-start flex-grow-1">
<LabelBar v-bind="labelBarProps('bottom', 'homePower')">
<shopicon-regular-home></shopicon-regular-home>
</LabelBar>
<LabelBar
v-for="(lp, index) in loadpoints"
:key="index"
v-bind="labelBarProps('bottom', 'loadpoints', lp.power)"
>
<VehicleIcon :names="[lp.icon]" />
</LabelBar>
<LabelBar v-bind="labelBarProps('bottom', 'batteryCharge')">
<BatteryIcon :soc="batterySoc" :gridCharge="batteryGridCharge" />
</LabelBar>
<LabelBar v-bind="labelBarProps('bottom', 'gridExport')">
<shopicon-regular-powersupply></shopicon-regular-powersupply>
</LabelBar>
<LabelBar v-bind="labelBarProps('bottom', 'unknownOutput')">
<QuestionIcon />
</LabelBar>
</div>
<div class="label-scale-name">Out</div>
</div>
<BatteryIcon hold class="battery-hold" :class="{ 'battery-hold--active': batteryHold }" />
</div>
</template>
<script>
import formatter, { POWER_UNIT } from "../../mixins/formatter";
import BatteryIcon from "./BatteryIcon.vue";
import LabelBar from "./LabelBar.vue";
import AnimatedNumber from "../AnimatedNumber.vue";
import VehicleIcon from "../VehicleIcon";
import QuestionIcon from "../MaterialIcon/Question.vue";
import "@h2d2/shopicons/es/regular/sun";
import "@h2d2/shopicons/es/regular/home";
export default {
name: "Visualization",
components: { BatteryIcon, LabelBar, AnimatedNumber, VehicleIcon, QuestionIcon },
mixins: [formatter],
props: {
gridImport: { type: Number, default: 0 },
selfPv: { type: Number, default: 0 },
selfBattery: { type: Number, default: 0 },
pvExport: { type: Number, default: 0 },
loadpoints: { type: Array, default: () => [] },
batteryCharge: { type: Number, default: 0 },
batteryDischarge: { type: Number, default: 0 },
batteryHold: { type: Boolean, default: false },
batteryGridCharge: { type: Boolean, default: false },
pvProduction: { type: Number, default: 0 },
homePower: { type: Number, default: 0 },
batterySoc: { type: Number, default: 0 },
powerUnit: { type: String, default: POWER_UNIT.KW },
inPower: { type: Number, default: 0 },
outPower: { type: Number, default: 0 },
},
data: function () {
return { width: 0 };
},
computed: {
gridExport: function () {
return this.applyThreshold(this.pvExport);
},
totalRaw: function () {
return this.gridImport + this.selfPv + this.selfBattery + this.pvExport;
},
gridImportAdjusted: function () {
return this.applyThreshold(this.gridImport);
},
selfPvAdjusted: function () {
return this.applyThreshold(this.selfPv);
},
selfBatteryAdjusted: function () {
return this.applyThreshold(this.selfBattery);
},
pvExportAdjusted: function () {
return this.applyThreshold(this.pvExport);
},
totalAdjusted: function () {
return (
this.gridImportAdjusted +
this.selfPvAdjusted +
this.selfBatteryAdjusted +
this.pvExportAdjusted
);
},
unknownImport: function () {
// input/output mismatch > 10%
return this.applyThreshold(Math.max(0, this.outPower - this.inPower), 10);
},
unknownOutput: function () {
// input/output mismatch > 10%
return this.applyThreshold(Math.max(0, this.inPower - this.outPower), 10);
},
visualizationReady: function () {
return this.totalAdjusted > 0 && this.width > 0;
},
},
mounted: function () {
this.$nextTick(function () {
window.addEventListener("resize", this.updateElementWidth);
this.updateElementWidth();
});
},
beforeUnmount() {
window.removeEventListener("resize", this.updateElementWidth);
},
methods: {
widthTotal: function (power) {
if (this.totalAdjusted === 0 || power === 0) return "0";
return (100 / this.totalAdjusted) * power + "%";
},
fmtBarValue: function (watt) {
if (!this.enoughSpaceForValue(watt)) {
return "";
}
const withUnit = this.enoughSpaceForUnit(watt);
return this.fmtW(watt, this.powerUnit, withUnit);
},
powerLabelAvailableSpace(power) {
if (this.totalAdjusted === 0) return 0;
const percent = (100 / this.totalAdjusted) * power;
return (this.width / 100) * percent;
},
enoughSpaceForValue(power) {
return this.powerLabelAvailableSpace(power) > 40;
},
enoughSpaceForUnit(power) {
return this.powerLabelAvailableSpace(power) > 60;
},
hideLabelIcon(power, minWidth = 32) {
if (this.totalAdjusted === 0) return true;
const percent = (100 / this.totalAdjusted) * power;
return (this.width / 100) * percent < minWidth;
},
applyThreshold(power, threshold = 2) {
const percent = (100 / this.totalRaw) * power;
return percent < threshold ? 0 : power;
},
updateElementWidth() {
this.width = this.$refs.site_progress.getBoundingClientRect().width;
},
labelBarProps(position, name, val) {
const value = val === undefined ? this[name] : val;
const minWidth = 40;
return {
value,
hideIcon: this.hideLabelIcon(value, minWidth),
style: { "flex-basis": this.widthTotal(value) },
[position]: true,
};
},
},
};
</script>
<style scoped>
.site-progress {
--height: 2.5rem;
height: var(--height);
border-radius: 10px;
display: flex;
overflow: hidden;
margin-right: 1.2rem;
}
.label-scale-name {
color: var(--evcc-gray);
flex-basis: 1.2rem;
flex-grow: 0;
flex-shrink: 0;
writing-mode: tb-rl;
line-height: 1;
text-align: center;
}
.site-progress-bar {
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
position: relative;
width: 0;
}
.visualization--ready .site-progress-bar {
transition-property: width;
transition-duration: var(--evcc-transition-medium);
transition-timing-function: linear;
}
.grid-import {
background-color: var(--evcc-grid);
color: var(--bs-white);
}
html.dark .grid-import {
color: var(--bs-dark);
}
.self-pv {
background-color: var(--evcc-pv);
color: var(--bs-dark);
}
.self-battery {
background-color: var(--evcc-battery);
color: var(--bs-dark);
}
.pv-export {
background-color: var(--evcc-export);
color: var(--bs-dark);
}
.unknown-output {
background-color: var(--evcc-gray);
color: var(--bs-dark);
}
.power {
display: block;
margin: 0 0.2rem;
white-space: nowrap;
overflow: hidden;
}
.visualization--ready :deep(.label-bar) {
transition-property: flex-basis, opacity;
transition-duration: var(--evcc-transition-medium), var(--evcc-transition-fast);
transition-timing-function: linear, ease;
}
.visualization--ready :deep(.label-bar-icon) {
transition-duration: var(--evcc-transition-very-fast), 500ms;
}
.battery-hold {
position: absolute;
top: 2.5rem;
right: -0.25rem;
color: var(--evcc-gray);
opacity: 0;
transition-property: opacity;
transition-duration: var(--evcc-transition-medium);
transition-timing-function: linear;
}
.battery-hold--active {
opacity: 1;
}
</style>