├── .eslintrc.json ├── public ├── tekken.jpg ├── favicon.ico └── vercel.svg ├── next.config.js ├── pages ├── _app.tsx ├── api │ └── hello.ts └── index.tsx ├── next-env.d.ts ├── styles ├── globals.css └── Home.module.css ├── .gitignore ├── tsconfig.json ├── package.json ├── temp.js ├── README.md └── components ├── KeyboardUtils.tsx └── PointerUtils.tsx /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /public/tekken.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/constraint-systems/terminal/main/public/tekken.jpg -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/constraint-systems/terminal/main/public/favicon.ico -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | module.exports = { 3 | reactStrictMode: true, 4 | } 5 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css' 2 | import type { AppProps } from 'next/app' 3 | 4 | function MyApp({ Component, pageProps }: AppProps) { 5 | return 6 | } 7 | 8 | export default MyApp 9 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/basic-features/typescript for more information. 7 | -------------------------------------------------------------------------------- /pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | 4 | type Data = { 5 | name: string 6 | } 7 | 8 | export default function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | res.status(200).json({ name: 'John Doe' }) 13 | } 14 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 7 | } 8 | 9 | a { 10 | color: inherit; 11 | text-decoration: none; 12 | } 13 | 14 | * { 15 | box-sizing: border-box; 16 | } 17 | 18 | img, 19 | canvas { 20 | display: block; 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "youtube", 3 | "private": true, 4 | "scripts": { 5 | "dev": "next dev", 6 | "build": "next build", 7 | "start": "next start", 8 | "lint": "next lint" 9 | }, 10 | "dependencies": { 11 | "@types/three": "^0.134.0", 12 | "next": "12.0.4", 13 | "react": "17.0.2", 14 | "react-dom": "17.0.2", 15 | "three": "^0.134.0" 16 | }, 17 | "devDependencies": { 18 | "@types/node": "16.11.9", 19 | "@types/react": "17.0.36", 20 | "eslint": "7.32.0", 21 | "eslint-config-next": "12.0.4", 22 | "typescript": "4.5.2" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /temp.js: -------------------------------------------------------------------------------- 1 | // Instanced 2 | const raw = document.createElement("canvas"); 3 | raw.width = 16; 4 | raw.height = 16; 5 | const rtx = raw.getContext("2d")!; 6 | rtx.fillStyle = "orange"; 7 | rtx.fillRect(0, 0, 16, 16); 8 | // rtx.fillStyle = "pink"; 9 | // rtx.fillRect(0, 0, 16 / 2, 16 / 2); 10 | const texture = new THREE.CanvasTexture(raw); 11 | texture.magFilter = THREE.NearestFilter; 12 | 13 | // set up cube 14 | const geometry = new THREE.PlaneBufferGeometry(2, 2); 15 | const material = new THREE.MeshBasicMaterial({ 16 | map: texture, 17 | wireframe: true, 18 | }); 19 | const mesh = new THREE.InstancedMesh(geometry, material, 90 * 120); 20 | scene.add(mesh); 21 | 22 | camera.position.z = 5; 23 | 24 | const visibleHeight = 25 | 2 * Math.tan((camera.fov * Math.PI) / 360) * camera.position.z; 26 | const zoomPixel = visibleHeight / window.innerHeight; 27 | 28 | camera.position.z = 0; 29 | 30 | const size = zoomPixel * 16; 31 | const scaler = new THREE.Vector3(size / 2, size / 2, 1); 32 | const matrix = new THREE.Matrix4(); 33 | matrix.scale(scaler); 34 | for (let r = 0; r < 120; r++) { 35 | for (let c = 0; c < 90; c++) { 36 | matrix.setPosition( 37 | c * size - (90 / 2) * size, 38 | r * size - (120 / 2) * size, 39 | 0 40 | ); 41 | mesh.setMatrixAt(r * 90 + c, matrix); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 16 | 17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 18 | 19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 0 2rem; 3 | } 4 | 5 | .main { 6 | min-height: 100vh; 7 | padding: 4rem 0; 8 | flex: 1; 9 | display: flex; 10 | flex-direction: column; 11 | justify-content: center; 12 | align-items: center; 13 | } 14 | 15 | .footer { 16 | display: flex; 17 | flex: 1; 18 | padding: 2rem 0; 19 | border-top: 1px solid #eaeaea; 20 | justify-content: center; 21 | align-items: center; 22 | } 23 | 24 | .footer a { 25 | display: flex; 26 | justify-content: center; 27 | align-items: center; 28 | flex-grow: 1; 29 | } 30 | 31 | .title a { 32 | color: #0070f3; 33 | text-decoration: none; 34 | } 35 | 36 | .title a:hover, 37 | .title a:focus, 38 | .title a:active { 39 | text-decoration: underline; 40 | } 41 | 42 | .title { 43 | margin: 0; 44 | line-height: 1.15; 45 | font-size: 4rem; 46 | } 47 | 48 | .title, 49 | .description { 50 | text-align: center; 51 | } 52 | 53 | .description { 54 | margin: 4rem 0; 55 | line-height: 1.5; 56 | font-size: 1.5rem; 57 | } 58 | 59 | .code { 60 | background: #fafafa; 61 | border-radius: 5px; 62 | padding: 0.75rem; 63 | font-size: 1.1rem; 64 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, 65 | Bitstream Vera Sans Mono, Courier New, monospace; 66 | } 67 | 68 | .grid { 69 | display: flex; 70 | align-items: center; 71 | justify-content: center; 72 | flex-wrap: wrap; 73 | max-width: 800px; 74 | } 75 | 76 | .card { 77 | margin: 1rem; 78 | padding: 1.5rem; 79 | text-align: left; 80 | color: inherit; 81 | text-decoration: none; 82 | border: 1px solid #eaeaea; 83 | border-radius: 10px; 84 | transition: color 0.15s ease, border-color 0.15s ease; 85 | max-width: 300px; 86 | } 87 | 88 | .card:hover, 89 | .card:focus, 90 | .card:active { 91 | color: #0070f3; 92 | border-color: #0070f3; 93 | } 94 | 95 | .card h2 { 96 | margin: 0 0 1rem 0; 97 | font-size: 1.5rem; 98 | } 99 | 100 | .card p { 101 | margin: 0; 102 | font-size: 1.25rem; 103 | line-height: 1.5; 104 | } 105 | 106 | .logo { 107 | height: 1em; 108 | margin-left: 0.5rem; 109 | } 110 | 111 | @media (max-width: 600px) { 112 | .grid { 113 | width: 100%; 114 | flex-direction: column; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /components/KeyboardUtils.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | 3 | export const UseKeyboardPan = (cameraRef) => { 4 | const pressedRef = useRef([]); 5 | 6 | useEffect(() => { 7 | const pressed = pressedRef.current; 8 | const camera = cameraRef.current; 9 | 10 | const discretePanCamera = (diff: Array) => { 11 | const visibleHeight = 12 | 2 * Math.tan((camera.fov * Math.PI) / 360) * camera.position.z; 13 | const zoomPixel = visibleHeight / window.innerHeight; 14 | camera.position.x -= 16 * diff[0] * zoomPixel; 15 | camera.position.y += 16 * diff[1] * zoomPixel; 16 | }; 17 | 18 | const discreteZoom = (change: number) => { 19 | const percent = (window.innerHeight - change) / window.innerHeight; 20 | camera.position.z = Math.min( 21 | 32, 22 | Math.max(1, camera.position.z / percent) 23 | ); 24 | }; 25 | 26 | const downHandler = (e: KeyboardEvent) => { 27 | let press = e.key.toLowerCase(); 28 | if (press === "-") { 29 | discreteZoom(32 * 2); 30 | } else if (press === "+" || press === "=") { 31 | discreteZoom(-32 * 2); 32 | } 33 | if (!pressed.includes(press)) { 34 | pressed.push(press); 35 | } 36 | if (pressed.includes("arrowleft") || pressed.includes("h")) { 37 | discretePanCamera([1 * 2, 0]); 38 | } 39 | if (pressed.includes("arrowright") || pressed.includes("l")) { 40 | discretePanCamera([-1 * 2, 0]); 41 | } 42 | if (pressed.includes("arrowup") || pressed.includes("k")) { 43 | if (e.shiftKey) { 44 | discreteZoom(32 * 2); 45 | } else { 46 | discretePanCamera([0, 1 * 2]); 47 | } 48 | } 49 | if (pressed.includes("arrowdown") || pressed.includes("j")) { 50 | if (e.shiftKey) { 51 | discreteZoom(-32 * 2); 52 | } else { 53 | discretePanCamera([0, -1 * 2]); 54 | } 55 | } 56 | }; 57 | 58 | const upHandler = (e: KeyboardEvent) => { 59 | let press = e.key.toLowerCase(); 60 | const index = pressed.indexOf(press); 61 | if (index !== -1) { 62 | pressed.splice(index, 1); 63 | } 64 | }; 65 | 66 | window.addEventListener("keydown", downHandler); 67 | window.addEventListener("keyup", upHandler); 68 | return () => { 69 | window.removeEventListener("keydown", downHandler); 70 | window.removeEventListener("keyup", upHandler); 71 | }; 72 | }, []); 73 | }; 74 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from "next"; 2 | import Head from "next/head"; 3 | import { useRef, useEffect } from "react"; 4 | import * as THREE from "three"; 5 | import { UseKeyboardPan } from "../components/KeyboardUtils"; 6 | import { 7 | UsePointerPan, 8 | UsePointerRay, 9 | UseWheelZoom, 10 | } from "../components/PointerUtils"; 11 | 12 | const Home: NextPage = () => { 13 | const canvasRef = useRef(null); 14 | const cameraRef = useRef(null); 15 | 16 | useEffect(() => { 17 | // set up three js scene 18 | const scene = new THREE.Scene(); 19 | const camera = new THREE.PerspectiveCamera( 20 | 75, 21 | window.innerWidth / window.innerHeight, 22 | 0.1, 23 | 1000 24 | ); 25 | cameraRef.current = camera; 26 | 27 | const renderer = new THREE.WebGLRenderer({ canvas: canvasRef.current! }); 28 | renderer.setSize(window.innerWidth, window.innerHeight); 29 | 30 | { 31 | const visibleHeight = 2 * Math.tan((camera.fov * Math.PI) / 360) * 5; 32 | const zoomPixel = visibleHeight / window.innerHeight; 33 | 34 | const canvas = document.createElement("canvas"); 35 | canvas.width = 2048; 36 | canvas.height = 2048; 37 | const ctx = canvas.getContext("2d")!; 38 | ctx.fillStyle = "white"; 39 | ctx.fillRect(0, 0, canvas.width, canvas.height); 40 | 41 | const canvasTexture = new THREE.CanvasTexture(canvas); 42 | 43 | const img = new Image(); 44 | img.onload = () => { 45 | ctx.drawImage(img, 0, 0, ctx.canvas.width, ctx.canvas.height); 46 | canvasTexture.needsUpdate = true; 47 | }; 48 | img.src = "/tekken.jpg"; 49 | 50 | const geometry = new THREE.PlaneGeometry( 51 | (canvas.width * zoomPixel) / 4, 52 | (canvas.height * zoomPixel) / 4 53 | ); 54 | const material = new THREE.MeshBasicMaterial({ map: canvasTexture }); 55 | const mesh = new THREE.Mesh(geometry, material); 56 | scene.add(mesh); 57 | } 58 | 59 | camera.position.z = 5; 60 | 61 | // render 62 | function animate() { 63 | requestAnimationFrame(animate); 64 | renderer.render(scene, camera); 65 | } 66 | animate(); 67 | }, []); 68 | 69 | UsePointerRay(canvasRef, cameraRef); 70 | UseWheelZoom(canvasRef, cameraRef); 71 | UseKeyboardPan(cameraRef); 72 | 73 | return ( 74 |
75 | 76 | Create Next App 77 | 78 | 79 | 80 | 81 | 82 |
83 | ); 84 | }; 85 | 86 | export default Home; 87 | -------------------------------------------------------------------------------- /components/PointerUtils.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo, useRef } from "react"; 2 | import * as THREE from "three"; 3 | 4 | export const UseWheelZoom = (rendererRef: any, cameraRef: any) => { 5 | const cameraDown = useRef(new THREE.Vector3()); 6 | 7 | useEffect(() => { 8 | const renderer = rendererRef.current; 9 | const camera = cameraRef.current; 10 | 11 | const handleMousewheel = (e: WheelEvent) => { 12 | e.preventDefault(); 13 | 14 | if (camera) { 15 | cameraDown.current.copy(camera.position); 16 | 17 | const percent = 18 | (window.innerHeight - e.deltaY * 2) / window.innerHeight; 19 | const nextZoom = Math.min(32, Math.max(1, camera.position.z / percent)); 20 | 21 | const visibleHeight = 22 | 2 * Math.tan((camera.fov * Math.PI) / 360) * cameraDown.current.z; 23 | const zoomPixel = visibleHeight / window.innerHeight; 24 | const relx = e.clientX - window.innerWidth / 2; 25 | const rely = -(e.clientY - window.innerHeight / 2); 26 | const worldRelX = relx * zoomPixel; 27 | const worldRelY = rely * zoomPixel; 28 | 29 | const newVisibleHeight = 30 | 2 * Math.tan((camera.fov * Math.PI) / 360) * nextZoom; 31 | const newZoomPixel = newVisibleHeight / window.innerHeight; 32 | 33 | const newWorldX = relx * newZoomPixel; 34 | const newWorldY = rely * newZoomPixel; 35 | 36 | const diffX = newWorldX - worldRelX; 37 | const diffY = newWorldY - worldRelY; 38 | 39 | camera.position.x = cameraDown.current.x - diffX; 40 | camera.position.y = cameraDown.current.y - diffY; 41 | camera.position.z = nextZoom; 42 | } 43 | }; 44 | 45 | if (renderer) { 46 | renderer.addEventListener("wheel", handleMousewheel, { 47 | passive: false, 48 | }); 49 | return () => { 50 | renderer.removeEventListener("wheel", handleMousewheel); 51 | }; 52 | } 53 | }, [rendererRef, cameraRef]); 54 | }; 55 | 56 | export const UsePointerRay = (canvasRef: any, cameraRef: any) => { 57 | const initVectors = useMemo((): [THREE.Raycaster, THREE.Vector2] => { 58 | const raycaster = new THREE.Raycaster(); 59 | const mouse = new THREE.Vector2(); 60 | return [raycaster, mouse]; 61 | }, []); 62 | 63 | const [raycaster, mouse] = initVectors; 64 | useEffect(() => { 65 | const canvas = canvasRef.current; 66 | const camera = cameraRef.current; 67 | 68 | const handlePointerMove = (e: MouseEvent) => { 69 | e.preventDefault(); 70 | 71 | mouse.x = (e.clientX / window.innerWidth) * 2 - 1; 72 | mouse.y = -(e.clientY / window.innerHeight) * 2 + 1; 73 | 74 | raycaster.setFromCamera(mouse, camera); 75 | }; 76 | 77 | if (canvas) { 78 | canvas.addEventListener("pointermove", handlePointerMove); 79 | return () => { 80 | canvas.removeEventListener("pointermove", handlePointerMove); 81 | }; 82 | } 83 | }, [raycaster, mouse, canvasRef, cameraRef, initVectors]); 84 | 85 | console.log(raycaster); 86 | return raycaster; 87 | }; 88 | 89 | export const UsePointerPan = (rendererRef: any, cameraRef: any) => { 90 | const cameraDown = useRef(new THREE.Vector3()); 91 | const diff = useRef(new THREE.Vector2()); 92 | const pointersRef = useRef([]); 93 | 94 | useEffect(() => { 95 | const camera = cameraRef.current; 96 | const renderer = rendererRef.current; 97 | const pointers = pointersRef.current; 98 | 99 | if (!camera) return; 100 | 101 | const handlePointerDown = (e: PointerEvent) => { 102 | e.preventDefault(); 103 | 104 | pointers.push({ 105 | id: e.pointerId, 106 | x: e.clientX, 107 | y: e.clientY, 108 | pointerDown: [e.clientX, e.clientY], 109 | primary: e.isPrimary, 110 | }); 111 | for (const pointer of pointers) { 112 | pointer.pointerDown = [pointer.x, pointer.y]; 113 | } 114 | cameraDown.current.copy(camera.position); 115 | 116 | renderer.setPointerCapture(e.pointerId); 117 | }; 118 | 119 | const handlePointerMove = (e: PointerEvent) => { 120 | e.preventDefault(); 121 | 122 | if (pointers.length === 1) { 123 | const pointer = pointers[0]; 124 | pointer.x = e.clientX; 125 | pointer.y = e.clientY; 126 | const visibleHeight = 127 | 2 * Math.tan((camera.fov * Math.PI) / 360) * cameraDown.current.z; 128 | const zoomPixel = visibleHeight / window.innerHeight; 129 | diff.current.x = (e.clientX - pointer.pointerDown[0]) * zoomPixel; 130 | diff.current.y = (e.clientY - pointer.pointerDown[1]) * zoomPixel; 131 | camera.position.x = cameraDown.current.x - diff.current.x; 132 | camera.position.y = cameraDown.current.y + diff.current.y; 133 | } else if (pointers.length === 2) { 134 | const pointer = pointers.filter((p) => p.id === e.pointerId)[0]; 135 | pointer.x = e.clientX; 136 | pointer.y = e.clientY; 137 | 138 | const a = pointers[0]; 139 | const b = pointers[1]; 140 | const minDown = [ 141 | Math.min(a.pointerDown[0], b.pointerDown[0]), 142 | Math.min(a.pointerDown[1], b.pointerDown[1]), 143 | ]; 144 | const maxDown = [ 145 | Math.max(a.pointerDown[0], b.pointerDown[0]), 146 | Math.max(a.pointerDown[1], b.pointerDown[1]), 147 | ]; 148 | const min = [Math.min(a.x, b.x), Math.min(a.y, b.y)]; 149 | const max = [Math.max(a.x, b.x), Math.max(a.y, b.y)]; 150 | const combined = { 151 | down: [ 152 | minDown[0] + (maxDown[0] - minDown[0]) / 2, 153 | minDown[1] + (maxDown[1] - minDown[1]) / 2, 154 | ], 155 | current: [ 156 | min[0] + (max[0] - min[0]) / 2, 157 | min[1] + (max[1] - min[1]) / 2, 158 | ], 159 | }; 160 | 161 | const visibleHeight = 162 | 2 * Math.tan((camera.fov * Math.PI) / 360) * cameraDown.current.z; 163 | const zoomPixel = visibleHeight / window.innerHeight; 164 | 165 | const dragged = [ 166 | (combined.current[0] - combined.down[0]) * zoomPixel, 167 | (combined.current[1] - combined.down[1]) * zoomPixel, 168 | ]; 169 | 170 | const adjustedDown = new THREE.Vector3(); 171 | adjustedDown.x = cameraDown.current.x - dragged[0]; 172 | adjustedDown.y = cameraDown.current.y + dragged[1]; 173 | 174 | const downDiff = Math.sqrt( 175 | Math.pow(a.pointerDown[0] - b.pointerDown[0], 2) + 176 | Math.pow(a.pointerDown[1] - b.pointerDown[1], 2) 177 | ); 178 | const currDiff = Math.sqrt( 179 | Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2) 180 | ); 181 | const percent = (currDiff - downDiff) / downDiff + 1; 182 | 183 | const relx = combined.current[0] - window.innerWidth / 2; 184 | const rely = -(combined.current[1] - window.innerHeight / 2); 185 | const worldRelX = relx * zoomPixel; 186 | const worldRelY = rely * zoomPixel; 187 | 188 | const nextZoom = Math.min( 189 | 32, 190 | Math.max(1, cameraDown.current.z / percent) 191 | ); 192 | 193 | const newVisibleHeight = 194 | 2 * Math.tan((camera.fov * Math.PI) / 360) * nextZoom; 195 | const newZoomPixel = newVisibleHeight / window.innerHeight; 196 | 197 | const newWorldX = relx * newZoomPixel; 198 | const newWorldY = rely * newZoomPixel; 199 | 200 | const diffX = newWorldX - worldRelX; 201 | const diffY = newWorldY - worldRelY; 202 | 203 | camera.position.x = adjustedDown.x - diffX; 204 | camera.position.y = adjustedDown.y - diffY; 205 | camera.position.z = nextZoom; 206 | } 207 | }; 208 | 209 | const handlePointerUp = (e: PointerEvent) => { 210 | e.preventDefault(); 211 | 212 | pointers.splice( 213 | pointers.findIndex((p) => p.id === e.pointerId), 214 | 1 215 | ); 216 | for (const pointer of pointers) { 217 | pointer.pointerDown = [pointer.x, pointer.y]; 218 | } 219 | 220 | cameraDown.current.copy(camera.position); 221 | 222 | renderer.releasePointerCapture(e.pointerId); 223 | }; 224 | 225 | if (renderer) { 226 | renderer.addEventListener("pointerdown", handlePointerDown); 227 | renderer.addEventListener("pointermove", handlePointerMove); 228 | renderer.addEventListener("pointerup", handlePointerUp); 229 | return () => { 230 | renderer.removeEventListener("pointerdown", handlePointerDown); 231 | renderer.removeEventListener("pointermove", handlePointerMove); 232 | renderer.removeEventListener("pointerup", handlePointerUp); 233 | }; 234 | } 235 | }, [rendererRef, cameraRef]); 236 | }; 237 | --------------------------------------------------------------------------------