341 lines
8.3 KiB
Vue
341 lines
8.3 KiB
Vue
<template>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm text-nowrap w-auto">
|
|
<thead>
|
|
<tr>
|
|
<th scope="col" class="sticky-col"></th>
|
|
<th
|
|
v-for="hour in hourGroups"
|
|
:key="hour.key"
|
|
scope="col"
|
|
:colspan="hour.span"
|
|
class="text-start hour-start"
|
|
>
|
|
{{ hour.label }}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template v-for="(group, groupIndex) in groups" :key="groupIndex">
|
|
<tr v-if="group.title">
|
|
<th scope="row" class="sticky-col group-header">
|
|
<div class="d-flex align-items-center gap-2">
|
|
<span
|
|
v-if="group.color"
|
|
class="battery-indicator"
|
|
:style="{ backgroundColor: group.color }"
|
|
></span>
|
|
<span
|
|
v-else-if="group.dash"
|
|
class="dash"
|
|
:style="{ backgroundColor: group.dash }"
|
|
></span>
|
|
{{ group.title }}
|
|
</div>
|
|
</th>
|
|
<td :colspan="slotCount"></td>
|
|
</tr>
|
|
<tr v-for="row in group.rows" :key="row.label + (row.unit || '')">
|
|
<th scope="row" class="sticky-col fw-normal">
|
|
{{ row.label }}
|
|
<span v-if="row.unit" class="text-muted small">{{ row.unit }}</span>
|
|
</th>
|
|
<td
|
|
v-for="(value, index) in row.display"
|
|
:key="index"
|
|
class="text-end"
|
|
:class="{
|
|
zero: row.nums[index] === 0,
|
|
'hour-start': hourStarts[index],
|
|
}"
|
|
:style="heatStyle(row, index)"
|
|
>
|
|
{{ value }}
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { defineComponent, type PropType } from "vue";
|
|
import formatter from "@/mixins/formatter";
|
|
import colors from "@/colors";
|
|
import type { CURRENCY, BatteryDetail } from "@/types/evcc";
|
|
|
|
export interface EvoptData {
|
|
req: {
|
|
time_series: {
|
|
ft: number[];
|
|
p_E: number[];
|
|
p_N: number[];
|
|
gt: number[];
|
|
dt: number[];
|
|
};
|
|
batteries?: Array<{
|
|
c_min: number;
|
|
c_max: number;
|
|
d_max: number;
|
|
}>;
|
|
};
|
|
res: {
|
|
grid_export: number[];
|
|
grid_import: number[];
|
|
batteries: Array<{
|
|
charging_power: number[];
|
|
discharging_power: number[];
|
|
state_of_charge: number[];
|
|
}>;
|
|
};
|
|
}
|
|
|
|
interface Row {
|
|
label: string;
|
|
unit?: string;
|
|
display: string[];
|
|
nums: number[];
|
|
heatColor?: string;
|
|
aMax?: number;
|
|
ref?: number; // heat reference; defaults to the row's absolute maximum
|
|
}
|
|
|
|
interface RowGroup {
|
|
title?: string;
|
|
color?: string;
|
|
dash?: string;
|
|
rows: Row[];
|
|
}
|
|
|
|
export default defineComponent({
|
|
name: "TimeSeriesDataTable",
|
|
mixins: [formatter],
|
|
props: {
|
|
evopt: {
|
|
type: Object as PropType<EvoptData>,
|
|
required: true,
|
|
},
|
|
mode: {
|
|
type: String as PropType<"request" | "response">,
|
|
required: true,
|
|
},
|
|
batteryDetails: {
|
|
type: Array as PropType<BatteryDetail[]>,
|
|
required: true,
|
|
},
|
|
timestamps: {
|
|
type: Array as PropType<string[]>,
|
|
default: () => [],
|
|
},
|
|
currency: {
|
|
type: String as PropType<CURRENCY>,
|
|
required: true,
|
|
},
|
|
batteryColors: {
|
|
type: Array as PropType<string[]>,
|
|
default: () => [],
|
|
},
|
|
},
|
|
computed: {
|
|
slotCount(): number {
|
|
return this.evopt?.req.time_series.dt.length || 0;
|
|
},
|
|
hourStarts(): boolean[] {
|
|
return this.timestamps.map((ts, i) => i > 0 && new Date(ts).getMinutes() === 0);
|
|
},
|
|
// one th per hour, spanning its slots; midnight carries the weekday
|
|
hourGroups(): { key: string; label: string; span: number }[] {
|
|
const groups: { key: string; label: string; span: number }[] = [];
|
|
for (let i = 0; i < this.slotCount; i++) {
|
|
const d = new Date(this.timestamps[i] || 0);
|
|
const key = `${d.getDate()}-${d.getHours()}`;
|
|
const last = groups[groups.length - 1];
|
|
if (last?.key === key) {
|
|
last.span++;
|
|
} else {
|
|
const hour = d.getHours();
|
|
const label = hour === 0 ? `${this.weekdayShort(d)} 0` : String(hour);
|
|
groups.push({ key, label, span: 1 });
|
|
}
|
|
}
|
|
return groups;
|
|
},
|
|
groups(): RowGroup[] {
|
|
return this.mode === "request" ? this.requestGroups() : this.responseGroups();
|
|
},
|
|
},
|
|
methods: {
|
|
requestGroups(): RowGroup[] {
|
|
const ts = this.evopt.req.time_series;
|
|
const priceUnit = this.pricePerKWhUnit(this.currency);
|
|
return [
|
|
{
|
|
rows: [
|
|
this.powerRow("Solar Forecast", ts.ft, colors.self || ""),
|
|
this.powerRow("Household Demand", ts.gt, colors.muted || ""),
|
|
{
|
|
label: "Time Step",
|
|
unit: "h",
|
|
display: ts.dt.map((s) => (s / 3600).toFixed(2)),
|
|
nums: ts.dt,
|
|
},
|
|
this.priceRow("Grid Import", priceUnit, ts.p_N, colors.price || ""),
|
|
this.priceRow("Grid Export", priceUnit, ts.p_E, colors.export || ""),
|
|
],
|
|
},
|
|
];
|
|
},
|
|
responseGroups(): RowGroup[] {
|
|
const res = this.evopt.res;
|
|
const groups: RowGroup[] = [
|
|
{
|
|
title: "Grid",
|
|
dash: colors.grid || "",
|
|
rows: [
|
|
this.powerRow("Grid Export", res.grid_export, colors.export || ""),
|
|
this.powerRow("Grid Import", res.grid_import, colors.muted || ""),
|
|
],
|
|
},
|
|
];
|
|
res.batteries.forEach((battery, i) => {
|
|
const color = this.batteryColors[i] || "";
|
|
const config = this.evopt.req.batteries?.[i];
|
|
const capacity = this.batteryDetails[i]?.capacity || 0;
|
|
groups.push({
|
|
title: this.getBatteryTitle(i),
|
|
color,
|
|
rows: [
|
|
this.powerRow("Charging Power", battery.charging_power, color, {
|
|
ref: (config?.c_max || 0) / 1000,
|
|
}),
|
|
this.powerRow("Discharging Power", battery.discharging_power, color, {
|
|
ref: (config?.d_max || 0) / 1000,
|
|
}),
|
|
{
|
|
label: "State of Charge",
|
|
unit: "kWh",
|
|
display: battery.state_of_charge.map((wh) =>
|
|
this.fmtWh(wh, this.POWER_UNIT.KW, false, 1)
|
|
),
|
|
nums: battery.state_of_charge,
|
|
heatColor: color,
|
|
ref: capacity * 1000,
|
|
},
|
|
{
|
|
label: "State of Charge",
|
|
unit: "%",
|
|
display: battery.state_of_charge.map((wh) =>
|
|
capacity > 0 ? this.fmtNumber((wh / 1000 / capacity) * 100, 0) : "-"
|
|
),
|
|
nums: battery.state_of_charge.map((wh) =>
|
|
capacity > 0 ? (wh / 1000 / capacity) * 100 : 0
|
|
),
|
|
heatColor: color,
|
|
aMax: 0.3,
|
|
ref: 100,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
return groups;
|
|
},
|
|
powerRow(label: string, whValues: number[], color: string, opts: Partial<Row> = {}): Row {
|
|
const dt = this.evopt.req.time_series.dt;
|
|
const nums = whValues.map((wh, i) => wh / ((dt[i] || 1) / 3600) / 1000);
|
|
return {
|
|
label,
|
|
unit: "kW",
|
|
display: nums.map((kw) => this.fmtNumber(kw, 1)),
|
|
nums,
|
|
heatColor: color,
|
|
...opts,
|
|
};
|
|
},
|
|
priceRow(label: string, unit: string, prices: number[], color: string): Row {
|
|
return {
|
|
label,
|
|
unit,
|
|
display: prices.map((p) =>
|
|
this.fmtPricePerKWh(p * 1000, this.currency, false, false)
|
|
),
|
|
nums: prices,
|
|
heatColor: color,
|
|
aMax: 0.4,
|
|
};
|
|
},
|
|
getBatteryTitle(index: number): string {
|
|
const detail = this.batteryDetails[index];
|
|
return detail ? detail.title || detail.name : `Battery ${index + 1}`;
|
|
},
|
|
heatStyle(row: Row, index: number): Record<string, string> | undefined {
|
|
if (!row.heatColor) return undefined;
|
|
const ref = row.ref ?? Math.max(...row.nums.map(Math.abs));
|
|
if (!ref) return undefined;
|
|
const ratio = Math.min(1, Math.abs(row.nums[index] ?? 0) / ref);
|
|
const alpha = 0.06 + (row.aMax ?? 0.5) * ratio;
|
|
const hex = Math.round(alpha * 255)
|
|
.toString(16)
|
|
.padStart(2, "0");
|
|
return { backgroundColor: row.heatColor.trim() + hex };
|
|
},
|
|
},
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.table {
|
|
font-size: 0.8125rem;
|
|
}
|
|
.table .small {
|
|
font-size: 0.75rem;
|
|
}
|
|
.table td,
|
|
.table th {
|
|
font-variant-numeric: tabular-nums;
|
|
padding: 0.25rem 0.5rem;
|
|
border-color: rgba(147, 148, 158, 0.1);
|
|
}
|
|
.table thead th {
|
|
color: var(--bs-gray-medium);
|
|
font-weight: normal;
|
|
border-left: 1px solid rgba(147, 148, 158, 0.25);
|
|
}
|
|
.sticky-col {
|
|
position: sticky;
|
|
left: 0;
|
|
z-index: 1;
|
|
background: var(--evcc-box);
|
|
border-left: none;
|
|
border-right: 1px solid rgba(147, 148, 158, 0.25);
|
|
padding-right: 1rem;
|
|
}
|
|
.table thead .sticky-col {
|
|
border: none;
|
|
}
|
|
.table thead th:nth-child(2) {
|
|
border-left: none;
|
|
}
|
|
.group-header {
|
|
font-weight: bold;
|
|
padding-top: 0.75rem;
|
|
}
|
|
.hour-start {
|
|
border-left: 1px solid rgba(147, 148, 158, 0.25);
|
|
}
|
|
.zero {
|
|
color: var(--bs-gray-medium);
|
|
}
|
|
.battery-indicator {
|
|
width: 1rem;
|
|
height: 1rem;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
.dash {
|
|
width: 1rem;
|
|
height: 2px;
|
|
border-radius: 1px;
|
|
flex-shrink: 0;
|
|
}
|
|
</style>
|