working charts

This commit is contained in:
Nico Albanese committed 2024-10-09 22:02:54 +01:00
1 parent 05615d5e02
commit 299cf8f9da
8 files changed
+347 -185

No files matched your search

+62 -9
View File
@@ -7,6 +7,7 @@ import {
configSchema,
dataSchema,
explanationsSchema,
Result,
Unicorn,
} from "@/lib/types";
import { openai } from "@ai-sdk/openai";
@@ -20,8 +21,9 @@ export const generateQuery = async (input: string) => {
try {
const result = await generateObject({
model: openai("gpt-4o"),
system: `You are a SQL (postgres) expert. Your job is to help the user write a SQL query to retrieve the data they need. The table schema is as follows:
unicorns (
system: `You are a SQL (postgres) and data visualization expert. Your job is to help the user write a SQL query to retrieve the data they need. The table schema is as follows:
unicorns (
id SERIAL PRIMARY KEY,
company VARCHAR(255) NOT NULL UNIQUE,
valuation DECIMAL(10, 2) NOT NULL,
@@ -39,7 +41,7 @@ export const generateQuery = async (input: string) => {
Note: select_investors is a comma-separated list of investors. Trim whitespace to ensure you're grouping properly. Note, some fields may be null or have onnly one value.
When answering questions about a specific field, ensure you are selecting the identifying column (ie. what is Vercel's valuation would select company and valuation').
The industries tagged are:
The industries available are:
- healthcare & life sciences
- consumer & retail
- financial services
@@ -53,8 +55,12 @@ export const generateQuery = async (input: string) => {
Note: valuation is in billions of dollars so 10b would be 10.0.
Note: if the user asks for a rate, return it as a decimal. For example, 0.1 would be 10%.
Ensure the query is also viable for building a chart. Use multiple columns where applicable. Use counts.
If the user asks for 'over time' data, return by year.
When searching for UK or USA, write out United Kingdom or United States respectively.
EVERY QUERY SHOULD RETURN QUANTITATIVE DATA THAT CAN BE PLOTTED ON A CHART! There should always be at least two columns. If the user asks for a single column, return the column and the count of the column. If the user asks for a rate, return the rate as a decimal. For example, 0.1 would be 10%.
`,
prompt: `Generate the query necessary to retrieve the data the user wants: ${input}`,
schema: z.object({
@@ -91,7 +97,7 @@ export const getCompanies = async (query: string) => {
}
}
return data.rows as Unicorn[];
return data.rows as Result[];
};
export const explainQuery = async (input: string, sqlQuery: string) => {
@@ -133,7 +139,7 @@ export const explainQuery = async (input: string, sqlQuery: string) => {
}
};
export const generateAChart = async (results: Unicorn[], userQuery: string) => {
export const generateAChart = async (results: Result[], userQuery: string) => {
"use server";
try {
const result = await generateObject({
@@ -162,7 +168,7 @@ export const generateAChart = async (results: Unicorn[], userQuery: string) => {
}
};
export const generateChart = async (results: Unicorn[], userQuery: string) => {
export const generateChart = async (results: Result[], userQuery: string) => {
"use server";
const system = `You are a data visualization expert. `;
@@ -196,8 +202,8 @@ export const generateChart = async (results: Unicorn[], userQuery: string) => {
console.log(config);
const colors: Record<string, string> = {};
config.yKeys.forEach((key) => {
colors[key] = `#808080`;
config.yKeys.forEach((key, index) => {
colors[key] = `hsl(var(--chart-${index+1}))`;
});
const updatedConfig: Config = { ...config, colors };
@@ -302,3 +308,50 @@ export const generateChartData = async (
throw Error();
}
};
export const generateChartConfig = async (results: Result[], userQuery: string) => {
"use server";
const system = `You are a SQL and data visualization expert.`;
try {
const { object: config } = await generateObject({
model: openai("gpt-4o"),
system,
prompt: `Given the following data from a SQL query result, generate the chart config that could visualises the data and answers the users query without any data modification.
For multiple groups use multi-lines.
Here is an example complete config:
export const chartConfig = {
type: "pie",
xKey: "month",
yKeys: ["sales", "profit", "expenses"],
colors: {
sales: "#4CAF50", // Green for sales
profit: "#2196F3", // Blue for profit
expenses: "#F44336" // Red for expenses
},
legend: true
}
User Query:
${userQuery}
Data:
${JSON.stringify(results, null, 2)}`,
schema: configSchema,
});
console.log(config);
const colors: Record<string, string> = {};
config.yKeys.forEach((key, index) => {
colors[key] = `hsl(var(--chart-${index+1}))`;
});
const updatedConfig: Config = { ...config, colors };
return { config: updatedConfig };
} catch (e) {
// @ts-expect-errore
console.error(e.message);
throw new Error("Failed to generate chart suggestion");
}
};
+35 -34
View File
@@ -9,59 +9,60 @@
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--chart-1: 240 2% 26%;
--chart-2: 240 1% 42%;
--chart-3: 240 1% 58%;
--chart-4: 240 1% 74%;
--chart-5: 359 2% 90%;
}
.dark {
--background: 240 10% 3.9%;
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 359 2% 90%;
--chart-2: 240 1% 74%;
--chart-3: 240 1% 58%;
--chart-4: 240 1% 42%;
--chart-5: 240 2% 26%;
}
}
+26 -110
View File
@@ -27,36 +27,14 @@ import { Info, X, Search, Sparkles, Loader2, BarChart2 } from "lucide-react";
import Link from "next/link";
import { useOutsideClick } from "@/lib/use-outside-click";
import { QueryWithTooltips } from "@/components/ui/query-with-tooltips";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { DynamicChart } from "@/components/dynamic-chart";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
function parseCSV(csv: string) {
const [header, ...rows] = csv.trim().split("\n");
const columns = header.split(",");
return rows.map((row) => {
const values = row.split(",");
return columns.reduce(
(obj, col, index) => {
obj[col] = isNaN(Number(values[index]))
? values[index]
: Number(values[index]);
return obj;
},
{} as Record<string, string | number>,
);
});
}
import { SkeletonCard } from "@/components/skeleton-card";
export default function Component() {
const [inputValue, setInputValue] = useState("");
const [submitted, setSubmitted] = useState(false);
const [results, setResults] = useState<Unicorn[]>([]);
const [results, setResults] = useState<Record<string, string | number>[]>([]);
const [columns, setColumns] = useState<string[]>([]);
const [activeQuery, setActiveQuery] = useState("");
const [loading, setLoading] = useState(false);
@@ -67,7 +45,6 @@ export default function Component() {
>();
const [loadingExplanation, setLoadingExplanation] = useState(false);
const [isChartModalOpen, setIsChartModalOpen] = useState(false);
const [chartData, setChartData] = useState<Data | null>(null);
const [chartConfig, setChartConfig] = useState<Config | null>(null);
const [isGeneratingChart, setIsGeneratingChart] = useState(false);
@@ -94,6 +71,12 @@ export default function Component() {
}, [isModalOpen, isChartModalOpen]);
const suggestionQueries = [
"Show the number of unicorns founded each year over the past two decades",
"Track the average valuation of unicorns year by year",
"Display the cumulative total valuation of unicorns over time",
"Illustrate the growth in number of AI unicorns annually",
"Compare the yearly funding amounts for fintech vs healthtech unicorns",
"Plot the trend of unicorn valuations in the US vs China over time",
"Which cities have with most AI unicorns",
"Show the countries with highest unicorn density",
"Show the number of unicorns (grouped by year) over the past decade",
@@ -122,6 +105,9 @@ export default function Component() {
setResults(companies);
setColumns(columns);
setLoading(false);
const generation = await generateChart(companies, inputValue);
console.log(generation.config);
setChartConfig(generation.config);
};
const handleSuggestionClick = (suggestion: string) => {
@@ -134,7 +120,6 @@ export default function Component() {
setActiveQuery("");
setQueryExplanations(null);
setChartConfig(null);
setChartData(null);
setIsGeneratingChart(false);
};
@@ -185,14 +170,8 @@ export default function Component() {
try {
const generation = await generateChart(results, inputValue);
setChartConfig(generation.config);
console.log(results, generation.config);
setIsChartModalOpen(true);
const { data } = await generateChartData(
generation.config,
results,
inputValue,
);
const parsed = parseCSV(data);
setChartData(parsed);
} catch (error) {
console.error("Failed to generate chart:", error);
} finally {
@@ -215,33 +194,6 @@ export default function Component() {
<Sparkles className="mr-2" />
Unicorn Query
</span>
{chartConfig ? (
<Button
variant={"secondary"}
onClick={() => setIsChartModalOpen(true)}
>
Show chart
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={handleGenerateChart}
disabled={results.length === 0 || isGeneratingChart}
>
{isGeneratingChart ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Generating Chart...
</>
) : (
<>
<BarChart2 className="h-4 w-4 mr-2" />
Generate Chart
</>
)}
</Button>
)}
</h1>
<form onSubmit={handleSubmit} className="mb-6">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center space-y-4 sm:space-y-0 sm:space-x-4">
@@ -274,7 +226,7 @@ export default function Component() {
</div>
</form>
<div className="h-[600px] flex flex-col">
<div className="flex-grow overflow-hidden relative">
<div className="flex-grow overflow-hidden relative h-full">
<AnimatePresence>
{!submitted && (
<motion.div
@@ -307,7 +259,7 @@ export default function Component() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="h-full flex flex-col"
className="h-full relative flex flex-col"
>
{activeQuery.length > 0 && (
<div className="mb-4 relative group">
@@ -346,10 +298,13 @@ export default function Component() {
</div>
) : (
<div className="flex-grow flex flex-col">
<Tabs defaultValue="table" className="w-full flex-grow flex flex-col">
<Tabs
defaultValue="table"
className="w-full flex-grow flex flex-col"
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="table">Table</TabsTrigger>
<TabsTrigger value="charts">Chart</TabsTrigger>
<TabsTrigger value="charts" disabled={Object.keys(results[0] || {}).length <= 1}>Chart</TabsTrigger>
</TabsList>
<TabsContent value="table" className="flex-grow">
<div className="h-[10px] bg-orange-500">
@@ -389,42 +344,18 @@ export default function Component() {
</Table>
</div>
</TabsContent>
<TabsContent value="charts" className="flex-grow overflow-auto">
<TabsContent
value="charts"
className="flex-grow overflow-auto"
>
<div className="mt-4">
{chartConfig ? (
<DynamicChart
chartData={chartData}
chartData={results}
chartConfig={chartConfig}
/>
) : (
<Button
variant="outline"
size="sm"
onClick={handleGenerateChart}
disabled={isGeneratingChart}
>
{isGeneratingChart ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Generating Chart...
</>
) : (
<>
<BarChart2 className="h-4 w-4 mr-2" />
Generate Chart
</>
)}
</Button>
)}
{chartConfig && (
<>
<p className="mt-4 text-sm">
{chartConfig.description}
</p>
<p className="mt-4 text-sm">
{chartConfig.takeaway}
</p>
</>
<SkeletonCard />
)}
</div>
</TabsContent>
@@ -525,21 +456,6 @@ export default function Component() {
</motion.div>
)}
</AnimatePresence>
<Dialog open={isChartModalOpen} onOpenChange={setIsChartModalOpen}>
<DialogContent className="sm:max-w-[800px]">
<DialogHeader>
<DialogTitle>{chartConfig?.title || "Chart"}</DialogTitle>
</DialogHeader>
<div className="mt-4">
{chartConfig && (
<DynamicChart chartData={chartData} chartConfig={chartConfig} />
)}
<p className="mt-4 text-sm">{chartConfig?.description}</p>
<p className="mt-4 text-sm">{chartConfig?.takeaway}</p>
</div>
</DialogContent>
</Dialog>
</div>
);
}
}
+129 -31
View File
@@ -28,46 +28,141 @@ import { Input } from "@/components/ui/input";
import { Loader2 } from "lucide-react";
import { generateChart } from "@/app/actions";
import { Config, Data, Unicorn } from "@/lib/types";
import { Label } from "recharts";
import { transformDataForMultiLineChart } from "@/lib/rechart-format";
function toTitleCase(str: string): string {
return str
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
const colors = [
"hsl(var(--chart-1))",
"hsl(var(--chart-2))",
"hsl(var(--chart-3))",
"hsl(var(--chart-4))",
"hsl(var(--chart-5))",
"hsl(var(--chart-6))",
"hsl(var(--chart-7))",
"hsl(var(--chart-8))",
];
export function DynamicChart({
chartData,
chartConfig,
}: {
chartData: Data | null;
chartData: Data;
chartConfig: Config;
}) {
const renderChart = () => {
if (!chartData || !chartConfig) return <div>No chart data</div>;
const parsedChartData = chartData.map((item) => {
const parsedItem: { [key: string]: any } = {};
for (const [key, value] of Object.entries(item)) {
parsedItem[key] = isNaN(Number(value)) ? value : Number(value);
}
return parsedItem;
});
chartData = parsedChartData;
const processChartData = (data: Data, chartType: string) => {
if (chartType === "bar" || chartType === "pie") {
if (data.length <= 8) {
return data;
}
const subset = data.slice(0, 20);
return subset;
}
return data;
};
chartData = processChartData(chartData, chartConfig.type);
console.log(chartData);
switch (chartConfig.type) {
case "bar":
return (
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={chartConfig.xKey} />
<YAxis />
<XAxis dataKey={chartConfig.xKey}>
<Label
value={toTitleCase(chartConfig.xKey)}
offset={0}
position="insideBottom"
/>
</XAxis>
<YAxis>
<Label
value={toTitleCase(chartConfig.yKeys[0])}
angle={-90}
position="insideLeft"
/>
</YAxis>
<ChartTooltip content={<ChartTooltipContent />} />
{chartConfig.legend && <Legend />}
{chartConfig.yKeys.map((key) => (
<Bar key={key} dataKey={key} />
{chartConfig.yKeys.map((key, index) => (
<Bar
key={key}
dataKey={key}
fill={colors[index % colors.length]}
/>
))}
</BarChart>
);
case "line":
const { data, xAxisField, lineFields } = transformDataForMultiLineChart(
chartData,
chartConfig.measurementColumn ?? "value",
);
const useTransformedData =
chartConfig.multipleLines &&
chartConfig.measurementColumn &&
chartConfig.yKeys.includes(chartConfig.measurementColumn);
console.log(useTransformedData, "useTransformedData");
// const useTransformedData = false;
return (
<LineChart data={chartData}>
<LineChart data={useTransformedData ? data : chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={chartConfig.xKey} />
<YAxis />
<XAxis
dataKey={useTransformedData ? chartConfig.xKey : chartConfig.xKey}
>
<Label
value={toTitleCase(
useTransformedData ? xAxisField : chartConfig.xKey,
)}
offset={0}
position="insideBottom"
/>
</XAxis>
<YAxis>
<Label
value={toTitleCase(chartConfig.yKeys[0])}
angle={-90}
position="insideLeft"
/>
</YAxis>
<ChartTooltip content={<ChartTooltipContent />} />
{chartConfig.legend && <Legend />}
{chartConfig.yKeys.map((key) => (
<Line
key={key}
type="monotone"
dataKey={key}
/>
))}
{useTransformedData
? lineFields.map((key, index) => (
<Line
key={key}
type="monotone"
dataKey={key}
stroke={colors[index % colors.length]}
/>
))
: chartConfig.yKeys.map((key, index) => (
<Line
key={key}
type="monotone"
dataKey={key}
stroke={colors[index % colors.length]}
/>
))}
</LineChart>
);
case "area":
@@ -78,13 +173,13 @@ export function DynamicChart({
<YAxis />
<ChartTooltip content={<ChartTooltipContent />} />
{chartConfig.legend && <Legend />}
{chartConfig.yKeys.map((key) => (
{chartConfig.yKeys.map((key, index) => (
<Area
key={key}
type="monotone"
dataKey={key}
stroke={chartConfig.colors[key]}
fill={chartConfig.colors[key]}
fill={colors[index % colors.length]}
stroke={colors[index % colors.length]}
/>
))}
</AreaChart>
@@ -98,12 +193,12 @@ export function DynamicChart({
nameKey={chartConfig.xKey}
cx="50%"
cy="50%"
outerRadius={150}
outerRadius={120}
>
{chartData.map((entry, index) => (
{chartData.map((_, index) => (
<Cell
key={`cell-${index}`}
fill={chartConfig.colors[entry[chartConfig.xKey]]}
fill={colors[index % colors.length]}
/>
))}
</Pie>
@@ -117,26 +212,29 @@ export function DynamicChart({
};
return (
<div className="w-full">
{!chartData ? (
<div className="flex items-center justify-center h-[400px] w-full animate-pulse bg-neutral-100"></div>
) : null}
<div className="w-full flex flex-col justify-center items-center">
<h2 className="text-lg font-bold mb-2">{chartConfig.title}</h2>
{chartConfig && chartData && (
<ChartContainer
config={chartConfig.yKeys.reduce(
(acc, key) => {
acc[key] = { label: key, color: chartConfig.colors[key] };
(acc, key, index) => {
acc[key] = {
label: key,
color: colors[index % colors.length],
};
return acc;
},
{} as Record<string, { label: string; color: string }>,
)}
className="h-[400px]"
className="h-[320px] w-full"
>
<ResponsiveContainer width="100%" height="100%">
{renderChart()}
</ResponsiveContainer>
{renderChart()}
</ChartContainer>
)}
<div className="">
<p className="mt-4 text-sm">{chartConfig.description}</p>
<p className="mt-4 text-sm">{chartConfig.takeaway}</p>
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { Skeleton } from "@/components/ui/skeleton";
export function SkeletonCard() {
return (
<div className="flex flex-col space-y-3 w-full">
<div className="w-full flex items-center justify-center">
<Skeleton className="h-[36px] w-72 rounded-xl" />
</div>
<Skeleton className="h-[300px] w-full rounded-xl" />
<div className="w-full flex justify-center items-center">
<Skeleton className="h-4 w-20" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-[450px]" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-[350px]" />
</div>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+52
View File
@@ -0,0 +1,52 @@
type InputDataPoint = Record<string, string | number>;
interface TransformedDataPoint {
[key: string]: string | number | null;
}
interface TransformationResult {
data: TransformedDataPoint[];
xAxisField: string;
lineFields: string[];
}
export function transformDataForMultiLineChart(
data: InputDataPoint[],
measureColumn: string
): TransformationResult {
console.log("Input data:", data);
const fields = Object.keys(data[0]);
const xAxisField = fields.find(field => field !== measureColumn && field !== 'country') || '';
const lineField = 'country';
console.log("X-axis field:", xAxisField);
console.log("Line field:", lineField);
const xAxisValues = Array.from(new Set(data.map(item => String(item[xAxisField]))));
const lineCategories = Array.from(new Set(data.map(item => String(item[lineField]))));
console.log("X-axis values:", xAxisValues);
console.log("Line categories:", lineCategories);
const transformedData: TransformedDataPoint[] = xAxisValues.map(xValue => {
const dataPoint: TransformedDataPoint = { [xAxisField]: xValue };
lineCategories.forEach(category => {
const matchingItem = data.find(item =>
String(item[xAxisField]) === xValue && String(item[lineField]) === category
);
dataPoint[category] = matchingItem ? matchingItem[measureColumn] : null;
});
return dataPoint;
});
transformedData.sort((a, b) => Number(a[xAxisField]) - Number(b[xAxisField]));
console.log("Transformed data:", transformedData);
return {
data: transformedData,
xAxisField,
lineFields: lineCategories
};
}
+5 -1
View File
@@ -11,6 +11,8 @@ export type Unicorn = {
select_investors: string;
};
export type Result = Record<string, string | number>;
export const explanationSchema = z.object({
section: z.string(),
explanation: z.string(),
@@ -66,7 +68,9 @@ export const configSchema = z
type: z.enum(["bar", "line", "area", "pie"]).describe("Type of chart"),
title: z.string(),
xKey: z.string().describe("Key for x-axis or category"),
yKeys: z.array(z.string()).describe("Key(s) for y-axis values"),
yKeys: z.array(z.string()).describe("Key(s) for y-axis values this is typically the quantitative column"),
multipleLines: z.boolean().describe("For line charts only: whether the chart is comparing groups of data.").optional(),
measurementColumn: z.string().describe("For line charts only: key for quantitative y-axis column to measure against (eg. values, counts etc.)").optional(),
colors: z
.record(
z.string().describe("Any of the yKeys"),