registry.tsx 27 KB

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