registry.tsx 30 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072
  1. "use client";
  2. import { useState, useRef, type ReactNode } from "react";
  3. import { useBoundProp, defineRegistry } from "@json-render/react";
  4. import {
  5. Bar,
  6. BarChart as RechartsBarChart,
  7. CartesianGrid,
  8. Legend,
  9. Line,
  10. LineChart as RechartsLineChart,
  11. Pie,
  12. PieChart as RechartsPieChart,
  13. XAxis,
  14. } from "recharts";
  15. import {
  16. ChartContainer,
  17. ChartTooltip,
  18. ChartTooltipContent,
  19. type ChartConfig,
  20. } from "@/components/ui/chart";
  21. import {
  22. Card,
  23. CardHeader,
  24. CardTitle,
  25. CardDescription,
  26. CardContent,
  27. } from "@/components/ui/card";
  28. import { Badge } from "@/components/ui/badge";
  29. import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
  30. import { Separator } from "@/components/ui/separator";
  31. import {
  32. Table,
  33. TableHeader,
  34. TableBody,
  35. TableHead,
  36. TableRow,
  37. TableCell,
  38. } from "@/components/ui/table";
  39. import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
  40. import {
  41. Accordion as AccordionRoot,
  42. AccordionItem,
  43. AccordionTrigger,
  44. AccordionContent,
  45. } from "@/components/ui/accordion";
  46. import { Progress } from "@/components/ui/progress";
  47. import { Skeleton } from "@/components/ui/skeleton";
  48. import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
  49. import {
  50. Select,
  51. SelectContent,
  52. SelectItem,
  53. SelectTrigger,
  54. SelectValue,
  55. } from "@/components/ui/select";
  56. import { Input } from "@/components/ui/input";
  57. import { Button } from "@/components/ui/button";
  58. import { Label } from "@/components/ui/label";
  59. import {
  60. TrendingUp,
  61. TrendingDown,
  62. Minus,
  63. Info,
  64. Lightbulb,
  65. AlertTriangle,
  66. Star,
  67. ArrowUpDown,
  68. ArrowUp,
  69. ArrowDown,
  70. } from "lucide-react";
  71. // 3D imports
  72. import { Canvas, useFrame } from "@react-three/fiber";
  73. import {
  74. OrbitControls,
  75. Stars as DreiStars,
  76. Text as DreiText,
  77. } from "@react-three/drei";
  78. import type * as THREE from "three";
  79. import { explorerCatalog } from "./catalog";
  80. // =============================================================================
  81. // 3D Helper Types & Components
  82. // =============================================================================
  83. type Vec3Tuple = [number, number, number];
  84. interface Animation3D {
  85. rotate?: number[] | null;
  86. }
  87. interface Mesh3DProps {
  88. position?: number[] | null;
  89. rotation?: number[] | null;
  90. scale?: number[] | null;
  91. color?: string | null;
  92. args?: number[] | null;
  93. metalness?: number | null;
  94. roughness?: number | null;
  95. emissive?: string | null;
  96. emissiveIntensity?: number | null;
  97. wireframe?: boolean | null;
  98. opacity?: number | null;
  99. animation?: Animation3D | null;
  100. }
  101. function toVec3(v: number[] | null | undefined): Vec3Tuple | undefined {
  102. if (!v || v.length < 3) return undefined;
  103. return v.slice(0, 3) as Vec3Tuple;
  104. }
  105. function toGeoArgs<T extends unknown[]>(
  106. v: number[] | null | undefined,
  107. fallback: T,
  108. ): T {
  109. if (!v || v.length === 0) return fallback;
  110. return v as unknown as T;
  111. }
  112. /** Shared hook for continuous rotation animation */
  113. function useRotationAnimation(
  114. ref: React.RefObject<THREE.Object3D | null>,
  115. animation?: Animation3D | null,
  116. ) {
  117. useFrame(() => {
  118. if (!ref.current || !animation?.rotate) return;
  119. const [rx, ry, rz] = animation.rotate;
  120. ref.current.rotation.x += rx ?? 0;
  121. ref.current.rotation.y += ry ?? 0;
  122. ref.current.rotation.z += rz ?? 0;
  123. });
  124. }
  125. /** Standard material props shared by all mesh primitives */
  126. function StandardMaterial({
  127. color,
  128. metalness,
  129. roughness,
  130. emissive,
  131. emissiveIntensity,
  132. wireframe,
  133. opacity,
  134. }: Mesh3DProps) {
  135. return (
  136. <meshStandardMaterial
  137. color={color ?? "#cccccc"}
  138. metalness={metalness ?? 0.1}
  139. roughness={roughness ?? 0.8}
  140. emissive={emissive ?? undefined}
  141. emissiveIntensity={emissiveIntensity ?? 1}
  142. wireframe={wireframe ?? false}
  143. transparent={opacity != null && opacity < 1}
  144. opacity={opacity ?? 1}
  145. />
  146. );
  147. }
  148. /** Generic mesh wrapper for all geometry primitives */
  149. function MeshPrimitive({
  150. meshProps,
  151. children,
  152. onClick,
  153. }: {
  154. meshProps: Mesh3DProps;
  155. children: ReactNode;
  156. onClick?: () => void;
  157. }) {
  158. const ref = useRef<THREE.Mesh>(null);
  159. useRotationAnimation(ref, meshProps.animation);
  160. return (
  161. <mesh
  162. ref={ref}
  163. position={toVec3(meshProps.position)}
  164. rotation={toVec3(meshProps.rotation)}
  165. scale={toVec3(meshProps.scale)}
  166. onClick={onClick}
  167. >
  168. {children}
  169. <StandardMaterial {...meshProps} />
  170. </mesh>
  171. );
  172. }
  173. /** Animated group wrapper */
  174. function AnimatedGroup({
  175. position,
  176. rotation,
  177. scale,
  178. animation,
  179. children,
  180. }: {
  181. position?: number[] | null;
  182. rotation?: number[] | null;
  183. scale?: number[] | null;
  184. animation?: Animation3D | null;
  185. children?: ReactNode;
  186. }) {
  187. const ref = useRef<THREE.Group>(null);
  188. useRotationAnimation(ref, animation);
  189. return (
  190. <group
  191. ref={ref}
  192. position={toVec3(position)}
  193. rotation={toVec3(rotation)}
  194. scale={toVec3(scale)}
  195. >
  196. {children}
  197. </group>
  198. );
  199. }
  200. // =============================================================================
  201. // Registry
  202. // =============================================================================
  203. export const { registry, handlers } = defineRegistry(explorerCatalog, {
  204. components: {
  205. Stack: ({ props, children }) => {
  206. const gapClass =
  207. { sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"] ?? "gap-4";
  208. return (
  209. <div
  210. className={`flex ${props.direction === "horizontal" ? "flex-row" : "flex-col"} ${props.wrap ? "flex-wrap" : ""} ${gapClass}`}
  211. >
  212. {children}
  213. </div>
  214. );
  215. },
  216. Card: ({ props, children }) => (
  217. <Card>
  218. {(props.title || props.description) && (
  219. <CardHeader>
  220. {props.title && <CardTitle>{props.title}</CardTitle>}
  221. {props.description && (
  222. <CardDescription>{props.description}</CardDescription>
  223. )}
  224. </CardHeader>
  225. )}
  226. <CardContent className="flex flex-col gap-4">{children}</CardContent>
  227. </Card>
  228. ),
  229. Grid: ({ props, children }) => {
  230. const colsClass =
  231. {
  232. "1": "grid-cols-1",
  233. "2": "grid-cols-1 md:grid-cols-2",
  234. "3": "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
  235. "4": "grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
  236. }[props.columns ?? "3"] ?? "grid-cols-1 md:grid-cols-2 lg:grid-cols-3";
  237. const gapClass =
  238. { sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"] ?? "gap-4";
  239. return <div className={`grid ${colsClass} ${gapClass}`}>{children}</div>;
  240. },
  241. Heading: ({ props }) => {
  242. const Tag = (props.level ?? "h2") as "h1" | "h2" | "h3" | "h4";
  243. const sizeClass = {
  244. h1: "text-3xl font-bold tracking-tight",
  245. h2: "text-2xl font-semibold tracking-tight",
  246. h3: "text-xl font-semibold",
  247. h4: "text-lg font-medium",
  248. }[props.level ?? "h2"];
  249. return <Tag className={sizeClass}>{props.text}</Tag>;
  250. },
  251. Text: ({ props }) => (
  252. <p className={props.muted ? "text-muted-foreground" : ""}>
  253. {props.content}
  254. </p>
  255. ),
  256. Badge: ({ props }) => (
  257. <Badge variant={props.variant ?? "default"}>{props.text}</Badge>
  258. ),
  259. Alert: ({ props }) => (
  260. <Alert variant={props.variant ?? "default"}>
  261. <AlertTitle>{props.title}</AlertTitle>
  262. {props.description ? (
  263. <AlertDescription>{props.description}</AlertDescription>
  264. ) : null}
  265. </Alert>
  266. ),
  267. Separator: () => <Separator />,
  268. Metric: ({ props }) => {
  269. const TrendIcon =
  270. props.trend === "up"
  271. ? TrendingUp
  272. : props.trend === "down"
  273. ? TrendingDown
  274. : Minus;
  275. const trendColor =
  276. props.trend === "up"
  277. ? "text-green-500"
  278. : props.trend === "down"
  279. ? "text-red-500"
  280. : "text-muted-foreground";
  281. return (
  282. <div className="flex flex-col gap-1">
  283. <p className="text-sm text-muted-foreground">{props.label}</p>
  284. <div className="flex items-center gap-2">
  285. <span className="text-2xl font-bold">{props.value}</span>
  286. {props.trend && <TrendIcon className={`h-4 w-4 ${trendColor}`} />}
  287. </div>
  288. {props.detail && (
  289. <p className="text-xs text-muted-foreground">{props.detail}</p>
  290. )}
  291. </div>
  292. );
  293. },
  294. Table: ({ props }) => {
  295. const rawData = props.data;
  296. const items: Array<Record<string, unknown>> = Array.isArray(rawData)
  297. ? rawData
  298. : Array.isArray((rawData as Record<string, unknown>)?.data)
  299. ? ((rawData as Record<string, unknown>).data as Array<
  300. Record<string, unknown>
  301. >)
  302. : [];
  303. const [sortKey, setSortKey] = useState<string | null>(null);
  304. const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
  305. if (items.length === 0) {
  306. return (
  307. <div className="text-center py-4 text-muted-foreground">
  308. {props.emptyMessage ?? "No data"}
  309. </div>
  310. );
  311. }
  312. const sorted = sortKey
  313. ? [...items].sort((a, b) => {
  314. const av = a[sortKey];
  315. const bv = b[sortKey];
  316. // numeric comparison when both values are numbers
  317. if (typeof av === "number" && typeof bv === "number") {
  318. return sortDir === "asc" ? av - bv : bv - av;
  319. }
  320. const as = String(av ?? "");
  321. const bs = String(bv ?? "");
  322. return sortDir === "asc"
  323. ? as.localeCompare(bs)
  324. : bs.localeCompare(as);
  325. })
  326. : items;
  327. const handleSort = (key: string) => {
  328. if (sortKey === key) {
  329. setSortDir((d) => (d === "asc" ? "desc" : "asc"));
  330. } else {
  331. setSortKey(key);
  332. setSortDir("asc");
  333. }
  334. };
  335. return (
  336. <Table>
  337. <TableHeader>
  338. <TableRow>
  339. {props.columns.map((col) => {
  340. const SortIcon =
  341. sortKey === col.key
  342. ? sortDir === "asc"
  343. ? ArrowUp
  344. : ArrowDown
  345. : ArrowUpDown;
  346. return (
  347. <TableHead key={col.key}>
  348. <button
  349. type="button"
  350. className="inline-flex items-center gap-1 hover:text-foreground transition-colors"
  351. onClick={() => handleSort(col.key)}
  352. >
  353. {col.label}
  354. <SortIcon className="h-3 w-3 text-muted-foreground" />
  355. </button>
  356. </TableHead>
  357. );
  358. })}
  359. </TableRow>
  360. </TableHeader>
  361. <TableBody>
  362. {sorted.map((item, i) => (
  363. <TableRow key={i}>
  364. {props.columns.map((col) => (
  365. <TableCell key={col.key}>
  366. {String(item[col.key] ?? "")}
  367. </TableCell>
  368. ))}
  369. </TableRow>
  370. ))}
  371. </TableBody>
  372. </Table>
  373. );
  374. },
  375. Link: ({ props }) => (
  376. <a
  377. href={props.href}
  378. target="_blank"
  379. rel="noopener noreferrer"
  380. className="text-primary underline underline-offset-4 hover:text-primary/80"
  381. >
  382. {props.text}
  383. </a>
  384. ),
  385. BarChart: ({ props }) => {
  386. const rawData = props.data;
  387. const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
  388. ? rawData
  389. : Array.isArray((rawData as Record<string, unknown>)?.data)
  390. ? ((rawData as Record<string, unknown>).data as Array<
  391. Record<string, unknown>
  392. >)
  393. : [];
  394. const { items, valueKey } = processChartData(
  395. rawItems,
  396. props.xKey,
  397. props.yKey,
  398. props.aggregate,
  399. );
  400. const chartColor = props.color ?? "var(--chart-1)";
  401. const chartConfig = {
  402. [valueKey]: {
  403. label: valueKey,
  404. color: chartColor,
  405. },
  406. } satisfies ChartConfig;
  407. if (items.length === 0) {
  408. return (
  409. <div className="text-center py-4 text-muted-foreground">
  410. No data available
  411. </div>
  412. );
  413. }
  414. return (
  415. <div className="w-full">
  416. {props.title && (
  417. <p className="text-sm font-medium mb-2">{props.title}</p>
  418. )}
  419. <ChartContainer
  420. config={chartConfig}
  421. className="min-h-[200px] w-full"
  422. style={{ height: props.height ?? 300 }}
  423. >
  424. <RechartsBarChart accessibilityLayer data={items}>
  425. <CartesianGrid vertical={false} />
  426. <XAxis
  427. dataKey="label"
  428. tickLine={false}
  429. tickMargin={10}
  430. axisLine={false}
  431. />
  432. <ChartTooltip content={<ChartTooltipContent />} />
  433. <Bar
  434. dataKey={valueKey}
  435. fill={`var(--color-${valueKey})`}
  436. radius={4}
  437. />
  438. </RechartsBarChart>
  439. </ChartContainer>
  440. </div>
  441. );
  442. },
  443. LineChart: ({ props }) => {
  444. const rawData = props.data;
  445. const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
  446. ? rawData
  447. : Array.isArray((rawData as Record<string, unknown>)?.data)
  448. ? ((rawData as Record<string, unknown>).data as Array<
  449. Record<string, unknown>
  450. >)
  451. : [];
  452. const { items, valueKey } = processChartData(
  453. rawItems,
  454. props.xKey,
  455. props.yKey,
  456. props.aggregate,
  457. );
  458. const chartColor = props.color ?? "var(--chart-1)";
  459. const chartConfig = {
  460. [valueKey]: {
  461. label: valueKey,
  462. color: chartColor,
  463. },
  464. } satisfies ChartConfig;
  465. if (items.length === 0) {
  466. return (
  467. <div className="text-center py-4 text-muted-foreground">
  468. No data available
  469. </div>
  470. );
  471. }
  472. return (
  473. <div className="w-full">
  474. {props.title && (
  475. <p className="text-sm font-medium mb-2">{props.title}</p>
  476. )}
  477. <ChartContainer
  478. config={chartConfig}
  479. className="min-h-[200px] w-full [&_svg]:overflow-visible"
  480. style={{ height: props.height ?? 300 }}
  481. >
  482. <RechartsLineChart accessibilityLayer data={items}>
  483. <CartesianGrid vertical={false} />
  484. <XAxis
  485. dataKey="label"
  486. tickLine={false}
  487. tickMargin={10}
  488. axisLine={false}
  489. interval={
  490. items.length > 12
  491. ? Math.ceil(items.length / 8) - 1
  492. : undefined
  493. }
  494. />
  495. <ChartTooltip content={<ChartTooltipContent />} />
  496. <Line
  497. type="monotone"
  498. dataKey={valueKey}
  499. stroke={`var(--color-${valueKey})`}
  500. strokeWidth={2}
  501. dot={false}
  502. />
  503. </RechartsLineChart>
  504. </ChartContainer>
  505. </div>
  506. );
  507. },
  508. Tabs: ({ props, children }) => (
  509. <Tabs defaultValue={props.defaultValue ?? (props.tabs ?? [])[0]?.value}>
  510. <TabsList>
  511. {(props.tabs ?? []).map((tab) => (
  512. <TabsTrigger key={tab.value} value={tab.value}>
  513. {tab.label}
  514. </TabsTrigger>
  515. ))}
  516. </TabsList>
  517. {children}
  518. </Tabs>
  519. ),
  520. TabContent: ({ props, children }) => (
  521. <TabsContent value={props.value}>{children}</TabsContent>
  522. ),
  523. Progress: ({ props }) => (
  524. <Progress value={props.value} max={props.max ?? 100} />
  525. ),
  526. Skeleton: ({ props }) => (
  527. <Skeleton
  528. className={`${props.width ?? "w-full"} ${props.height ?? "h-4"}`}
  529. />
  530. ),
  531. Callout: ({ props }) => {
  532. const config = {
  533. info: {
  534. icon: Info,
  535. border: "border-l-blue-500",
  536. bg: "bg-blue-500/5",
  537. iconColor: "text-blue-500",
  538. },
  539. tip: {
  540. icon: Lightbulb,
  541. border: "border-l-emerald-500",
  542. bg: "bg-emerald-500/5",
  543. iconColor: "text-emerald-500",
  544. },
  545. warning: {
  546. icon: AlertTriangle,
  547. border: "border-l-amber-500",
  548. bg: "bg-amber-500/5",
  549. iconColor: "text-amber-500",
  550. },
  551. important: {
  552. icon: Star,
  553. border: "border-l-purple-500",
  554. bg: "bg-purple-500/5",
  555. iconColor: "text-purple-500",
  556. },
  557. }[props.type ?? "info"] ?? {
  558. icon: Info,
  559. border: "border-l-blue-500",
  560. bg: "bg-blue-500/5",
  561. iconColor: "text-blue-500",
  562. };
  563. const Icon = config.icon;
  564. return (
  565. <div
  566. className={`border-l-4 ${config.border} ${config.bg} rounded-r-lg p-4`}
  567. >
  568. <div className="flex items-start gap-3">
  569. <Icon className={`h-5 w-5 mt-0.5 shrink-0 ${config.iconColor}`} />
  570. <div className="flex-1 min-w-0">
  571. {props.title && (
  572. <p className="font-semibold text-sm mb-1">{props.title}</p>
  573. )}
  574. <p className="text-sm text-muted-foreground">{props.content}</p>
  575. </div>
  576. </div>
  577. </div>
  578. );
  579. },
  580. Accordion: ({ props }) => (
  581. <AccordionRoot
  582. type={props.type === "single" ? "single" : "multiple"}
  583. collapsible={props.type === "single" ? true : undefined}
  584. className="w-full"
  585. >
  586. {(props.items ?? []).map((item, i) => (
  587. <AccordionItem key={i} value={`item-${i}`}>
  588. <AccordionTrigger>{item.title}</AccordionTrigger>
  589. <AccordionContent>
  590. <p className="text-muted-foreground">{item.content}</p>
  591. </AccordionContent>
  592. </AccordionItem>
  593. ))}
  594. </AccordionRoot>
  595. ),
  596. Timeline: ({ props }) => (
  597. <div className="relative pl-8">
  598. {/* Vertical line centered on dots: dot is 12px wide starting at 0px, center = 6px */}
  599. <div className="absolute left-[5.5px] top-3 bottom-3 w-px bg-border" />
  600. <div className="flex flex-col gap-6">
  601. {(props.items ?? []).map((item, i) => {
  602. const dotColor =
  603. item.status === "completed"
  604. ? "bg-emerald-500"
  605. : item.status === "current"
  606. ? "bg-blue-500"
  607. : "bg-muted-foreground/30";
  608. return (
  609. <div key={i} className="relative">
  610. <div
  611. className={`absolute -left-8 top-0.5 h-3 w-3 rounded-full ${dotColor} ring-2 ring-background`}
  612. />
  613. <div className="flex-1 min-w-0">
  614. <div className="flex items-center gap-2 flex-wrap">
  615. <p className="font-medium text-sm">{item.title}</p>
  616. {item.date && (
  617. <span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
  618. {item.date}
  619. </span>
  620. )}
  621. </div>
  622. {item.description && (
  623. <p className="text-sm text-muted-foreground mt-1">
  624. {item.description}
  625. </p>
  626. )}
  627. </div>
  628. </div>
  629. );
  630. })}
  631. </div>
  632. </div>
  633. ),
  634. PieChart: ({ props }) => {
  635. const rawData = props.data;
  636. const items: Array<Record<string, unknown>> = Array.isArray(rawData)
  637. ? rawData
  638. : Array.isArray((rawData as Record<string, unknown>)?.data)
  639. ? ((rawData as Record<string, unknown>).data as Array<
  640. Record<string, unknown>
  641. >)
  642. : [];
  643. if (items.length === 0) {
  644. return (
  645. <div className="text-center py-4 text-muted-foreground">
  646. No data available
  647. </div>
  648. );
  649. }
  650. const chartConfig: ChartConfig = {};
  651. items.forEach((item, i) => {
  652. const name = String(item[props.nameKey] ?? `Segment ${i + 1}`);
  653. chartConfig[name] = {
  654. label: name,
  655. color: PIE_COLORS[i % PIE_COLORS.length],
  656. };
  657. });
  658. return (
  659. <div className="w-full">
  660. {props.title && (
  661. <p className="text-sm font-medium mb-2">{props.title}</p>
  662. )}
  663. <ChartContainer
  664. config={chartConfig}
  665. className="mx-auto aspect-square w-full"
  666. style={{ height: props.height ?? 300 }}
  667. >
  668. <RechartsPieChart>
  669. <ChartTooltip content={<ChartTooltipContent />} />
  670. <Pie
  671. data={items.map((item, i) => ({
  672. name: String(item[props.nameKey] ?? `Segment ${i + 1}`),
  673. value:
  674. typeof item[props.valueKey] === "number"
  675. ? item[props.valueKey]
  676. : parseFloat(String(item[props.valueKey])) || 0,
  677. fill: PIE_COLORS[i % PIE_COLORS.length],
  678. }))}
  679. dataKey="value"
  680. nameKey="name"
  681. innerRadius="40%"
  682. outerRadius="70%"
  683. paddingAngle={2}
  684. />
  685. <Legend />
  686. </RechartsPieChart>
  687. </ChartContainer>
  688. </div>
  689. );
  690. },
  691. RadioGroup: ({ props, bindings }) => {
  692. const [value, setValue] = useBoundProp<string>(
  693. props.value as string | undefined,
  694. bindings?.value,
  695. );
  696. const current = value ?? "";
  697. return (
  698. <div className="flex flex-col gap-2">
  699. {props.label && (
  700. <Label className="text-sm font-medium">{props.label}</Label>
  701. )}
  702. <RadioGroup
  703. value={current}
  704. onValueChange={(v: string) => setValue(v)}
  705. >
  706. {(props.options ?? []).map((opt) => (
  707. <div key={opt.value} className="flex items-center gap-2">
  708. <RadioGroupItem value={opt.value} id={`rg-${opt.value}`} />
  709. <Label
  710. htmlFor={`rg-${opt.value}`}
  711. className="font-normal cursor-pointer"
  712. >
  713. {opt.label}
  714. </Label>
  715. </div>
  716. ))}
  717. </RadioGroup>
  718. </div>
  719. );
  720. },
  721. SelectInput: ({ props, bindings }) => {
  722. const [value, setValue] = useBoundProp<string>(
  723. props.value as string | undefined,
  724. bindings?.value,
  725. );
  726. const current = value ?? "";
  727. return (
  728. <div className="flex flex-col gap-2">
  729. {props.label && (
  730. <Label className="text-sm font-medium">{props.label}</Label>
  731. )}
  732. <Select value={current} onValueChange={(v: string) => setValue(v)}>
  733. <SelectTrigger>
  734. <SelectValue placeholder={props.placeholder ?? "Select..."} />
  735. </SelectTrigger>
  736. <SelectContent>
  737. {(props.options ?? []).map((opt) => (
  738. <SelectItem key={opt.value} value={opt.value}>
  739. {opt.label}
  740. </SelectItem>
  741. ))}
  742. </SelectContent>
  743. </Select>
  744. </div>
  745. );
  746. },
  747. TextInput: ({ props, bindings }) => {
  748. const [value, setValue] = useBoundProp<string>(
  749. props.value as string | undefined,
  750. bindings?.value,
  751. );
  752. const current = value ?? "";
  753. return (
  754. <div className="flex flex-col gap-2">
  755. {props.label && (
  756. <Label className="text-sm font-medium">{props.label}</Label>
  757. )}
  758. <Input
  759. type={props.type ?? "text"}
  760. placeholder={props.placeholder ?? ""}
  761. value={current}
  762. onChange={(e) => setValue(e.target.value)}
  763. />
  764. </div>
  765. );
  766. },
  767. Button: ({ props, emit }) => (
  768. <Button
  769. variant={props.variant ?? "default"}
  770. size={props.size ?? "default"}
  771. disabled={props.disabled ?? false}
  772. onClick={() => emit("press")}
  773. >
  774. {props.label}
  775. </Button>
  776. ),
  777. // =========================================================================
  778. // 3D Scene Components
  779. // =========================================================================
  780. Scene3D: ({ props, children }) => (
  781. <div
  782. style={{
  783. height: props.height ?? "400px",
  784. width: "100%",
  785. background: props.background ?? "#111111",
  786. borderRadius: 8,
  787. overflow: "hidden",
  788. }}
  789. >
  790. <Canvas
  791. camera={{
  792. position: toVec3(props.cameraPosition) ?? [0, 10, 30],
  793. fov: props.cameraFov ?? 50,
  794. }}
  795. >
  796. <OrbitControls
  797. autoRotate={props.autoRotate ?? false}
  798. enablePan
  799. enableZoom
  800. />
  801. {children}
  802. </Canvas>
  803. </div>
  804. ),
  805. Group3D: ({ props, children }) => (
  806. <AnimatedGroup
  807. position={props.position}
  808. rotation={props.rotation}
  809. scale={props.scale}
  810. animation={props.animation}
  811. >
  812. {children}
  813. </AnimatedGroup>
  814. ),
  815. Box: ({ props, emit }) => (
  816. <MeshPrimitive meshProps={props} onClick={() => emit("press")}>
  817. <boxGeometry
  818. args={toGeoArgs<[number, number, number]>(props.args, [1, 1, 1])}
  819. />
  820. </MeshPrimitive>
  821. ),
  822. Sphere: ({ props, emit }) => (
  823. <MeshPrimitive meshProps={props} onClick={() => emit("press")}>
  824. <sphereGeometry
  825. args={toGeoArgs<[number, number, number]>(props.args, [1, 32, 32])}
  826. />
  827. </MeshPrimitive>
  828. ),
  829. Cylinder: ({ props, emit }) => (
  830. <MeshPrimitive meshProps={props} onClick={() => emit("press")}>
  831. <cylinderGeometry
  832. args={toGeoArgs<[number, number, number, number]>(
  833. props.args,
  834. [1, 1, 2, 32],
  835. )}
  836. />
  837. </MeshPrimitive>
  838. ),
  839. Cone: ({ props, emit }) => (
  840. <MeshPrimitive meshProps={props} onClick={() => emit("press")}>
  841. <coneGeometry
  842. args={toGeoArgs<[number, number, number]>(props.args, [1, 2, 32])}
  843. />
  844. </MeshPrimitive>
  845. ),
  846. Torus: ({ props, emit }) => (
  847. <MeshPrimitive meshProps={props} onClick={() => emit("press")}>
  848. <torusGeometry
  849. args={toGeoArgs<[number, number, number, number]>(
  850. props.args,
  851. [1, 0.4, 16, 100],
  852. )}
  853. />
  854. </MeshPrimitive>
  855. ),
  856. Plane: ({ props, emit }) => (
  857. <MeshPrimitive meshProps={props} onClick={() => emit("press")}>
  858. <planeGeometry
  859. args={toGeoArgs<[number, number]>(props.args, [10, 10])}
  860. />
  861. </MeshPrimitive>
  862. ),
  863. Ring: ({ props, emit }) => (
  864. <MeshPrimitive meshProps={props} onClick={() => emit("press")}>
  865. <ringGeometry
  866. args={toGeoArgs<[number, number, number]>(props.args, [0.5, 1, 64])}
  867. />
  868. </MeshPrimitive>
  869. ),
  870. AmbientLight: ({ props }) => (
  871. <ambientLight
  872. color={props.color ?? undefined}
  873. intensity={props.intensity ?? 0.5}
  874. />
  875. ),
  876. PointLight: ({ props }) => (
  877. <pointLight
  878. position={toVec3(props.position)}
  879. color={props.color ?? undefined}
  880. intensity={props.intensity ?? 1}
  881. distance={props.distance ?? 0}
  882. />
  883. ),
  884. DirectionalLight: ({ props }) => (
  885. <directionalLight
  886. position={toVec3(props.position)}
  887. color={props.color ?? undefined}
  888. intensity={props.intensity ?? 1}
  889. />
  890. ),
  891. Stars: ({ props }) => (
  892. <DreiStars
  893. radius={props.radius ?? 100}
  894. depth={props.depth ?? 50}
  895. count={props.count ?? 5000}
  896. factor={props.factor ?? 4}
  897. fade={props.fade ?? true}
  898. speed={props.speed ?? 1}
  899. />
  900. ),
  901. Label3D: ({ props }) => (
  902. <DreiText
  903. position={toVec3(props.position)}
  904. rotation={toVec3(props.rotation)}
  905. color={props.color ?? "#ffffff"}
  906. fontSize={props.fontSize ?? 1}
  907. anchorX={props.anchorX ?? "center"}
  908. anchorY={props.anchorY ?? "middle"}
  909. >
  910. {props.text}
  911. </DreiText>
  912. ),
  913. },
  914. actions: {},
  915. });
  916. // =============================================================================
  917. // Chart Helpers
  918. // =============================================================================
  919. const PIE_COLORS = [
  920. "var(--chart-1)",
  921. "var(--chart-2)",
  922. "var(--chart-3)",
  923. "var(--chart-4)",
  924. "var(--chart-5)",
  925. ];
  926. function processChartData(
  927. items: Array<Record<string, unknown>>,
  928. xKey: string,
  929. yKey: string,
  930. aggregate: "sum" | "count" | "avg" | null | undefined,
  931. ): { items: Array<Record<string, unknown>>; valueKey: string } {
  932. if (items.length === 0) {
  933. return { items: [], valueKey: yKey };
  934. }
  935. if (!aggregate) {
  936. const formatted = items.map((item) => ({
  937. ...item,
  938. label: String(item[xKey] ?? ""),
  939. }));
  940. return { items: formatted, valueKey: yKey };
  941. }
  942. const groups = new Map<string, Array<Record<string, unknown>>>();
  943. for (const item of items) {
  944. const groupKey = String(item[xKey] ?? "unknown");
  945. const group = groups.get(groupKey) ?? [];
  946. group.push(item);
  947. groups.set(groupKey, group);
  948. }
  949. const valueKey = aggregate === "count" ? "count" : yKey;
  950. const aggregated: Array<Record<string, unknown>> = [];
  951. const sortedKeys = Array.from(groups.keys()).sort();
  952. for (const key of sortedKeys) {
  953. const group = groups.get(key)!;
  954. let value: number;
  955. if (aggregate === "count") {
  956. value = group.length;
  957. } else if (aggregate === "sum") {
  958. value = group.reduce((sum, item) => {
  959. const v = item[yKey];
  960. return sum + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
  961. }, 0);
  962. } else {
  963. const sum = group.reduce((s, item) => {
  964. const v = item[yKey];
  965. return s + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
  966. }, 0);
  967. value = group.length > 0 ? sum / group.length : 0;
  968. }
  969. aggregated.push({ label: key, [valueKey]: value });
  970. }
  971. return { items: aggregated, valueKey };
  972. }
  973. // =============================================================================
  974. // Fallback Component
  975. // =============================================================================
  976. export function Fallback({ type }: { type: string }) {
  977. return (
  978. <div className="p-4 border border-dashed rounded-lg text-muted-foreground text-sm">
  979. Unknown component: {type}
  980. </div>
  981. );
  982. }