diff --git a/packages/react-ui/src/genui-lib/Charts/PieChart.ts b/packages/react-ui/src/genui-lib/Charts/PieChart.ts index f513554b1..b58696296 100644 --- a/packages/react-ui/src/genui-lib/Charts/PieChart.ts +++ b/packages/react-ui/src/genui-lib/Charts/PieChart.ts @@ -4,7 +4,7 @@ import { defineComponent } from "@openuidev/react-lang"; import React from "react"; import { z } from "zod/v4"; import { PieChart as PieChartComponent } from "../../components/Charts"; -import { asArray, buildSliceData } from "../helpers"; +import { buildLabeledValueData, buildSliceData } from "../helpers"; export const PieChartSchema = z.object({ labels: z.array(z.string()), @@ -17,16 +17,8 @@ export const PieChart = defineComponent({ props: PieChartSchema, description: "Circular slices; use plucked arrays: PieChart(data.categories, data.values)", component: ({ props }) => { - const labels = asArray(props.labels) as string[]; - const values = asArray(props.values) as number[]; - - // New format: labels[] + values[] - if (labels.length > 0 && values.length > 0) { - const data = labels.map((cat, i) => ({ - category: cat, - value: typeof values[i] === "number" ? values[i] : 0, - })); - if (!data.length) return null; + const data = buildLabeledValueData(props.labels, props.values); + if (data.length > 0) { return React.createElement(PieChartComponent, { data, categoryKey: "category", @@ -36,7 +28,6 @@ export const PieChart = defineComponent({ }); } - // Legacy fallback: Slice[] objects (backwards compat) const sliceData = buildSliceData(props.labels); if (sliceData.length) { return React.createElement(PieChartComponent, { diff --git a/packages/react-ui/src/genui-lib/Charts/RadialChart.ts b/packages/react-ui/src/genui-lib/Charts/RadialChart.ts index bb94daa31..45ecfd53f 100644 --- a/packages/react-ui/src/genui-lib/Charts/RadialChart.ts +++ b/packages/react-ui/src/genui-lib/Charts/RadialChart.ts @@ -4,7 +4,7 @@ import { defineComponent } from "@openuidev/react-lang"; import React from "react"; import { z } from "zod/v4"; import { RadialChart as RadialChartComponent } from "../../components/Charts"; -import { asArray, buildSliceData } from "../helpers"; +import { buildLabeledValueData, buildSliceData } from "../helpers"; export const RadialChartSchema = z.object({ labels: z.array(z.string()), @@ -16,15 +16,8 @@ export const RadialChart = defineComponent({ props: RadialChartSchema, description: "Radial bars; use plucked arrays: RadialChart(data.categories, data.values)", component: ({ props }) => { - const labels = asArray(props.labels) as string[]; - const values = asArray(props.values) as number[]; - - if (labels.length > 0 && values.length > 0) { - const data = labels.map((cat, i) => ({ - category: cat, - value: typeof values[i] === "number" ? values[i] : 0, - })); - if (!data.length) return null; + const data = buildLabeledValueData(props.labels, props.values); + if (data.length > 0) { return React.createElement(RadialChartComponent, { data, categoryKey: "category", diff --git a/packages/react-ui/src/genui-lib/Charts/ScatterChart.ts b/packages/react-ui/src/genui-lib/Charts/ScatterChart.ts index ba00a2fe1..fd5f091f8 100644 --- a/packages/react-ui/src/genui-lib/Charts/ScatterChart.ts +++ b/packages/react-ui/src/genui-lib/Charts/ScatterChart.ts @@ -4,7 +4,7 @@ import { defineComponent } from "@openuidev/react-lang"; import React from "react"; import { z } from "zod/v4"; import { ScatterChart as ScatterChartComponent } from "../../components/Charts"; -import { asArray, hasAllProps } from "../helpers"; +import { buildScatterChartData, hasAllProps } from "../helpers"; import { ScatterSeriesSchema } from "./ScatterSeries"; export const ScatterChartSchema = z.object({ @@ -13,30 +13,13 @@ export const ScatterChartSchema = z.object({ yLabel: z.string().optional(), }); -const unwrap = (node: any) => (node?.type === "element" ? node.props : node); - export const ScatterChart = defineComponent({ name: "ScatterChart", props: ScatterChartSchema, description: "X/Y scatter plot; use for correlations, distributions, and clustering", component: ({ props }) => { if (!hasAllProps(props as Record, "datasets")) return null; - const rawDatasets = asArray((props as any).datasets); - const data = rawDatasets.map((ds: any) => { - const dsProps = unwrap(ds); - const rawPoints = asArray(dsProps?.points); - return { - name: (dsProps?.name ?? "") as string, - data: rawPoints.map((pt: any) => { - const ptProps = unwrap(pt); - return { - x: Number(ptProps?.x), - y: Number(ptProps?.y), - ...(ptProps?.z != null ? { z: Number(ptProps.z) } : {}), - }; - }), - }; - }); + const data = buildScatterChartData((props as Record).datasets); if (!data.length) return null; return React.createElement(ScatterChartComponent, { data, diff --git a/packages/react-ui/src/genui-lib/Charts/SingleStackedBarChart.ts b/packages/react-ui/src/genui-lib/Charts/SingleStackedBarChart.ts index f3584a13a..4db55600d 100644 --- a/packages/react-ui/src/genui-lib/Charts/SingleStackedBarChart.ts +++ b/packages/react-ui/src/genui-lib/Charts/SingleStackedBarChart.ts @@ -4,7 +4,7 @@ import { defineComponent } from "@openuidev/react-lang"; import React from "react"; import { z } from "zod/v4"; import { SingleStackedBar as SingleStackedBarChartComponent } from "../../components/Charts"; -import { asArray, buildSliceData } from "../helpers"; +import { buildLabeledValueData, buildSliceData } from "../helpers"; export const SingleStackedBarChartSchema = z.object({ labels: z.array(z.string()), @@ -17,15 +17,8 @@ export const SingleStackedBarChart = defineComponent({ description: "Single horizontal stacked bar; use plucked arrays: SingleStackedBarChart(data.categories, data.values)", component: ({ props }) => { - const labels = asArray(props.labels) as string[]; - const values = asArray(props.values) as number[]; - - if (labels.length > 0 && values.length > 0) { - const data = labels.map((cat, i) => ({ - category: cat, - value: typeof values[i] === "number" ? values[i] : 0, - })); - if (!data.length) return null; + const data = buildLabeledValueData(props.labels, props.values); + if (data.length > 0) { return React.createElement(SingleStackedBarChartComponent, { data, categoryKey: "category", diff --git a/packages/react-ui/src/genui-lib/helpers.ts b/packages/react-ui/src/genui-lib/helpers.ts index 767cbf0a4..31d121423 100644 --- a/packages/react-ui/src/genui-lib/helpers.ts +++ b/packages/react-ui/src/genui-lib/helpers.ts @@ -3,6 +3,33 @@ type ElementLike = { props: Record; }; +type ScatterPointLike = { + x: number; + y: number; + z?: number; +}; + +type ScatterDatasetLike = { + name: string; + data: ScatterPointLike[]; +}; + +type ChartPoint = Record; + +type NamedSeries = { + category: string; + values: number[]; +}; + +type SliceDatum = { + category: string; + value: number; +}; + +function isDefined(value: T | null): value is T { + return value !== null; +} + export function hasAllProps(obj: Record, ...keys: string[]): boolean { return keys.every((k) => obj[k] != null); } @@ -20,11 +47,70 @@ function asElementNodes(v: unknown): ElementLike[] { ); } -export function buildChartData( - labels: unknown, - series: unknown, -): Record[] { - const lbls = asArray(labels) as string[]; +function unwrapProps(v: unknown): Record { + if (typeof v !== "object" || v === null) { + return {}; + } + + if ((v as Record)["type"] === "element") { + const props = (v as Record)["props"]; + if (typeof props === "object" && props !== null) { + return props as Record; + } + } + + return v as Record; +} + +function asStringArray(v: unknown): string[] | null { + const values = asArray(v); + if (values.length === 0) { + return []; + } + + if (!values.every((value) => typeof value === "string")) { + return null; + } + + return values; +} + +function asFiniteNumber(v: unknown): number | null { + if (typeof v === "number") { + return Number.isFinite(v) ? v : null; + } + + if (typeof v === "string" && v.trim() !== "") { + const parsed = Number(v); + return Number.isFinite(parsed) ? parsed : null; + } + + return null; +} + +function asNumberArray(v: unknown): number[] | null { + const values = asArray(v); + if (values.length === 0) { + return []; + } + + const normalized: number[] = []; + for (const value of values) { + const parsed = asFiniteNumber(value); + if (parsed === null) { + return null; + } + normalized.push(parsed); + } + + return normalized; +} + +export function buildChartData(labels: unknown, series: unknown): ChartPoint[] { + const lbls = asStringArray(labels); + if (lbls === null || lbls.length === 0) { + return []; + } // Tabular format: labels = column names, series = 2D rows from Query results // e.g. AreaChart(data.columns, data.results) where columns=["day","views","users"] @@ -33,35 +119,169 @@ export function buildChartData( if (rows.length > 0 && Array.isArray(rows[0])) { // Column 0 = category labels, columns 1+ = series values const seriesNames = lbls.slice(1); - return rows.map((row) => { + if (seriesNames.length === 0) { + return []; + } + + const tabularData = rows.map((row): ChartPoint | null => { + if (!Array.isArray(row)) { + return null; + } + const cells = row as unknown[]; - const point: Record = { category: String(cells[0] ?? "") }; + if (cells.length < lbls.length || cells[0] == null) { + return null; + } + + const point: ChartPoint = { category: String(cells[0]) }; seriesNames.forEach((name, si) => { - const val = cells[si + 1]; - point[name] = typeof val === "number" ? val : Number(val) || 0; + const val = asFiniteNumber(cells[si + 1]); + if (val === null) { + point[name] = Number.NaN; + return; + } + point[name] = val; }); + + if (seriesNames.some((name) => !Number.isFinite(point[name] as number))) { + return null; + } + return point; }); + + if (tabularData.some((point) => point === null)) { + return []; + } + + return tabularData.filter(isDefined); } // Original format: labels = x-axis values, series = Series() elements const seriesNodes = asElementNodes(series); + if (seriesNodes.length === 0) { + return []; + } + + const normalizedSeries = seriesNodes.map((node): NamedSeries | null => { + const category = node.props["category"]; + const values = asNumberArray(node.props["values"]); + + if (typeof category !== "string" || values === null || values.length !== lbls.length) { + return null; + } + + return { category, values }; + }); + + if (normalizedSeries.some((entry) => entry === null)) { + return []; + } + + const seriesData = normalizedSeries.filter(isDefined); + return lbls.map((label, i) => { - const point: Record = { category: label }; - seriesNodes.forEach((s) => { - const cat = s.props["category"]; - const vals = s.props["values"]; - if (typeof cat === "string" && Array.isArray(vals) && i < vals.length) { - point[cat] = vals[i]!; - } + const point: ChartPoint = { category: label }; + seriesData.forEach((entry) => { + point[entry.category] = entry.values[i]!; }); return point; }); } -export function buildSliceData(slices: unknown): Record[] { - return asElementNodes(slices).map((s) => ({ - category: s.props["category"] as string, - value: s.props["value"] as number, +export function buildLabeledValueData(labels: unknown, values: unknown): ChartPoint[] { + const categories = asStringArray(labels); + const normalizedValues = asNumberArray(values); + + if ( + categories === null || + normalizedValues === null || + categories.length === 0 || + categories.length !== normalizedValues.length + ) { + return []; + } + + return categories.map((category, index) => ({ + category, + value: normalizedValues[index]!, })); } + +export function buildSliceData(slices: unknown): Record[] { + const sliceNodes = asElementNodes(slices); + if (sliceNodes.length === 0) { + return []; + } + + const normalizedSlices = sliceNodes.map((slice): SliceDatum | null => { + const category = slice.props["category"]; + const value = asFiniteNumber(slice.props["value"]); + + if (typeof category !== "string" || value === null) { + return null; + } + + return { category, value }; + }); + + if (normalizedSlices.some((slice) => slice === null)) { + return []; + } + + return normalizedSlices.filter(isDefined); +} + +export function buildScatterChartData(datasets: unknown): ScatterDatasetLike[] { + const rawDatasets = asArray(datasets); + if (rawDatasets.length === 0) { + return []; + } + + const normalizedDatasets = rawDatasets.map((dataset): ScatterDatasetLike | null => { + const datasetProps = unwrapProps(dataset); + const name = datasetProps["name"]; + const rawPoints = asArray(datasetProps["points"]); + + if (typeof name !== "string" || rawPoints.length === 0) { + return null; + } + + const points = rawPoints.map((point) => { + const pointProps = unwrapProps(point); + const x = asFiniteNumber(pointProps["x"]); + const y = asFiniteNumber(pointProps["y"]); + const rawZ = pointProps["z"]; + + if (x === null || y === null) { + return null; + } + + if (rawZ == null) { + return { x, y }; + } + + const z = asFiniteNumber(rawZ); + if (z === null) { + return null; + } + + return { x, y, z }; + }); + + if (points.some((point) => point === null)) { + return null; + } + + return { + name, + data: points as ScatterPointLike[], + }; + }); + + if (normalizedDatasets.some((dataset) => dataset === null)) { + return []; + } + + return normalizedDatasets.filter(isDefined); +}