[WIP] modify pology, style it a bit

This commit is contained in:
Sina Blattmann 2023-06-13 14:48:28 +02:00
parent 3a4c768074
commit 4b9f3e6a84
12 changed files with 192 additions and 215 deletions

View File

@ -103,6 +103,7 @@ const CheckboxTree = () => {
overflowX: "hidden", overflowX: "hidden",
position: ["sticky", "-webkit-sticky"], position: ["sticky", "-webkit-sticky"],
top: 1, top: 1,
maxHeight: "90vh",
}} }}
> >
{renderTree(toggles)} {renderTree(toggles)}

View File

@ -1,16 +1,9 @@
import * as React from "react"; import * as React from "react";
import { Button } from "@mui/material"; import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { UnixTime, TimeSpan } from "../../../dataCache/time";
import { createTimes } from "../../../util/graph.util";
import {
DatePicker,
DateTimeValidationError,
LocalizationProvider,
} from "@mui/x-date-pickers";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { useState } from "react"; import ShortcutButton from "./ShortcutButton";
interface DateRangePickerProps { interface DateRangePickerProps {
setRange: (value: Date[]) => void; setRange: (value: Date[]) => void;
@ -19,10 +12,6 @@ interface DateRangePickerProps {
} }
const DateRangePicker = (props: DateRangePickerProps) => { const DateRangePicker = (props: DateRangePickerProps) => {
const { setRange, range, getCacheSeries } = props; const { setRange, range, getCacheSeries } = props;
const [fromDateError, setFromDateError] =
useState<DateTimeValidationError | null>(null);
const [toDateError, setToDateError] =
useState<DateTimeValidationError | null>(null);
const handleChange = (fromDate: Date, toDate: Date) => { const handleChange = (fromDate: Date, toDate: Date) => {
setRange([fromDate, toDate]); setRange([fromDate, toDate]);
@ -31,26 +20,6 @@ const DateRangePicker = (props: DateRangePickerProps) => {
return ( return (
<> <>
<div>
<Button
onClick={() => {
const weekRange = createTimes(
UnixTime.now().rangeBefore(TimeSpan.fromDays(7)),
100
);
setRange([
weekRange[0].toDate(),
weekRange[weekRange.length - 1].toDate(),
]);
getCacheSeries(
weekRange[0].ticks,
weekRange[weekRange.length - 1].ticks
);
}}
>
<FormattedMessage id="lastWeek" defaultMessage="Last week" />
</Button>
</div>
<LocalizationProvider dateAdapter={AdapterDayjs}> <LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker <DatePicker
disableFuture disableFuture
@ -62,16 +31,6 @@ const DateRangePicker = (props: DateRangePickerProps) => {
handleChange(newValue.toDate(), range[1]); handleChange(newValue.toDate(), range[1]);
} }
}} }}
onError={(err) => setFromDateError(err)}
slotProps={{
textField: {
variant: "outlined",
error: !!fromDateError,
helperText: fromDateError
? "From date needs to be before to date"
: "",
},
}}
/> />
<DatePicker <DatePicker
disableFuture disableFuture
@ -83,23 +42,36 @@ const DateRangePicker = (props: DateRangePickerProps) => {
color: "red", color: "red",
}, },
}} }}
onError={(err) => setToDateError(err)}
onChange={(newValue) => { onChange={(newValue) => {
if (newValue) { if (newValue) {
handleChange(range[0], newValue.toDate()); handleChange(range[0], newValue.toDate());
} }
}} }}
slotProps={{
textField: {
variant: "outlined",
error: !!toDateError,
helperText: toDateError
? "To date needs to be after from date"
: "",
},
}}
/> />
</LocalizationProvider> </LocalizationProvider>
<div>
<ShortcutButton
dayRange={1}
setRange={setRange}
getCacheSeries={getCacheSeries}
>
<FormattedMessage id="today" defaultMessage="Today" />
</ShortcutButton>
<ShortcutButton
dayRange={7}
setRange={setRange}
getCacheSeries={getCacheSeries}
>
<FormattedMessage id="lastWeek" defaultMessage="Last week" />
</ShortcutButton>
<ShortcutButton
dayRange={30}
setRange={setRange}
getCacheSeries={getCacheSeries}
>
<FormattedMessage id="lastMonth" defaultMessage="Last month" />
</ShortcutButton>
</div>
</> </>
); );
}; };

