UI: show inactive phases as dots in phase indicator (#33173)
This commit is contained in:
parent
fb120252a0
commit
8ccd72e141
10 changed files with 187 additions and 98 deletions
|
|
@ -2,6 +2,7 @@ import { type Preview, setup } from "@storybook/vue3";
|
|||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import smoothscroll from "smoothscroll-polyfill";
|
||||
import setupI18n from "../assets/js/i18n";
|
||||
import tooltip from "../assets/js/directives/tooltip";
|
||||
import "../assets/css/app.css";
|
||||
import { watchThemeChanges } from "../assets/js/theme";
|
||||
|
||||
|
|
@ -35,6 +36,7 @@ export default {
|
|||
|
||||
setup((app) => {
|
||||
app.use(setupI18n());
|
||||
app.directive("tooltip", tooltip);
|
||||
|
||||
// Mock router-link for Storybook
|
||||
app.component("router-link", {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@
|
|||
--evcc-gray: var(--bs-gray-medium);
|
||||
--evcc-gray-50: #93949e80;
|
||||
--evcc-gray-25: #93949e40;
|
||||
--evcc-gray-15: #93949e26;
|
||||
--evcc-gray-10: #93949e20;
|
||||
--evcc-accent1: var(--evcc-dark-yellow);
|
||||
--evcc-accent2: var(--evcc-darker-green);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { VueHeadMixin, createHead } from "@unhead/vue/client";
|
|||
import App from "./views/App.vue";
|
||||
import setupRouter from "./router.ts";
|
||||
import setupI18n from "./i18n.ts";
|
||||
import tooltip from "./directives/tooltip.ts";
|
||||
import { watchThemeChanges } from "./theme.ts";
|
||||
import { applyUrlSettings } from "./urlSettings.ts";
|
||||
import { appDetection, sendToApp } from "./utils/native";
|
||||
|
|
@ -99,6 +100,7 @@ app.use(i18n);
|
|||
app.use(router);
|
||||
app.use(head);
|
||||
app.mixin(VueHeadMixin);
|
||||
app.directive("tooltip", tooltip);
|
||||
window.app = app.mount("#app");
|
||||
|
||||
watchThemeChanges();
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
<template>
|
||||
<span
|
||||
ref="root"
|
||||
v-tooltip="tooltip"
|
||||
class="d-flex align-items-center gap-2 text-nowrap evcc-gray"
|
||||
:role="tooltip ? 'img' : undefined"
|
||||
:aria-label="tooltip || undefined"
|
||||
:title="tooltip || undefined"
|
||||
:data-bs-toggle="tooltip ? 'tooltip' : undefined"
|
||||
>
|
||||
<span class="d-inline-block rounded-circle status-dot" :class="dotClass"></span>
|
||||
<slot />
|
||||
|
|
@ -14,7 +12,6 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import Tooltip from "bootstrap/js/dist/tooltip";
|
||||
|
||||
type Variant = "success" | "warning" | "danger" | "muted";
|
||||
|
||||
|
|
@ -24,9 +21,6 @@ export default defineComponent({
|
|||
variant: { type: String as PropType<Variant>, default: "muted" },
|
||||
tooltip: { type: String, default: "" },
|
||||
},
|
||||
data() {
|
||||
return { tooltipInstance: null as Tooltip | null };
|
||||
},
|
||||
computed: {
|
||||
dotClass(): string {
|
||||
switch (this.variant) {
|
||||
|
|
@ -41,30 +35,6 @@ export default defineComponent({
|
|||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
tooltip() {
|
||||
this.initTooltip();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.initTooltip();
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.tooltipInstance?.dispose();
|
||||
},
|
||||
methods: {
|
||||
initTooltip() {
|
||||
this.$nextTick(() => {
|
||||
this.tooltipInstance?.dispose();
|
||||
this.tooltipInstance = null;
|
||||
const el = this.$refs["root"] as Element | undefined;
|
||||
if (el && this.tooltip) {
|
||||
// explicit title: bootstrap clears the attr, so :title alone goes stale
|
||||
this.tooltipInstance = new Tooltip(el, { title: this.tooltip });
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import Loadpoints from "./Loadpoints.vue";
|
||||
import type { Meta, StoryFn } from "@storybook/vue3";
|
||||
import { CURRENCY, SMART_COST_TYPE } from "@/types/evcc";
|
||||
import { CHARGE_MODE, CURRENCY, SMART_COST_TYPE } from "@/types/evcc";
|
||||
|
||||
// Create LoadpointCompact structure for the Loadpoints component
|
||||
const createLoadpoint = (opts: any = {}) => {
|
||||
|
|
@ -117,11 +117,18 @@ TwoLoadpoints.args = {
|
|||
index: 0,
|
||||
vehicleName: "vehicle_3",
|
||||
charging: true,
|
||||
chargePower: 8050,
|
||||
power: 8050,
|
||||
chargePower: 1380,
|
||||
power: 1380,
|
||||
connected: true,
|
||||
soc: 66,
|
||||
vehicleSoc: 66,
|
||||
mode: CHARGE_MODE.PV,
|
||||
phasesConfigured: 3,
|
||||
phasesActive: 1,
|
||||
offeredCurrent: 9,
|
||||
chargeCurrents: [6, 0, 0],
|
||||
minCurrent: 6,
|
||||
maxCurrent: 16,
|
||||
}),
|
||||
createLoadpoint({
|
||||
title: "Charging point with a very very very long title that tests layout compatibility!!!",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import Phases from "./Phases.vue";
|
||||
import type { Meta, StoryFn } from "@storybook/vue3";
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
|
||||
export default {
|
||||
title: "Loadpoints/Phases",
|
||||
|
|
@ -9,6 +10,7 @@ export default {
|
|||
},
|
||||
argTypes: {
|
||||
phasesActive: { control: { type: "number" } },
|
||||
phasesConfigured: { control: { type: "number" } },
|
||||
minCurrent: { control: { type: "number" } },
|
||||
maxCurrent: { control: { type: "number" } },
|
||||
offeredCurrent: { control: { type: "number" } },
|
||||
|
|
@ -24,72 +26,117 @@ const Template: StoryFn<typeof Phases> = (args) => ({
|
|||
template: '<Phases v-bind="args" />',
|
||||
});
|
||||
|
||||
export const OnePhase = Template.bind({});
|
||||
OnePhase.args = {
|
||||
phasesActive: 1,
|
||||
const base = {
|
||||
phasesConfigured: 3,
|
||||
minCurrent: 6,
|
||||
maxCurrent: 16,
|
||||
offeredCurrent: 8,
|
||||
chargeCurrents: undefined,
|
||||
};
|
||||
|
||||
export const TwoPhase = Template.bind({});
|
||||
TwoPhase.args = {
|
||||
...OnePhase.args,
|
||||
phasesActive: 2,
|
||||
};
|
||||
// --- expected phases only (no per-phase measurement) ---
|
||||
|
||||
export const ThreePhase = Template.bind({});
|
||||
ThreePhase.args = {
|
||||
...OnePhase.args,
|
||||
phasesActive: 3,
|
||||
};
|
||||
export const NoData = Template.bind({});
|
||||
NoData.args = { ...base, phasesActive: undefined, offeredCurrent: 0 };
|
||||
|
||||
export const RealCurrents = Template.bind({});
|
||||
RealCurrents.args = {
|
||||
...OnePhase.args,
|
||||
phasesActive: 3,
|
||||
offeredCurrent: 13,
|
||||
chargeCurrents: [11, 9, 12],
|
||||
};
|
||||
export const Expected1p = Template.bind({});
|
||||
Expected1p.args = { ...base, phasesActive: 1 };
|
||||
|
||||
export const OnePhaseMoreAvailable = Template.bind({});
|
||||
OnePhaseMoreAvailable.args = {
|
||||
...OnePhase.args,
|
||||
offeredCurrent: 12,
|
||||
chargeCurrents: [6, 0.2, 0],
|
||||
};
|
||||
export const Expected2p = Template.bind({});
|
||||
Expected2p.args = { ...base, phasesActive: 2 };
|
||||
|
||||
export const TwoPhasesActive = Template.bind({});
|
||||
TwoPhasesActive.args = {
|
||||
...OnePhase.args,
|
||||
phasesActive: 2,
|
||||
offeredCurrent: 16,
|
||||
chargeCurrents: [16, 16, 0.3],
|
||||
};
|
||||
export const Expected3p = Template.bind({});
|
||||
Expected3p.args = { ...base, phasesActive: 3 };
|
||||
|
||||
export const AsymetricPhases = Template.bind({});
|
||||
AsymetricPhases.args = {
|
||||
...OnePhase.args,
|
||||
phasesActive: 2,
|
||||
offeredCurrent: 16,
|
||||
chargeCurrents: [8, 0.9, 14],
|
||||
};
|
||||
// --- measured per-phase currents ---
|
||||
|
||||
export const OnlySecondPhase = Template.bind({});
|
||||
OnlySecondPhase.args = {
|
||||
...OnePhase.args,
|
||||
export const Measured1p = Template.bind({});
|
||||
Measured1p.args = { ...base, phasesActive: 1, offeredCurrent: 12, chargeCurrents: [6, 0.2, 0] };
|
||||
|
||||
export const Measured2p = Template.bind({});
|
||||
Measured2p.args = { ...base, phasesActive: 2, offeredCurrent: 16, chargeCurrents: [16, 16, 0.3] };
|
||||
|
||||
export const Measured3p = Template.bind({});
|
||||
Measured3p.args = { ...base, phasesActive: 3, offeredCurrent: 13, chargeCurrents: [11, 9, 12] };
|
||||
|
||||
export const Asymmetric = Template.bind({});
|
||||
Asymmetric.args = { ...base, phasesActive: 2, offeredCurrent: 16, chargeCurrents: [8, 0.9, 14] };
|
||||
|
||||
export const OnlyL2 = Template.bind({});
|
||||
OnlyL2.args = { ...base, phasesActive: 1, offeredCurrent: 13, chargeCurrents: [0, 13, 0] };
|
||||
|
||||
export const MainlyL3 = Template.bind({});
|
||||
MainlyL3.args = {
|
||||
...base,
|
||||
phasesActive: 1,
|
||||
offeredCurrent: 13,
|
||||
chargeCurrents: [0, 13, 0],
|
||||
};
|
||||
|
||||
export const MainlyThirdPhase = Template.bind({});
|
||||
MainlyThirdPhase.args = {
|
||||
...OnePhase.args,
|
||||
phasesActive: 1,
|
||||
offeredCurrent: 10,
|
||||
chargeCurrents: [0.007, 0.009, 5.945],
|
||||
minCurrent: 6,
|
||||
offeredCurrent: 20,
|
||||
maxCurrent: 20,
|
||||
chargeCurrents: [0.007, 0.009, 5.945],
|
||||
};
|
||||
|
||||
// 1p device but 3 phases measured: configuration error, warning color + tooltip
|
||||
export const Mismatch1p = Template.bind({});
|
||||
Mismatch1p.args = {
|
||||
...base,
|
||||
phasesConfigured: 1,
|
||||
phasesActive: 3,
|
||||
offeredCurrent: 10,
|
||||
chargeCurrents: [10, 10, 10],
|
||||
};
|
||||
|
||||
// cycles 1p → 3p → 1p every few seconds to inspect the transition
|
||||
export const Switching: StoryFn<typeof Phases> = (args) => ({
|
||||
components: { Phases },
|
||||
setup() {
|
||||
const phasesActive = ref(1);
|
||||
let timer: ReturnType<typeof setInterval>;
|
||||
onMounted(() => {
|
||||
timer = setInterval(() => {
|
||||
phasesActive.value = phasesActive.value === 1 ? 3 : 1;
|
||||
}, 3000);
|
||||
});
|
||||
onUnmounted(() => clearInterval(timer));
|
||||
return { args, phasesActive };
|
||||
},
|
||||
template: '<Phases v-bind="args" :phasesActive="phasesActive" />',
|
||||
});
|
||||
Switching.args = { ...base, phasesConfigured: 0, offeredCurrent: 12 };
|
||||
|
||||
// --- overview: rows = scenario, columns = device phase configuration ---
|
||||
|
||||
const scenarios = [
|
||||
{ name: "NoData", args: NoData.args },
|
||||
{ name: "Expected1p", args: Expected1p.args },
|
||||
{ name: "Expected2p", args: Expected2p.args },
|
||||
{ name: "Expected3p", args: Expected3p.args },
|
||||
{ name: "Measured1p", args: Measured1p.args },
|
||||
{ name: "Measured2p", args: Measured2p.args },
|
||||
{ name: "Measured3p", args: Measured3p.args },
|
||||
{ name: "Asymmetric", args: Asymmetric.args },
|
||||
{ name: "OnlyL2", args: OnlyL2.args },
|
||||
{ name: "MainlyL3", args: MainlyL3.args },
|
||||
];
|
||||
|
||||
// 1p3p auto (0) behaves like 3p
|
||||
const configs = [
|
||||
{ name: "1p device", phasesConfigured: 1 },
|
||||
{ name: "3p/auto", phasesConfigured: 3 },
|
||||
];
|
||||
|
||||
export const Overview: StoryFn<typeof Phases> = () => ({
|
||||
components: { Phases },
|
||||
setup() {
|
||||
return { scenarios, configs };
|
||||
},
|
||||
template: `
|
||||
<div style="display: grid; grid-template-columns: 140px repeat(2, 120px); gap: 0.75rem 1.5rem; align-items: center; padding: 1rem; background: var(--evcc-box); border-radius: 0.5rem; color: var(--evcc-default-text); font-size: 0.85rem;">
|
||||
<div></div>
|
||||
<div v-for="c in configs" :key="c.name" style="opacity: 0.7;">{{ c.name }}</div>
|
||||
<template v-for="s in scenarios" :key="s.name">
|
||||
<div style="opacity: 0.7;">{{ s.name }}</div>
|
||||
<Phases v-for="c in configs" :key="c.name" v-bind="{ ...s.args, phasesConfigured: c.phasesConfigured }" />
|
||||
</template>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
Overview.parameters = { controls: { disable: true } };
|
||||
|
|
|
|||
|
|
@ -1,12 +1,23 @@
|
|||
<template>
|
||||
<div :class="`phases d-flex justify-content-between`">
|
||||
<div
|
||||
v-tooltip="phasesMismatch ? $t('main.loadpoint.phasesMismatch') : undefined"
|
||||
class="phases d-flex"
|
||||
:class="{ 'phases-warning': phasesMismatch }"
|
||||
>
|
||||
<div
|
||||
v-for="num in [1, 2, 3]"
|
||||
:key="num"
|
||||
class="phase me-1"
|
||||
:class="{ 'phase-inactive': !isPhaseActive(num) }"
|
||||
:class="{
|
||||
'phase-inactive': !isPhaseActive(num),
|
||||
'phase-hidden': !isPhaseActive(num) && (maxPhases === 1 || num > maxPhases),
|
||||
}"
|
||||
>
|
||||
<div class="target" :style="{ width: `${targetWidth()}%` }"></div>
|
||||
<div
|
||||
v-show="targetWidth() > 0"
|
||||
class="target"
|
||||
:style="{ width: `${targetWidth()}%` }"
|
||||
></div>
|
||||
<div class="real" :style="{ width: `${realWidth(num)}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -23,6 +34,7 @@ export default defineComponent({
|
|||
offeredCurrent: { type: Number, default: 0 },
|
||||
chargeCurrents: { type: Array as PropType<number[]> },
|
||||
phasesActive: { type: Number as PropType<PHASES> },
|
||||
phasesConfigured: { type: Number, default: 3 },
|
||||
minCurrent: { type: Number, default: 6 },
|
||||
maxCurrent: { type: Number, default: 16 },
|
||||
},
|
||||
|
|
@ -31,9 +43,17 @@ export default defineComponent({
|
|||
if (!this.chargeCurrents) return false;
|
||||
return this.chargeCurrents.filter((c) => c >= MIN_ACTIVE_CURRENT).length > 0;
|
||||
},
|
||||
maxPhases() {
|
||||
// 0 = automatic 1p3p switching
|
||||
return this.phasesConfigured || 3;
|
||||
},
|
||||
phasesMismatch() {
|
||||
return [1, 2, 3].filter((num) => this.isPhaseActive(num)).length > this.maxPhases;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
targetWidth() {
|
||||
if (this.offeredCurrent <= 0) return 0;
|
||||
const current = Math.min(
|
||||
Math.max(this.minCurrent, this.offeredCurrent),
|
||||
this.maxCurrent
|
||||
|
|
@ -52,7 +72,8 @@ export default defineComponent({
|
|||
const current = this.chargeCurrents[num - 1];
|
||||
return current !== undefined && current >= MIN_ACTIVE_CURRENT;
|
||||
}
|
||||
return num <= (this.phasesActive || 0);
|
||||
// unknown active phases: assume all
|
||||
return num <= (this.phasesActive || this.maxPhases);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -63,15 +84,14 @@ export default defineComponent({
|
|||
width: 73px;
|
||||
}
|
||||
.phase {
|
||||
background-color: var(--bs-gray-bright);
|
||||
background-color: var(--evcc-gray-15);
|
||||
height: 4px;
|
||||
flex-grow: 1;
|
||||
flex: 1 1 0;
|
||||
position: relative;
|
||||
border-radius: 1px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
flex-basis: 100%;
|
||||
opacity: 1;
|
||||
transition-property: flex-basis, margin, opacity;
|
||||
transition-property: flex-basis, flex-grow, margin, opacity;
|
||||
transition-duration: var(--evcc-transition-slow);
|
||||
transition-timing-function: ease-in;
|
||||
}
|
||||
|
|
@ -79,6 +99,13 @@ html.dark .phase {
|
|||
background-color: var(--bs-gray-bright);
|
||||
}
|
||||
.phase-inactive {
|
||||
flex: 0 0 4px;
|
||||
}
|
||||
.phase-inactive .target,
|
||||
.phase-inactive .real {
|
||||
opacity: 0;
|
||||
}
|
||||
.phase-hidden {
|
||||
flex-basis: 0;
|
||||
margin-right: 0 !important;
|
||||
opacity: 0;
|
||||
|
|
@ -96,9 +123,15 @@ html.dark .phase {
|
|||
opacity: 1;
|
||||
}
|
||||
.target {
|
||||
background-color: var(--evcc-green);
|
||||
background-color: color-mix(in srgb, var(--evcc-dark-green) 40%, white);
|
||||
}
|
||||
.real {
|
||||
background-color: var(--evcc-dark-green);
|
||||
}
|
||||
.phases-warning .target {
|
||||
background-color: color-mix(in srgb, var(--bs-warning) 40%, white);
|
||||
}
|
||||
.phases-warning .real {
|
||||
background-color: var(--bs-warning);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
25
assets/js/directives/tooltip.ts
Normal file
25
assets/js/directives/tooltip.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import Tooltip from "bootstrap/js/dist/tooltip";
|
||||
import type { Directive, DirectiveBinding } from "vue";
|
||||
|
||||
function update(el: HTMLElement, { value, oldValue }: DirectiveBinding<string | undefined>) {
|
||||
if (value === oldValue) return;
|
||||
const instance = Tooltip.getInstance(el);
|
||||
if (!value) {
|
||||
instance?.dispose();
|
||||
} else if (instance) {
|
||||
instance.setContent({ ".tooltip-inner": value });
|
||||
} else {
|
||||
new Tooltip(el, { title: value });
|
||||
}
|
||||
}
|
||||
|
||||
// v-tooltip="text": bootstrap tooltip bound to text, removed when text is empty
|
||||
const tooltip: Directive<HTMLElement, string | undefined> = {
|
||||
mounted: update,
|
||||
updated: update,
|
||||
beforeUnmount(el) {
|
||||
Tooltip.getInstance(el)?.dispose();
|
||||
},
|
||||
};
|
||||
|
||||
export default tooltip;
|
||||
|
|
@ -1432,6 +1432,7 @@
|
|||
"finished": "Ladeende",
|
||||
"last24hEnergy": "Letzte 24h",
|
||||
"last7dEnergy": "Letzte 7 Tage",
|
||||
"phasesMismatch": "Mehr Phasen aktiv als konfiguriert. Phasenkonfiguration prüfen.",
|
||||
"power": "Leistung",
|
||||
"price": "Kosten",
|
||||
"remaining": "Restzeit",
|
||||
|
|
|
|||
|
|
@ -1432,6 +1432,7 @@
|
|||
"finished": "Finish time",
|
||||
"last24hEnergy": "Last 24h",
|
||||
"last7dEnergy": "Last 7 days",
|
||||
"phasesMismatch": "More phases active than configured. Check phase configuration.",
|
||||
"power": "Power",
|
||||
"price": "Cost",
|
||||
"remaining": "Remaining",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue