Animate changing values in UI (#3779)

This commit is contained in:
Michael Geers 2022-07-12 22:06:02 +02:00 • committed by GitHub
parent 815cc58555
commit 5c719fcb03
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 258 additions and 150 deletions

View file

@ -6,6 +6,7 @@ import { createApp, h } from "vue";
import { createMetaManager, plugin as metaPlugin } from "vue-meta";
import api from "./api";
import App from "./views/App.vue";
import VueNumber from "vue-number-animation";
import router from "./router";
import i18n from "./i18n";
import featureflags from "./featureflags";
@ -68,6 +69,7 @@ app.use(router);
app.use(createMetaManager());
app.use(metaPlugin);
app.use(featureflags);
app.use(VueNumber);
window.app = app.mount("#app");
window.setInterval(function () {

View file

@ -0,0 +1,23 @@
<template>
<number :to="to" :format="format" :duration="duration" />
</template>
<script>
const DURATION = 0.5;
export default {
name: "AnimatedNumber",
props: {
to: { type: Number },
format: { type: Function },
},
data() {
return { duration: 0 };
},
watch: {
to: function () {
this.duration = DURATION;
},
},
};
</script>

View file

@ -46,7 +46,9 @@
>
<div class="d-flex justify-content-between align-items-end mb-4">
<h3 class="m-0">In</h3>
<span class="fw-bold">{{ kw(inPower) }}</span>
<span class="fw-bold">
<AnimatedNumber :to="inPower" :format="kw" />
</span>
</div>
<div>
<EnergyflowEntry
@ -76,7 +78,9 @@
>
<div class="d-flex justify-content-between align-items-end mb-4">
<h3 class="m-0">Out</h3>
<span class="fw-bold">{{ kw(outPower) }}</span>
<span class="fw-bold">
<AnimatedNumber :to="outPower" :format="kw" />
</span>
</div>
<div>
<EnergyflowEntry
@ -121,10 +125,11 @@ import "@h2d2/shopicons/es/filled/square";
import Visualization from "./Visualization.vue";
import EnergyflowEntry from "./EnergyflowEntry.vue";
import formatter from "../../mixins/formatter";
import AnimatedNumber from "../AnimatedNumber.vue";
export default {
name: "Energyflow",
components: { Visualization, EnergyflowEntry },
components: { Visualization, EnergyflowEntry, AnimatedNumber },
mixins: [formatter],
props: {
gridConfigured: Boolean,

View file

@ -6,8 +6,9 @@
</span>
<span class="text-nowrap flex-grow-1 ms-3">{{ name }}</span>
<span class="text-end text-nowrap ps-1 fw-bold"
><span v-if="hasSoC">{{ soc }}% / </span>{{ kw(power) }}</span
>
><span v-if="hasSoC">{{ soc }}% / </span>
<AnimatedNumber :to="power" :format="kw" />
</span>
</div>
</template>
@ -18,10 +19,11 @@ import "@h2d2/shopicons/es/regular/home";
import "@h2d2/shopicons/es/regular/car3";
import BatteryIcon from "./BatteryIcon.vue";
import formatter from "../../mixins/formatter";
import AnimatedNumber from "../AnimatedNumber.vue";
export default {
name: "EnergyflowEntry",
components: { BatteryIcon },
components: { BatteryIcon, AnimatedNumber },
mixins: [formatter],
props: {
name: { type: String },

View file

@ -19,34 +19,34 @@
class="site-progress-bar self-consumption"
:style="{ width: widthTotal(selfConsumptionAdjusted) }"
>
<span v-if="powerLabelEnoughSpace(selfConsumption)" class="power">
{{ kw(selfConsumption) }}
</span>
<span v-else-if="powerLabelSomeSpace(selfConsumption)" class="power">
{{ kwNoUnit(selfConsumption) }}
</span>
<AnimatedNumber
v-if="selfConsumption"
class="power"
:to="selfConsumption"
:format="fmtBarValue"
/>
</div>
<div
class="site-progress-bar grid-import"
:style="{ width: widthTotal(gridImportAdjusted) }"
>
<span v-if="powerLabelEnoughSpace(gridImport)" class="power">
{{ kw(gridImport) }}
</span>
<span v-else-if="powerLabelSomeSpace(gridImport)" class="power">
{{ kwNoUnit(gridImport) }}
</span>
<AnimatedNumber
v-if="gridImport"
class="power"
:to="gridImport"
:format="fmtBarValue"
/>
</div>
<div
class="site-progress-bar pv-export"
:style="{ width: widthTotal(pvExportAdjusted) }"
>
<span v-if="powerLabelEnoughSpace(pvExport)" class="power">
{{ kw(pvExport) }}
</span>
<span v-else-if="powerLabelSomeSpace(pvExport)" class="power">
{{ kwNoUnit(pvExport) }}
</span>
<AnimatedNumber
v-if="pvExport"
class="power"
:to="pvExport"
:format="fmtBarValue"
/>
</div>
<div v-if="totalAdjusted <= 0" class="site-progress-bar bg-light border no-wrap w-100">
<span>{{ $t("main.energyflow.noEnergy") }}</span>
@ -76,13 +76,14 @@
import formatter from "../../mixins/formatter";
import BatteryIcon from "./BatteryIcon.vue";
import LabelBar from "./LabelBar.vue";
import AnimatedNumber from "../AnimatedNumber.vue";
import "@h2d2/shopicons/es/regular/car3";
import "@h2d2/shopicons/es/regular/sun";
import "@h2d2/shopicons/es/regular/home";
export default {
name: "Visualization",
components: { BatteryIcon, LabelBar },
components: { BatteryIcon, LabelBar, AnimatedNumber },
mixins: [formatter],
props: {
gridImport: { type: Number, default: 0 },
@ -141,11 +142,9 @@ export default {
if (this.totalAdjusted === 0) return "0%";
return (100 / this.totalAdjusted) * power + "%";
},
kw: function (watt) {
return this.fmtKw(watt, this.valuesInKw, true);
},
kwNoUnit: function (watt) {
return this.fmtKw(watt, this.valuesInKw, false);
fmtBarValue: function (watt) {
const valueInKw = this.powerLabelEnoughSpace(watt);
return this.fmtKw(watt, this.valuesInKw, valueInKw);
},
powerLabelAvailableSpace(power) {
if (this.totalAdjusted === 0) return 0;

View file

@ -1,24 +1,38 @@
<template>
<div class="root">
<div class="mb-2 label">{{ label }}</div>
<div class="mb-2 label" :class="labelClass">{{ label }}</div>
<slot>
<h3 class="value m-0">
{{ value }}
<span v-if="extraValue" class="extraValue d-block d-sm-inline text-nowrap">
<h3 class="value m-0 d-block d-sm-flex align-items-baseline" :class="valueClass">
<AnimatedNumber v-if="valueFmt" :to="value" :format="valueFmt" />
<span v-else>{{ value }}</span>
<div v-if="extraValue" class="extraValue ms-0 ms-sm-1 text-nowrap">
{{ extraValue }}
</span>
</div>
</h3>
</slot>
</div>
</template>
<script>
import AnimatedNumber from "./AnimatedNumber.vue";
export default {
name: "LabelAndValue",
components: { AnimatedNumber },
props: {
label: String,
value: String,
value: [Number, String],
valueFmt: Function,
extraValue: String,
align: { type: String, default: "center" },
},
computed: {
labelClass() {
return `text-${this.align}`;
},
valueClass() {
return `justify-content-${this.align}`;
},
},
};
</script>

View file

@ -33,8 +33,10 @@
<div class="d-flex align-items-center">
<LabelAndValue
:label="$t('main.loadpoint.power')"
:value="fmtKw(chargePower, formatChargePowerInKw)"
:value="chargePower"
:valueFmt="fmtkWUnit"
class="mb-2"
align="start"
/>
<shopicon-regular-lightning
class="text-evcc opacity-transiton"
@ -48,13 +50,18 @@
:class="`opacity-${charging ? '100' : '0'}`"
/>
</div>
<LabelAndValue :label="$t('main.loadpoint.charged')" :value="fmtKWh(chargedEnergy)" />
<LabelAndValue
:label="$t('main.loadpoint.charged')"
:value="fmtKWh(chargedEnergy)"
align="center"
/>
<LabelAndValue
v-if="chargeRemainingDurationInterpolated"
:label="$t('main.loadpoint.remaining')"
:value="`
${fmtShortDuration(chargeRemainingDurationInterpolated)}
${fmtShortDurationUnit(chargeRemainingDurationInterpolated, true)}`"
align="end"
/>
<LabelAndValue
v-else
@ -62,6 +69,7 @@
:value="`
${fmtShortDuration(chargeDurationInterpolated)}
${fmtShortDurationUnit(chargeDurationInterpolated)}`"
align="end"
/>
</div>
<hr class="divider" />
@ -157,9 +165,6 @@ export default {
vehicle: function () {
return this.collectProps(Vehicle);
},
formatChargePowerInKw: function () {
return this.chargePower == 0 || this.chargePower >= 1000;
},
},
watch: {
phaseRemaining() {
@ -211,6 +216,10 @@ export default {
removeTargetTime: function () {
api.delete(this.apiPath("targetcharge"));
},
fmtkWUnit(value) {
const inKw = value == 0 || value >= 1000;
return this.fmtKw(value, inKw);
},
},
};
</script>

View file

@ -7,7 +7,7 @@
>
<button
class="btn btn-link p-0 value text-center"
:class="targetChargeEnabled ? 'evcc-default-text' : 'text-gray-light'"
:class="targetChargeEnabled ? 'evcc-default-text' : 'text-gray'"
data-bs-toggle="modal"
:data-bs-target="`#${modalId}`"
:disabled="disabled"

View file

@ -1,28 +1,31 @@
<template>
<LabelAndValue class="flex-grow-1" :label="$t('main.vehicle.targetSoC')">
<h3 class="value m-0">
<label class="d-inline-block position-relative">
<LabelAndValue class="flex-grow-1" :label="$t('main.vehicle.targetSoC')" align="end">
<h3 class="value m-0 d-block d-sm-flex align-items-baseline justify-content-end">
<label class="position-relative">
<select :value="targetSoc" class="custom-select" @change="change">
<option v-for="{ soc, text } in options" :key="soc" :value="soc">
{{ text }}
</option>
</select>
<span class="text-decoration-underline">{{ targetSoc }}%</span>
<span class="text-decoration-underline">
<AnimatedNumber :to="targetSoc" :format="formatSoC" />
</span>
</label>
<span v-if="estimatedTargetRange" class="extraValue d-block d-sm-inline text-nowrap">
&nbsp;{{ estimatedTargetRange }} km
</span>
<div v-if="estimatedTargetRange" class="extraValue ms-0 ms-sm-1 text-nowrap">
<AnimatedNumber :to="estimatedTargetRange" :format="formatKm" />
</div>
</h3>
</LabelAndValue>
</template>
<script>
import LabelAndValue from "./LabelAndValue.vue";
import AnimatedNumber from "./AnimatedNumber.vue";
export default {
name: "TargetSoCSelect",
components: { LabelAndValue },
components: { LabelAndValue, AnimatedNumber },
props: {
targetSoc: Number,
rangePerSoc: Number,
@ -33,10 +36,10 @@ export default {
options: function () {
const result = [];
for (let soc = 20; soc <= 100; soc += 5) {
let text = `${soc}%`;
let text = this.formatSoC(soc);
const range = this.estimatedRange(soc);
if (range) {
text += ` (${range} km)`;
text += ` (${this.formatKm(range)})`;
}
result.push({ soc, text });
}
@ -56,6 +59,12 @@ export default {
}
return null;
},
formatSoC: function (value) {
return `${Math.round(value)}%`;
},
formatKm: function (value) {
return `${Math.round(value)} km`;
},
},
};
</script>
@ -65,7 +74,7 @@ export default {
font-size: 18px;
}
.extraValue {
color: var(--bs-gray-light);
color: var(--evcc-gray);
font-size: 14px;
}
.custom-select {

View file

@ -11,10 +11,11 @@
<div v-if="vehiclePresent">
<div class="details d-flex flex-wrap justify-content-between">
<LabelAndValue
class="flex-grow-1 text-start"
class="flex-grow-1"
:label="$t('main.vehicle.vehicleSoC')"
:value="vehicleSoC ? `${vehicleSoC}%` : '--'"
:extraValue="vehicleRange ? `${vehicleRange} km` : null"
align="start"
/>
<TargetCharge
class="flex-grow-1 text-center target-charge"

109
dist/assets/index.0f8a9c87.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View file

@ -20,8 +20,8 @@
<meta name="theme-color" content="#020318" />
<title>evcc</title>
<script type="module" crossorigin src="./assets/index.33cb7319.js"></script>
<link rel="stylesheet" href="./assets/index.1555a0f0.css">
<script type="module" crossorigin src="./assets/index.0f8a9c87.js"></script>
<link rel="stylesheet" href="./assets/index.955a5e82.css">
</head>
<body>
<script>

27
package-lock.json generated
View file

@ -17,6 +17,7 @@
"vue-hot-reload-api": "^2.3.4",
"vue-i18n": "^9.1.9",
"vue-meta": "^3.0.0-alpha.2",
"vue-number-animation": "^1.1.2",
"vue-router": "^4.0.12"
},
"devDependencies": {
@ -14617,6 +14618,11 @@
"integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==",
"dev": true
},
"node_modules/gsap": {
"version": "3.10.4",
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.10.4.tgz",
"integrity": "sha512-6QatdkKxXCMfvCW4rM++0RqyLQAzFX5nwl3yHS0XPgkZBkiSEY3VZVbMltrdtsbER/xZonLtyHt684wRp4erlQ=="
},
"node_modules/handlebars": {
"version": "4.7.7",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz",
@ -23915,6 +23921,14 @@
"vue": "^3.0.0"
}
},
"node_modules/vue-number-animation": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vue-number-animation/-/vue-number-animation-1.1.2.tgz",
"integrity": "sha512-3tPgGl8OSWpEVOcoUSl/sikhx8AufYQL+oFAy/xzfQap7o7PeppD3W9WLOn9RvhMi9cXrja5aSeFnfdLS8iFww==",
"dependencies": {
"gsap": "^3.6.0"
}
},
"node_modules/vue-router": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.0.16.tgz",
@ -36144,6 +36158,11 @@
"integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==",
"dev": true
},
"gsap": {
"version": "3.10.4",
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.10.4.tgz",
"integrity": "sha512-6QatdkKxXCMfvCW4rM++0RqyLQAzFX5nwl3yHS0XPgkZBkiSEY3VZVbMltrdtsbER/xZonLtyHt684wRp4erlQ=="
},
"handlebars": {
"version": "4.7.7",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz",
@ -43352,6 +43371,14 @@
"integrity": "sha512-rYeIGkhm1gKBcReEkPHiV6LV2Y6BZTMGTkGw1mQIZTxfFcVJL0srLZsL1zAmLeTGxMrlDYylMQEVSYRkDnwz3A==",
"requires": {}
},
"vue-number-animation": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vue-number-animation/-/vue-number-animation-1.1.2.tgz",
"integrity": "sha512-3tPgGl8OSWpEVOcoUSl/sikhx8AufYQL+oFAy/xzfQap7o7PeppD3W9WLOn9RvhMi9cXrja5aSeFnfdLS8iFww==",
"requires": {
"gsap": "^3.6.0"
}
},
"vue-router": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.0.16.tgz",

View file

@ -22,6 +22,7 @@
"vue-hot-reload-api": "^2.3.4",
"vue-i18n": "^9.1.9",
"vue-meta": "^3.0.0-alpha.2",
"vue-number-animation": "^1.1.2",
"vue-router": "^4.0.12"
},
"devDependencies": {