evcc-io/assets/js/components/Tariff/TariffChart.vue

297 lines
6.9 KiB
Vue

<template>
<div class="root position-relative">
<div
class="chart scroll-overlay-fix position-relative"
:class="{ 'chart--with-target': targetText, inactive }"
>
<div
v-for="(slot, index) in slots"
:key="`${slot.day}-${fmtHourMinute(slot.start)}`"
:data-index="index"
class="slot user-select-none"
:class="{
active: slot.charging,
hovered: activeIndex === index,
toLate: slot.toLate,
warning: slot.warning,
'cursor-pointer': slot.selectable,
faded: activeIndex !== null && activeIndex !== index,
}"
@touchstart="startLongPress(index)"
@touchend="cancelLongPress"
@touchmove="cancelLongPress"
@mouseenter="hoverSlot(index)"
@mouseleave="hoverSlot(null)"
@mouseup="hoverSlot(null)"
@click="selectSlot(index)"
>
<div
class="slot-bar"
:style="valueStyle(slot.value)"
:class="{ unknown: slot.value === undefined && avgValue }"
></div>
<div class="slot-label">
<span v-if="slot.start.getMinutes() === 0 && slot.start.getHours() % 2 === 0">{{
formatHour(slot.start.getHours())
}}</span>
<br />
<span v-if="showWeekday(index)">{{ slot.day }}</span>
</div>
</div>
<div
v-if="targetText && !targetNearlyOutOfRange"
class="target-inline"
:class="{ 'target-inline--faded': activeIndex !== null }"
:style="{ left: targetLeft }"
>
<div class="target-inline-marker"></div>
<div class="text-nowrap target-inline-text" data-testid="target-text">
{{ targetText }}
</div>
</div>
</div>
<div v-if="targetText && targetNearlyOutOfRange" ref="targetFixed" class="target-fixed">
<div class="text-nowrap" data-testid="target-text">{{ targetText }}</div>
<PlanEndIcon v-if="targetOutOfRange" />
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import "@h2d2/shopicons/es/regular/arrowright";
import { is12hFormat } from "@/units";
import PlanEndIcon from "../MaterialIcon/PlanEnd.vue";
import formatter from "@/mixins/formatter";
import type { Slot } from "@/types/evcc";
const BAR_WIDTH = 8;
export default defineComponent({
name: "TariffChart",
components: {
PlanEndIcon,
},
mixins: [formatter],
props: {
slots: { type: Array as PropType<Slot[]>, default: () => [] },
targetText: [String, null],
targetOffset: { type: Number, default: 0 },
inactive: { type: Boolean, default: false },
},
emits: ["slot-hovered", "slot-selected"],
data() {
return {
activeIndex: null as number | null,
startTime: new Date(),
longPressTimer: undefined as number | undefined,
isLongPress: false,
};
},
computed: {
valueInfo() {
let max = Number.MIN_VALUE;
let min = 0;
this.slots
.map((s) => s.value)
.filter((value) => value !== undefined)
.forEach((value) => {
max = Math.max(max, value);
min = Math.min(min, value);
});
return { min, range: max - min };
},
targetLeft() {
return `${(this.targetOffset / 0.25) * BAR_WIDTH}px`;
},
targetNearlyOutOfRange() {
return this.targetOffset > this.slots.length - 8;
},
targetOutOfRange() {
return this.targetOffset > this.slots.length;
},
avgValue() {
let sum = 0;
let count = 0;
this.slots.forEach((s) => {
if (s.value !== undefined) {
sum += s.value;
count++;
}
});
return sum / count;
},
},
methods: {
hoverSlot(index: number | null) {
this.activeIndex = index;
this.$emit("slot-hovered", index);
},
selectSlot(index: number) {
if (this.isLongPress) {
this.isLongPress = false;
return;
}
if (this.slots[index]?.selectable) {
this.$emit("slot-selected", index);
}
},
showWeekday(index: number) {
const slot = this.slots[index];
if (!slot) {
return false;
}
for (let i = 0; i < 7; i++) {
if (this.slots[index - i]?.isTarget) {
return false;
}
}
if (slot.start.getHours() === 0 && slot.start.getMinutes() === 0) {
return true;
}
return false;
},
valueStyle(value: number | undefined) {
const val = value === undefined ? this.avgValue : value;
const height =
value !== undefined && !isNaN(val)
? `${10 + (90 / this.valueInfo.range) * (val - this.valueInfo.min)}%`
: "50%";
return { height };
},
startLongPress(index: number) {
this.longPressTimer = setTimeout(() => {
this.isLongPress = true;
this.hoverSlot(index);
}, 300) as unknown as number;
},
cancelLongPress() {
clearTimeout(this.longPressTimer);
this.hoverSlot(null);
},
formatHour(hour: number) {
return is12hFormat() ? hour % 12 : hour;
},
},
});
</script>
<style scoped>
.chart {
display: flex;
height: 150px;
overflow-x: auto;
align-items: flex-end;
overflow-y: none;
padding-bottom: 35px;
}
.chart--with-target {
padding-bottom: 57px;
}
.target-inline {
height: 130px;
position: absolute;
top: 0;
display: flex;
align-items: flex-end;
color: var(--bs-primary);
opacity: 1;
transition-property: opacity, color, left;
transition-duration: var(--evcc-transition-fast);
pointer-events: none;
transition-timing-function: ease-in;
}
.target-inline--faded {
opacity: 0.33;
}
.target-fixed {
position: absolute;
background-color: var(--evcc-box);
color: var(--bs-primary);
padding-left: 1rem;
right: 0;
top: 110px;
display: flex;
gap: 0.25rem;
align-items: center;
}
.target-inline-marker {
height: 100%;
border: 1px solid var(--bs-primary);
border-width: 0 0 1px 1px;
width: 0.75rem;
height: 1.5rem;
margin-right: 0.25rem;
margin-bottom: 11px;
}
.slot {
text-align: center;
padding: 2px;
height: 100%;
display: flex;
justify-content: flex-end;
flex-direction: column;
position: relative;
opacity: 1;
transition-property: opacity, background, color;
transition-duration: var(--evcc-transition-fast);
transition-timing-function: ease-in;
width: 8px;
flex-grow: 0;
flex-shrink: 0;
}
.slot-bar {
background-clip: content-box !important;
background: var(--bs-gray-light);
border-radius: 2px;
width: 100%;
align-items: center;
display: flex;
justify-content: center;
color: var(--bs-white);
transition: height var(--evcc-transition-fast) ease-in;
}
.slot-bar.unknown {
opacity: 0.33;
}
.slot-label {
color: var(--bs-gray-light);
line-height: 1.1;
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
text-align: center;
}
.slot.active .slot-bar {
background: var(--bs-primary);
}
.slot.active .slot-label {
color: var(--bs-primary);
}
.slot.toLate {
opacity: 0.33;
}
.slot.active {
opacity: 1;
}
.slot.warning .slot-bar {
background: var(--bs-warning);
}
.slot.warning .slot-label {
color: var(--bs-warning);
}
.slot.hovered {
opacity: 1;
}
.slot.faded {
opacity: 0.33;
}
.chart.inactive .slot.active .slot-bar,
.chart.inactive .slot.warning .slot-bar {
background: var(--bs-gray-medium);
}
.chart.inactive .slot.active .slot-label,
.chart.inactive .slot.warning .slot-label {
color: var(--bs-gray-medium);
}
</style>