init
This commit is contained in:
		
						commit
						8df4f7578f
					
				
					 20 changed files with 2918 additions and 0 deletions
				
			
		
							
								
								
									
										24
									
								
								.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,24 @@
 | 
				
			||||||
 | 
					# Logs
 | 
				
			||||||
 | 
					logs
 | 
				
			||||||
 | 
					*.log
 | 
				
			||||||
 | 
					npm-debug.log*
 | 
				
			||||||
 | 
					yarn-debug.log*
 | 
				
			||||||
 | 
					yarn-error.log*
 | 
				
			||||||
 | 
					pnpm-debug.log*
 | 
				
			||||||
 | 
					lerna-debug.log*
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					node_modules
 | 
				
			||||||
 | 
					dist
 | 
				
			||||||
 | 
					dist-ssr
 | 
				
			||||||
 | 
					*.local
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Editor directories and files
 | 
				
			||||||
 | 
					.vscode/*
 | 
				
			||||||
 | 
					!.vscode/extensions.json
 | 
				
			||||||
 | 
					.idea
 | 
				
			||||||
 | 
					.DS_Store
 | 
				
			||||||
 | 
					*.suo
 | 
				
			||||||
 | 
					*.ntvs*
 | 
				
			||||||
 | 
					*.njsproj
 | 
				
			||||||
 | 
					*.sln
 | 
				
			||||||
 | 
					*.sw?
 | 
				
			||||||
							
								
								
									
										69
									
								
								README.md
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								README.md
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,69 @@
 | 
				
			||||||
 | 
					# React + TypeScript + Vite
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Currently, two official plugins are available:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
 | 
				
			||||||
 | 
					- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Expanding the ESLint configuration
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```js
 | 
				
			||||||
 | 
					export default tseslint.config([
 | 
				
			||||||
 | 
					  globalIgnores(['dist']),
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    files: ['**/*.{ts,tsx}'],
 | 
				
			||||||
 | 
					    extends: [
 | 
				
			||||||
 | 
					      // Other configs...
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Remove tseslint.configs.recommended and replace with this
 | 
				
			||||||
 | 
					      ...tseslint.configs.recommendedTypeChecked,
 | 
				
			||||||
 | 
					      // Alternatively, use this for stricter rules
 | 
				
			||||||
 | 
					      ...tseslint.configs.strictTypeChecked,
 | 
				
			||||||
 | 
					      // Optionally, add this for stylistic rules
 | 
				
			||||||
 | 
					      ...tseslint.configs.stylisticTypeChecked,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Other configs...
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    languageOptions: {
 | 
				
			||||||
 | 
					      parserOptions: {
 | 
				
			||||||
 | 
					        project: ['./tsconfig.node.json', './tsconfig.app.json'],
 | 
				
			||||||
 | 
					        tsconfigRootDir: import.meta.dirname,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      // other options...
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					])
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```js
 | 
				
			||||||
 | 
					// eslint.config.js
 | 
				
			||||||
 | 
					import reactX from 'eslint-plugin-react-x'
 | 
				
			||||||
 | 
					import reactDom from 'eslint-plugin-react-dom'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default tseslint.config([
 | 
				
			||||||
 | 
					  globalIgnores(['dist']),
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    files: ['**/*.{ts,tsx}'],
 | 
				
			||||||
 | 
					    extends: [
 | 
				
			||||||
 | 
					      // Other configs...
 | 
				
			||||||
 | 
					      // Enable lint rules for React
 | 
				
			||||||
 | 
					      reactX.configs['recommended-typescript'],
 | 
				
			||||||
 | 
					      // Enable lint rules for React DOM
 | 
				
			||||||
 | 
					      reactDom.configs.recommended,
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    languageOptions: {
 | 
				
			||||||
 | 
					      parserOptions: {
 | 
				
			||||||
 | 
					        project: ['./tsconfig.node.json', './tsconfig.app.json'],
 | 
				
			||||||
 | 
					        tsconfigRootDir: import.meta.dirname,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      // other options...
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					])
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
							
								
								
									
										23
									
								
								eslint.config.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								eslint.config.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,23 @@
 | 
				
			||||||
 | 
					import js from '@eslint/js'
 | 
				
			||||||
 | 
					import globals from 'globals'
 | 
				
			||||||
 | 
					import reactHooks from 'eslint-plugin-react-hooks'
 | 
				
			||||||
 | 
					import reactRefresh from 'eslint-plugin-react-refresh'
 | 
				
			||||||
 | 
					import tseslint from 'typescript-eslint'
 | 
				
			||||||
 | 
					import { globalIgnores } from 'eslint/config'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default tseslint.config([
 | 
				
			||||||
 | 
					  globalIgnores(['dist']),
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    files: ['**/*.{ts,tsx}'],
 | 
				
			||||||
 | 
					    extends: [
 | 
				
			||||||
 | 
					      js.configs.recommended,
 | 
				
			||||||
 | 
					      tseslint.configs.recommended,
 | 
				
			||||||
 | 
					      reactHooks.configs['recommended-latest'],
 | 
				
			||||||
 | 
					      reactRefresh.configs.vite,
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    languageOptions: {
 | 
				
			||||||
 | 
					      ecmaVersion: 2020,
 | 
				
			||||||
 | 
					      globals: globals.browser,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					])
 | 
				
			||||||
							
								
								
									
										13
									
								
								index.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								index.html
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,13 @@
 | 
				
			||||||
 | 
					<!doctype html>
 | 
				
			||||||
 | 
					<html lang="en">
 | 
				
			||||||
 | 
					  <head>
 | 
				
			||||||
 | 
					    <meta charset="UTF-8" />
 | 
				
			||||||
 | 
					    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
 | 
				
			||||||
 | 
					    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 | 
				
			||||||
 | 
					    <title>Vite + React + TS</title>
 | 
				
			||||||
 | 
					  </head>
 | 
				
			||||||
 | 
					  <body>
 | 
				
			||||||
 | 
					    <div id="root"></div>
 | 
				
			||||||
 | 
					    <script type="module" src="/src/main.tsx"></script>
 | 
				
			||||||
 | 
					  </body>
 | 
				
			||||||
 | 
					</html>
 | 
				
			||||||
							
								
								
									
										32
									
								
								package.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								package.json
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,32 @@
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					  "name": "physics",
 | 
				
			||||||
 | 
					  "private": true,
 | 
				
			||||||
 | 
					  "version": "0.0.0",
 | 
				
			||||||
 | 
					  "type": "module",
 | 
				
			||||||
 | 
					  "scripts": {
 | 
				
			||||||
 | 
					    "dev": "vite",
 | 
				
			||||||
 | 
					    "build": "tsc -b && vite build",
 | 
				
			||||||
 | 
					    "lint": "eslint .",
 | 
				
			||||||
 | 
					    "preview": "vite preview"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "dependencies": {
 | 
				
			||||||
 | 
					    "@tailwindcss/vite": "^4.1.12",
 | 
				
			||||||
 | 
					    "react": "^19.1.1",
 | 
				
			||||||
 | 
					    "react-dom": "^19.1.1",
 | 
				
			||||||
 | 
					    "tailwindcss": "^4.1.12"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "devDependencies": {
 | 
				
			||||||
 | 
					    "@eslint/js": "^9.33.0",
 | 
				
			||||||
 | 
					    "@types/react": "^19.1.10",
 | 
				
			||||||
 | 
					    "@types/react-dom": "^19.1.7",
 | 
				
			||||||
 | 
					    "@vitejs/plugin-react-swc": "^4.0.0",
 | 
				
			||||||
 | 
					    "eslint": "^9.33.0",
 | 
				
			||||||
 | 
					    "eslint-plugin-react-hooks": "^5.2.0",
 | 
				
			||||||
 | 
					    "eslint-plugin-react-refresh": "^0.4.20",
 | 
				
			||||||
 | 
					    "globals": "^16.3.0",
 | 
				
			||||||
 | 
					    "typescript": "~5.8.3",
 | 
				
			||||||
 | 
					    "typescript-eslint": "^8.39.1",
 | 
				
			||||||
 | 
					    "vite": "^7.1.2"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "packageManager": "pnpm@10.13.1+sha512.37ebf1a5c7a30d5fabe0c5df44ee8da4c965ca0c5af3dbab28c3a1681b70a256218d05c81c9c0dcf767ef6b8551eb5b960042b9ed4300c59242336377e01cfad"
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										2253
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										2253
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
							
								
								
									
										4
									
								
								pnpm-workspace.yaml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								pnpm-workspace.yaml
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,4 @@
 | 
				
			||||||
 | 
					onlyBuiltDependencies:
 | 
				
			||||||
 | 
					  - '@swc/core'
 | 
				
			||||||
 | 
					  - '@tailwindcss/oxide'
 | 
				
			||||||
 | 
					  - esbuild
 | 
				
			||||||
							
								
								
									
										1
									
								
								public/vite.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								public/vite.svg
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1 @@
 | 
				
			||||||
 | 
					<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
 | 
				
			||||||
| 
		 After Width: | Height: | Size: 1.5 KiB  | 
							
								
								
									
										0
									
								
								src/App.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src/App.css
									
										
									
									
									
										Normal file
									
								
							
							
								
								
									
										37
									
								
								src/App.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/App.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,37 @@
 | 
				
			||||||
 | 
					import './App.css'
 | 
				
			||||||
 | 
					import Pendulum from './pendulum/mod'
 | 
				
			||||||
 | 
					import Tabs, { Tab, TabPanel } from './components/Tabs'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function App() {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <main className='container mx-auto p-4'>
 | 
				
			||||||
 | 
					      <h1 className='text-2xl font-bold text-center mb-4'>
 | 
				
			||||||
 | 
					        Welcome to the Physics App
 | 
				
			||||||
 | 
					      </h1>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div className="border rounded-lg p-3">
 | 
				
			||||||
 | 
					        <Tabs defaultActive="pendulum">
 | 
				
			||||||
 | 
					          <div className="flex gap-2 mb-3" role="tablist">
 | 
				
			||||||
 | 
					            <Tab id="pendulum">Pendulum</Tab>
 | 
				
			||||||
 | 
					            <Tab id="placeholder">Other Example</Tab>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <TabPanel id="pendulum">
 | 
				
			||||||
 | 
					            <div className="h-96">
 | 
				
			||||||
 | 
					              <Pendulum />
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </TabPanel>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <TabPanel id="placeholder">
 | 
				
			||||||
 | 
					            <div className="p-4">
 | 
				
			||||||
 | 
					              <h2 className="text-lg font-semibold">Another Example</h2>
 | 
				
			||||||
 | 
					              <p className="text-sm text-gray-600">Replace this with your new example component.</p>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </TabPanel>
 | 
				
			||||||
 | 
					        </Tabs>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </main>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default App
 | 
				
			||||||
							
								
								
									
										1
									
								
								src/assets/react.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/assets/react.svg
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1 @@
 | 
				
			||||||
 | 
					<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
 | 
				
			||||||
| 
		 After Width: | Height: | Size: 4 KiB  | 
							
								
								
									
										109
									
								
								src/components/Tabs.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								src/components/Tabs.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,109 @@
 | 
				
			||||||
 | 
					import { createContext, useContext, useEffect, useRef, useState } from "react";
 | 
				
			||||||
 | 
					import type { ReactNode, KeyboardEvent } from "react";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type TabsContextType = {
 | 
				
			||||||
 | 
					  activeId: string | null;
 | 
				
			||||||
 | 
					  setActiveId: (id: string) => void;
 | 
				
			||||||
 | 
					  registerTab: (id: string) => void;
 | 
				
			||||||
 | 
					  tabs: string[];
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const TabsContext = createContext<TabsContextType | null>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default function Tabs({
 | 
				
			||||||
 | 
					  children,
 | 
				
			||||||
 | 
					  defaultActive,
 | 
				
			||||||
 | 
					}: {
 | 
				
			||||||
 | 
					  children: ReactNode;
 | 
				
			||||||
 | 
					  defaultActive?: string;
 | 
				
			||||||
 | 
					}) {
 | 
				
			||||||
 | 
					  const [activeId, setActiveId] = useState<string | null>(defaultActive ?? null);
 | 
				
			||||||
 | 
					  const tabsRef = useRef<string[]>([]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const registerTab = (id: string) => {
 | 
				
			||||||
 | 
					    if (!tabsRef.current.includes(id)) {
 | 
				
			||||||
 | 
					      tabsRef.current.push(id);
 | 
				
			||||||
 | 
					      // if no active tab yet, pick the first registered one
 | 
				
			||||||
 | 
					      if (!activeId) setActiveId(id);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    // if a defaultActive is provided and tabs register after mount,
 | 
				
			||||||
 | 
					    // ensure it's respected
 | 
				
			||||||
 | 
					    if (defaultActive) setActiveId(defaultActive);
 | 
				
			||||||
 | 
					  }, [defaultActive]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <TabsContext.Provider
 | 
				
			||||||
 | 
					      value={{ activeId, setActiveId: (id: string) => setActiveId(id), registerTab, tabs: tabsRef.current }}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      {children}
 | 
				
			||||||
 | 
					    </TabsContext.Provider>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Tab({ id, children }: { id: string; children: ReactNode }) {
 | 
				
			||||||
 | 
					  const ctx = useContext(TabsContext);
 | 
				
			||||||
 | 
					  if (!ctx) throw new Error("Tab must be used inside Tabs");
 | 
				
			||||||
 | 
					  const { activeId, setActiveId, registerTab, tabs } = ctx;
 | 
				
			||||||
 | 
					  const ref = useRef<HTMLButtonElement | null>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => registerTab(id), [id, registerTab]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleKey = (e: KeyboardEvent) => {
 | 
				
			||||||
 | 
					    if (!tabs || tabs.length === 0) return;
 | 
				
			||||||
 | 
					    const idx = tabs.indexOf(id);
 | 
				
			||||||
 | 
					    if (idx === -1) return;
 | 
				
			||||||
 | 
					    if (e.key === "ArrowRight") {
 | 
				
			||||||
 | 
					      const next = tabs[(idx + 1) % tabs.length];
 | 
				
			||||||
 | 
					      setActiveId(next);
 | 
				
			||||||
 | 
					      // focus next button (if present)
 | 
				
			||||||
 | 
					      // slight delay to ensure it's mounted
 | 
				
			||||||
 | 
					      setTimeout(() => {
 | 
				
			||||||
 | 
					        const btn = document.querySelector<HTMLButtonElement>(`button[data-tab-id="${next}"]`);
 | 
				
			||||||
 | 
					        btn?.focus();
 | 
				
			||||||
 | 
					      }, 0);
 | 
				
			||||||
 | 
					      e.preventDefault();
 | 
				
			||||||
 | 
					    } else if (e.key === "ArrowLeft") {
 | 
				
			||||||
 | 
					      const prev = tabs[(idx - 1 + tabs.length) % tabs.length];
 | 
				
			||||||
 | 
					      setActiveId(prev);
 | 
				
			||||||
 | 
					      setTimeout(() => {
 | 
				
			||||||
 | 
					        const btn = document.querySelector<HTMLButtonElement>(`button[data-tab-id="${prev}"]`);
 | 
				
			||||||
 | 
					        btn?.focus();
 | 
				
			||||||
 | 
					      }, 0);
 | 
				
			||||||
 | 
					      e.preventDefault();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const selected = activeId === id;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <button
 | 
				
			||||||
 | 
					      ref={ref}
 | 
				
			||||||
 | 
					      data-tab-id={id}
 | 
				
			||||||
 | 
					      role="tab"
 | 
				
			||||||
 | 
					      aria-selected={selected}
 | 
				
			||||||
 | 
					      onClick={() => setActiveId(id)}
 | 
				
			||||||
 | 
					      onKeyDown={handleKey}
 | 
				
			||||||
 | 
					      className={
 | 
				
			||||||
 | 
					        "px-3 py-1 rounded-md focus:outline-none " +
 | 
				
			||||||
 | 
					        (selected ? "bg-gray-200 dark:bg-gray-700 font-semibold" : "hover:bg-gray-100 dark:hover:bg-gray-800")
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      {children}
 | 
				
			||||||
 | 
					    </button>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function TabPanel({ id, children }: { id: string; children: ReactNode }) {
 | 
				
			||||||
 | 
					  const ctx = useContext(TabsContext);
 | 
				
			||||||
 | 
					  if (!ctx) throw new Error("TabPanel must be used inside Tabs");
 | 
				
			||||||
 | 
					  const { activeId } = ctx;
 | 
				
			||||||
 | 
					  if (activeId !== id) return null;
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div role="tabpanel" aria-hidden={activeId !== id} className="w-full">
 | 
				
			||||||
 | 
					      {children}
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										14
									
								
								src/index.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/index.css
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,14 @@
 | 
				
			||||||
 | 
					@import "tailwindcss";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					:root {
 | 
				
			||||||
 | 
					  font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
 | 
				
			||||||
 | 
					  line-height: 1.5;
 | 
				
			||||||
 | 
					  font-weight: 400;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  color-scheme: light dark;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  font-synthesis: none;
 | 
				
			||||||
 | 
					  text-rendering: optimizeLegibility;
 | 
				
			||||||
 | 
					  -webkit-font-smoothing: antialiased;
 | 
				
			||||||
 | 
					  -moz-osx-font-smoothing: grayscale;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										10
									
								
								src/main.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/main.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,10 @@
 | 
				
			||||||
 | 
					import { StrictMode } from 'react'
 | 
				
			||||||
 | 
					import { createRoot } from 'react-dom/client'
 | 
				
			||||||
 | 
					import './index.css'
 | 
				
			||||||
 | 
					import App from './App.tsx'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					createRoot(document.getElementById('root')!).render(
 | 
				
			||||||
 | 
					  <StrictMode>
 | 
				
			||||||
 | 
					    <App />
 | 
				
			||||||
 | 
					  </StrictMode>,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
							
								
								
									
										260
									
								
								src/pendulum/mod.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										260
									
								
								src/pendulum/mod.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,260 @@
 | 
				
			||||||
 | 
					import { useEffect, useRef, useState } from "react";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// x, y 2D point
 | 
				
			||||||
 | 
					type Point = [number, number];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type BallState = {
 | 
				
			||||||
 | 
					  pos: Point;
 | 
				
			||||||
 | 
					  prevPos: Point;      // 이전 위치
 | 
				
			||||||
 | 
					  mass: number;
 | 
				
			||||||
 | 
					  prevDt: number;      // 이전 프레임의 dt
 | 
				
			||||||
 | 
					  isFixed?: boolean;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type ConstraintState = {
 | 
				
			||||||
 | 
					  p1Idx: number; // index of first ball
 | 
				
			||||||
 | 
					  p2Idx: number; // index of second ball
 | 
				
			||||||
 | 
					  restLength: number; // rest length of the constraint
 | 
				
			||||||
 | 
					  stiffness: number; // stiffness of the constraint (0 to 1)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type WorldState = {
 | 
				
			||||||
 | 
					  balls: BallState[];
 | 
				
			||||||
 | 
					  constraints: ConstraintState[];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  ballDrawers: BallDrawer[];
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const SCENE_GRAVITY: Point = [0, 9.81 * 100]; // gravitational acceleration
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function updateUnconstrainedBall(ball: BallState, dt: number = 0.016) {
 | 
				
			||||||
 | 
					  if (ball.isFixed) return;
 | 
				
			||||||
 | 
					  // Verlet integration with variable dt
 | 
				
			||||||
 | 
					  const acc: Point = [SCENE_GRAVITY[0], SCENE_GRAVITY[1]];
 | 
				
			||||||
 | 
					  const nextPos: Point = [
 | 
				
			||||||
 | 
					    ball.pos[0] + (ball.pos[0] - ball.prevPos[0]) * (dt / ball.prevDt) + acc[0] 
 | 
				
			||||||
 | 
					    * (dt + ball.prevDt) / 2 
 | 
				
			||||||
 | 
					    * dt,
 | 
				
			||||||
 | 
					    ball.pos[1] + (ball.pos[1] - ball.prevPos[1]) * (dt / ball.prevDt) + acc[1] 
 | 
				
			||||||
 | 
					    * (dt + ball.prevDt) / 2
 | 
				
			||||||
 | 
					    * dt,
 | 
				
			||||||
 | 
					  ];
 | 
				
			||||||
 | 
					  ball.prevPos = [...ball.pos];
 | 
				
			||||||
 | 
					  ball.pos = nextPos;
 | 
				
			||||||
 | 
					  ball.prevDt = dt;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class BallDrawer {
 | 
				
			||||||
 | 
					  radius: number;
 | 
				
			||||||
 | 
					  previousPositions: Point[] = []; // 이동 경로 저장
 | 
				
			||||||
 | 
					  maxPositions: number;
 | 
				
			||||||
 | 
					  constructor({ radius = 10, maxPositions = 60 }: { radius?: number; maxPositions?: number } = {}) {
 | 
				
			||||||
 | 
					    this.maxPositions = maxPositions;
 | 
				
			||||||
 | 
					    this.radius = radius;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  draw(ctx: CanvasRenderingContext2D, ball: BallState) {
 | 
				
			||||||
 | 
					    // 이동 경로 저장
 | 
				
			||||||
 | 
					    this.previousPositions.push([ball.pos[0], ball.pos[1]]);
 | 
				
			||||||
 | 
					    if (this.previousPositions.length > this.maxPositions) { // 최대 maxPositions개만 저장
 | 
				
			||||||
 | 
					      this.previousPositions.shift();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    // 이동 경로 그리기 (알파값 점점 낮추기)
 | 
				
			||||||
 | 
					    ctx.save();
 | 
				
			||||||
 | 
					    if (this.previousPositions.length > 1) {
 | 
				
			||||||
 | 
					      ctx.lineWidth = 2;
 | 
				
			||||||
 | 
					      for (let i = 1; i < this.previousPositions.length; i++) {
 | 
				
			||||||
 | 
					        const alpha = (i / this.previousPositions.length); // 오래된 점일수록 더 투명
 | 
				
			||||||
 | 
					        ctx.strokeStyle = `rgba(0,0,255,${alpha * 0.5})`;
 | 
				
			||||||
 | 
					        ctx.beginPath();
 | 
				
			||||||
 | 
					        ctx.moveTo(this.previousPositions[i - 1][0], this.previousPositions[i - 1][1]);
 | 
				
			||||||
 | 
					        ctx.lineTo(this.previousPositions[i][0], this.previousPositions[i][1]);
 | 
				
			||||||
 | 
					        ctx.stroke();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    ctx.beginPath();
 | 
				
			||||||
 | 
					    ctx.arc(ball.pos[0], ball.pos[1], this.radius, 0, Math.PI * 2);
 | 
				
			||||||
 | 
					    ctx.fillStyle = "blue";
 | 
				
			||||||
 | 
					    ctx.fill();
 | 
				
			||||||
 | 
					    ctx.restore();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function satisfyConstraint(ball1: BallState, ball2: BallState, constraint: ConstraintState) {
 | 
				
			||||||
 | 
					  const dx = ball2.pos[0] - ball1.pos[0];
 | 
				
			||||||
 | 
					  const dy = ball2.pos[1] - ball1.pos[1];
 | 
				
			||||||
 | 
					  const length = Math.sqrt(dx * dx + dy * dy);
 | 
				
			||||||
 | 
					  if (length == 0) return; // avoid division by zero
 | 
				
			||||||
 | 
					  const diff = length - constraint.restLength;
 | 
				
			||||||
 | 
					  const invMass1 = 1 / ball1.mass;
 | 
				
			||||||
 | 
					  const invMass2 = 1 / ball2.mass;
 | 
				
			||||||
 | 
					  const sumInvMass = invMass1 + invMass2;
 | 
				
			||||||
 | 
					  if (sumInvMass == 0) return; // both infinite mass. we can't move them
 | 
				
			||||||
 | 
					  const p1Prop = invMass1 / sumInvMass;
 | 
				
			||||||
 | 
					  const p2Prop = invMass2 / sumInvMass;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // positional correction
 | 
				
			||||||
 | 
					  const correction = diff * constraint.stiffness / length;
 | 
				
			||||||
 | 
					  ball1.pos[0] += dx * correction * p1Prop;
 | 
				
			||||||
 | 
					  ball1.pos[1] += dy * correction * p1Prop;
 | 
				
			||||||
 | 
					  ball2.pos[0] -= dx * correction * p2Prop;
 | 
				
			||||||
 | 
					  ball2.pos[1] -= dy * correction * p2Prop;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function drawConstraint(ctx: CanvasRenderingContext2D, ball1: BallState, ball2: BallState) {
 | 
				
			||||||
 | 
					  ctx.beginPath();
 | 
				
			||||||
 | 
					  ctx.moveTo(ball1.pos[0], ball1.pos[1]);
 | 
				
			||||||
 | 
					  ctx.lineTo(ball2.pos[0], ball2.pos[1]);
 | 
				
			||||||
 | 
					  ctx.strokeStyle = "black";
 | 
				
			||||||
 | 
					  ctx.lineWidth = 2;
 | 
				
			||||||
 | 
					  ctx.stroke();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function updateWorld(world: WorldState, dt: number = 0.016, constraintIterations: number = 50) {
 | 
				
			||||||
 | 
					  // unconstrained motion
 | 
				
			||||||
 | 
					  for (const ball of world.balls) {
 | 
				
			||||||
 | 
					    updateUnconstrainedBall(ball, dt);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  // constraints
 | 
				
			||||||
 | 
					  for (let i = 0; i < constraintIterations; i++) {
 | 
				
			||||||
 | 
					    for (const constraint of world.constraints) {
 | 
				
			||||||
 | 
					      const b1 = world.balls[constraint.p1Idx];
 | 
				
			||||||
 | 
					      const b2 = world.balls[constraint.p2Idx];
 | 
				
			||||||
 | 
					      satisfyConstraint(b1, b2, constraint);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function drawWorld(ctx: CanvasRenderingContext2D, world: WorldState,) {
 | 
				
			||||||
 | 
					  // draw constraints
 | 
				
			||||||
 | 
					  for (const constraint of world.constraints) {
 | 
				
			||||||
 | 
					    const b1 = world.balls[constraint.p1Idx];
 | 
				
			||||||
 | 
					    const b2 = world.balls[constraint.p2Idx];
 | 
				
			||||||
 | 
					    drawConstraint(ctx, b1, b2);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  // draw balls
 | 
				
			||||||
 | 
					  for (let i = 0; i < world.balls.length; i++) {
 | 
				
			||||||
 | 
					    const ball = world.balls[i];
 | 
				
			||||||
 | 
					    const drawer = world.ballDrawers[i];
 | 
				
			||||||
 | 
					    drawer.draw(ctx, ball);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function getDefaultMyWorld() {
 | 
				
			||||||
 | 
					  const balls: BallState[] = [
 | 
				
			||||||
 | 
					    { pos: [200, 50], prevPos: [200, 50], mass: Infinity, prevDt: 0.016, isFixed: true },
 | 
				
			||||||
 | 
					    { pos: [300, 50], prevPos: [300, 50], mass: 1, prevDt: 0.016 },
 | 
				
			||||||
 | 
					    { pos: [300, 0], prevPos: [300, 0], mass: 1, prevDt: 0.016 },
 | 
				
			||||||
 | 
					  ];
 | 
				
			||||||
 | 
					  const constraints: ConstraintState[] = [
 | 
				
			||||||
 | 
					    { p1Idx: 0, p2Idx: 1, restLength: 100, stiffness: 1 },
 | 
				
			||||||
 | 
					    { p1Idx: 1, p2Idx: 2, restLength: 50, stiffness: 1},
 | 
				
			||||||
 | 
					  ];
 | 
				
			||||||
 | 
					  const ballDrawers = [
 | 
				
			||||||
 | 
					    new BallDrawer({ radius: 10 }),
 | 
				
			||||||
 | 
					    new BallDrawer({ radius: 10 }),
 | 
				
			||||||
 | 
					    new BallDrawer({ radius: 10 }),
 | 
				
			||||||
 | 
					  ];
 | 
				
			||||||
 | 
					  return { balls, constraints, ballDrawers };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default function Pendulum() {
 | 
				
			||||||
 | 
					  const canvasRef = useRef<HTMLCanvasElement>(null);
 | 
				
			||||||
 | 
					  const [subStep, setSubStep] = useState(10); // number of sub-steps per frame
 | 
				
			||||||
 | 
					  const [constraintIterations, setConstraintIterations] = useState(1); // number of constraint iterations per sub-step
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // refs to hold pendulum instances and animation state so we can reset from UI
 | 
				
			||||||
 | 
					  const worldRef = useRef<WorldState | null>(null);
 | 
				
			||||||
 | 
					  const rafRef = useRef<number | null>(null);
 | 
				
			||||||
 | 
					  const prevTimeRef = useRef<number>(0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    const canvas = canvasRef.current;
 | 
				
			||||||
 | 
					    if (!canvas) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const ctx = canvas.getContext("2d");
 | 
				
			||||||
 | 
					    if (!ctx) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const dpr = window.devicePixelRatio || 1;
 | 
				
			||||||
 | 
					    const resize = () => {
 | 
				
			||||||
 | 
					      const w = canvas.clientWidth || 600;
 | 
				
			||||||
 | 
					      const h = canvas.clientHeight || 400;
 | 
				
			||||||
 | 
					      canvas.width = Math.max(1, Math.round(w * dpr));
 | 
				
			||||||
 | 
					      canvas.height = Math.max(1, Math.round(h * dpr));
 | 
				
			||||||
 | 
					      // scale drawing so 1 unit == 1 CSS pixel
 | 
				
			||||||
 | 
					      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					    resize();
 | 
				
			||||||
 | 
					    window.addEventListener("resize", resize);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!worldRef.current) {
 | 
				
			||||||
 | 
					      // create a simple pendulum world
 | 
				
			||||||
 | 
					      worldRef.current = getDefaultMyWorld();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    prevTimeRef.current = performance.now();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const animate = (time: number) => {
 | 
				
			||||||
 | 
					      const deltaTime = time - prevTimeRef.current;
 | 
				
			||||||
 | 
					      prevTimeRef.current = time;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const dt = Math.min(0.1, deltaTime / 1000) / subStep; // cap deltaTime to avoid large jumps
 | 
				
			||||||
 | 
					      for (let i = 0; i < subStep; i++) {
 | 
				
			||||||
 | 
					        updateWorld(worldRef.current!, dt, constraintIterations);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
 | 
				
			||||||
 | 
					      drawWorld(ctx, worldRef.current!);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      rafRef.current = requestAnimationFrame(animate);
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					    rafRef.current = requestAnimationFrame(animate);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return () => {
 | 
				
			||||||
 | 
					      window.removeEventListener("resize", resize);
 | 
				
			||||||
 | 
					      if (rafRef.current) cancelAnimationFrame(rafRef.current);
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }, [constraintIterations, subStep]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // reset handler to re-create pendulums and avoid large dt on next frame
 | 
				
			||||||
 | 
					  const reset = () => {
 | 
				
			||||||
 | 
					    worldRef.current = getDefaultMyWorld();
 | 
				
			||||||
 | 
					    prevTimeRef.current = performance.now();
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div className="w-full h-full flex flex-col">
 | 
				
			||||||
 | 
					      <div className="p-2">
 | 
				
			||||||
 | 
					        <button
 | 
				
			||||||
 | 
					          onClick={reset}
 | 
				
			||||||
 | 
					          className="px-3 py-1.5 rounded border border-gray-300 bg-white text-sm hover:bg-gray-50 focus:outline-none"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          리셋
 | 
				
			||||||
 | 
					        </button>
 | 
				
			||||||
 | 
					        <label className="ml-4 text-sm">
 | 
				
			||||||
 | 
					          서브스텝:
 | 
				
			||||||
 | 
					          <input
 | 
				
			||||||
 | 
					            type="number"
 | 
				
			||||||
 | 
					            value={subStep}
 | 
				
			||||||
 | 
					            onChange={(e) => setSubStep(Math.max(1, Math.min(100, Number(e.target.value))))}
 | 
				
			||||||
 | 
					            className="ml-2 w-16 px-2 py-1 border border-gray-300 rounded focus:outline-none"
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					        </label>
 | 
				
			||||||
 | 
					        <label className="ml-4 text-sm">
 | 
				
			||||||
 | 
					          제약 반복 횟수:
 | 
				
			||||||
 | 
					          <input
 | 
				
			||||||
 | 
					            type="number"
 | 
				
			||||||
 | 
					            value={constraintIterations}
 | 
				
			||||||
 | 
					            onChange={(e) => setConstraintIterations(Math.max(1, Math.min(200, Number(e.target.value))))}
 | 
				
			||||||
 | 
					            className="ml-2 w-16 px-2 py-1 border border-gray-300 rounded focus:outline-none"
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					        </label>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div className="flex-1 min-h-0">
 | 
				
			||||||
 | 
					        <canvas
 | 
				
			||||||
 | 
					          ref={canvasRef}
 | 
				
			||||||
 | 
					          className="w-full h-full"
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										1
									
								
								src/vite-env.d.ts
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/vite-env.d.ts
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1 @@
 | 
				
			||||||
 | 
					/// <reference types="vite/client" />
 | 
				
			||||||
							
								
								
									
										27
									
								
								tsconfig.app.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								tsconfig.app.json
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,27 @@
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					  "compilerOptions": {
 | 
				
			||||||
 | 
					    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
 | 
				
			||||||
 | 
					    "target": "ES2022",
 | 
				
			||||||
 | 
					    "useDefineForClassFields": true,
 | 
				
			||||||
 | 
					    "lib": ["ES2022", "DOM", "DOM.Iterable"],
 | 
				
			||||||
 | 
					    "module": "ESNext",
 | 
				
			||||||
 | 
					    "skipLibCheck": true,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /* Bundler mode */
 | 
				
			||||||
 | 
					    "moduleResolution": "bundler",
 | 
				
			||||||
 | 
					    "allowImportingTsExtensions": true,
 | 
				
			||||||
 | 
					    "verbatimModuleSyntax": true,
 | 
				
			||||||
 | 
					    "moduleDetection": "force",
 | 
				
			||||||
 | 
					    "noEmit": true,
 | 
				
			||||||
 | 
					    "jsx": "react-jsx",
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /* Linting */
 | 
				
			||||||
 | 
					    "strict": true,
 | 
				
			||||||
 | 
					    "noUnusedLocals": true,
 | 
				
			||||||
 | 
					    "noUnusedParameters": true,
 | 
				
			||||||
 | 
					    "erasableSyntaxOnly": true,
 | 
				
			||||||
 | 
					    "noFallthroughCasesInSwitch": true,
 | 
				
			||||||
 | 
					    "noUncheckedSideEffectImports": true
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "include": ["src"]
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										7
									
								
								tsconfig.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								tsconfig.json
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,7 @@
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					  "files": [],
 | 
				
			||||||
 | 
					  "references": [
 | 
				
			||||||
 | 
					    { "path": "./tsconfig.app.json" },
 | 
				
			||||||
 | 
					    { "path": "./tsconfig.node.json" }
 | 
				
			||||||
 | 
					  ]
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										25
									
								
								tsconfig.node.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								tsconfig.node.json
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,25 @@
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					  "compilerOptions": {
 | 
				
			||||||
 | 
					    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
 | 
				
			||||||
 | 
					    "target": "ES2023",
 | 
				
			||||||
 | 
					    "lib": ["ES2023"],
 | 
				
			||||||
 | 
					    "module": "ESNext",
 | 
				
			||||||
 | 
					    "skipLibCheck": true,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /* Bundler mode */
 | 
				
			||||||
 | 
					    "moduleResolution": "bundler",
 | 
				
			||||||
 | 
					    "allowImportingTsExtensions": true,
 | 
				
			||||||
 | 
					    "verbatimModuleSyntax": true,
 | 
				
			||||||
 | 
					    "moduleDetection": "force",
 | 
				
			||||||
 | 
					    "noEmit": true,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /* Linting */
 | 
				
			||||||
 | 
					    "strict": true,
 | 
				
			||||||
 | 
					    "noUnusedLocals": true,
 | 
				
			||||||
 | 
					    "noUnusedParameters": true,
 | 
				
			||||||
 | 
					    "erasableSyntaxOnly": true,
 | 
				
			||||||
 | 
					    "noFallthroughCasesInSwitch": true,
 | 
				
			||||||
 | 
					    "noUncheckedSideEffectImports": true
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "include": ["vite.config.ts"]
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										8
									
								
								vite.config.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								vite.config.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,8 @@
 | 
				
			||||||
 | 
					import { defineConfig } from 'vite'
 | 
				
			||||||
 | 
					import react from '@vitejs/plugin-react-swc'
 | 
				
			||||||
 | 
					import tailwindcss from '@tailwindcss/vite'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// https://vite.dev/config/
 | 
				
			||||||
 | 
					export default defineConfig({
 | 
				
			||||||
 | 
					  plugins: [ tailwindcss(), react()],
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
		Loading…
	
	Add table
		
		Reference in a new issue