345 lines
9.1 KiB
Vue
345 lines
9.1 KiB
Vue
<template>
|
|
<div
|
|
class="container container--loadpoint px-0 mb-md-2 d-flex flex-column justify-content-center"
|
|
data-testid="loadpoints"
|
|
>
|
|
<div
|
|
v-if="loadpoints.length > 0"
|
|
ref="carousel"
|
|
class="carousel d-lg-flex flex-wrap"
|
|
:class="[`carousel--${loadpoints.length}`, { 'carousel--fullwidth': fullWidth }]"
|
|
>
|
|
<div
|
|
v-for="loadpoint in loadpoints"
|
|
:key="loadpoint.id"
|
|
class="flex-grow-1 mb-3 m-lg-0 p-lg-0"
|
|
>
|
|
<Loadpoint
|
|
v-bind="loadpoint"
|
|
data-testid="loadpoint"
|
|
:vehicles="vehicles"
|
|
:smartCostType="smartCostType"
|
|
:smartCostAvailable="smartCostAvailable"
|
|
:smartFeedInPriorityAvailable="smartFeedInPriorityAvailable"
|
|
:tariffGrid="tariffGrid"
|
|
:tariffCo2="tariffCo2"
|
|
:tariffFeedIn="tariffFeedIn"
|
|
:currency="currency"
|
|
:multipleLoadpoints="multipleLoadpoints"
|
|
:fullWidth="fullWidth"
|
|
:gridConfigured="gridConfigured"
|
|
:pvConfigured="pvConfigured"
|
|
:batteryConfigured="batteryConfigured"
|
|
:batterySoc="batterySoc"
|
|
:batteryMode="batteryMode"
|
|
:forecast="forecast"
|
|
class="h-100"
|
|
:class="{ 'loadpoint-unselected': !selected(loadpoint.id) }"
|
|
@click="goTo(loadpoint.id)"
|
|
@open-charging-plan-modal="openChargingPlanModal(loadpoint.id)"
|
|
@open-settings-modal="openSettingsModal(loadpoint.id)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div v-if="loadpoints.length > 1" class="d-flex d-lg-none justify-content-center flex-wrap">
|
|
<button
|
|
v-for="loadpoint in loadpoints"
|
|
:key="loadpoint.id"
|
|
class="btn btn-sm btn-link p-0 mx-1 indicator d-flex justify-content-center align-items-center evcc-default-text"
|
|
:class="{ 'indicator--selected': selected(loadpoint.id) }"
|
|
@click="goTo(loadpoint.id)"
|
|
>
|
|
<shopicon-filled-lightning
|
|
v-if="isCharging(loadpoint)"
|
|
class="indicator-icon"
|
|
></shopicon-filled-lightning>
|
|
<shopicon-filled-circle
|
|
v-else-if="loadpoint.connected"
|
|
class="indicator-icon"
|
|
></shopicon-filled-circle>
|
|
<shopicon-bold-circle v-else class="indicator-icon"></shopicon-bold-circle>
|
|
</button>
|
|
</div>
|
|
<div>
|
|
<ChargingPlanModal
|
|
ref="chargingPlanModal"
|
|
:loadpoints="loadpoints"
|
|
:vehicles="vehicles"
|
|
:smartCostType="smartCostType"
|
|
:currency="currency"
|
|
:forecast="forecast"
|
|
/>
|
|
<SettingsModal
|
|
ref="settingsModal"
|
|
:loadpoints="loadpoints"
|
|
:multipleLoadpoints="multipleLoadpoints"
|
|
:currency="currency"
|
|
:tariffGrid="tariffGrid"
|
|
:smartFeedInPriorityAvailable="smartFeedInPriorityAvailable"
|
|
:smartCostAvailable="smartCostAvailable"
|
|
:smartCostType="smartCostType"
|
|
:battery-configured="batteryConfigured"
|
|
:forecast="forecast"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import "@h2d2/shopicons/es/filled/circle";
|
|
import "@h2d2/shopicons/es/bold/circle";
|
|
import "@h2d2/shopicons/es/filled/lightning";
|
|
|
|
import Loadpoint from "./Loadpoint.vue";
|
|
import { defineComponent, type PropType } from "vue";
|
|
import type {
|
|
UiLoadpoint,
|
|
SMART_COST_TYPE,
|
|
Timeout,
|
|
Vehicle,
|
|
BATTERY_MODE,
|
|
UiForecast,
|
|
CURRENCY,
|
|
} from "@/types/evcc";
|
|
import ChargingPlanModal from "../ChargingPlans/ChargingPlanModal.vue";
|
|
import SettingsModal from "../Loadpoints/SettingsModal.vue";
|
|
|
|
export default defineComponent({
|
|
name: "Loadpoints",
|
|
components: { Loadpoint, ChargingPlanModal, SettingsModal },
|
|
props: {
|
|
loadpoints: { type: Array as PropType<UiLoadpoint[]>, default: () => [] },
|
|
vehicles: { type: Array as PropType<Vehicle[]> },
|
|
smartCostType: String as PropType<SMART_COST_TYPE>,
|
|
smartCostAvailable: Boolean,
|
|
smartFeedInPriorityAvailable: Boolean,
|
|
tariffGrid: Number,
|
|
tariffCo2: Number,
|
|
tariffFeedIn: Number,
|
|
currency: String as PropType<CURRENCY>,
|
|
selectedId: String,
|
|
gridConfigured: Boolean,
|
|
pvConfigured: Boolean,
|
|
batteryConfigured: Boolean,
|
|
batterySoc: Number,
|
|
batteryMode: String as PropType<BATTERY_MODE>,
|
|
forecast: Object as PropType<UiForecast>,
|
|
},
|
|
emits: ["id-changed"],
|
|
data() {
|
|
return {
|
|
snapTimeout: null as Timeout,
|
|
scrollTimeout: null as Timeout,
|
|
highlightedIndex: 0,
|
|
viewportHeight: 0 as number,
|
|
};
|
|
},
|
|
computed: {
|
|
selectedIndex() {
|
|
return this.indexById(this.selectedId);
|
|
},
|
|
multipleLoadpoints() {
|
|
return this.loadpoints.length > 1;
|
|
},
|
|
fullWidth() {
|
|
return (
|
|
// breakpoint lg, tall screen, 2 loadpoints rows
|
|
(this.loadpoints.length === 2 && this.viewportHeight >= 1450) ||
|
|
// breakpoint lg, taller screen, 3 loadpoints rows
|
|
(this.loadpoints.length === 3 && this.viewportHeight >= 1900)
|
|
);
|
|
},
|
|
},
|
|
watch: {
|
|
selectedIndex(newIndex) {
|
|
this.scrollTo(newIndex);
|
|
},
|
|
},
|
|
mounted() {
|
|
this.updateViewport();
|
|
window.addEventListener("resize", this.updateViewport);
|
|
|
|
if (this.selectedIndex > 0) {
|
|
this.$refs["carousel"]?.scrollTo({ top: 0, left: this.left(this.selectedIndex) });
|
|
}
|
|
this.$refs["carousel"]?.addEventListener("scroll", this.handleCarouselScroll);
|
|
},
|
|
unmounted() {
|
|
window.removeEventListener("resize", this.updateViewport);
|
|
this.$refs["carousel"]?.removeEventListener("scroll", this.handleCarouselScroll);
|
|
},
|
|
methods: {
|
|
indexById(id: string | undefined) {
|
|
return this.loadpoints.findIndex((lp) => lp.id === id) || 0;
|
|
},
|
|
idByIndex(index: number) {
|
|
return this.loadpoints[index]?.id;
|
|
},
|
|
handleCarouselScroll() {
|
|
const carousel = this.$refs["carousel"] as HTMLElement | undefined;
|
|
if (!carousel || !carousel.children.length) {
|
|
return;
|
|
}
|
|
|
|
const { scrollLeft } = carousel;
|
|
const { offsetWidth } = carousel.children[0] as HTMLElement;
|
|
this.highlightedIndex = Math.round((scrollLeft - 7.5) / offsetWidth);
|
|
|
|
// save scroll position to url if not changing for 2s
|
|
if (this.scrollTimeout) {
|
|
clearTimeout(this.scrollTimeout);
|
|
}
|
|
this.scrollTimeout = setTimeout(() => {
|
|
if (this.highlightedIndex !== this.selectedIndex) {
|
|
this.$emit("id-changed", this.idByIndex(this.highlightedIndex));
|
|
}
|
|
}, 2000);
|
|
},
|
|
goTo(id: string) {
|
|
this.$emit("id-changed", id);
|
|
},
|
|
isCharging(lp: UiLoadpoint) {
|
|
return lp.charging && lp.chargePower > 0;
|
|
},
|
|
selected(id: string) {
|
|
return this.highlightedIndex === this.indexById(id);
|
|
},
|
|
updateViewport() {
|
|
this.viewportHeight = window.innerHeight;
|
|
},
|
|
left(index: number) {
|
|
const carousel = this.$refs["carousel"] as HTMLElement | undefined;
|
|
if (!carousel || !carousel.children.length) {
|
|
return 0;
|
|
}
|
|
return (carousel.children[0] as HTMLElement).offsetWidth * index;
|
|
},
|
|
scrollTo(index: number) {
|
|
this.highlightedIndex = index;
|
|
const $carousel = this.$refs["carousel"];
|
|
if ($carousel) {
|
|
$carousel.style.scrollSnapType = "none";
|
|
$carousel?.scrollTo({ top: 0, left: this.left(index), behavior: "smooth" });
|
|
}
|
|
|
|
if (this.snapTimeout) {
|
|
clearTimeout(this.snapTimeout);
|
|
}
|
|
this.snapTimeout = setTimeout(() => {
|
|
if (this.$refs["carousel"]) {
|
|
this.$refs["carousel"].style.scrollSnapType = "x mandatory";
|
|
}
|
|
}, 1000);
|
|
},
|
|
openChargingPlanModal(loadpointId: string) {
|
|
const modal = this.$refs["chargingPlanModal"] as
|
|
| InstanceType<typeof ChargingPlanModal>
|
|
| undefined;
|
|
modal?.open(loadpointId);
|
|
},
|
|
openSettingsModal(loadpointId: string) {
|
|
const modal = this.$refs["settingsModal"] as
|
|
| InstanceType<typeof SettingsModal>
|
|
| undefined;
|
|
modal?.open(loadpointId);
|
|
},
|
|
},
|
|
});
|
|
</script>
|
|
<style scoped>
|
|
@import "../../../css/breakpoints.css";
|
|
|
|
.container--loadpoint:not(:empty) {
|
|
min-height: 300px;
|
|
}
|
|
|
|
@media (max-width: 991.98px) {
|
|
.carousel {
|
|
scroll-snap-type: x mandatory;
|
|
overflow-x: scroll;
|
|
display: flex;
|
|
flex-wrap: nowrap !important;
|
|
scrollbar-width: none; /* Firefox */
|
|
-ms-overflow-style: none; /* IE 10+ */
|
|
}
|
|
.carousel::-webkit-scrollbar {
|
|
display: none; /* Blink, Webkit */
|
|
}
|
|
.carousel > * {
|
|
scroll-snap-align: center;
|
|
min-width: 100%;
|
|
}
|
|
.indicator {
|
|
width: 32px;
|
|
height: 32px;
|
|
opacity: 0.3;
|
|
transition: opacity var(--evcc-transition-fast) ease-in;
|
|
}
|
|
.indicator--selected {
|
|
opacity: 1;
|
|
}
|
|
.indicator-icon {
|
|
width: 18px;
|
|
}
|
|
.loadpoint {
|
|
opacity: 1;
|
|
transform: scale(1);
|
|
transition-property: opacity, transform;
|
|
transition-duration: var(--evcc-transition-fast);
|
|
transition-timing-function: ease-in;
|
|
}
|
|
.loadpoint-unselected {
|
|
transform: scale(0.95);
|
|
opacity: 0.5;
|
|
}
|
|
}
|
|
|
|
/* show truncated tiles on breakpoint sm,md */
|
|
@media (--sm-to-lg) {
|
|
.container--loadpoint {
|
|
max-width: none;
|
|
}
|
|
.carousel > *:first-child {
|
|
margin-left: calc((100vw - var(--slide-width)) / 2);
|
|
}
|
|
.carousel > *:last-child {
|
|
margin-right: calc((100vw - var(--slide-width)) / 2);
|
|
}
|
|
/* fixes safari issue with end-side padding https://webplatform.news/issues/2019-08-07 */
|
|
.carousel::after {
|
|
content: "";
|
|
padding-right: 0.02px;
|
|
}
|
|
.carousel > * {
|
|
min-width: var(--slide-width);
|
|
}
|
|
}
|
|
|
|
/* breakpoint sm */
|
|
@media (--sm-to-md) {
|
|
.carousel {
|
|
--slide-width: 540px;
|
|
}
|
|
}
|
|
|
|
/* breakpoint md */
|
|
@media (--md-to-lg) {
|
|
.carousel {
|
|
--slide-width: 720px;
|
|
}
|
|
}
|
|
|
|
/* breakpoint lg, 2-col grid */
|
|
@media (--lg-and-up) {
|
|
.carousel {
|
|
display: grid !important;
|
|
grid-gap: 2rem;
|
|
grid-template-columns: repeat(auto-fit, minmax(450px, 1fr));
|
|
}
|
|
/* breakpoint lg, full width override */
|
|
.carousel--fullwidth {
|
|
grid-gap: 4rem;
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|