View File

@ -6,6 +6,7 @@ const Log = () => {
return ( return (
<> <>
<TopologyView /> <TopologyView />
<ScalarGraph /> <ScalarGraph />
</> </>
); );

View File

@ -1,6 +1,7 @@
import Plot from "react-plotly.js"; import Plot from "react-plotly.js";
import { RecordSeries } from "../../../dataCache/data"; import { RecordSeries } from "../../../dataCache/data";
import { import {
Csv,
GraphData, GraphData,
createTimes, createTimes,
flattenToggles, flattenToggles,
@ -19,6 +20,11 @@ import { LogContext } from "../../Context/LogContextProvider";
import { isDefined } from "../../../dataCache/utils/maybe"; import { isDefined } from "../../../dataCache/utils/maybe";
import { Data, Layout } from "plotly.js"; import { Data, Layout } from "plotly.js";
import { VariableSizeList as List, areEqual } from "react-window"; import { VariableSizeList as List, areEqual } from "react-window";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { FormattedMessage } from "react-intl";
import DateRangePicker from "./DateRangePicker";
import { LocalizationProvider } from "@mui/x-date-pickers";
import { Alert } from "@mui/material";
const NUMBER_OF_NODES = 100; const NUMBER_OF_NODES = 100;
@ -526,9 +532,7 @@ const ScalarGraph = () => {
return () => subscription.unsubscribe(); return () => subscription.unsubscribe();
}, [toggles]); }, [toggles]);
const fetchData = ( const fetchData = (timestamp: UnixTime): Promise<FetchResult<Csv>> => {
timestamp: UnixTime
): Promise<FetchResult<Record<string, number>>> => {
const s3Path = `${timestamp.ticks}.csv`; const s3Path = `${timestamp.ticks}.csv`;
return s3Access return s3Access
.get(s3Path) .get(s3Path)
@ -569,7 +573,7 @@ const ScalarGraph = () => {
transformedObject[key].x.push( transformedObject[key].x.push(
new Date(item.time.ticks * 1000).toISOString() new Date(item.time.ticks * 1000).toISOString()
); );
transformedObject[key].y.push(item.value?.[key]); transformedObject[key].y.push(item.value?.[key].value);
}); });
} }
if ( if (
@ -601,6 +605,7 @@ const ScalarGraph = () => {
), ),
NUMBER_OF_NODES NUMBER_OF_NODES
); );
console.log("getcacheseries");
cache.getSeries(times); cache.getSeries(times);
times$.next(times); times$.next(times);
}; };
@ -630,9 +635,6 @@ const ScalarGraph = () => {
barnorm: "percent", barnorm: "percent",
} }
: {}; : {};
if (!isScalar) {
console.log("graphData", data[visibleGraphs[index]]);
}
return ( return (
<div style={style}> <div style={style}>
<Plot <Plot
@ -679,116 +681,43 @@ const ScalarGraph = () => {
return null; return null;
}, areEqual); }, areEqual);
/* const renderGraphs = () => { if (
if (checkedToggles) { checkedToggles &&
const coordinateTimeSeries = transformToGraphData(timeSeries); Object.keys(checkedToggles).find((toggle) => checkedToggles[toggle])
const visibleGraphs = Object.keys(coordinateTimeSeries).filter((path) => { ) {
return checkedToggles[path];
});
if (visibleGraphs.length > 0) {
return (
<>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DateRangePicker
setRange={setRange}
range={range}
getCacheSeries={getCacheSeries}
/>
</LocalizationProvider>
{visibleGraphs.map((path) => {
const isScalar = isNumeric(coordinateTimeSeries[path].y[0]);
const data = isScalar
? [
{
...coordinateTimeSeries[path],
type: "scatter",
mode: "lines+markers",
fill: "tozeroy",
},
]
: transformToBarGraphData(coordinateTimeSeries[path]);
const barGraphLayout: Partial<Layout> = !isScalar
? {
bargap: 0,
barmode: "stack",
barnorm: "percent",
}
: {};
return (
<Plot
key={path}
data={data as Data[]}
layout={{
width: 1000,
height: 500,
title: path,
uirevision: uiRevision,
xaxis: {
autorange: false,
range: range,
type: "date",
},
yaxis: {
rangemode: "tozero",
},
...barGraphLayout,
}}
config={{
modeBarButtonsToRemove: [
"lasso2d",
"select2d",
"pan2d",
"autoScale2d",
],
}}
onRelayout={(params) => {
const xaxisRange0 = params["xaxis.range[0]"];
const xaxisRange1 = params["xaxis.range[1]"];
if (xaxisRange0 && xaxisRange1) {
setRange([new Date(xaxisRange0), new Date(xaxisRange1)]);
setUiRevision(Math.random());
getCacheSeries(xaxisRange0, xaxisRange1);
}
}}
/>
);
})}
</>
);
}
return (
<Alert sx={{ mt: 2 }} severity="info">
<FormattedMessage
id="makeASelection"
defaultMessage="Please make a selection on the left"
/>
</Alert>
);
}
}; */
if (checkedToggles) {
const coordinateTimeSeries = transformToGraphData(timeSeries); const coordinateTimeSeries = transformToGraphData(timeSeries);
console.log(
"length",
Object.keys(checkedToggles).filter((toggle) => checkedToggles[toggle])
.length
);
return ( return (
<List <>
height={1000} <LocalizationProvider dateAdapter={AdapterDayjs}>
itemCount={ <DateRangePicker
Object.keys(checkedToggles).filter((toggle) => checkedToggles[toggle]) setRange={setRange}
.length range={range}
} getCacheSeries={getCacheSeries}
itemSize={() => 500} />
width="100%" </LocalizationProvider>
itemData={coordinateTimeSeries} <List
> height={1000}
{Row} itemCount={
</List> Object.keys(checkedToggles).filter(
(toggle) => checkedToggles[toggle]
).length
}
itemSize={() => 500}
width="100%"
itemData={coordinateTimeSeries}
>
{Row}
</List>
</>
); );
} }
return null; return (
<Alert sx={{ mt: 2 }} severity="info">
<FormattedMessage
id="makeASelection"
defaultMessage="Please make a selection on the left"
/>
</Alert>
);
}; };
export default ScalarGraph; export default ScalarGraph;

