From 4d451ee09e71fa04b9d683cd422fb244cbb93c4f Mon Sep 17 00:00:00 2001 From: dvmartinweigl Date: Mon, 11 Dec 2023 17:42:38 +0100 Subject: [PATCH 01/12] handle null value only column in raincloud plot --- src/vis/raincloud/Raincloud.tsx | 137 +++++++++++++++++--------------- 1 file changed, 73 insertions(+), 64 deletions(-) diff --git a/src/vis/raincloud/Raincloud.tsx b/src/vis/raincloud/Raincloud.tsx index 6c06e3aa6..9a97b097e 100644 --- a/src/vis/raincloud/Raincloud.tsx +++ b/src/vis/raincloud/Raincloud.tsx @@ -20,6 +20,7 @@ import { Circle } from './rain/Circle'; import { DotPlot } from './rain/DotPlot'; import { StripPlot } from './rain/StripPlot'; import { WheatPlot } from './rain/WheatPlot'; +import { InvalidCols } from '../general/InvalidCols'; const margin = { top: 0, @@ -103,6 +104,8 @@ export function Raincloud({ .rollup({ values: op.mean('values'), ids: op.array_agg('ids') }); }, [baseTable]); + const hasData = column.resolvedValues.filter((row) => row.val !== null).length > 0; + return ( {column.info.name} - - {config.cloudType === ECloudType.HEATMAP ? ( - - ) : config.cloudType === ECloudType.HISTOGRAM ? ( - - ) : ( - - )} - - {config.rainType === ERainType.DOTPLOT ? ( - MAX_NON_AGGREGATED_COUNT ? aggregatedTable : baseTable} - /> - ) : config.rainType === ERainType.BEESWARM ? ( - MAX_NON_AGGREGATED_COUNT ? aggregatedTable : baseTable} - yPos={height / 2} - width={width} - height={height / 2} - config={config} - numCol={column} - circleCallback={circlesCallback} - /> - ) : config.rainType === ERainType.WHEATPLOT ? ( - MAX_NON_AGGREGATED_COUNT ? aggregatedTable : baseTable} - /> - ) : config.rainType === ERainType.STRIPPLOT ? ( - MAX_NON_AGGREGATED_COUNT ? aggregatedTable : baseTable} - /> - ) : null} - {circlesRendered} - {config.lightningType === ELightningType.MEAN_AND_DEV ? ( - - ) : config.lightningType === ELightningType.MEAN ? ( - - ) : config.lightningType === ELightningType.MEDIAN_AND_DEV ? ( - - ) : config.lightningType === ELightningType.BOXPLOT ? ( - - ) : null} - - - - + {hasData ? ( + + {config.cloudType === ECloudType.HEATMAP ? ( + + ) : config.cloudType === ECloudType.HISTOGRAM ? ( + + ) : ( + + )} + + {config.rainType === ERainType.DOTPLOT ? ( + MAX_NON_AGGREGATED_COUNT ? aggregatedTable : baseTable} + /> + ) : config.rainType === ERainType.BEESWARM ? ( + MAX_NON_AGGREGATED_COUNT ? aggregatedTable : baseTable} + yPos={height / 2} + width={width} + height={height / 2} + config={config} + numCol={column} + circleCallback={circlesCallback} + /> + ) : config.rainType === ERainType.WHEATPLOT ? ( + MAX_NON_AGGREGATED_COUNT ? aggregatedTable : baseTable} + /> + ) : config.rainType === ERainType.STRIPPLOT ? ( + MAX_NON_AGGREGATED_COUNT ? aggregatedTable : baseTable} + /> + ) : null} + {circlesRendered} + {config.lightningType === ELightningType.MEAN_AND_DEV ? ( + + ) : config.lightningType === ELightningType.MEAN ? ( + + ) : config.lightningType === ELightningType.MEDIAN_AND_DEV ? ( + + ) : config.lightningType === ELightningType.BOXPLOT ? ( + + ) : null} + + + + + ) : ( + + + + )} ) : null} From 8eec9f12b48ecbb07ba5857f2f3868264195dd8c Mon Sep 17 00:00:00 2001 From: dvvanessastoiber Date: Thu, 14 Dec 2023 15:15:19 +0100 Subject: [PATCH 02/12] prepare next dev version --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 752b50e51..563cdf9cf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "visyn_core", "description": "Core repository for datavisyn applications.", - "version": "7.0.0", + "version": "7.0.1-SNAPSHOT", "author": { "name": "datavisyn GmbH", "email": "contact@datavisyn.io", @@ -309,7 +309,7 @@ "react-plotly.js": "^2.5.1", "react-spring": "^9.7.1", "use-deep-compare-effect": "^1.8.0", - "visyn_scripts": "^7.0.1" + "visyn_scripts": "git+ssh://git@github.com/datavisyn/visyn_scripts#develop" }, "devDependencies": { "@babel/core": "^7.17.7", From aa1ac2c43dd8ad9bbebdd212a04d19de9de95f51 Mon Sep 17 00:00:00 2001 From: Michael Puehringer Date: Thu, 21 Dec 2023 13:23:47 +0100 Subject: [PATCH 03/12] feat: add get_default_postgres_url utility --- visyn_core/settings/model.py | 4 ++++ visyn_core/settings/utils.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/visyn_core/settings/model.py b/visyn_core/settings/model.py index 8e8ddfe71..bba8bca92 100644 --- a/visyn_core/settings/model.py +++ b/visyn_core/settings/model.py @@ -205,6 +205,10 @@ class VisynCoreSettings(BaseModel): class GlobalSettings(BaseSettings): env: Literal["development", "production"] = "production" + ci: bool = False + """ + Set to true in CI environments like Github Actions. + """ secret_key: str = "VERY_SECRET_STUFF_T0IB84wlQrdMH8RVT28w" # JWT options mostly inspired by flask-jwt-extended: https://flask-jwt-extended.readthedocs.io/en/stable/options/#general-options diff --git a/visyn_core/settings/utils.py b/visyn_core/settings/utils.py index 786bee19d..254f20d68 100644 --- a/visyn_core/settings/utils.py +++ b/visyn_core/settings/utils.py @@ -28,3 +28,17 @@ def load_config_file(path: str) -> dict[str, Any]: """ with codecs.open(path, "r", "utf-8") as fi: return jsoncfg.loads(fi.read()) or {} + + +def get_default_postgres_url( + *, + user: str = "admin", + password: str = "admin", + host: str = os.getenv("POSTGRES_HOSTNAME", "localhost"), + port: int = 5432, + database: str = "db", +) -> str: + """ + Returns a default postgres url, including the default values for `user`, `password`, `host`, `port` and `database`. + """ + return f"postgresql://{user}:{password}@{host}:{port}/{database}" From 83f6732b6af6557d731ae140631bb5e6d9a79754 Mon Sep 17 00:00:00 2001 From: Michael Puehringer Date: Fri, 22 Dec 2023 13:49:03 +0100 Subject: [PATCH 04/12] Add host_fallback option --- visyn_core/settings/utils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/visyn_core/settings/utils.py b/visyn_core/settings/utils.py index 254f20d68..57a3746bd 100644 --- a/visyn_core/settings/utils.py +++ b/visyn_core/settings/utils.py @@ -34,11 +34,12 @@ def get_default_postgres_url( *, user: str = "admin", password: str = "admin", - host: str = os.getenv("POSTGRES_HOSTNAME", "localhost"), + host: str | None = os.getenv("POSTGRES_HOSTNAME"), + host_fallback: str = "localhost", port: int = 5432, database: str = "db", ) -> str: """ Returns a default postgres url, including the default values for `user`, `password`, `host`, `port` and `database`. """ - return f"postgresql://{user}:{password}@{host}:{port}/{database}" + return f"postgresql://{user}:{password}@{host or host_fallback}:{port}/{database}" From 24dd4f5e78f73bc7f0e759304f5e17f5ee903d84 Mon Sep 17 00:00:00 2001 From: Usama Ansari Date: Fri, 29 Dec 2023 12:17:23 +0100 Subject: [PATCH 05/12] fix: violin plot bug - checks if all the x and y values in the traces are non-nullish - if not, it will not view the trace for the corresponding plot - calculates the `rows`based on the length of the plots in the traces --- src/vis/stories/fetchIrisData.tsx | 3 ++- src/vis/violin/ViolinVis.tsx | 24 +++++++++++++++++------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/vis/stories/fetchIrisData.tsx b/src/vis/stories/fetchIrisData.tsx index 9f1681b5d..bc0232b4a 100644 --- a/src/vis/stories/fetchIrisData.tsx +++ b/src/vis/stories/fetchIrisData.tsx @@ -10,7 +10,8 @@ export function fetchIrisData(): VisColumn[] { name: 'Sepal Length', }, type: EColumnTypes.NUMERICAL, - values: () => dataPromise.map((r) => r.sepalLength).map((val, i) => ({ id: i.toString(), val })), + // values: () => dataPromise.map((r) => r.sepalLength).map((val, i) => ({ id: i.toString(), val })), + values: () => dataPromise.map((r) => r.sepalLength).map((val, i) => ({ id: i.toString(), val: i < 10 ? val : null })), }, { info: { diff --git a/src/vis/violin/ViolinVis.tsx b/src/vis/violin/ViolinVis.tsx index 7548aa256..cc8b94ccd 100644 --- a/src/vis/violin/ViolinVis.tsx +++ b/src/vis/violin/ViolinVis.tsx @@ -19,6 +19,16 @@ export function ViolinVis({ config, columns, scales, dimensions, selectedList, s const [layout, setLayout] = useState>(null); + const filteredTraces = useMemo(() => { + if (!traces) return null; + const filtered = { + ...traces, + plots: traces?.plots.filter((p) => (p.data.x as unknown[]).filter(Boolean).length === (p.data.y as unknown[]).filter(Boolean).length), + }; + filtered.rows = Math.ceil(filtered.plots.length); + return filtered; + }, [traces]); + const onClick = (e: Readonly | null) => { if (!e || !e.points || !e.points[0]) { selectionCallback([]); @@ -78,7 +88,7 @@ export function ViolinVis({ config, columns, scales, dimensions, selectedList, s }, [clearTimeoutValue]); useEffect(() => { - if (!traces) { + if (!filteredTraces) { return; } @@ -99,12 +109,12 @@ export function ViolinVis({ config, columns, scales, dimensions, selectedList, s }, clickmode: 'event+select', autosize: true, - grid: { rows: traces.rows, columns: traces.cols, xgap: 0.3, pattern: 'independent' }, + grid: { rows: filteredTraces.rows, columns: filteredTraces.cols, xgap: 0.3, pattern: 'independent' }, shapes: [], }; - setLayout((prev) => ({ ...prev, ...beautifyLayout(traces, innerLayout, prev, true) })); - }, [traces]); + setLayout((prev) => ({ ...prev, ...beautifyLayout(filteredTraces, innerLayout, prev, true) })); + }, [filteredTraces]); return ( - {traceStatus === 'success' && layout && traces?.plots.length > 0 ? ( + {traceStatus === 'success' && layout && filteredTraces?.plots.length > 0 ? ( p.data), ...traces.legendPlots.map((p) => p.data)]} + data={[...filteredTraces.plots.map((p) => p.data), ...filteredTraces.legendPlots.map((p) => p.data)]} layout={layout} config={{ responsive: true, displayModeBar: false }} useResizeHandler @@ -139,7 +149,7 @@ export function ViolinVis({ config, columns, scales, dimensions, selectedList, s }} /> ) : traceStatus !== 'pending' && traceStatus !== 'idle' && layout ? ( - + ) : null} ); From 92c2d5b0453d0b76e96e2efca8ff20fe5f1fa781 Mon Sep 17 00:00:00 2001 From: Holger Stitz Date: Thu, 4 Jan 2024 14:37:41 +0100 Subject: [PATCH 06/12] feat: debug log for access_token in oauth security store (#139) --- visyn_core/security/store/oauth2_security_store.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/visyn_core/security/store/oauth2_security_store.py b/visyn_core/security/store/oauth2_security_store.py index 595f40e97..1486bd706 100644 --- a/visyn_core/security/store/oauth2_security_store.py +++ b/visyn_core/security/store/oauth2_security_store.py @@ -24,7 +24,7 @@ def load_from_request(self, req: Request): # Get token data from header access_token = req.headers.get(token_field) if access_token: - # Try to decode the oidc data jwt + _log.debug(f"Try to decode the oidc data jwt with access token: {access_token}") user = jwt.decode(access_token, options={"verify_signature": False}) # Go through all the fields we want to check for the user id From bd9dda2442646b39138aa181ec2ef7400de081a2 Mon Sep 17 00:00:00 2001 From: Daniela Date: Tue, 9 Jan 2024 19:18:09 +0100 Subject: [PATCH 07/12] filter out null values to make tooltip work again --- src/vis/violin/ViolinVis.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/vis/violin/ViolinVis.tsx b/src/vis/violin/ViolinVis.tsx index cc8b94ccd..59d661c97 100644 --- a/src/vis/violin/ViolinVis.tsx +++ b/src/vis/violin/ViolinVis.tsx @@ -19,13 +19,25 @@ export function ViolinVis({ config, columns, scales, dimensions, selectedList, s const [layout, setLayout] = useState>(null); + // Filter out null values from traces as null values cause the tooltip to not show up const filteredTraces = useMemo(() => { if (!traces) return null; + const indexWithNull = traces.plots?.map((plot) => plot?.data.y?.reduce((acc, curr, i) => (curr === null ? [...acc, i] : acc), [])); const filtered = { ...traces, - plots: traces?.plots.filter((p) => (p.data.x as unknown[]).filter(Boolean).length === (p.data.y as unknown[]).filter(Boolean).length), + plots: traces?.plots?.map((p, p_index) => { + return { + ...p, + data: { + ...p.data, + y: p.data?.y?.filter((v, i) => !indexWithNull[p_index].includes(i)), + x: p.data?.x?.filter((v, i) => !indexWithNull[p_index].includes(i)), + ids: p.data?.ids?.filter((v, i) => !indexWithNull[p_index].includes(i)), + transforms: p.data?.transforms?.map((t) => t.groups?.filter((v, i) => !indexWithNull[p_index].includes(i))), + }, + }; + }), }; - filtered.rows = Math.ceil(filtered.plots.length); return filtered; }, [traces]); From 0040cac69491f4d019314989635f3f76b04441e3 Mon Sep 17 00:00:00 2001 From: Daniela Date: Tue, 9 Jan 2024 19:26:18 +0100 Subject: [PATCH 08/12] fix typing --- src/vis/violin/ViolinVis.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/vis/violin/ViolinVis.tsx b/src/vis/violin/ViolinVis.tsx index 59d661c97..4ab9c56e8 100644 --- a/src/vis/violin/ViolinVis.tsx +++ b/src/vis/violin/ViolinVis.tsx @@ -22,7 +22,7 @@ export function ViolinVis({ config, columns, scales, dimensions, selectedList, s // Filter out null values from traces as null values cause the tooltip to not show up const filteredTraces = useMemo(() => { if (!traces) return null; - const indexWithNull = traces.plots?.map((plot) => plot?.data.y?.reduce((acc, curr, i) => (curr === null ? [...acc, i] : acc), [])); + const indexWithNull = traces.plots?.map((plot) => (plot?.data.y as PlotlyTypes.Datum[])?.reduce((acc, curr, i) => (curr === null ? [...acc, i] : acc), [])); const filtered = { ...traces, plots: traces?.plots?.map((p, p_index) => { @@ -30,10 +30,10 @@ export function ViolinVis({ config, columns, scales, dimensions, selectedList, s ...p, data: { ...p.data, - y: p.data?.y?.filter((v, i) => !indexWithNull[p_index].includes(i)), - x: p.data?.x?.filter((v, i) => !indexWithNull[p_index].includes(i)), + y: (p.data?.y as PlotlyTypes.Datum[])?.filter((v, i) => !indexWithNull[p_index].includes(i)), + x: (p.data?.x as PlotlyTypes.Datum[])?.filter((v, i) => !indexWithNull[p_index].includes(i)), ids: p.data?.ids?.filter((v, i) => !indexWithNull[p_index].includes(i)), - transforms: p.data?.transforms?.map((t) => t.groups?.filter((v, i) => !indexWithNull[p_index].includes(i))), + transforms: p.data?.transforms?.map((t) => (t.groups as number[] | string[])?.filter((v, i) => !indexWithNull[p_index].includes(i))), }, }; }), From 83074a225ea8ecaa51e620c8b1c9c1a7995eed53 Mon Sep 17 00:00:00 2001 From: Daniela Date: Tue, 9 Jan 2024 19:36:51 +0100 Subject: [PATCH 09/12] add ts-ignore --- src/vis/violin/ViolinVis.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/vis/violin/ViolinVis.tsx b/src/vis/violin/ViolinVis.tsx index 4ab9c56e8..a54613bad 100644 --- a/src/vis/violin/ViolinVis.tsx +++ b/src/vis/violin/ViolinVis.tsx @@ -22,7 +22,8 @@ export function ViolinVis({ config, columns, scales, dimensions, selectedList, s // Filter out null values from traces as null values cause the tooltip to not show up const filteredTraces = useMemo(() => { if (!traces) return null; - const indexWithNull = traces.plots?.map((plot) => (plot?.data.y as PlotlyTypes.Datum[])?.reduce((acc, curr, i) => (curr === null ? [...acc, i] : acc), [])); + // @ts-ignore + const indexWithNull = traces.plots?.map((plot) => plot?.data.y?.reduce((acc, curr, i) => (curr === null ? [...acc, i] : acc), [])); const filtered = { ...traces, plots: traces?.plots?.map((p, p_index) => { @@ -30,10 +31,13 @@ export function ViolinVis({ config, columns, scales, dimensions, selectedList, s ...p, data: { ...p.data, - y: (p.data?.y as PlotlyTypes.Datum[])?.filter((v, i) => !indexWithNull[p_index].includes(i)), - x: (p.data?.x as PlotlyTypes.Datum[])?.filter((v, i) => !indexWithNull[p_index].includes(i)), + // @ts-ignore + y: p.data?.y?.filter((v, i) => !indexWithNull[p_index].includes(i)), + // @ts-ignore + x: p.data?.x?.filter((v, i) => !indexWithNull[p_index].includes(i)), ids: p.data?.ids?.filter((v, i) => !indexWithNull[p_index].includes(i)), - transforms: p.data?.transforms?.map((t) => (t.groups as number[] | string[])?.filter((v, i) => !indexWithNull[p_index].includes(i))), + // @ts-ignore + transforms: p.data?.transforms?.map((t) => t.groups?.filter((v, i) => !indexWithNull[p_index].includes(i))), }, }; }), From 0f2cf4b0f919232fb578e7fc6b0a3ab9a6b5095c Mon Sep 17 00:00:00 2001 From: Usama Ansari Date: Wed, 10 Jan 2024 10:59:49 +0100 Subject: [PATCH 10/12] fix: cast types to avoid ts-ignore --- src/vis/violin/ViolinVis.tsx | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/vis/violin/ViolinVis.tsx b/src/vis/violin/ViolinVis.tsx index a54613bad..f395b8bbc 100644 --- a/src/vis/violin/ViolinVis.tsx +++ b/src/vis/violin/ViolinVis.tsx @@ -22,8 +22,9 @@ export function ViolinVis({ config, columns, scales, dimensions, selectedList, s // Filter out null values from traces as null values cause the tooltip to not show up const filteredTraces = useMemo(() => { if (!traces) return null; - // @ts-ignore - const indexWithNull = traces.plots?.map((plot) => plot?.data.y?.reduce((acc, curr, i) => (curr === null ? [...acc, i] : acc), [])); + const indexWithNull = traces.plots?.map( + (plot) => (plot?.data.y as PlotlyTypes.Datum[])?.reduce((acc: number[], curr, i) => (curr === null ? [...acc, i] : acc), []) as number[], + ); const filtered = { ...traces, plots: traces?.plots?.map((p, p_index) => { @@ -31,13 +32,12 @@ export function ViolinVis({ config, columns, scales, dimensions, selectedList, s ...p, data: { ...p.data, - // @ts-ignore - y: p.data?.y?.filter((v, i) => !indexWithNull[p_index].includes(i)), - // @ts-ignore - x: p.data?.x?.filter((v, i) => !indexWithNull[p_index].includes(i)), + y: (p.data?.y as PlotlyTypes.Datum[])?.filter((v, i) => !indexWithNull[p_index].includes(i)), + x: (p.data?.x as PlotlyTypes.Datum[])?.filter((v, i) => !indexWithNull[p_index].includes(i)), ids: p.data?.ids?.filter((v, i) => !indexWithNull[p_index].includes(i)), - // @ts-ignore - transforms: p.data?.transforms?.map((t) => t.groups?.filter((v, i) => !indexWithNull[p_index].includes(i))), + transforms: p.data?.transforms?.map( + (t) => (t.groups as unknown[])?.filter((v, i) => !indexWithNull[p_index].includes(i)) as Partial, + ), }, }; }), @@ -45,16 +45,14 @@ export function ViolinVis({ config, columns, scales, dimensions, selectedList, s return filtered; }, [traces]); - const onClick = (e: Readonly | null) => { + const onClick = (e: (Readonly & { event: MouseEvent }) | null) => { if (!e || !e.points || !e.points[0]) { selectionCallback([]); return; } - // @ts-ignore const shiftPressed = e.event.shiftKey; - // @ts-ignore - const eventIds = e.points[0]?.fullData.ids; + const eventIds = (e.points[0] as Readonly['points'][number] & { fullData: { ids: string[] } })?.fullData.ids; // Multiselect enabled if (shiftPressed) { From dc3808eece0c9c92563cfc56215738854381b41b Mon Sep 17 00:00:00 2001 From: dvvanessastoiber Date: Wed, 10 Jan 2024 14:36:42 +0100 Subject: [PATCH 11/12] prepare release 7.0.1 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 563cdf9cf..e0ff80b39 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "visyn_core", "description": "Core repository for datavisyn applications.", - "version": "7.0.1-SNAPSHOT", + "version": "7.0.1", "author": { "name": "datavisyn GmbH", "email": "contact@datavisyn.io", @@ -309,7 +309,7 @@ "react-plotly.js": "^2.5.1", "react-spring": "^9.7.1", "use-deep-compare-effect": "^1.8.0", - "visyn_scripts": "git+ssh://git@github.com/datavisyn/visyn_scripts#develop" + "visyn_scripts": "^7.0.1" }, "devDependencies": { "@babel/core": "^7.17.7", From a2d31c1fe73c248c3aa42f14af8a1c0f200855d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20P=C3=BChringer?= <51900829+puehringer@users.noreply.github.com> Date: Wed, 10 Jan 2024 14:44:04 +0100 Subject: [PATCH 12/12] Update fetchIrisData.tsx --- src/vis/stories/fetchIrisData.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/vis/stories/fetchIrisData.tsx b/src/vis/stories/fetchIrisData.tsx index bc0232b4a..9f1681b5d 100644 --- a/src/vis/stories/fetchIrisData.tsx +++ b/src/vis/stories/fetchIrisData.tsx @@ -10,8 +10,7 @@ export function fetchIrisData(): VisColumn[] { name: 'Sepal Length', }, type: EColumnTypes.NUMERICAL, - // values: () => dataPromise.map((r) => r.sepalLength).map((val, i) => ({ id: i.toString(), val })), - values: () => dataPromise.map((r) => r.sepalLength).map((val, i) => ({ id: i.toString(), val: i < 10 ? val : null })), + values: () => dataPromise.map((r) => r.sepalLength).map((val, i) => ({ id: i.toString(), val })), }, { info: {