evcc-io/assets/js/components/Vehicles/Soc.vue

577 lines
15 KiB
Vue

<template>
<div class="vehicle-soc">
<div class="progress">
<div v-if="connected" class="bar-fills">
<!-- behind: track from current position up to the limit, muted -->
<div
v-if="showRemaining"
class="progress-bar bar-fill bar-fill--remaining"
role="progressbar"
:class="remainingClass"
:style="remainingStyle"
></div>
<!-- front: current fill (solid color, or heating gradient) -->
<div
v-if="!showWarmBar"
class="progress-bar bar-fill"
role="progressbar"
:class="fillClass"
:style="mainFillStyle"
>
<div v-if="heatingActive && charging" class="heating-stripes"></div>
</div>
<!-- heating without a reading yet: warm half of the scale -->
<div v-else class="progress-bar bar-fill thermal-warm" role="progressbar">
<div v-if="charging" class="heating-stripes"></div>
</div>
</div>
<div
v-show="vehicleLimitSoc"
ref="vehicleLimitSoc"
class="vehicle-limit-soc"
data-bs-toggle="tooltip"
title=" "
:class="{ 'vehicle-limit-soc--active': vehicleLimitSocActive }"
:style="{ left: `${vehicleLimitSocPosition}%` }"
/>
<div
v-show="energyLimitMarkerPosition"
class="energy-limit-marker"
data-bs-toggle="tooltip"
:class="{
'energy-limit-marker--active': energyLimitMarkerActive,
'energy-limit-marker--visible':
energyLimitMarkerPosition !== null && energyLimitMarkerPosition < 100,
}"
:style="{ left: `${energyLimitMarkerPosition}%` }"
/>
<div
v-show="planMarkerAvailable"
class="plan-marker"
data-bs-toggle="tooltip"
:style="{ left: `${planMarkerPosition}%` }"
data-testid="plan-marker"
@click="$emit('plan-clicked')"
>
<shopicon-regular-clock></shopicon-regular-clock>
</div>
</div>
<div class="target">
<input
v-if="socBasedCharging && connected && (!heating || heatingHasTemp)"
type="range"
:min="lowerBound"
:max="upperBound"
:step="step"
:value="visibleLimitSoc"
class="slider"
:class="{ 'slider--active': sliderActive, 'slider--heating': heating }"
:style="{ '--thumb-pos': `${limitPosition}%` }"
@mousedown="changeLimitSocStart"
@touchstart="changeLimitSocStart"
@input="movedLimitSoc"
@mouseup="changeLimitSocEnd"
@touchend="changeLimitSocEnd"
/>
</div>
</div>
</template>
<script lang="ts">
import Tooltip from "bootstrap/js/dist/tooltip";
import "@h2d2/shopicons/es/regular/clock";
import formatter from "@/mixins/formatter";
import { defineComponent, type PropType } from "vue";
import type { LoadpointUi } from "@/types/evcc";
export default defineComponent({
name: "VehicleSoc",
mixins: [formatter],
props: {
connected: Boolean,
vehicleSoc: { type: Number, default: 0 },
vehicleLimitSoc: { type: Number, default: 0 },
enabled: Boolean,
charging: Boolean,
heating: Boolean,
ui: Object as PropType<LoadpointUi>,
minSoc: { type: Number, default: 0 },
minSocNotReached: Boolean,
effectivePlanSoc: { type: Number, default: 0 },
effectiveLimitSoc: Number,
limitEnergy: { type: Number, default: 0 },
planEnergy: { type: Number, default: 0 },
chargedEnergy: { type: Number, default: 0 },
socBasedCharging: Boolean,
socBasedPlanning: Boolean,
},
emits: ["limit-soc-drag", "limit-soc-updated", "plan-clicked"],
data() {
return {
selectedLimitSoc: undefined as number | undefined,
interactionStartScreenY: null,
tooltip: null as Tooltip | null,
dragging: false,
};
},
computed: {
step() {
return this.heating ? 1 : 5;
},
minTemp() {
return this.ui?.minTemp ?? 0;
},
maxTemp() {
return this.ui?.maxTemp ?? 100;
},
lowerBound() {
if (!this.heating) {
return 0;
}
// fixed scale; an out-of-range temp clips to the edge (see toPercent)
return this.maxTemp > this.minTemp ? this.minTemp : 0;
},
upperBound() {
if (!this.heating) {
return 100;
}
return this.maxTemp > this.minTemp ? this.maxTemp : 100;
},
limitWidth() {
// current position plus the remaining span up to the limit (absolute, from 0)
return this.vehicleSocDisplayWidth + (this.remainingSocWidth ?? 0);
},
limitPosition() {
return this.toPercent(this.visibleLimitSoc);
},
heatingHasTemp() {
return this.socBasedCharging && this.vehicleSoc > 0;
},
// heating with a usable reading -> render the temperature gradient.
// emergency (minSocNotReached) falls back to the solid danger color.
heatingActive() {
return this.heating && !this.minSocNotReached;
},
showWarmBar() {
return this.heatingActive && !this.heatingHasTemp;
},
showRemaining() {
return (
this.connected &&
this.enabled &&
this.remainingSocWidth !== null &&
this.remainingSocWidth > 0 &&
(!this.heating || this.heatingHasTemp)
);
},
fillClass() {
if (this.heatingActive) {
return ["thermal"];
}
return [
this.progressColor,
{ "progress-bar-striped": this.charging, "progress-bar-animated": this.charging },
];
},
remainingClass() {
return this.heatingActive ? ["thermal"] : [this.progressColor];
},
mainFillStyle() {
return {
width: `${this.vehicleSocDisplayWidth}%`,
"--temp-pos": this.vehicleSocDisplayWidth,
...this.transition,
};
},
remainingStyle() {
return {
width: `${this.limitWidth}%`,
"--temp-pos": this.vehicleSocDisplayWidth,
...this.transition,
};
},
vehicleLimitSocPosition() {
return this.toPercent(this.vehicleLimitSoc);
},
vehicleSocDisplayWidth() {
if (this.socBasedCharging) {
if (this.vehicleSoc >= 0) {
return this.toPercent(this.vehicleSoc);
}
return 100;
} else {
if (this.maxEnergy) {
return (100 / this.maxEnergy) * (this.chargedEnergy / 1e3);
}
return 100;
}
},
transition() {
if (this.dragging) {
return { transition: "none" };
}
return { transition: "width var(--evcc-transition-fast) linear" };
},
maxEnergy() {
return Math.max(this.planEnergy, this.limitEnergy, this.chargedEnergy / 1e3);
},
vehicleLimitSocActive() {
return this.vehicleLimitSoc > 0 && this.vehicleLimitSoc > this.vehicleSoc;
},
planMarkerPosition(): number {
if (this.socBasedPlanning) {
return this.toPercent(this.effectivePlanSoc);
}
const maxEnergy = Math.max(this.planEnergy, this.limitEnergy);
if (maxEnergy) {
return (100 / maxEnergy) * this.planEnergy;
}
return 0;
},
planMarkerAvailable() {
if (this.socBasedCharging && !this.socBasedPlanning) {
// mixed mode (% limit and kWh plan): hide marker
return false;
}
return this.planMarkerPosition > 0;
},
energyLimitMarkerPosition() {
if (this.socBasedCharging) {
return null;
}
if (this.limitEnergy) {
return (100 / this.maxEnergy) * this.limitEnergy;
}
return 100;
},
energyLimitMarkerActive() {
if (this.socBasedCharging) {
return false;
}
if (this.planEnergy) {
return this.limitEnergy >= this.planEnergy;
}
return true;
},
sliderActive() {
const isBelowVehicleLimit = this.visibleLimitSoc <= (this.vehicleLimitSoc || 100);
const isAbovePlanLimit = this.visibleLimitSoc >= (this.effectivePlanSoc || 0);
return isBelowVehicleLimit && isAbovePlanLimit;
},
progressColor() {
if (this.minSocNotReached) {
return "bg-danger";
}
return "bg-primary";
},
remainingSocWidth() {
if (this.socBasedCharging) {
if (this.vehicleSocDisplayWidth === 100) {
return null;
}
if (this.minSocNotReached) {
return this.toPercent(this.minSoc) - this.vehicleSocDisplayWidth;
}
const limit = Math.min(
this.vehicleLimitSoc || 100,
Math.max(this.visibleLimitSoc, this.effectivePlanSoc || 0)
);
if (limit > this.vehicleSoc) {
return this.toPercent(limit) - this.vehicleSocDisplayWidth;
}
} else {
return 100 - this.vehicleSocDisplayWidth;
}
return null;
},
visibleLimitSoc() {
return Number(this.selectedLimitSoc || this.effectiveLimitSoc);
},
},
watch: {
effectiveLimitSoc() {
this.selectedLimitSoc = this.effectiveLimitSoc;
},
vehicleLimitSoc() {
this.updateTooltip();
},
},
mounted() {
this.updateTooltip();
},
methods: {
toPercent(value: number) {
const span = this.upperBound - this.lowerBound;
if (span <= 0) {
return 0;
}
const pct = ((value - this.lowerBound) / span) * 100;
return Math.min(100, Math.max(0, pct));
},
changeLimitSocStart(e: Event) {
this.dragging = true;
e.stopPropagation();
},
changeLimitSocEnd(e: Event) {
this.dragging = false;
const value = parseInt((e.target as HTMLInputElement).value, 10);
// value changed
if (value !== this.effectiveLimitSoc) {
this.$emit("limit-soc-updated", value);
}
},
movedLimitSoc(e: Event) {
const value = parseInt((e.target as HTMLInputElement).value, 10);
e.stopPropagation();
const minLimit = this.heating ? this.lowerBound : 20;
if (value < minLimit) {
(e.target as HTMLInputElement).value = minLimit.toString();
this.selectedLimitSoc = value;
e.preventDefault();
return false;
}
this.selectedLimitSoc = value;
this.$emit("limit-soc-drag", this.selectedLimitSoc);
return true;
},
updateTooltip() {
if (!this.tooltip) {
this.tooltip = new Tooltip(this.$refs["vehicleLimitSoc"] as HTMLElement);
}
const value = this.heating
? this.fmtTemperature(this.vehicleLimitSoc)
: this.fmtPercentage(this.vehicleLimitSoc);
const key = this.heating ? "heatingStatus" : "vehicleStatus";
const content = `${this.$t(`main.${key}.vehicleLimit`)}: ${value}`;
this.tooltip.setContent({ ".tooltip-inner": content });
},
},
});
</script>
<style scoped>
.vehicle-soc {
--height: 32px;
--thumb-overlap: 6px;
--thumb-width: 12px;
--label-height: 26px;
position: relative;
height: var(--height);
/* 100cqw on the thumb gradient resolves to the bar width */
container-type: inline-size;
}
.progress {
position: relative;
height: 100%;
font-size: 1rem;
background: var(--evcc-background);
}
/* clip wrapper so the fills get the bar's rounded corners; markers stay outside */
.bar-fills {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: inherit;
}
/* both fills stack from the left edge; the remaining one sits behind the front fill */
.bar-fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
overflow: hidden;
}
.bar-fill--remaining {
opacity: 0.5;
}
/* enlarge a sub-window of the gradient and slide it by temp: cold (left) at low
temp -> solid blue, warm (right) at high temp. 500cqw = 5x zoom on the track,
so each state shows a narrow slice and reads as a distinct color. */
.thermal {
background: var(--evcc-heating-gradient);
background-size: 500cqw 100%;
background-position-x: calc(var(--temp-pos, 0) * -4cqw);
background-repeat: no-repeat;
}
/* no reading: full-width bar showing only the warm (right) half of the scale */
.thermal-warm {
right: 0;
background: var(--evcc-heating-gradient);
background-size: 200% 100%;
background-position: right;
}
.heating-stripes {
position: absolute;
inset: 0;
/* same diagonal direction as the charging (bootstrap) striped bar */
background-image: linear-gradient(
45deg,
rgba(255, 255, 255, 0.24) 25%,
transparent 25%,
transparent 50%,
rgba(255, 255, 255, 0.24) 50%,
rgba(255, 255, 255, 0.24) 75%,
transparent 75%,
transparent
);
background-size: 18px 18px;
animation: stripeShift 0.7s linear infinite;
}
@keyframes stripeShift {
from {
background-position: 0 0;
}
to {
background-position: 18px 0;
}
}
@media (prefers-reduced-motion: reduce) {
.heating-stripes {
animation: none;
}
}
.bg-light {
color: var(--bs-gray-dark);
}
.slider {
-webkit-appearance: none;
position: absolute;
top: calc(var(--thumb-overlap) * -1);
height: calc(100% + 2 * var(--thumb-overlap));
width: 100%;
background: transparent;
pointer-events: none;
}
.slider:focus {
outline: none;
}
/* Note: Safari,Chrome,Blink and Firefox specific styles need to be in separate definitions to work */
.slider::-webkit-slider-runnable-track {
position: relative;
background: transparent;
border: none;
height: 100%;
cursor: auto;
/* ensure slider start/end is at the edge and not inside the track */
margin: 0 -6px;
}
.slider::-moz-range-track {
background: transparent;
border: none;
height: 100%;
cursor: auto;
/* ensure slider start/end is at the edge and not inside the track */
margin: 0 -6px;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
position: relative;
margin-left: var(--thumb-width) / 2;
height: 100%;
width: var(--thumb-width);
background-color: var(--evcc-gray);
cursor: grab;
border: none;
opacity: 1;
border-radius: var(--thumb-overlap);
box-shadow: 0 0 6px var(--evcc-background);
pointer-events: auto;
transition: background-color var(--evcc-transition-fast) linear;
}
.slider::-moz-range-thumb {
position: relative;
height: 100%;
width: var(--thumb-width);
background-color: var(--evcc-gray);
cursor: grab;
border: none;
opacity: 1;
border-radius: var(--thumb-overlap);
box-shadow: 0 0 6px var(--evcc-background);
pointer-events: auto;
transition: background-color var(--evcc-transition-fast) linear;
}
.slider--active::-webkit-slider-thumb {
background-color: var(--evcc-dark-green);
}
.slider--active::-moz-range-thumb {
background-color: var(--evcc-dark-green);
}
/* thumb shows the gradient slice at its position, shifted via --thumb-pos */
.slider--heating.slider--active::-webkit-slider-thumb {
background: var(--evcc-heating-gradient);
background-size: 100cqw 100%;
background-position-x: var(--thumb-pos, 50%);
background-repeat: no-repeat;
}
.slider--heating.slider--active::-moz-range-thumb {
background: var(--evcc-heating-gradient);
background-size: 100cqw 100%;
background-position-x: var(--thumb-pos, 50%);
background-repeat: no-repeat;
}
.slider--heating:not(.slider--active)::-webkit-slider-thumb {
background: var(--evcc-gray);
}
.slider--heating:not(.slider--active)::-moz-range-thumb {
background: var(--evcc-gray);
}
.vehicle-limit-soc {
position: absolute;
top: 0;
bottom: 0;
width: 20px;
transform: translateX(-8px);
background-color: transparent;
background-clip: padding-box;
border-width: 0 8px;
border-style: solid;
border-color: transparent;
transition-property: background-color, left;
transition-timing-function: linear;
transition-duration: var(--evcc-transition-fast);
}
.vehicle-limit-soc--active {
background-color: var(--evcc-box);
}
.plan-marker {
position: absolute;
top: 0;
transform: translateX(-50%);
color: var(--evcc-darker-green);
transition-property: color, left, opacity;
transition-timing-function: linear;
cursor: pointer;
transition-duration: var(--evcc-transition-fast);
}
.plan-marker::before {
content: "";
display: block;
height: var(--height);
border-width: 0 10px;
background-clip: padding-box;
border-style: solid;
border-color: transparent;
background-color: var(--evcc-darker-green);
transition: background-color var(--evcc-transition-fast) linear;
}
.energy-limit-marker {
position: absolute;
top: -4px;
bottom: -4px;
transform: translateX(-50%);
width: 4px;
opacity: 0;
background-color: var(--evcc-gray);
transition-property: opacity, left, background-color;
transition-timing-function: linear;
transition-duration: var(--evcc-transition-fast);
}
.energy-limit-marker--active {
background-color: var(--evcc-dark-green);
}
.energy-limit-marker--visible {
opacity: 1;
}
</style>