View File

@ -0,0 +1,36 @@
import { UnixTime, TimeSpan } from "../../../dataCache/time";
import { createTimes } from "../../../util/graph.util";
import InnovenergyButton from "../../Layout/InnovenergyButton";
interface ShortcutButtonProps {
setRange: (value: Date[]) => void;
getCacheSeries: (xaxisRange0: number, xaxisRange1: number) => void;
dayRange: number;
children?: React.ReactNode;
}
const ShortcutButton = (props: ShortcutButtonProps) => {
return (
<InnovenergyButton
onClick={() => {
const weekRange = createTimes(
UnixTime.now().rangeBefore(TimeSpan.fromDays(props.dayRange)),
100
);
props.setRange([
weekRange[0].toDate(),
weekRange[weekRange.length - 1].toDate(),
]);
props.getCacheSeries(
weekRange[0].ticks,
weekRange[weekRange.length - 1].ticks
);
}}
sx={{ mt: 2, mb: 2, mr: 2 }}
>
{props.children}
</InnovenergyButton>
);
};
export default ShortcutButton;

View File

@ -34,6 +34,7 @@ const TopologyBox = (props: TopologyBoxProps) => {
<div> <div>
{el.label} {el.label}
{el.values.map((value) => { {el.values.map((value) => {
console.log("value", value);
return ( return (
<p <p
style={{ marginBlockStart: "2px", marginBlockEnd: "2px" }} style={{ marginBlockStart: "2px", marginBlockEnd: "2px" }}

View File

@ -1,5 +1,5 @@
import { Box } from "@mui/material"; import { Box } from "@mui/material";
import TopologyBox, { TopologyBoxProps } from "./ToplogyBox"; import TopologyBox, { TopologyBoxProps } from "./TopologyBox";
import TopologyFlow, { TopologyFlowProps } from "./TopologyFlow"; import TopologyFlow, { TopologyFlowProps } from "./TopologyFlow";
type TopologyColumnProps = { type TopologyColumnProps = {

View File

@ -1,39 +1,65 @@
import { Box } from "@mui/material"; import { Box } from "@mui/material";
import { BOX_SIZE, BoxData } from "./ToplogyBox"; import { BOX_SIZE, BoxData } from "./TopologyBox";
import "./TopologyFlow.scss"; import "./TopologyFlow.scss";
export type TopologyFlowProps = { export type TopologyFlowProps = {
orientation?: "vertical" | "horizontal"; orientation?: "vertical" | "horizontal";
amount?: number; amount?: number;
direction?: "leftToRight" | "rightToLeft"; rightToLeft?: boolean;
data?: BoxData[]; data?: BoxData[];
hidden?: boolean; hidden?: boolean;
}; };
const TopologyFlow = (props: TopologyFlowProps) => { const TopologyFlow = (props: TopologyFlowProps) => {
const length = Math.abs((props.amount ?? 1) * (BOX_SIZE - 20)); const length = Math.abs((props.amount ?? 1) * BOX_SIZE);
return ( return (
<> <div
style={{
display: "flex",
justifyContent: "center",
height: BOX_SIZE,
width: BOX_SIZE,
alignItems: "center",
}}
>
<Box <Box
sx={{ sx={{
width: props.orientation === "horizontal" ? BOX_SIZE - 20 : length, width: props.orientation === "horizontal" ? BOX_SIZE : length,
height: props.orientation === "vertical" ? BOX_SIZE - 20 : length, height: props.orientation === "vertical" ? BOX_SIZE : length,
backgroundColor: "#f4b3504d", backgroundColor: "#f4b3504d",
visibility: props.hidden || !props.data ? "hidden" : "visible", visibility: props.hidden || !props.data ? "hidden" : "visible",
display: "flex",
}} }}
> >
{props.data?.map((value) => value.values)}
<div <div
className="container" className="container"
style={{ style={{
transform: transform:
props.orientation === "vertical" props.orientation === "vertical"
? "rotate(90deg)" ? "rotate(90deg)"
: props.direction === "rightToLeft" : props.rightToLeft
? "rotate(180deg)" ? "rotate(180deg)"
: "", : "",
overflow: "hidden",
display: "flex",
height: BOX_SIZE,
width: BOX_SIZE,
}} }}
> >
<div className="data-flow"> <p
style={{
position: "absolute",
zIndex: 1,
transform:
props.orientation === "vertical"
? "rotate(-90deg)"
: props.rightToLeft
? "rotate(-180deg)"
: "",
}}
>
{props.data?.map((value) => value.values)}
</p>
<div className="data-flow" style={{ overflow: "hidden" }}>
<div className="dot"></div> <div className="dot"></div>
<div className="dot"></div> <div className="dot"></div>
<div className="dot"></div> <div className="dot"></div>
@ -47,7 +73,7 @@ const TopologyFlow = (props: TopologyFlowProps) => {
</div> </div>
</div> </div>
</Box> </Box>
</> </div>
); );
}; };

View File

@ -18,6 +18,7 @@ const TopologyView = () => {
flexDirection: "row", flexDirection: "row",
overflow: "auto", overflow: "auto",
padding: 2, padding: 2,
fontFamily: `"Ubuntu", sans-serif`,
}} }}
> >
<div> <div>
@ -27,9 +28,9 @@ const TopologyView = () => {
data: values.acInBus, data: values.acInBus,
}} }}
centerConnection={{ centerConnection={{
amount: 0.5, amount: 0.6,
data: values.gridToAcIn, data: values.gridToAcIn,
direction: "rightToLeft", rightToLeft: true,
}} }}
/> />
</div> </div>
@ -40,9 +41,8 @@ const TopologyView = () => {
data: values.acInBus, data: values.acInBus,
}} }}
centerConnection={{ centerConnection={{
amount: 0.5, amount: 0.3,
data: values.gridToAcIn, data: values.gridToAcIn,
direction: "leftToRight",
}} }}
/> />
</div> </div>
@ -55,7 +55,6 @@ const TopologyView = () => {
centerConnection={{ centerConnection={{
amount: 0.5, amount: 0.5,
data: values.gridToAcIn, data: values.gridToAcIn,
direction: "leftToRight",
}} }}
/> />
</div> </div>
@ -66,9 +65,8 @@ const TopologyView = () => {
data: values.acOutBus, data: values.acOutBus,
}} }}
centerConnection={{ centerConnection={{
amount: 0.5, amount: 0.3,
data: values.gridToAcIn, data: values.gridToAcIn,
direction: "leftToRight",
}} }}
/> />
</div> </div>
@ -79,7 +77,7 @@ const TopologyView = () => {
data: values.acOutBus, data: values.acOutBus,
}} }}
topConnection={{ topConnection={{
amount: 0.5, amount: 0.6,
data: values.gridToAcIn, data: values.gridToAcIn,
}} }}
centerBox={{ centerBox={{
@ -87,9 +85,9 @@ const TopologyView = () => {
data: values.acOutBus, data: values.acOutBus,
}} }}
centerConnection={{ centerConnection={{
amount: 0.5, amount: 0.2,
data: values.gridToAcIn, data: values.gridToAcIn,
direction: "rightToLeft", rightToLeft: true,
}} }}
/> />
</div> </div>
@ -100,7 +98,7 @@ const TopologyView = () => {
data: values.acOutBus, data: values.acOutBus,
}} }}
centerConnection={{ centerConnection={{
amount: 0.5, amount: 0.8,
data: values.gridToAcIn, data: values.gridToAcIn,
}} }}
/> />

