page.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490
  1. "use client";
  2. import { useState, useSyncExternalStore, useCallback } from "react";
  3. import { defineCatalog } from "@json-render/core";
  4. import type { ComputedFunction } from "@json-render/core";
  5. import { schema, defineRegistry } from "@json-render/react";
  6. import {
  7. threeComponentDefinitions,
  8. threeComponents,
  9. ThreeCanvas,
  10. } from "@json-render/react-three-fiber";
  11. import { scenes } from "./scenes";
  12. const catalog = defineCatalog(schema, {
  13. components: {
  14. ...threeComponentDefinitions,
  15. },
  16. actions: {},
  17. });
  18. const { registry } = defineRegistry(catalog, {
  19. components: {
  20. ...threeComponents,
  21. },
  22. });
  23. function num(v: unknown, fallback: number): number {
  24. return typeof v === "number" ? v : fallback;
  25. }
  26. const computedFunctions: Record<string, ComputedFunction> = {
  27. halfHeight: (a) => num(a.h, 1) / 2,
  28. helixX: (a) => {
  29. const angle =
  30. (num(a.i, 0) / num(a.count, 40)) * num(a.turns, 3) * 2 * Math.PI +
  31. num(a.strand, 0) * Math.PI;
  32. return num(a.radius, 1.8) * Math.cos(angle);
  33. },
  34. helixY: (a) => num(a.i, 0) * num(a.spacing, 0.3) + num(a.offset, -6),
  35. helixZ: (a) => {
  36. const angle =
  37. (num(a.i, 0) / num(a.count, 40)) * num(a.turns, 3) * 2 * Math.PI +
  38. num(a.strand, 0) * Math.PI;
  39. return num(a.radius, 1.8) * Math.sin(angle);
  40. },
  41. helixHue: (a) => {
  42. const t = num(a.i, 0) / num(a.count, 40);
  43. return `hsl(${Math.round(t * 270 + 180)}, 85%, 60%)`;
  44. },
  45. spiralX: (a) => {
  46. const t = num(a.i, 0) / num(a.count, 60);
  47. const r = 0.5 + t * num(a.maxRadius, 7);
  48. const angle = t * num(a.turns, 4) * 2 * Math.PI;
  49. return r * Math.cos(angle);
  50. },
  51. spiralZ: (a) => {
  52. const t = num(a.i, 0) / num(a.count, 60);
  53. const r = 0.5 + t * num(a.maxRadius, 7);
  54. const angle = t * num(a.turns, 4) * 2 * Math.PI;
  55. return r * Math.sin(angle);
  56. },
  57. spiralY: (a) => {
  58. const i = num(a.i, 0);
  59. return Math.sin(i * 1.7) * 0.4;
  60. },
  61. spiralScale: (a) => {
  62. const t = num(a.i, 0) / num(a.count, 60);
  63. return 0.08 + (1 - t) * 0.25;
  64. },
  65. spiralEmissive: (a) => {
  66. const t = num(a.i, 0) / num(a.count, 60);
  67. return Math.max(0, 1 - t * 1.5);
  68. },
  69. circleX: (a) => {
  70. const angle = (num(a.i, 0) / num(a.count, 8)) * 2 * Math.PI;
  71. return num(a.radius, 3) * Math.cos(angle);
  72. },
  73. circleZ: (a) => {
  74. const angle = (num(a.i, 0) / num(a.count, 8)) * 2 * Math.PI;
  75. return num(a.radius, 3) * Math.sin(angle);
  76. },
  77. circleY: (a) => {
  78. const angle = (num(a.i, 0) / num(a.count, 8)) * 2 * Math.PI;
  79. return Math.sin(angle * num(a.freq, 3)) * num(a.amp, 0.3);
  80. },
  81. circleAngle: (a) => {
  82. return (num(a.i, 0) / num(a.count, 8)) * 2 * Math.PI;
  83. },
  84. circleHue: (a) => {
  85. const t = num(a.i, 0) / num(a.count, 8);
  86. return `hsl(${Math.round(t * 360)}, ${num(a.sat, 70)}%, ${num(a.lit, 80)}%)`;
  87. },
  88. };
  89. function highlightJson(json: string): string {
  90. return json.replace(
  91. /("(?:\\.|[^"\\])*")\s*(:)|("(?:\\.|[^"\\])*")|([-+]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|(\btrue\b|\bfalse\b|\bnull\b)|([{}[\]:,])/g,
  92. (_, key, colon, str, num, lit, punct) => {
  93. if (key) return `<span style="color:#FF4D8D">${key}</span>${colon}`;
  94. if (str) return `<span style="color:#00CA50">${str}</span>`;
  95. if (num) return `<span style="color:#47A8FF">${num}</span>`;
  96. if (lit) return `<span style="color:#47A8FF">${lit}</span>`;
  97. if (punct) return `<span style="color:#666">${punct}</span>`;
  98. return _;
  99. },
  100. );
  101. }
  102. const MOBILE_BREAKPOINT = 768;
  103. function subscribeToResize(cb: () => void) {
  104. window.addEventListener("resize", cb);
  105. return () => window.removeEventListener("resize", cb);
  106. }
  107. function getIsMobile() {
  108. return typeof window !== "undefined"
  109. ? window.innerWidth < MOBILE_BREAKPOINT
  110. : false;
  111. }
  112. function useIsMobile() {
  113. return useSyncExternalStore(subscribeToResize, getIsMobile, () => false);
  114. }
  115. const LIST_WIDTH = 220;
  116. const JSON_WIDTH = 380;
  117. const HEADER_HEIGHT = 40;
  118. const MOBILE_HEADER_HEIGHT = 48;
  119. const headerStyle: React.CSSProperties = {
  120. height: HEADER_HEIGHT,
  121. display: "flex",
  122. alignItems: "center",
  123. padding: "0 16px",
  124. fontSize: 11,
  125. fontWeight: 600,
  126. color: "#555",
  127. letterSpacing: "0.08em",
  128. textTransform: "uppercase",
  129. fontFamily: "ui-monospace, monospace",
  130. borderBottom: "1px solid #1e1e1e",
  131. flexShrink: 0,
  132. whiteSpace: "nowrap",
  133. overflow: "hidden",
  134. textOverflow: "ellipsis",
  135. boxSizing: "border-box",
  136. };
  137. function MobileLayout() {
  138. const [selectedIndex, setSelectedIndex] = useState(0);
  139. const [showJson, setShowJson] = useState(false);
  140. const [showScenes, setShowScenes] = useState(false);
  141. const selected = scenes[selectedIndex]!;
  142. const closePanels = useCallback(() => {
  143. setShowJson(false);
  144. setShowScenes(false);
  145. }, []);
  146. return (
  147. <div
  148. style={{
  149. height: "100dvh",
  150. display: "flex",
  151. flexDirection: "column",
  152. background: "#0a0a0a",
  153. overflow: "hidden",
  154. }}
  155. >
  156. <div
  157. style={{
  158. height: MOBILE_HEADER_HEIGHT,
  159. display: "flex",
  160. alignItems: "center",
  161. justifyContent: "space-between",
  162. padding: "0 12px",
  163. borderBottom: "1px solid #1e1e1e",
  164. background: "#0f0f0f",
  165. flexShrink: 0,
  166. gap: 8,
  167. }}
  168. >
  169. <button
  170. onClick={() => {
  171. setShowScenes((v) => !v);
  172. setShowJson(false);
  173. }}
  174. style={{
  175. background: showScenes ? "rgba(255,255,255,0.1)" : "transparent",
  176. border: "1px solid #333",
  177. borderRadius: 6,
  178. color: "#ccc",
  179. fontSize: 12,
  180. fontWeight: 500,
  181. padding: "6px 10px",
  182. cursor: "pointer",
  183. fontFamily: "inherit",
  184. whiteSpace: "nowrap",
  185. overflow: "hidden",
  186. textOverflow: "ellipsis",
  187. maxWidth: "50%",
  188. }}
  189. >
  190. {selected.name}
  191. </button>
  192. <span
  193. style={{
  194. flex: 1,
  195. fontSize: 10,
  196. color: "#555",
  197. textAlign: "center",
  198. overflow: "hidden",
  199. textOverflow: "ellipsis",
  200. whiteSpace: "nowrap",
  201. fontFamily: "ui-monospace, monospace",
  202. textTransform: "uppercase",
  203. letterSpacing: "0.06em",
  204. }}
  205. >
  206. {selected.description}
  207. </span>
  208. <button
  209. onClick={() => {
  210. setShowJson((v) => !v);
  211. setShowScenes(false);
  212. }}
  213. style={{
  214. background: showJson ? "rgba(255,255,255,0.1)" : "transparent",
  215. border: "1px solid #333",
  216. borderRadius: 6,
  217. color: "#ccc",
  218. fontSize: 11,
  219. fontWeight: 500,
  220. padding: "6px 10px",
  221. cursor: "pointer",
  222. fontFamily: "ui-monospace, monospace",
  223. whiteSpace: "nowrap",
  224. letterSpacing: "0.04em",
  225. }}
  226. >
  227. JSON
  228. </button>
  229. </div>
  230. <div style={{ flex: 1, position: "relative", minHeight: 0 }}>
  231. <ThreeCanvas
  232. key={selectedIndex}
  233. spec={selected.spec}
  234. registry={registry}
  235. functions={computedFunctions}
  236. shadows
  237. camera={{ position: [0, 0, 5], fov: 50 }}
  238. style={{ width: "100%", height: "100%" }}
  239. />
  240. {showScenes && (
  241. <>
  242. <div
  243. onClick={closePanels}
  244. style={{
  245. position: "absolute",
  246. inset: 0,
  247. background: "rgba(0,0,0,0.5)",
  248. zIndex: 10,
  249. }}
  250. />
  251. <div
  252. style={{
  253. position: "absolute",
  254. top: 0,
  255. left: 0,
  256. bottom: 0,
  257. width: "75%",
  258. maxWidth: 280,
  259. background: "#0f0f0f",
  260. borderRight: "1px solid #1e1e1e",
  261. zIndex: 11,
  262. display: "flex",
  263. flexDirection: "column",
  264. overflowY: "auto",
  265. WebkitOverflowScrolling: "touch",
  266. }}
  267. >
  268. <div style={{ ...headerStyle, height: 36 }}>Scenes</div>
  269. <div style={{ padding: "6px 0" }}>
  270. {scenes.map((scene, i) => (
  271. <button
  272. key={scene.name}
  273. onClick={() => {
  274. setSelectedIndex(i);
  275. setShowScenes(false);
  276. }}
  277. style={{
  278. display: "block",
  279. width: "100%",
  280. padding: "10px 16px",
  281. fontSize: 14,
  282. border: "none",
  283. textAlign: "left",
  284. background:
  285. i === selectedIndex
  286. ? "rgba(255,255,255,0.08)"
  287. : "transparent",
  288. color: i === selectedIndex ? "#fff" : "#888",
  289. fontWeight: i === selectedIndex ? 500 : 400,
  290. cursor: "pointer",
  291. borderLeft:
  292. i === selectedIndex
  293. ? "2px solid #fff"
  294. : "2px solid transparent",
  295. fontFamily: "inherit",
  296. }}
  297. >
  298. {scene.name}
  299. </button>
  300. ))}
  301. </div>
  302. </div>
  303. </>
  304. )}
  305. {showJson && (
  306. <>
  307. <div
  308. onClick={closePanels}
  309. style={{
  310. position: "absolute",
  311. inset: 0,
  312. background: "rgba(0,0,0,0.5)",
  313. zIndex: 10,
  314. }}
  315. />
  316. <div
  317. style={{
  318. position: "absolute",
  319. top: 0,
  320. right: 0,
  321. bottom: 0,
  322. width: "85%",
  323. maxWidth: 400,
  324. background: "#0d0d0d",
  325. borderLeft: "1px solid #1e1e1e",
  326. zIndex: 11,
  327. display: "flex",
  328. flexDirection: "column",
  329. }}
  330. >
  331. <div style={{ ...headerStyle, height: 36 }}>Spec JSON</div>
  332. <pre
  333. style={{
  334. flex: 1,
  335. margin: 0,
  336. padding: 14,
  337. overflowY: "auto",
  338. overflowX: "auto",
  339. WebkitOverflowScrolling: "touch",
  340. fontSize: 11,
  341. lineHeight: 1.6,
  342. fontFamily:
  343. "ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace",
  344. color: "#EDEDED",
  345. tabSize: 2,
  346. }}
  347. dangerouslySetInnerHTML={{
  348. __html: highlightJson(JSON.stringify(selected.spec, null, 2)),
  349. }}
  350. />
  351. </div>
  352. </>
  353. )}
  354. </div>
  355. </div>
  356. );
  357. }
  358. function DesktopLayout() {
  359. const [selectedIndex, setSelectedIndex] = useState(0);
  360. const selected = scenes[selectedIndex]!;
  361. return (
  362. <div style={{ height: "100vh", display: "flex", background: "#0a0a0a" }}>
  363. <div
  364. style={{
  365. width: LIST_WIDTH,
  366. flexShrink: 0,
  367. display: "flex",
  368. flexDirection: "column",
  369. borderRight: "1px solid #1e1e1e",
  370. background: "#0f0f0f",
  371. }}
  372. >
  373. <div style={headerStyle}>Scenes</div>
  374. <div style={{ flex: 1, overflowY: "auto", padding: "6px 0" }}>
  375. {scenes.map((scene, i) => (
  376. <button
  377. key={scene.name}
  378. onClick={() => setSelectedIndex(i)}
  379. style={{
  380. display: "block",
  381. width: "100%",
  382. padding: "8px 16px",
  383. fontSize: 13,
  384. border: "none",
  385. textAlign: "left",
  386. background:
  387. i === selectedIndex
  388. ? "rgba(255,255,255,0.08)"
  389. : "transparent",
  390. color: i === selectedIndex ? "#fff" : "#888",
  391. fontWeight: i === selectedIndex ? 500 : 400,
  392. cursor: "pointer",
  393. borderLeft:
  394. i === selectedIndex
  395. ? "2px solid #fff"
  396. : "2px solid transparent",
  397. fontFamily: "inherit",
  398. }}
  399. >
  400. {scene.name}
  401. </button>
  402. ))}
  403. </div>
  404. </div>
  405. <div
  406. style={{
  407. flex: 1,
  408. display: "flex",
  409. flexDirection: "column",
  410. minWidth: 0,
  411. }}
  412. >
  413. <div style={headerStyle}>{selected.description}</div>
  414. <div style={{ flex: 1, background: "#000", position: "relative" }}>
  415. <ThreeCanvas
  416. key={selectedIndex}
  417. spec={selected.spec}
  418. registry={registry}
  419. functions={computedFunctions}
  420. shadows
  421. camera={{ position: [0, 0, 5], fov: 50 }}
  422. style={{ width: "100%", height: "100%" }}
  423. />
  424. </div>
  425. </div>
  426. <div
  427. style={{
  428. width: JSON_WIDTH,
  429. flexShrink: 0,
  430. display: "flex",
  431. flexDirection: "column",
  432. borderLeft: "1px solid #1e1e1e",
  433. background: "#0d0d0d",
  434. }}
  435. >
  436. <div style={headerStyle}>Spec JSON</div>
  437. <pre
  438. style={{
  439. flex: 1,
  440. margin: 0,
  441. padding: 14,
  442. overflowY: "auto",
  443. overflowX: "auto",
  444. fontSize: 11,
  445. lineHeight: 1.6,
  446. fontFamily:
  447. "ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace",
  448. color: "#EDEDED",
  449. tabSize: 2,
  450. }}
  451. dangerouslySetInnerHTML={{
  452. __html: highlightJson(JSON.stringify(selected.spec, null, 2)),
  453. }}
  454. />
  455. </div>
  456. </div>
  457. );
  458. }
  459. export default function Page() {
  460. const isMobile = useIsMobile();
  461. return isMobile ? <MobileLayout /> : <DesktopLayout />;
  462. }