View File

@ -1,13 +1,14 @@
import { Maybe } from "yup"; import { Maybe } from "yup";
import { Timestamped } from "./types"; import { Timestamped } from "./types";
import { isDefined } from "./utils/maybe"; import { isDefined } from "./utils/maybe";
import { CsvEntry } from "../util/graph.util";
export type DataRecord = Record<string, number | string>; export type DataRecord = Record<string, CsvEntry>;
export type DataPoint = Timestamped<Maybe<DataRecord>>; export type DataPoint = Timestamped<Maybe<DataRecord>>;
export type RecordSeries = Array<DataPoint>; export type RecordSeries = Array<DataPoint>;
export type PointSeries = Array<Timestamped<Maybe<number | string>>>; export type PointSeries = Array<Timestamped<Maybe<CsvEntry>>>;
export type DataSeries = Array<Maybe<number | string>>; export type DataSeries = Array<Maybe<CsvEntry>>;
export function getPoints( export function getPoints(
recordSeries: RecordSeries, recordSeries: RecordSeries,

View File

@ -6,7 +6,8 @@ import { createDispatchQueue } from "./promiseQueue";
import { SkipListNode } from "./skipList/skipListNode"; import { SkipListNode } from "./skipList/skipListNode";
import { RecordSeries } from "./data"; import { RecordSeries } from "./data";
import { Maybe, isUndefined } from "./utils/maybe"; import { Maybe, isUndefined } from "./utils/maybe";
import { isNumber, isString } from "./utils/runtimeTypeChecking"; import { isNumber } from "./utils/runtimeTypeChecking";
import { CsvEntry } from "../util/graph.util";
export const FetchResult = { export const FetchResult = {
notAvailable: "N/A", notAvailable: "N/A",
@ -30,7 +31,7 @@ function reverseBits(x: number): number {
return x >>> 0; return x >>> 0;
} }
export default class DataCache<T extends Record<string, number>> { export default class DataCache<T extends Record<string, CsvEntry>> {
private readonly cache: SkipList<Maybe<T>> = new SkipList<Maybe<T>>(); private readonly cache: SkipList<Maybe<T>> = new SkipList<Maybe<T>>();
private readonly resolution: TimeSpan; private readonly resolution: TimeSpan;
@ -103,15 +104,20 @@ export default class DataCache<T extends Record<string, number>> {
const n = after.index - t; const n = after.index - t;
const pn = p + n; const pn = p + n;
let interpolated: Partial<Record<string, number>> = {}; let interpolated: Partial<Record<string, CsvEntry>> = {};
//What about string nodes? like Alarms //What about string nodes? like Alarms
for (const k of Object.keys(dataBefore)) { for (const k of Object.keys(dataBefore)) {
interpolated[k] = isNumber(dataBefore[k]) const beforeData = dataBefore[k].value;
? (dataBefore[k] * n + dataAfter[k] * p) / pn const afterData = Number(dataAfter[k].value);
: n < p let foo = interpolated[k];
? dataAfter[k] if (foo) {
: dataBefore[k]; foo.value = isNumber(beforeData)
? (beforeData * n + afterData * p) / pn
: n < p
? afterData
: beforeData;
}
} }
return interpolated as T; return interpolated as T;

View File

@ -6,7 +6,7 @@ import {
import { TimeRange, UnixTime } from "../dataCache/time"; import { TimeRange, UnixTime } from "../dataCache/time";
import { DataPoint, DataRecord } from "../dataCache/data"; import { DataPoint, DataRecord } from "../dataCache/data";
import { isDefined } from "../dataCache/utils/maybe"; import { isDefined } from "../dataCache/utils/maybe";
import { BoxData } from "../components/Installations/Log/ToplogyBox"; import { BoxData } from "../components/Installations/Log/TopologyBox";
export interface GraphCoordinates { export interface GraphCoordinates {
x: Datum[] | Datum[][] | TypedArray; x: Datum[] | Datum[][] | TypedArray;
@ -50,22 +50,28 @@ export const extractTopologyValues = (
timeSeriesData: DataPoint timeSeriesData: DataPoint
): TopologyValues | null => { ): TopologyValues | null => {
const timeSeriesValue = timeSeriesData.value; const timeSeriesValue = timeSeriesData.value;
let topologyValues: (string | number)[];
if (isDefined(timeSeriesValue)) { if (isDefined(timeSeriesValue)) {
return Object.keys(topologyValues).reduce((acc, topologyKey) => { return Object.keys(topologyPaths).reduce((acc, topologyKey) => {
const values = topologyValues[topologyKey].map( const values = topologyPaths[topologyKey].map(
(topologyPath) => timeSeriesValue[topologyPath] (topologyPath) => timeSeriesValue[topologyPath]
); );
console.log("values", topologyValues); switch (topologyKey) {
case "gridToAcIn":
topologyValues = [
values.reduce((acc, curr) => Number(acc) + Number(curr.value), 0),
];
break;
default:
topologyValues = values.map(({ value }) => value);
}
return { return {
...acc, ...acc,
[topologyKey]: [ [topologyKey]: [
{ {
values: values: topologyValues,
topologyKey === "gridToAcIn" label: topologyPaths[topologyKey][0].split("/").pop(),
? [values.reduce((acc, curr) => Number(acc) + Number(curr))] unit: values[0].unit,
: values,
label: topologyValues[topologyKey][0].split("/").pop(),
unit: "V",
} as BoxData, } as BoxData,
], ],
}; };
@ -74,7 +80,7 @@ export const extractTopologyValues = (
return null; return null;
}; };
export const topologyValues: { [key: string]: string[] } = { export const topologyPaths: { [key: string]: string[] } = {
gridToAcIn: [ gridToAcIn: [
"/GridMeter/Ac/L1/Power/Apparent", "/GridMeter/Ac/L1/Power/Apparent",
"/GridMeter/Ac/L2/Power/Apparent", "/GridMeter/Ac/L2/Power/Apparent",
@ -101,7 +107,7 @@ export interface Csv {
[key: string]: CsvEntry; [key: string]: CsvEntry;
} }
export const parseCsv = (text: string) => { export const parseCsv = (text: string): Csv => {
console.log("split", text.split(/\r?\n/)); console.log("split", text.split(/\r?\n/));
const y = text const y = text
.split(/\r?\n/) .split(/\r?\n/)
@ -113,11 +119,11 @@ export const parseCsv = (text: string) => {
const x = y const x = y
.map((fields) => { .map((fields) => {
if (typeof fields[1] === "string") { if (typeof fields[1] === "string") {
return { [fields[0]]: fields[1] }; return { [fields[0]]: { value: fields[1], unit: fields[2] } };
} }
return { [fields[0]]: parseFloat(fields[1]) }; return { [fields[0]]: { value: parseFloat(fields[1]), unit: fields[2] } };
}) })
.reduce((acc, current) => ({ ...acc, ...current }), {} as any); .reduce((acc, current) => ({ ...acc, ...current }), {} as Csv);
return x; return x;
}; };