├── public └── .gitkeep ├── src ├── context │ └── AppContext.js ├── core │ ├── index.js │ ├── utils │ │ ├── Time.js │ │ ├── Sizes.js │ │ └── EventEmitter.js │ ├── WhiteBoard.js │ ├── Loader.js │ ├── core │ │ ├── ImageShader.js │ │ ├── CopyShader.js │ │ ├── FragmentShader.js │ │ ├── Card.js │ │ └── Image.js │ ├── Camera.js │ ├── Application.js │ ├── GUIPanel.js │ ├── Controls.js │ ├── CardSet.js │ └── World.js ├── main.jsx ├── components │ ├── Version │ │ └── Version.jsx │ ├── About │ │ └── About.jsx │ ├── Info │ │ └── Info.jsx │ ├── Social │ │ └── Social.jsx │ ├── Cards │ │ ├── Cards.jsx │ │ └── Card │ │ │ └── Card.jsx │ ├── UrlCards │ │ ├── hooks │ │ │ └── useGenerateUrlCard.js │ │ ├── UrlCards.jsx │ │ └── UrlCard │ │ │ └── UrlCard.jsx │ ├── TextCards │ │ ├── TextCards.tsx │ │ └── TextCard │ │ │ └── TextCard.tsx │ ├── ui │ │ └── Slider.tsx │ ├── Hint │ │ └── Hint.jsx │ ├── .deprecated │ │ └── Card │ │ │ └── Card.jsx │ └── FileSystem │ │ ├── FileSystem.tsx │ │ └── FileTree │ │ └── FileTree.tsx ├── utils │ ├── cn.ts │ ├── formatBytes.ts │ └── readDirectory.ts ├── hooks │ ├── useWhiteboardApp.js │ ├── useWhiteboardUpdate.js │ ├── deprecated │ │ ├── useCardList.js │ │ └── useCardRender.js │ ├── useHover.js │ └── useRightClick.js ├── global │ └── style.css └── App.jsx ├── vite.config.js ├── tailwind.config.js ├── index.html ├── .gitignore ├── .eslintrc.cjs ├── postcss.config.js ├── README.md └── package.json /public/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/context/AppContext.js: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react' 2 | 3 | export default createContext(); 4 | -------------------------------------------------------------------------------- /src/core/index.js: -------------------------------------------------------------------------------- 1 | import Application from './Application.js' 2 | 3 | window.application = new Application({ 4 | $canvas: document.querySelector('.webgl'), 5 | }) -------------------------------------------------------------------------------- /src/main.jsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom/client"; 2 | import App from "./App.jsx"; 3 | 4 | import "./global/style.css" 5 | 6 | ReactDOM.createRoot(document.getElementById("root")).render(); 7 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }); 8 | -------------------------------------------------------------------------------- /src/components/Version/Version.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function Version() { 4 | return ( 5 |
0.2.2
6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/cn.ts: -------------------------------------------------------------------------------- 1 | import { cx } from "@emotion/css"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | const cn = (...args: string[]): string => { 5 | return cx(twMerge(...args)); 6 | }; 7 | 8 | export { cn }; 9 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /src/core/utils/Time.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from './EventEmitter'; 2 | 3 | export default class Time extends EventEmitter { 4 | constructor() { 5 | super() 6 | } 7 | 8 | tick() { 9 | this.trigger('tick') 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/components/About/About.jsx: -------------------------------------------------------------------------------- 1 | import { cn } from '../../utils/cn' 2 | 3 | export default function About() { 4 | return ( 5 |
6 |

7 | Created by Yao Hsiao & contributers. 8 |

9 |
10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/formatBytes.ts: -------------------------------------------------------------------------------- 1 | export default function formatBytes(a, b = 2) { 2 | if (!+a) return "0 Bytes"; 3 | const c = 0 > b ? 0 : b, 4 | d = Math.floor(Math.log(a) / Math.log(1024)); 5 | return `${parseFloat((a / Math.pow(1024, d)).toFixed(c))} ${ 6 | ["Bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"][d] 7 | }`; 8 | } 9 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | VC Whiteboard 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/Info/Info.jsx: -------------------------------------------------------------------------------- 1 | import { cn } from '../../utils/cn' 2 | 3 | export default function Info() { 4 | return ( 5 |
6 |

VC Whiteboard

7 |

Make sense of complex topics in Vesuvius Challenge

8 |
9 | ) 10 | } 11 | 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | /public/* 16 | !/public/.gitkeep 17 | 18 | # Editor directories and files 19 | .vscode/* 20 | !.vscode/extensions.json 21 | .idea 22 | .DS_Store 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw? 28 | -------------------------------------------------------------------------------- /src/hooks/useWhiteboardApp.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import Application from "../core/Application"; 3 | 4 | const useWhiteboardApp = () => { 5 | const [app, setApp] = useState(null); 6 | useEffect(() => { 7 | const app = new Application({ 8 | $canvas: document.querySelector(".webgl"), 9 | }); 10 | setApp(app); 11 | }, []); 12 | return app; 13 | }; 14 | 15 | 16 | export default useWhiteboardApp -------------------------------------------------------------------------------- /src/hooks/useWhiteboardUpdate.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | import PubSub from "pubsub-js" 3 | 4 | const useWhiteboardUpdate = () => { 5 | 6 | const [whiteboard, setWhiteboard] = useState(null) 7 | 8 | useEffect(() => { 9 | PubSub.subscribe("onWhiteboardUpdate", (eventName, whiteboard) => { 10 | setWhiteboard(whiteboard) 11 | }) 12 | }, []) 13 | 14 | return whiteboard 15 | 16 | } 17 | 18 | export default useWhiteboardUpdate -------------------------------------------------------------------------------- /src/components/Social/Social.jsx: -------------------------------------------------------------------------------- 1 | import { AiOutlineGithub } from "react-icons/ai" 2 | import { cn } from '../../utils/cn' 3 | 4 | export default function Social() { 5 | return ( 6 |
7 | 8 | 9 | 10 |
11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /src/components/Cards/Cards.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import AppContext from "../../context/AppContext" 3 | import { filter, map } from "lodash" 4 | import Card from "./Card/Card" 5 | 6 | export default function Cards() { 7 | 8 | const { whiteboard } = useContext(AppContext) 9 | const cards = whiteboard ? filter(whiteboard.cards, (card) => card.type.split("/")[0] === "image") : null 10 | 11 | return ( 12 | map(cards, card => ) 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/core/WhiteBoard.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | 3 | export default class WhiteBoard { 4 | constructor(_option) { 5 | this.container = new THREE.Object3D(); 6 | this.container.matrixAutoUpdate = false; 7 | 8 | this.setWhiteBoard() 9 | } 10 | 11 | setWhiteBoard() { 12 | const geometry = new THREE.PlaneGeometry(60, 30) 13 | const material = new THREE.MeshBasicMaterial({ color: '#262626' }) 14 | const mesh = new THREE.Mesh(geometry, material) 15 | 16 | this.container.add(mesh) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 6 | 'plugin:react/jsx-runtime', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 11 | settings: { react: { version: '18.2' } }, 12 | plugins: ['react-refresh'], 13 | rules: { 14 | 'react-refresh/only-export-components': [ 15 | 'warn', 16 | { allowConstantExport: true }, 17 | ], 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /src/hooks/deprecated/useCardList.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | // generate cards, and return card list that react app need. 4 | 5 | export default (app) => { 6 | const [cardList, setCardList] = useState([]); 7 | 8 | useEffect(() => { 9 | if (app) { 10 | // when card generate 11 | app.API.on("cardGenerate", (data) => { 12 | setCardList([data, ...cardList]); 13 | }); 14 | } 15 | }, [app, cardList]); 16 | 17 | useEffect(() => { 18 | // console.log(cardList); 19 | }, [cardList]); 20 | 21 | return cardList; 22 | }; 23 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | /** The css files in this project is based on postcss. */ 2 | /** View postcss.config.js & https://www.postcss.parts/ to see more. */ 3 | /* 4 | * avilable features: 5 | * css nesting 6 | * tailwindcss features 7 | * auto prefixing 8 | * cssnano 9 | */ 10 | 11 | export default { 12 | plugins: { 13 | // postcss integration pack 14 | "tailwindcss/nesting": {}, 15 | // code compression for css 16 | cssnano: { preset: "default" }, 17 | // tailwindcss features 18 | tailwindcss: {}, 19 | // browser compatibility 20 | autoprefixer: {}, 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /src/hooks/useHover.js: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState, useEffect } from "react"; 2 | 3 | // Hook 4 | const useHover = () => { 5 | const [value, setValue] = useState(false); 6 | 7 | const ref = useRef(null); 8 | 9 | const handleMouseOver = () => setValue(true); 10 | const handleMouseOut = () => setValue(false); 11 | 12 | useEffect(() => { 13 | const node = ref.current; 14 | if (node) { 15 | node.addEventListener("mouseover", handleMouseOver); 16 | node.addEventListener("mouseout", handleMouseOut); 17 | } 18 | }); 19 | 20 | return [ref, value]; 21 | }; 22 | 23 | export { useHover }; 24 | -------------------------------------------------------------------------------- /src/core/Loader.js: -------------------------------------------------------------------------------- 1 | import { NRRDLoader } from 'three/examples/jsm/loaders/NRRDLoader' 2 | import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js' 3 | 4 | export default class Loader { 5 | constructor() { 6 | } 7 | 8 | static getVolumeMeta() { return fetch('volume/meta.json').then((res) => res.json()) } 9 | 10 | static getSegmentMeta() { return fetch('segment/meta.json').then((res) => res.json()) } 11 | 12 | static getVolumeData(filename) { return new NRRDLoader().loadAsync('volume/' + filename) } 13 | 14 | static getSegmentData(filename) { return new OBJLoader().loadAsync('segment/' + filename) } 15 | } 16 | -------------------------------------------------------------------------------- /src/components/UrlCards/hooks/useGenerateUrlCard.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import useRightClick from "../../../hooks/useRightClick"; 3 | import PubSub from "pubsub-js"; 4 | import { nanoid } from "nanoid"; 5 | 6 | const useGenerateUrlCard = () => { 7 | const { clicked, position } = useRightClick(); 8 | 9 | useEffect(() => { 10 | if (clicked) { 11 | PubSub.publish("onUrlCardGenerated", { 12 | id: nanoid(), 13 | x: position[0], 14 | y: position[1], 15 | width: 800, 16 | height: 525, 17 | }); 18 | } 19 | }, [clicked, position]); 20 | }; 21 | 22 | export default useGenerateUrlCard; 23 | -------------------------------------------------------------------------------- /src/utils/readDirectory.ts: -------------------------------------------------------------------------------- 1 | export default async function readDirectory( 2 | directoryHandle: FileSystemDirectoryHandle, 3 | path = "" 4 | ) { 5 | const files: any = {}; 6 | 7 | for await (const item of directoryHandle.values()) { 8 | if (item.kind === "directory") { 9 | const subDirectoryHandle = await directoryHandle.getDirectoryHandle( 10 | item.name 11 | ); 12 | files[item.name] = await readDirectory( 13 | subDirectoryHandle, 14 | path + item.name + "/" 15 | ); 16 | } else { 17 | const file = await item.getFile(); 18 | files[item.name] = file; 19 | } 20 | } 21 | 22 | return files; 23 | } 24 | -------------------------------------------------------------------------------- /src/components/TextCards/TextCards.tsx: -------------------------------------------------------------------------------- 1 | import { filter, map } from "lodash"; 2 | import React, { useContext, useEffect, useState } from "react"; 3 | import TextCard from "./TextCard/TextCard"; 4 | import PubSub from "pubsub-js"; 5 | import AppContext from "../../context/AppContext"; 6 | 7 | export default function TextCards() { 8 | const { whiteboard } = useContext(AppContext); 9 | const cards = whiteboard 10 | ? filter( 11 | whiteboard.cards, 12 | (card) => 13 | card.type.split("/")[0] === "text" || 14 | card.type.split("/")[0] === "application" 15 | ) 16 | : null; 17 | 18 | return map(cards, (card) => ); 19 | } 20 | -------------------------------------------------------------------------------- /src/components/UrlCards/UrlCards.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import AppContext from "../../context/AppContext" 3 | import useGenerateUrlCard from "./hooks/useGenerateUrlCard" 4 | import { filter, map } from "lodash"; 5 | import { cn } from "../../utils/cn" 6 | import { css } from "@emotion/css"; 7 | import UrlCard from "./UrlCard/UrlCard"; 8 | 9 | export default function UrlCards() { 10 | 11 | useGenerateUrlCard(); 12 | 13 | const { whiteboard } = useContext(AppContext) 14 | const urlCards = whiteboard ? filter(whiteboard.cards, (card) => card.type === "iframe") : null 15 | 16 | return ( 17 | map(urlCards, card => ) 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/core/core/ImageShader.js: -------------------------------------------------------------------------------- 1 | import { ShaderMaterial, DoubleSide } from "three" 2 | 3 | export class ImageShader extends ShaderMaterial { 4 | constructor(params) { 5 | super({ 6 | transparent: true, 7 | 8 | uniforms: { 9 | tDiffuse: { value: null }, 10 | opacity: { value: 1.0 } 11 | }, 12 | 13 | vertexShader: /* glsl */ ` 14 | varying vec2 vUv; 15 | void main() { 16 | vUv = uv; 17 | gl_Position = vec4( position, 1.0 ); 18 | } 19 | `, 20 | 21 | fragmentShader: /* glsl */ ` 22 | uniform float opacity; 23 | uniform sampler2D tDiffuse; 24 | varying vec2 vUv; 25 | 26 | void main() { 27 | vec4 color = texture2D( tDiffuse, vUv ); 28 | gl_FragColor = color; 29 | } 30 | ` 31 | }); 32 | 33 | this.setValues(params); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/core/core/CopyShader.js: -------------------------------------------------------------------------------- 1 | import { ShaderMaterial } from "three" 2 | 3 | // https://github.com/mrdoob/three.js/blob/master/examples/jsm/shaders/CopyShader.js 4 | 5 | export class CopyShader extends ShaderMaterial { 6 | constructor(params) { 7 | super({ 8 | transparent: true, 9 | 10 | uniforms: { 11 | tDiffuse: { value: null }, 12 | opacity: { value: 1.0 } 13 | }, 14 | 15 | vertexShader: /* glsl */ ` 16 | varying vec2 vUv; 17 | void main() { 18 | vUv = uv; 19 | gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 ); 20 | } 21 | `, 22 | 23 | fragmentShader: /* glsl */ ` 24 | uniform float opacity; 25 | uniform sampler2D tDiffuse; 26 | varying vec2 vUv; 27 | 28 | void main() { 29 | vec4 texel = texture2D( tDiffuse, vUv ); 30 | gl_FragColor = opacity * texel; 31 | } 32 | ` 33 | }); 34 | 35 | this.setValues(params); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

VC WhiteBoard

2 | 3 |

4 | Try to make sense of complex topics in Vesuvius Challenge 5 |

6 | 7 |

8 | 9 |

10 | 11 | ## Introduction 12 | 13 | This is a WIP tool that try to visualize scrolls and fragments data information on a endless whiteboard. 14 | 15 | ## Usage 16 | 17 | Download [this folder](https://www.kaggle.com/datasets/yaohsiao123/vc-whiteboard) and then run a localhost server on it (e.g. python server). Currently has some camera control issue on Windows, so it's more recommended to use Mac for now. Once you open the application, you can press `Enter` + `Click` to generate a card on the whiteboard. In the current application, we try to visualize the region along segment `20230509182749`. 18 | 19 | ## Notes 20 | 21 | There are still many bugs in this version, so if you encounter difficulties in using it, feel free to contact me directly. Will see is there anywhere that I can help, thanks! 22 | -------------------------------------------------------------------------------- /src/global/style.css: -------------------------------------------------------------------------------- 1 | /** The css files in this project is based on postcss. */ 2 | /** View postcss.config.js & https://www.postcss.parts/ to see more. */ 3 | /* 4 | * avilable features: 5 | * css nesting 6 | * tailwindcss features 7 | * auto prefixing 8 | * cssnano 9 | */ 10 | 11 | @tailwind base; 12 | @tailwind components; 13 | @tailwind utilities; 14 | 15 | * { 16 | margin: 0; 17 | padding: 0; 18 | user-select: none; 19 | box-sizing: border-box; 20 | } 21 | 22 | body { 23 | overflow: hidden; 24 | color: white; 25 | background-color: black; 26 | font-family: monospace; 27 | 28 | option { 29 | background-color: black; 30 | } 31 | 32 | .webgl { 33 | background-color: black; 34 | } 35 | 36 | .cardDOM { 37 | position: relative; 38 | 39 | .loadingCard { 40 | position: absolute; 41 | top: 50%; 42 | left: 50%; 43 | transform: translate(-50%, -50%); 44 | background-color: rgba(0, 0, 0, 0.3); 45 | color: white; 46 | font-size: 20px; 47 | padding: 5px 10px; 48 | border-radius: 5px; 49 | user-select: none; 50 | white-space: nowrap; 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/core/utils/Sizes.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from './EventEmitter' 2 | 3 | export default class Sizes extends EventEmitter { 4 | constructor() { 5 | super(); 6 | 7 | this.viewport = {}; 8 | this.$sizeViewport = document.createElement('div'); 9 | this.$sizeViewport.style.width = '100vw'; 10 | this.$sizeViewport.style.height = '100vh'; 11 | this.$sizeViewport.style.position = 'absolute'; 12 | this.$sizeViewport.style.top = 0; 13 | this.$sizeViewport.style.left = 0; 14 | this.$sizeViewport.style.pointerEvents = 'none'; 15 | 16 | this.resize = this.resize.bind(this); 17 | window.addEventListener('resize', this.resize); 18 | 19 | this.resize(); 20 | } 21 | 22 | resize() { 23 | document.body.appendChild(this.$sizeViewport); 24 | this.viewport.width = this.$sizeViewport.offsetWidth; 25 | this.viewport.height = this.$sizeViewport.offsetHeight; 26 | document.body.removeChild(this.$sizeViewport); 27 | 28 | this.width = window.innerWidth; 29 | this.height = window.innerHeight; 30 | 31 | this.trigger('resize'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/hooks/useRightClick.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useCallback } from "react"; 2 | 3 | const useRightClick = () => { 4 | const [isRightClick, setIsRightClick] = useState(false); 5 | const [rightClickPosition, setRightClickPosition] = useState([0, 0]); 6 | 7 | const handleRightClick = useCallback((e) => { 8 | e.preventDefault(); 9 | setIsRightClick(!isRightClick); 10 | setRightClickPosition([e.clientX, e.clientY]); 11 | }, [isRightClick]); 12 | 13 | const handleLeftClick = useCallback((e) => { 14 | if (e.button === 0) { 15 | setIsRightClick(false); 16 | } 17 | }, []); 18 | 19 | useEffect(() => { 20 | window.addEventListener("mousedown", handleLeftClick); 21 | window.addEventListener("contextmenu", handleRightClick); 22 | 23 | return () => { 24 | window.removeEventListener("contextmenu", handleRightClick); 25 | window.removeEventListener("mousedown", handleLeftClick); 26 | }; 27 | }, [handleLeftClick, handleRightClick]); 28 | 29 | return { clicked: isRightClick, position: rightClickPosition }; 30 | }; 31 | 32 | export default useRightClick; 33 | -------------------------------------------------------------------------------- /src/components/ui/Slider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as SliderPrimitive from "@radix-ui/react-slider"; 3 | 4 | import { cn } from "../../utils/cn"; 5 | 6 | const Slider = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | 19 | 20 | 21 | 22 | 23 | )); 24 | Slider.displayName = SliderPrimitive.Root.displayName; 25 | 26 | export { Slider }; 27 | -------------------------------------------------------------------------------- /src/core/core/FragmentShader.js: -------------------------------------------------------------------------------- 1 | import { ShaderMaterial, DoubleSide } from "three" 2 | 3 | export class FragmentShader extends ShaderMaterial { 4 | constructor(params) { 5 | super({ 6 | side: DoubleSide, 7 | transparent: true, 8 | 9 | uniforms: { 10 | tDiffuse: { value: null }, 11 | uMask: { value: null }, 12 | opacity: { value: 1.0 } 13 | }, 14 | 15 | vertexShader: /* glsl */ ` 16 | varying vec2 vUv; 17 | void main() { 18 | vUv = uv; 19 | gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 ); 20 | } 21 | `, 22 | 23 | fragmentShader: /* glsl */ ` 24 | uniform float opacity; 25 | uniform sampler2D uMask; 26 | uniform sampler2D tDiffuse; 27 | varying vec2 vUv; 28 | 29 | void main() { 30 | float intensity = texture2D( tDiffuse, vUv ).r; 31 | vec4 mask = texture2D( uMask, vUv ); 32 | float maskI = mask.a; 33 | if (intensity < 0.0001) { gl_FragColor = vec4(0.0); return; } 34 | 35 | vec3 color = intensity * 0.88 * vec3(0.93, 0.80, 0.70); 36 | 37 | if (maskI < 0.1) { gl_FragColor = vec4(color, 1.0); return; } 38 | gl_FragColor = vec4(color, 1.0) * (1.0 - maskI * opacity); 39 | // gl_FragColor = vec4(color, opacity); 40 | } 41 | ` 42 | }); 43 | 44 | this.setValues(params); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "volume-viewer", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@emotion/css": "^11.11.2", 14 | "@radix-ui/react-slider": "^1.1.2", 15 | "@types/lodash": "^4.14.201", 16 | "@types/pubsub-js": "^1.8.6", 17 | "@types/wicg-file-system-access": "^2023.10.3", 18 | "@vercel/analytics": "^1.1.1", 19 | "cssnano": "^6.0.1", 20 | "lodash": "^4.17.21", 21 | "nanoid": "^5.0.1", 22 | "postcss-nested": "^6.0.1", 23 | "postcss-preset-env": "^9.1.1", 24 | "pubsub-js": "^1.9.4", 25 | "re-resizable": "^6.9.11", 26 | "react": "^18.2.0", 27 | "react-dom": "^18.2.0", 28 | "react-icons": "^4.12.0", 29 | "tailwind-merge": "^1.14.0", 30 | "three": "^0.156.1", 31 | "three-mesh-bvh": "^0.6.3" 32 | }, 33 | "devDependencies": { 34 | "@types/react": "^18.2.15", 35 | "@types/react-dom": "^18.2.7", 36 | "@vitejs/plugin-react": "^4.0.3", 37 | "autoprefixer": "^10.4.15", 38 | "eslint": "^8.45.0", 39 | "eslint-plugin-react": "^7.32.2", 40 | "eslint-plugin-react-hooks": "^4.6.0", 41 | "eslint-plugin-react-refresh": "^0.4.3", 42 | "postcss": "^8.4.28", 43 | "tailwindcss": "^3.3.3", 44 | "vite": "^4.4.5" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/hooks/deprecated/useCardRender.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | // render card (threejs part) via id, and return data that react app need. 4 | 5 | export default (app) => { 6 | /** 7 | * $ renderer object 8 | * * x: number | undefined 9 | * * y: number | undefined 10 | * * z: number | undefined 11 | * * isLoad: boolean 12 | */ 13 | const [renderer, setRenderer] = useState({}); 14 | 15 | useEffect(() => { 16 | 17 | if (app) { 18 | // yao's code (may be a function call) 19 | // do the threejs rendering work, and provide data that react app need. 20 | // call setRenderer to update State 21 | // e.g. setRenderer({x: 1, y: 2, z: 3, isLoad: true}) 22 | app.API.on("cardInit", ({ id, x, y, width, height }) => { 23 | setRenderer({ id, x, y, width, height }); 24 | }); 25 | app.API.on("cardMove", ({ id, x, y, width, height }) => { 26 | // WB.API.cardMove({ x, y, width, height, id }); 27 | setRenderer({ id, x, y, width, height }); 28 | }); 29 | 30 | app.API.on("cardLoad", (id) => { 31 | // WB.API.cardLoad(id); 32 | setRenderer({ id, isLoadId: id }); 33 | }) 34 | 35 | app.API.on("cardSelect", ({ id, x, y, width, height }) => { 36 | // WB.API.cardSelect(x, y, width, height); 37 | setRenderer({ id, x, y, width, height }); 38 | }) 39 | 40 | app.API.on("cardLeave", ({ id }) => { 41 | // WB.API.cardLeave(id); 42 | setRenderer({ id }); 43 | }) 44 | } 45 | 46 | }, [app]); 47 | 48 | return renderer; 49 | }; 50 | -------------------------------------------------------------------------------- /src/core/Camera.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import { MOUSE, TOUCH } from 'three' 3 | import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' 4 | 5 | export default class Camera { 6 | constructor(_option) { 7 | this.time = _option.time 8 | this.sizes = _option.sizes 9 | this.renderer = _option.renderer 10 | 11 | this.container = new THREE.Object3D() 12 | this.container.matrixAutoUpdate = false 13 | 14 | this.setInstance() 15 | this.setOrbitControls() 16 | } 17 | 18 | setInstance() { 19 | const scope = 1.5 20 | const { width, height } = this.sizes.viewport 21 | this.instance = new THREE.OrthographicCamera(-scope * width / height, scope * width / height, scope, -scope, 0.1, 100) 22 | this.instance.position.z = 2 23 | this.container.add(this.instance) 24 | 25 | this.sizes.on('resize', () => { 26 | const { width, height } = this.sizes.viewport 27 | this.instance.aspect = width / height 28 | this.instance.updateProjectionMatrix() 29 | }) 30 | } 31 | 32 | setOrbitControls() { 33 | this.controls = new OrbitControls(this.instance, this.renderer.domElement) 34 | this.controls.enableDamping = false 35 | this.controls.screenSpacePanning = true // pan orthogonal to world-space direction camera.up 36 | this.controls.mouseButtons = { LEFT: MOUSE.PAN, MIDDLE: MOUSE.PAN, RIGHT: MOUSE.PAN } 37 | // this.controls.mouseButtons = { LEFT: MOUSE.PAN, MIDDLE: MOUSE.DOLLY, RIGHT: MOUSE.ROTATE } 38 | this.controls.touches = { ONE: TOUCH.PAN, TWO: TOUCH.PAN } 39 | // this.controls.touches = { ONE: TOUCH.PAN, TWO: TOUCH.DOLLY_PAN } 40 | 41 | this.controls.addEventListener('change', () => this.time.trigger('tick')) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/components/Hint/Hint.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { cn } from '../../utils/cn' 3 | 4 | export default function Hint(props) { 5 | 6 | const [show, setShow] = useState(true); 7 | 8 | useEffect(() => { 9 | // disable 10 | const handleDisable = () => { 11 | setShow(false) 12 | } 13 | window.addEventListener("mousedown", handleDisable) 14 | 15 | return () => { 16 | window.removeEventListener("mousedown", handleDisable) 17 | } 18 | }, [show]); 19 | 20 | return ( 21 | show ? 22 |
23 |
24 |

Hot Key

25 |
26 |
    27 | {props.children} 28 |
29 |
: <> 30 | ) 31 | } 32 | 33 | const hintStyles = { 34 | hint: cn( 35 | 'fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]', 36 | "bg-[#111] opacity-80", 37 | "p-8", 38 | "flex flex-col gap-8") 39 | } 40 | 41 | Hint.HotKey = function HotKey(props) { 42 | return ( 43 |
  • 44 |
    45 | { /*eslint-disable-next-line react/prop-types*/} 46 | {props.hotkey.map((h, i) => 47 | 48 | { /*eslint-disable-next-line react/prop-types*/} 49 | {h}{i !== props.hotkey.length - 1 ? " +" : ""} 50 | 51 | )} 52 |
    53 | { /*eslint-disable-next-line react/prop-types*/} 54 | {props.children} 55 |
  • 56 | ) 57 | 58 | } 59 | 60 | const hintHotKeyStyles = { 61 | hintHotKey: cn('flex w-[450px] justify-between') 62 | } -------------------------------------------------------------------------------- /src/components/TextCards/TextCard/TextCard.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { cn } from "../../../utils/cn"; 3 | import { css } from "@emotion/css"; 4 | import { useHover } from "../../../hooks/useHover"; 5 | 6 | export default function TextCard({ card }) { 7 | const [hover, isHover] = useHover(); 8 | const [fontSize, setFontSize] = useState(16); 9 | return ( 10 |
    25 | {isHover && ( 26 |
    30 |

    {card.name}

    31 |
    32 |

    font size

    33 | { 37 | setFontSize(Number(e.target.value)); 38 | }} 39 | type="text" 40 | /> 41 |
    42 |
    43 | )} 44 |
    55 |
    
    59 |       
    60 |
    61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /src/core/Application.js: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | 3 | import Time from "./utils/Time"; 4 | import Sizes from "./utils/Sizes"; 5 | 6 | import Camera from "./Camera"; 7 | import World from "./World"; 8 | 9 | export default class Application { 10 | constructor(_options) { 11 | this.$canvas = _options.$canvas; 12 | 13 | this.time = new Time(); 14 | this.sizes = new Sizes(); 15 | 16 | this.setRenderer(); 17 | this.setCamera(); 18 | this.setWorld(); 19 | } 20 | 21 | API = { 22 | on: (eventName, cb) => { 23 | this.API[eventName] = cb; 24 | }, 25 | }; 26 | 27 | setRenderer() { 28 | this.scene = new THREE.Scene(); 29 | 30 | this.renderer = new THREE.WebGLRenderer({ 31 | antialias: true, 32 | canvas: this.$canvas, 33 | }); 34 | 35 | const { width, height } = this.sizes.viewport; 36 | this.renderer.setSize(width, height); 37 | this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); 38 | this.renderer.setClearColor(0, 0); 39 | this.renderer.outputColorSpace = THREE.SRGBColorSpace; 40 | 41 | this.sizes.on("resize", () => { 42 | const { width, height } = this.sizes.viewport; 43 | this.renderer.setSize(width, height); 44 | this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); 45 | }); 46 | } 47 | 48 | setCamera() { 49 | this.camera = new Camera({ 50 | time: this.time, 51 | sizes: this.sizes, 52 | renderer: this.renderer, 53 | }); 54 | 55 | this.scene.add(this.camera.container); 56 | 57 | this.time.on("tick", () => { 58 | this.renderer.render(this.scene, this.camera.instance); 59 | // console.log('render') 60 | }); 61 | } 62 | 63 | setWorld() { 64 | this.world = new World({ 65 | app: this, 66 | time: this.time, 67 | sizes: this.sizes, 68 | camera: this.camera, 69 | renderer: this.renderer, 70 | }); 71 | this.scene.add(this.world.container); 72 | 73 | // render once 74 | this.renderer.render(this.scene, this.camera.instance); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/core/GUIPanel.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min' 3 | 4 | export default class GUIPanel { 5 | constructor(_option) { 6 | this.mode = _option.mode 7 | this.cardSet = _option.cardSet 8 | this.cardUnwrap = _option.cardUnwrap 9 | 10 | this.cardMode = null 11 | this.onCard = false 12 | this.gui = new GUI() 13 | this.gui.add(this, 'mode', ['segment', 'layer', 'volume', 'volume-segment']) 14 | this.gui.add(this.cardUnwrap.viewer.params, 'flatten', 0.0, 1.0).onChange(() => this.cardUnwrap.updateAllBuffer()) 15 | } 16 | 17 | currentCard() { 18 | if (this.onCard && this.cardSet.focusCard.userData.mode == this.cardMode) return 19 | this.cardMode = this.cardSet.focusCard.userData.mode 20 | this.onCard = true 21 | this.reset() 22 | 23 | const mode = this.cardMode 24 | const viewer = this.cardSet.viewer 25 | 26 | if (mode === 'segment') { 27 | const id = viewer.params.layers.select 28 | const clip = viewer.volumeMeta.nrrd[id].clip 29 | this.gui.add(viewer.params, 'alpha', 0.0, 1.0).onChange(() => this.cardSet.updateAllBuffer()) 30 | this.gui.add(viewer.params, 'layer', clip.z, clip.z + clip.d, 1).onChange(() => this.cardSet.updateAllBuffer()) 31 | } 32 | if (mode === 'volume') { return } 33 | if (mode === 'volume-segment') { 34 | this.gui.add(viewer.params, 'surface', 0.001, 0.5).onChange(() => this.cardSet.updateAllBuffer()) 35 | } 36 | if (mode === 'layer') { 37 | const id = viewer.params.layers.select 38 | const clip = viewer.volumeMeta.nrrd[id].clip 39 | 40 | viewer.params.layer = clip.z 41 | this.gui.add(viewer.params, 'inverse').onChange(() => this.cardSet.updateAllBuffer()) 42 | this.gui.add(viewer.params, 'surface', 0.001, 0.5).onChange(() => this.cardSet.updateAllBuffer()) 43 | this.gui.add(viewer.params, 'layer', clip.z, clip.z + clip.d, 1).onChange(() => this.cardSet.updateAllBuffer()) 44 | } 45 | } 46 | 47 | newCard() { 48 | if (!this.onCard) return 49 | this.onCard = false 50 | 51 | this.reset() 52 | this.gui.add(this, 'mode', ['segment', 'layer', 'volume', 'volume-segment']) 53 | } 54 | 55 | reset() { 56 | if (this.gui) { this.gui.destroy() } 57 | this.gui = new GUI() 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/core/core/Card.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import PubSub from "pubsub-js"; 3 | import { TextureLoader } from 'three' 4 | import { FragmentShader } from './FragmentShader' 5 | import { TIFFLoader } from 'three/addons/loaders/TIFFLoader.js' 6 | import { ArcballControls } from 'three/addons/controls/ArcballControls.js' 7 | 8 | export default class Card { 9 | constructor(_option) { 10 | this.scene = null 11 | this.camera = null 12 | this.controls = null 13 | this.renderer = null 14 | this.segmentID = null 15 | 16 | this.time = _option.time 17 | this.app = _option.app 18 | this.canvas = _option.canvas 19 | this.renderer = _option.renderer 20 | this.width = _option.info.w 21 | this.height = _option.info.h 22 | this.buffer = new THREE.WebGLRenderTarget(this.width, this.height) 23 | 24 | this.init() 25 | } 26 | 27 | init() { 28 | // scene setup 29 | this.scene = new THREE.Scene() 30 | 31 | // camera setup 32 | this.camera = new THREE.PerspectiveCamera(75, this.width / this.height, 0.1, 50) 33 | this.camera.position.copy(new THREE.Vector3(0.4, -0.4, -1.0).multiplyScalar(1.0)) 34 | this.camera.up.set(0, -1, 0) 35 | this.camera.far = 5 36 | this.camera.updateProjectionMatrix() 37 | 38 | // camera controls 39 | this.controls = new ArcballControls(this.camera, this.canvas, this.scene) 40 | } 41 | 42 | async create(segmentID, uuid, info) { 43 | const texture = await new TIFFLoader().loadAsync(`${segmentID}.tif`) 44 | 45 | let mtexture = null 46 | if (segmentID === '20230509182749') { mtexture = await new TextureLoader().loadAsync('20230509182749-mask.png') } 47 | 48 | const material = new FragmentShader() 49 | material.uniforms.tDiffuse.value = texture 50 | material.uniforms.uMask.value = mtexture 51 | 52 | const mesh = new THREE.Mesh(new THREE.PlaneGeometry(info.w / info.h, 1), material) 53 | this.scene.add(mesh) 54 | 55 | this.segmentID = segmentID 56 | 57 | this.render() 58 | this.time.trigger('tick') 59 | this.app.API.cardLoad(uuid) 60 | 61 | PubSub.publish("onFinishLoad", { id: uuid }) 62 | } 63 | 64 | render() { 65 | if (!this.renderer) return 66 | 67 | this.renderer.setRenderTarget(this.buffer) 68 | this.renderer.clear() 69 | 70 | this.renderer.render(this.scene, this.camera) 71 | this.renderer.setRenderTarget(null) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/components/.deprecated/Card/Card.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from "react"; 2 | import AppContext from "../../../context/AppContext"; 3 | import useCardRender from "../../../hooks/need-refactor/useCardRender"; 4 | import { css } from "@emotion/css"; 5 | import { cn } from "../../../utils/cn"; 6 | 7 | export default function Card(props) { 8 | 9 | const card = props.options.card; 10 | const renderer = props.options.renderer; 11 | const rendererId = renderer?.id; 12 | const id = card?.id; 13 | 14 | const [cardLoad, setCardLoad] = useState(false); 15 | const [cardSelected, setCardSelected] = useState(false); 16 | 17 | if (!cardLoad && renderer.isLoadId === id) { 18 | setCardLoad(true); 19 | } 20 | 21 | if (!cardSelected && id === rendererId) { 22 | setCardSelected(true) 23 | } 24 | if (cardSelected && id !== rendererId) { 25 | setCardSelected(false) 26 | } 27 | 28 | console.log(renderer) 29 | 30 | if (!cardLoad) { 31 | return
    42 |
    43 | Loading... 44 |
    45 |
    46 | } 47 | 48 | if (cardSelected) { 49 | return ( 50 |
    62 |
    63 | {card?.name} 64 |
    65 |
    66 | ); 67 | } else { 68 | return <> 69 | } 70 | 71 | 72 | 73 | 74 | 75 | } 76 | 77 | const styles = { 78 | card: cn("fixed translate-x-[-50%] translate-y-[-50%]"), 79 | }; 80 | -------------------------------------------------------------------------------- /src/core/Controls.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | 3 | export default class Controls { 4 | constructor(_option) { 5 | this.time = _option.time 6 | this.sizes = _option.sizes 7 | this.camera = _option.camera 8 | 9 | this.mousePress = false 10 | this.spacePress = false 11 | this.numKeyPress = [false, false, false, false] 12 | 13 | this.setMouse() 14 | } 15 | 16 | setMouse() { 17 | this.mouse = new THREE.Vector2() 18 | 19 | // triggered when the mouse moves 20 | window.addEventListener('mousemove', (e) => { 21 | this.mouse.x = e.clientX / this.sizes.width * 2 - 1 22 | this.mouse.y = -(e.clientY / this.sizes.height) * 2 + 1 23 | this.time.trigger('mouseMove') 24 | }) 25 | 26 | // after pressing down the mouse button (for left click only) 27 | window.addEventListener('pointerdown', (e) => { 28 | if (e.button !== 0) return 29 | 30 | const name = e.srcElement.className 31 | // console.log(name) 32 | // if (name !== 'webgl' && name !== 'cardDOM') return 33 | 34 | this.mousePress = true 35 | this.time.trigger('mouseDown') 36 | }) 37 | // can't use 'mousedown' event because of the OrbitControls library 38 | 39 | // after releasing the mouse button 40 | window.addEventListener('click', () => { 41 | this.mousePress = false 42 | this.time.trigger('mouseUp') 43 | }) 44 | 45 | // whether space key is pressed or not 46 | window.addEventListener('keydown', (e) => { 47 | this.spacePress = (e.code === 'Space') 48 | this.numKeyPress[0] = (e.code === 'Digit1') 49 | this.numKeyPress[1] = (e.code === 'Digit2') 50 | this.numKeyPress[2] = (e.code === 'Digit3') 51 | this.numKeyPress[3] = (e.code === 'Digit4') 52 | 53 | if (this.spacePress) this.time.trigger('spaceDown') 54 | if (this.spacePress && !e.repeat) this.time.trigger('spaceDownStart') 55 | }) 56 | window.addEventListener('keyup', (e) => { 57 | if (this.spacePress) this.time.trigger('spaceUp') 58 | if (this.numKeyPress[4]) this.time.trigger('dragUp') 59 | 60 | this.spacePress = false 61 | this.numKeyPress[0] = false 62 | this.numKeyPress[1] = false 63 | this.numKeyPress[2] = false 64 | this.numKeyPress[3] = false 65 | }) 66 | } 67 | 68 | getRayCast(meshes) { 69 | const raycaster = new THREE.Raycaster() 70 | raycaster.setFromCamera(this.mouse, this.camera.instance) 71 | const intersects = raycaster.intersectObjects(meshes) 72 | 73 | return intersects 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/core/core/Image.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import PubSub from "pubsub-js"; 3 | import { TextureLoader } from 'three' 4 | import { ImageShader } from './ImageShader' 5 | 6 | export default class Image { 7 | constructor(_option) { 8 | this.scene = null 9 | this.camera = null 10 | this.renderer = null 11 | this.width = null 12 | this.height = null 13 | this.buffer = null 14 | 15 | this.time = _option.time 16 | this.renderer = _option.renderer 17 | 18 | this.init() 19 | } 20 | 21 | init() { 22 | // scene setup 23 | this.scene = new THREE.Scene() 24 | 25 | // camera setup 26 | this.camera = new THREE.PerspectiveCamera(75, this.width / this.height, 0.1, 50) 27 | this.camera.updateProjectionMatrix() 28 | } 29 | 30 | async create(blob, uuid, card) { 31 | // create a full screen texture image for rendering 32 | const blobUrl = URL.createObjectURL(blob) 33 | const texture = await new TextureLoader().loadAsync(blobUrl) 34 | texture.minFilter = THREE.NearestFilter 35 | texture.magFilter = THREE.NearestFilter 36 | 37 | const material = new ImageShader() 38 | material.uniforms.tDiffuse.value = texture 39 | 40 | const mesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2, 1, 1), material) 41 | this.scene.add(mesh) 42 | 43 | // change card size via texture w, h info 44 | const tWidth = texture.image.width 45 | const tHeight = texture.image.height 46 | const lMax = Math.max(tWidth, tHeight) 47 | const s = (lMax < 10000) ? 1 : 10000 / lMax 48 | 49 | this.width = tWidth * s 50 | this.height = tHeight * s 51 | this.buffer = new THREE.WebGLRenderTarget(this.width, this.height) 52 | 53 | const size = 2 54 | const fw = (lMax === tWidth) ? 1 : tWidth / lMax 55 | const fh = (lMax === tHeight) ? 1 : tHeight / lMax 56 | 57 | card.userData.wo = size * fw 58 | card.userData.ho = size * fh 59 | 60 | card.userData.w = card.userData.wo 61 | card.userData.h = card.userData.ho 62 | card.scale.x = card.userData.wo 63 | card.scale.y = card.userData.ho 64 | card.material.uniforms.tDiffuse.value = this.buffer.texture 65 | 66 | this.render() 67 | this.time.trigger('tick') 68 | 69 | PubSub.publish("onFinishLoad", { id: uuid }) 70 | } 71 | 72 | render() { 73 | if (!this.renderer || !this.buffer) return 74 | 75 | this.renderer.setRenderTarget(this.buffer) 76 | this.renderer.clear() 77 | 78 | this.renderer.render(this.scene, this.camera) 79 | this.renderer.setRenderTarget(null) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * * this is the index.js file of Volume Viewer Core 4 | * * =============================================== 5 | * * It will load when the App component is mounted. 6 | * * 7 | */ 8 | import Info from './components/Info/Info'; 9 | import Hint from './components/Hint/Hint'; 10 | import Social from './components/Social/Social'; 11 | import AppContext from './context/AppContext'; 12 | import FileSystem from "./components/FileSystem/FileSystem"; 13 | import useWhiteboardUpdate from "./hooks/useWhiteboardUpdate"; 14 | import useWhiteboardApp from "./hooks/useWhiteboardApp"; 15 | import UrlCards from "./components/UrlCards/UrlCards"; 16 | import Cards from "./components/Cards/Cards"; 17 | import useCardRender from "./hooks/deprecated/useCardRender"; 18 | import Version from './components/Version/Version'; 19 | import TextCards from "./components/TextCards/TextCards" 20 | import { Analytics } from '@vercel/analytics/react'; 21 | 22 | 23 | export default function App() { 24 | 25 | // 白板本身 26 | const app = useWhiteboardApp(); 27 | // 白板狀態 28 | const whiteboard = useWhiteboardUpdate(); 29 | 30 | // 白板控制 (舊, 會重構) 31 | useCardRender(app); 32 | 33 | 34 | return ( 35 | 39 |
    40 | 41 | 42 | 43 | {/* 44 | {""} 45 | */} 46 | 47 | {""} 48 | 49 | 50 | {""} 51 | 52 | 53 | {""} 54 | 55 | {/* 56 | {""} 57 | */} 58 | 59 | {/**/} 60 | 61 | 62 | 63 | 64 | 65 | 66 |
    67 | 68 |
    69 | 70 | ) 71 | } 72 | -------------------------------------------------------------------------------- /src/components/FileSystem/FileSystem.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import readDirectory from "../../utils/readDirectory"; 3 | import FileTree from "./FileTree/FileTree"; 4 | import { isEmpty } from "lodash"; 5 | import { Resizable } from "re-resizable"; 6 | import { cn } from "../../utils/cn"; 7 | import PubSub from "pubsub-js"; 8 | import { nanoid } from "nanoid"; 9 | import { VscFiles } from "react-icons/vsc"; 10 | 11 | export default function FileSystem() { 12 | const [dir, setDir] = useState({}); 13 | const [isResize, setIsResize] = useState(false); 14 | const [isOpenFileSystem, setIsOpenFileSystem] = useState(true); 15 | 16 | const handleFileBtnOnClick = async () => { 17 | const directoryHandle = await window.showDirectoryPicker(); 18 | const dir = await readDirectory(directoryHandle); 19 | setDir(dir); 20 | }; 21 | 22 | const handleFileOnClick = async (file: File) => { 23 | const arraybuffer = await file.arrayBuffer(); 24 | const blob = new Blob([arraybuffer], { type: file.name }); 25 | const text = await file.text(); 26 | PubSub.publish("onFileSelect", { 27 | id: nanoid(), 28 | fileType: file.type, 29 | fileName: file.name, 30 | blob, 31 | text, 32 | }); 33 | }; 34 | 35 | const handleFolderOnClick = async () => {}; 36 | 37 | return ( 38 |
    39 | {!isEmpty(dir) ? ( 40 |
    41 |
    { 43 | setIsOpenFileSystem(!isOpenFileSystem); 44 | }} 45 | className="p-4 text-xl cursor-pointer" 46 | title={ 47 | isOpenFileSystem 48 | ? "close file system" 49 | : "open file system" 50 | } 51 | > 52 | 53 |
    54 | { 55 | { 57 | setIsResize(true); 58 | }} 59 | onResizeStop={() => { 60 | setIsResize(false); 61 | }} 62 | className={cn( 63 | isOpenFileSystem ? "visible" : "invisible", 64 | "text-lg bg-[#111] opacity-80 py-2 pr-4 overflow-hidden border-4 transition-[border]", 65 | isResize ? "" : "border-[#111]" 66 | )} 67 | > 68 | 73 | 74 | } 75 |
    76 | ) : ( 77 | 83 | )} 84 |
    85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /src/components/FileSystem/FileTree/FileTree.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from "react"; 2 | import { AiOutlineCaretDown, AiOutlineCaretRight } from "react-icons/ai"; 3 | import formatBytes from "../../../utils/formatBytes"; 4 | import { 5 | LuScroll, 6 | LuFolder, 7 | LuFolderOpen, 8 | LuImage, 9 | LuFile, 10 | LuFileText, 11 | } from "react-icons/lu"; 12 | 13 | const Dir = ({ name, item, fileOnClick, folderOnClick }) => { 14 | const [open, setOpen] = useState(false); 15 | 16 | const distinguishFileType = useCallback((name: string, type: string) => { 17 | const spl = type.split("/")[0]; 18 | if (spl === "image") { 19 | return ( 20 | <> 21 | 22 | {name} 23 | 24 | ); 25 | } else if (spl === "text" || spl === "application") { 26 | return ( 27 | <> 28 | 29 | {name} 30 | 31 | ); 32 | } else { 33 | return ( 34 | <> 35 | 36 | {name} 37 | 38 | ); 39 | } 40 | }, []); 41 | 42 | return ( 43 |
  • 44 | {item instanceof File ? ( 45 | // file 46 | { 48 | fileOnClick && fileOnClick(item); 49 | }} 50 | className="flex items-center pl-4 hover:underline" 51 | title={"file | " + formatBytes(item.size)} 52 | > 53 |
    54 | {distinguishFileType(name, item.type)} 55 |
    56 |
    57 | ) : ( 58 | // folder 59 | { 61 | setOpen(!open); 62 | folderOnClick && folderOnClick(item); 63 | }} 64 | className="flex items-center gap-1 hover:underline" 65 | title="segment" 66 | > 67 | {open ? : } 68 | {name.match(/202[34]\d+/gu) ? ( 69 |
    70 | 71 | {name} 72 |
    73 | ) : ( 74 |
    75 | {open ? ( 76 | 77 | ) : ( 78 | 79 | )} 80 | {name} 81 |
    82 | )} 83 |
    84 | )} 85 | {item instanceof Object && open && ( 86 | 91 | )} 92 |
  • 93 | ); 94 | }; 95 | 96 | const FileTree = ({ data, fileOnClick, folderOnClick }) => { 97 | return ( 98 |
      99 | {Object.entries(data).map(([name, item]) => ( 100 | 107 | ))} 108 |
    109 | ); 110 | }; 111 | 112 | export default FileTree; 113 | -------------------------------------------------------------------------------- /src/components/Cards/Card/Card.jsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/css" 2 | import { cn } from "../../../utils/cn" 3 | import { useContext, useEffect, useState } from "react" 4 | import AppContext from "../../../context/AppContext" 5 | import { useHover } from "../../../hooks/useHover" 6 | import { Slider } from "../../ui/Slider" 7 | 8 | export default function Card({ card }) { 9 | 10 | const { app } = useContext(AppContext) 11 | 12 | const [hover, isHover] = useHover(); 13 | const [isLoad, setIsLoad] = useState(false) 14 | 15 | useEffect(() => { 16 | PubSub.subscribe("onFinishLoad", (_, { id }) => { 17 | id === card.id && setIsLoad(true) 18 | }) 19 | setTimeout(() => { 20 | setIsLoad(true) 21 | }, 4000) 22 | }, [card.id]) 23 | 24 | 25 | const [opacity, setOpacity] = useState(1) 26 | useEffect(() => { 27 | PubSub.publish("onCardOpacityChange", { id: card.id, opacity }) 28 | }, [opacity, card.id]) 29 | 30 | const [rotation, setRotation] = useState(180); 31 | useEffect(() => { 32 | PubSub.publish("onCardRotationChange", { id: card.id, rotation: rotation - 180 }) 33 | }, [rotation, card.id]) 34 | 35 | const [scale, setScale] = useState(1); 36 | useEffect(() => { 37 | PubSub.publish("onCardScaleChange", { id: card.id, scale }) 38 | }, [scale, card.id]) 39 | 40 | return
    49 | {
    52 |

    {card.name}

    53 |
    54 |
    55 |

    rotation

    56 |
    57 | { 60 | setRotation(v[0]) 61 | }} 62 | className="w-full" max={360} step={1} /> 63 |
    64 |
    65 |
    66 |

    scale

    67 |
    68 | { 71 | setScale(v[0]) 72 | }} 73 | className="w-full" max={2} step={0.01} /> 74 |
    75 |
    76 |
    77 |

    opacity

    78 |
    79 | { 82 | setOpacity(v[0] / 100) 83 | }} 84 | className="w-full" max={100} step={1} /> 85 |
    86 |
    87 |
    88 |
    } 89 | {isLoad ||

    loading...

    } 90 |
    91 | } 92 | -------------------------------------------------------------------------------- /src/core/CardSet.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import Card from './core/Card' 3 | import Image from './core/Image' 4 | import PubSub from "pubsub-js"; 5 | import { CopyShader } from './core/CopyShader' 6 | 7 | export default class CardSet { 8 | constructor(_option) { 9 | this.app = _option.app 10 | this.time = _option.time 11 | this.sizes = _option.sizes 12 | this.camera = _option.camera 13 | this.renderer = _option.renderer 14 | 15 | this.list = [] 16 | this.targetCard = null 17 | } 18 | 19 | create(name, dom, mouse, center) { 20 | const info = {} 21 | if (name === '20230522181603') { info.w = 2912; info.h = 1060; } 22 | if (name === '20230509182749') { info.w = 3278; info.h = 1090; } 23 | if (name === '20230702185752') { info.w = 1746; info.h = 1726; } 24 | 25 | const viewer = new Card({ 26 | info, 27 | canvas: dom, 28 | renderer: this.renderer, 29 | time: this.time, 30 | app: this.app, 31 | }) 32 | 33 | viewer.controls.addEventListener('change', () => { 34 | this.render() 35 | this.time.trigger('tick') 36 | }) 37 | 38 | const w = parseFloat((info.w / 1500).toFixed(2)) 39 | const h = parseFloat((info.h / 1500).toFixed(2)) 40 | 41 | const geometry = new THREE.PlaneGeometry(w, h) 42 | const material = new CopyShader() 43 | material.uniforms.tDiffuse.value = viewer.buffer.texture 44 | 45 | const card = new THREE.Mesh(geometry, material) 46 | const id = card.uuid 47 | const type = 'card' 48 | card.position.copy(center) 49 | card.userData = { id, name, type, center, w, h, viewer, dom } 50 | 51 | viewer.create(name, id, info) 52 | this.list.push(card) 53 | 54 | return card 55 | } 56 | 57 | createImage(id, fileType, fileName, blob, center) { 58 | const viewer = new Image({ 59 | renderer: this.renderer, 60 | time: this.time, 61 | }) 62 | 63 | const geometry = new THREE.PlaneGeometry(1, 1) 64 | const material = new CopyShader() 65 | 66 | const name = fileName 67 | const type = fileType 68 | 69 | const card = new THREE.Mesh(geometry, material) 70 | card.position.copy(center) 71 | card.userData = { id, name, type, center, w: 1, h: 1 } 72 | this.list.push(card) 73 | 74 | viewer.create(blob, id, card) 75 | 76 | return card 77 | } 78 | 79 | createText(id, fileType, fileName, text, center, width, height) { 80 | const geometry = new THREE.PlaneGeometry(width, height) 81 | const material = new THREE.MeshBasicMaterial() 82 | 83 | const name = fileName 84 | const type = fileType 85 | 86 | const card = new THREE.Mesh(geometry, material) 87 | card.position.copy(center) 88 | card.userData = { id, name, type, center, content: text, w: width, h: height } 89 | this.list.push(card) 90 | 91 | PubSub.publish("onFinishLoad", { id }) 92 | 93 | return card 94 | } 95 | 96 | createIframe(id, center, width, height) { 97 | const geometry = new THREE.PlaneGeometry(width, height) 98 | const material = new THREE.MeshBasicMaterial() 99 | 100 | const name = '' 101 | const type = 'iframe' 102 | 103 | const card = new THREE.Mesh(geometry, material) 104 | card.position.copy(center) 105 | card.userData = { id, name, type, center, w: width, h: height, wo: width, ho: height } 106 | this.list.push(card) 107 | 108 | PubSub.publish("onFinishLoad", { id }) 109 | 110 | return card 111 | } 112 | 113 | updateCanvas(card) { 114 | if (!card) return 115 | 116 | const { center, dom, w, h } = card.userData 117 | 118 | const bl = new THREE.Vector3(center.x - w / 2, center.y - h / 2, 0) 119 | const tr = new THREE.Vector3(center.x + w / 2, center.y + h / 2, 0) 120 | // bottom-left (-1, -1) top-right (1, 1) 121 | const pbl = bl.clone().project(this.camera.instance) 122 | const ptr = tr.clone().project(this.camera.instance) 123 | 124 | return [ pbl, ptr ] 125 | } 126 | 127 | render() { 128 | if (!this.targetCard) return 129 | 130 | const { viewer } = this.targetCard.userData 131 | viewer.render() 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/core/utils/EventEmitter.js: -------------------------------------------------------------------------------- 1 | export default class EventEmiter { 2 | constructor() { 3 | this.callbacks = {}; 4 | this.callbacks.base = {}; 5 | } 6 | 7 | // input : this.on('xx/yy/zz.alpha', callback) 8 | // result: this.callbacks['base'] = {'xx':[..., callback], 'yy':[..., callback]} 9 | // this.callbacks['alpha'] = {'zz':[..., callback]} 10 | on(_names, _callback, _unsubsrcibe) { 11 | if (typeof _names === 'undefined' || _names === '') { 12 | console.warn('wrong names'); 13 | return false; 14 | } 15 | if (typeof _callback === 'undefined') { 16 | console.warn('wrong callback'); 17 | return false; 18 | } 19 | 20 | const names = EventEmiter.resolveNames(_names); 21 | 22 | names.forEach((_name) => { 23 | const name = EventEmiter.resolveName(_name); 24 | 25 | if (!(this.callbacks[name.namespace] instanceof Object)) { 26 | this.callbacks[name.namespace] = {}; 27 | } 28 | 29 | if (!(this.callbacks[name.namespace][name.value] instanceof Array)) { 30 | this.callbacks[name.namespace][name.value] = []; 31 | } 32 | 33 | this.callbacks[name.namespace][name.value].push(_callback); 34 | }); 35 | 36 | return { _names, _callback, _unsubsrcibe }; 37 | } 38 | 39 | // input : this.trigger('xx', [5,3]) 40 | // result: this.callbacks['base']['xx'] -> [c1, c2, ...] 41 | // execute c1(5,3), c2(5,3) 42 | trigger(_name, _args) { 43 | if (typeof _name === 'undefined' || _name === '') { 44 | console.warn('wrong name'); 45 | return false; 46 | } 47 | 48 | let finalResult = null; 49 | let result = null; 50 | 51 | // Default args [] 52 | const args = !(_args instanceof Array) ? [] : _args; 53 | 54 | const names = EventEmiter.resolveNames(_name); 55 | const name = EventEmiter.resolveName(names[0]); 56 | const { namespace, value } = name; 57 | 58 | if (namespace === 'base') { 59 | const callbacks = this.callbacks[namespace]; 60 | const callback = this.callbacks[namespace][value]; 61 | 62 | if (callbacks instanceof Object && callback instanceof Array) { 63 | callback.forEach((callback) => { 64 | // execute callback 65 | result = callback.apply(this, args); 66 | 67 | if (typeof finalResult === 'undefined') { finalResult = result; } 68 | }); 69 | } 70 | } else if (this.callbacks[namespace] instanceof Object) { 71 | if (value === '') { 72 | console.warn('wrong name'); 73 | return this; 74 | } 75 | 76 | this.callbacks[namespace][value].forEach((callback) => { 77 | // execute callback 78 | result = callback.apply(this, args); 79 | 80 | if (typeof finalResult === 'undefined') { finalResult = result; } 81 | }); 82 | } 83 | 84 | return finalResult; 85 | } 86 | 87 | // ex: this.remove({_names: 'xx', _callback: ...}) 88 | // ex: this.remove({_names: 'xx/yy/zz.alpha', _callback: ...}) 89 | remove(_target) { 90 | const { _names, _callback, _unsubsrcibe } = _target; 91 | 92 | if (typeof _names === 'undefined' || _names === '') { 93 | console.warn('wrong names'); 94 | return false; 95 | } 96 | if (typeof _callback === 'undefined') { 97 | console.warn('wrong callback'); 98 | return false; 99 | } 100 | 101 | const names = EventEmiter.resolveNames(_names); 102 | 103 | names.forEach((_name) => { 104 | const name = EventEmiter.resolveName(_name); 105 | const { namespace, value } = name; 106 | 107 | if (!(this.callbacks[namespace] instanceof Object)) return; 108 | if (!(this.callbacks[namespace][value] instanceof Array)) return; 109 | 110 | // remove the callback 111 | this.callbacks[namespace][value] = this.callbacks[namespace][value].filter((callback) => callback !== _callback); 112 | // execute unsubsrcibe function 113 | if (_unsubsrcibe instanceof Function) _unsubsrcibe(); 114 | 115 | if (!this.callbacks[namespace][value].length) { 116 | delete this.callbacks[namespace][value]; 117 | } 118 | }); 119 | 120 | return this; 121 | } 122 | 123 | // ex: 'xx/yy/zz' -> ['xx', 'yy', 'zz'] 124 | static resolveNames(_names) { 125 | let names = _names; 126 | names = names.replace(/[^a-zA-Z0-9 ,/.]/g, ''); 127 | names = names.replace(/[,/]+/g, ' '); 128 | names = names.split(' '); 129 | 130 | return names; 131 | } 132 | 133 | // ex: 'xx' -> {original: 'xx', value: 'xx', namespace: 'base'} 134 | // ex: 'xx.yy' -> {original: 'xx.yy', value: 'xx', namespace: 'yy'} 135 | static resolveName(name) { 136 | const newName = {}; 137 | const [value, namespace] = name.split('.'); 138 | 139 | newName.original = name; 140 | newName.value = value; 141 | newName.namespace = 'base'; 142 | 143 | if (namespace) { newName.namespace = namespace; } 144 | 145 | return newName; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/components/UrlCards/UrlCard/UrlCard.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react" 2 | import { cn } from "../../../utils/cn" 3 | import { css } from "@emotion/css" 4 | import { Slider } from "../../ui/Slider" 5 | import { useHover } from "../../../hooks/useHover" 6 | 7 | export default function UrlCard({ card }) { 8 | 9 | const inputRef = useRef(null) 10 | 11 | const [inupt, setInput] = useState(""); 12 | const [url, setUrl] = useState(""); 13 | 14 | const [hover, isHover] = useHover(); 15 | const [isVisable, setIsVisable] = useState(true); 16 | 17 | const handleClose = () => { 18 | PubSub.publish("onUrlCardDelete", { id: card.id }) 19 | } 20 | 21 | const handleEnter = () => { 22 | setIsVisable(true) 23 | } 24 | 25 | const handleLeave = () => { 26 | if (url) { 27 | setIsVisable(false) 28 | } 29 | } 30 | 31 | const handleFlip = () => { 32 | setFlip(!flip) 33 | } 34 | 35 | useEffect(() => { 36 | inputRef.current?.focus() 37 | }, []) 38 | 39 | const [flip, setFlip] = useState(false); 40 | useEffect(() => { 41 | PubSub.publish("onCardFlipChange", { id: card.id, flip }) 42 | }, [flip, card.id]) 43 | 44 | const [rotation, setRotation] = useState(180); 45 | useEffect(() => { 46 | PubSub.publish("onCardRotationChange", { id: card.id, rotation: rotation - 180 }) 47 | }, [rotation, card.id]) 48 | 49 | const [scale, setScale] = useState(1); 50 | useEffect(() => { 51 | PubSub.publish("onCardScaleChange", { id: card.id, scale }) 52 | }, [scale, card.id]) 53 | 54 | return
    66 |
    68 |
    69 |

    flip

    70 |
    71 | 79 |
    80 |
    81 |
    82 |
    84 |
    85 |

    scale

    86 |
    87 | { 90 | setScale(v[0]) 91 | }} 92 | className="w-full" max={2} step={0.01} /> 93 |
    94 |
    95 |
    96 |
    98 |
    99 |

    rotation

    100 |
    101 | { 104 | setRotation(v[0]) 105 | }} 106 | className="w-full" max={360} step={1} /> 107 |
    108 |
    109 |
    110 |
    113 | {card.heightScreen < 150 ? <> : 114 |
    115 |

    From the web

    116 |
    [X]
    117 |
    } 118 | {card.heightScreen < 280 ? <> : { setInput(e.target.value) }} 122 | onKeyDown={(e) => { 123 | if (e.key === "Enter") { 124 | setUrl(inupt) 125 | } 126 | }} 127 | className={cn("w-full", "text-lg text-[#111]", "p-1")} 128 | type="text" />} 129 |
    130 | 138 |
    139 | } 140 | -------------------------------------------------------------------------------- /src/core/World.js: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | 3 | import WhiteBoard from "./WhiteBoard"; 4 | import CardSet from "./CardSet"; 5 | // import GUIPanel from './GUIPanel' 6 | import Controls from "./Controls"; 7 | import Application from "./Application"; 8 | 9 | import PubSub from "pubsub-js"; 10 | // import { TIFFLoader } from 'three/addons/loaders/TIFFLoader.js'; 11 | // import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js' 12 | 13 | export default class World { 14 | constructor(_option) { 15 | this.app = _option.app; 16 | this.time = _option.time; 17 | this.sizes = _option.sizes; 18 | this.camera = _option.camera; 19 | this.renderer = _option.renderer; 20 | 21 | this.container = new THREE.Object3D(); 22 | this.container.matrixAutoUpdate = false; 23 | 24 | this.start(); 25 | } 26 | 27 | start() { 28 | this.setControls(); 29 | this.setWhiteBoard(); 30 | this.setCard(); 31 | } 32 | 33 | setControls() { 34 | this.controls = new Controls({ 35 | time: this.time, 36 | sizes: this.sizes, 37 | camera: this.camera, 38 | }); 39 | } 40 | 41 | setWhiteBoard() { 42 | this.whiteBoard = new WhiteBoard({}); 43 | this.container.add(this.whiteBoard.container); 44 | 45 | this.time.trigger("tick"); 46 | } 47 | 48 | setCard() { 49 | this.cardSet = new CardSet({ 50 | app: this.app, 51 | time: this.time, 52 | sizes: this.sizes, 53 | camera: this.camera, 54 | renderer: this.renderer, 55 | }); 56 | 57 | // update whiteboard config info 58 | this.time.on('tick', () => { PubSub.publish("onWhiteboardUpdate", this.getConfig()) }) 59 | this.sizes.on("resize", () => { PubSub.publish("onWhiteboardUpdate", this.getConfig()) }); 60 | 61 | // iframe generate 62 | PubSub.subscribe("onUrlCardGenerated", (eventName, { id, x, y }) => { 63 | // I don't use the last two params (random numbers) 64 | const scenePos = this.getScenePosition(x, y, 100, 100) 65 | const card = this.cardSet.createIframe(id, scenePos.center, 800 / 300, 525 / 300) 66 | card.visible = false 67 | this.container.add(card) 68 | this.time.trigger("tick") 69 | }) 70 | 71 | // image card opacity 72 | PubSub.subscribe("onCardOpacityChange", (eventName, { id, opacity }) => { 73 | this.cardSet.list.forEach((card) => { 74 | if (id !== card.userData.id) return 75 | if (!card.material.uniforms.opacity) return 76 | card.material.uniforms.opacity.value = opacity 77 | this.time.trigger("tick") 78 | }) 79 | }) 80 | 81 | // image card rotation 82 | PubSub.subscribe("onCardRotationChange", (eventName, { id, rotation }) => { 83 | this.cardSet.list.forEach((card) => { 84 | if (id !== card.userData.id) return 85 | card.rotation.z = 2 * Math.PI * (rotation / 360) 86 | this.time.trigger("tick") 87 | }) 88 | }) 89 | 90 | // image card scale 91 | PubSub.subscribe("onCardScaleChange", (eventName, { id, scale }) => { 92 | this.cardSet.list.forEach((card) => { 93 | if (id !== card.userData.id) return 94 | 95 | const { wo, ho } = card.userData 96 | const { width, height } = card.geometry.parameters 97 | card.userData.w = wo * scale 98 | card.userData.h = ho * scale 99 | card.scale.x = (wo / width) * scale 100 | card.scale.y = (ho / height) * scale 101 | 102 | this.time.trigger("tick") 103 | }) 104 | }) 105 | 106 | PubSub.subscribe("onFileSelect", async (eventName, data) => { 107 | const spl = data.fileType.split('/')[0] 108 | 109 | // find out whiteboard position on screen center 110 | const raycaster = new THREE.Raycaster() 111 | raycaster.setFromCamera(new THREE.Vector3(), this.camera.instance) 112 | const intersects = raycaster.intersectObjects([this.whiteBoard.container]) 113 | if (!intersects.length) return; 114 | 115 | if (spl === 'image') { 116 | const { id, fileType, fileName, blob } = data 117 | const center = intersects[0].point 118 | const card = this.cardSet.createImage(id, fileType, fileName, blob, center) 119 | 120 | this.container.add(card) 121 | this.time.trigger("tick") 122 | return 123 | } 124 | 125 | if (spl === 'text' || spl === 'application') { 126 | const { id, fileType, fileName, text } = data 127 | const width = 9 / 3 128 | const height = 16 / 8 129 | const center = intersects[0].point 130 | const card = this.cardSet.createText(id, fileType, fileName, text, center, width, height) 131 | card.visible = false 132 | 133 | this.container.add(card) 134 | this.time.trigger("tick") 135 | 136 | return 137 | } 138 | }) 139 | 140 | // delete the card 141 | PubSub.subscribe("onUrlCardDelete", (evnetName, { id }) => { 142 | const index = this.cardSet.list.findIndex(card => card.userData.id === id) 143 | const card = this.cardSet.list[index] 144 | 145 | card.geometry.dispose() 146 | card.material.dispose() 147 | this.container.remove(card) 148 | 149 | this.cardSet.list.splice(index, 1) 150 | this.time.trigger("tick") 151 | }) 152 | 153 | // generate a card when clicking 154 | this.time.on("mouseDown", () => { 155 | let name; 156 | if (this.controls.numKeyPress[0]) name = "20230522181603"; 157 | if (this.controls.numKeyPress[1]) name = "20230509182749"; 158 | if (this.controls.numKeyPress[2]) name = "20230702185752"; 159 | if (this.controls.numKeyPress[3]) name = " "; 160 | if (!name) return; 161 | 162 | const intersects = this.controls.getRayCast([this.whiteBoard.container]); 163 | if (!intersects.length) return; 164 | 165 | const pos = intersects[0].point; 166 | const center = new THREE.Vector3(pos.x, pos.y, 0); 167 | const dom = this.setDOM(); 168 | const card = this.cardSet.create(name, dom, this.controls.mouse, center); 169 | this.container.add(card); 170 | 171 | this.time.trigger("tick"); 172 | 173 | // this api is the bridge from Whiteboard Engine to React App. 174 | const id = card.uuid; 175 | const { w, h } = card.userData; 176 | const c = card.position.clone(); 177 | const { x, y, width, height } = this.getScreenPosition(c, w, h); 178 | // this.app.API.cardGenerate({ id, name, x, y, width, height }); 179 | // this.app.API.cardInit({ id, name, x, y, width, height }); 180 | }); 181 | 182 | // mouse pointer 183 | // this.time.on('mouseMove', () => { 184 | // document.body.style.cursor = 'auto'; 185 | 186 | // const intersects = this.controls.getRayCast(this.cardSet.list); 187 | // if (!intersects.length) return; 188 | // document.body.style.cursor = 'pointer'; 189 | // }); 190 | 191 | // drag the card 192 | this.cardDonwPos = null 193 | this.mouseDownPos = null 194 | this.mouseNowPos = null 195 | 196 | this.time.on('mouseDown', () => { 197 | const intersects = this.controls.getRayCast(this.cardSet.list); 198 | if (!intersects.length) return; 199 | 200 | const card = intersects[intersects.length - 1].object; 201 | this.cardSet.targetCard = card; 202 | this.mouseDownPos = intersects[intersects.length - 1].point; 203 | this.cardDownPos = card.position.clone(); 204 | this.camera.controls.enabled = false; 205 | }); 206 | this.time.on('mouseMove', () => { 207 | if (!this.controls.mousePress) { this.cardDonwPos = null; this.mouseDownPos = null; this.mouseNowPos = null; return; } 208 | if (!this.mouseDownPos || !this.cardSet.targetCard || this.controls.spacePress) { return; } 209 | 210 | const intersects = this.controls.getRayCast([this.whiteBoard.container]); 211 | if (!intersects.length) return; 212 | 213 | const { dom } = this.cardSet.targetCard.userData; 214 | 215 | this.mouseNowPos = intersects[0].point; 216 | const pos = this.cardDownPos.clone().add(this.mouseNowPos).sub(this.mouseDownPos); 217 | this.cardSet.targetCard.position.copy(pos); 218 | this.cardSet.targetCard.userData.center = pos; 219 | 220 | const [pbl, ptr] = this.cardSet.updateCanvas(this.cardSet.targetCard); 221 | const { width, height } = this.sizes.viewport; 222 | 223 | if (dom) { 224 | dom.style.left = `${(pbl.x + 1) * width * 0.5}px`; 225 | dom.style.bottom = `${(pbl.y + 1) * height * 0.5}px`; 226 | dom.style.width = `${(ptr.x - pbl.x) * width * 0.5}px`; 227 | dom.style.height = `${(ptr.y - pbl.y) * height * 0.5}px`; 228 | dom.style.display = "none"; 229 | } 230 | 231 | const { w, h } = this.cardSet.targetCard.userData; 232 | const center = this.cardSet.targetCard.position.clone(); 233 | const info = this.getScreenPosition(center, w, h, ''); 234 | info.id = this.cardSet.targetCard.uuid; 235 | // this.app.API.cardMove(info); 236 | 237 | this.time.trigger("tick"); 238 | }); 239 | this.time.on('mouseUp', () => { 240 | this.camera.controls.enabled = true; 241 | if (!this.cardSet.targetCard) return; 242 | 243 | const { dom } = this.cardSet.targetCard.userData; 244 | if (!dom) return; 245 | 246 | dom.style.display = "none"; 247 | this.cardSet.targetCard = null; 248 | }); 249 | 250 | // make the whiteboard controllable (all scene in cards remains unchanged) 251 | this.time.on("spaceUp", () => { 252 | if (!this.cardSet.targetCard) return 253 | // this.app.API.cardLeave(!this.cardSet.targetCard.uuid) 254 | 255 | document.body.style.cursor = "auto"; 256 | this.camera.controls.enabled = true; 257 | this.cardSet.targetCard = null; 258 | 259 | this.cardSet.list.forEach((card) => { 260 | const { dom } = card.userData; 261 | if (!dom) return 262 | dom.style.display = "none"; 263 | }); 264 | }); 265 | 266 | // fix the whiteboard (scene in selected card is controllable) 267 | this.time.on("spaceDown", () => { 268 | this.camera.controls.enabled = false; 269 | const intersects = this.controls.getRayCast(this.cardSet.list); 270 | 271 | // if (!intersects.length && this.cardSet.targetCard) { this.app.API.cardLeave(this.cardSet.targetCard.uuid); } 272 | // if (intersects.length && this.cardSet.targetCard && this.cardSet.targetCard.uuid !== intersects[0].object.uuid) { this.app.API.cardLeave(this.cardSet.targetCard.uuid); } 273 | if (!intersects.length) { this.cardSet.targetCard = null; return; } 274 | 275 | const card = intersects[0].object; 276 | const { dom, viewer, w, h } = card.userData; 277 | 278 | if (!this.cardSet.targetCard || (this.cardSet.targetCard && this.cardSet.targetCard.uuid !== card.uuid)) { 279 | const u = card.userData; 280 | const center = card.position.clone(); 281 | const info = this.getScreenPosition(center, u.w, u.h); 282 | info.id = card.uuid; 283 | // this.app.API.cardSelect(info); 284 | } 285 | this.cardSet.targetCard = card; 286 | 287 | this.cardSet.list.forEach((c) => { 288 | const v = c.userData.viewer; 289 | if (v) v.controls.enabled = false; 290 | }); 291 | if (viewer) viewer.controls.enabled = true; 292 | 293 | const [pbl, ptr] = this.cardSet.updateCanvas(card); 294 | const { width, height } = this.sizes.viewport; 295 | 296 | if (!dom) return 297 | dom.style.left = `${(pbl.x + 1) * width * 0.5}px`; 298 | dom.style.bottom = `${(pbl.y + 1) * height * 0.5}px`; 299 | dom.style.width = `${(ptr.x - pbl.x) * width * 0.5}px`; 300 | dom.style.height = `${(ptr.y - pbl.y) * height * 0.5}px`; 301 | dom.style.display = "inline"; 302 | }); 303 | } 304 | 305 | getConfig() { 306 | const cameraInfo = {} 307 | cameraInfo.x = parseFloat(this.camera.instance.position.x.toFixed(5)) 308 | cameraInfo.y = parseFloat(this.camera.instance.position.y.toFixed(5)) 309 | cameraInfo.z = parseFloat(this.camera.instance.position.z.toFixed(5)) 310 | cameraInfo.zoom = parseFloat(this.camera.instance.zoom.toFixed(3)) 311 | 312 | const cardSetInfo = [] 313 | this.cardSet.list.forEach((card) => { 314 | const cardInfo = {} 315 | 316 | const position = {} 317 | position.x = parseFloat(card.userData.center.x.toFixed(5)) 318 | position.y = parseFloat(card.userData.center.y.toFixed(5)) 319 | position.z = parseFloat(card.userData.center.z.toFixed(5)) 320 | 321 | const { w, h } = card.userData; 322 | const center = card.position.clone() 323 | const info = this.getScreenPosition(center, w, h) 324 | 325 | const positionScreen = {} 326 | positionScreen.x = parseInt(info.x) 327 | positionScreen.y = parseInt(info.y) 328 | 329 | cardInfo.id = card.userData.id 330 | cardInfo.name = card.userData.name 331 | cardInfo.type = card.userData.type 332 | cardInfo.content = card.userData.content 333 | cardInfo.position = position 334 | cardInfo.positionScreen = positionScreen 335 | cardInfo.width = parseFloat(card.userData.w.toFixed(5)) 336 | cardInfo.height = parseFloat(card.userData.h.toFixed(5)) 337 | cardInfo.widthScreen = parseInt(info.width) 338 | cardInfo.heightScreen = parseInt(info.height) 339 | cardInfo.rotation = parseFloat((360 * card.rotation.z / (2 * Math.PI)).toFixed(1)) 340 | 341 | if (card.material && card.material.uniforms && card.material.uniforms.opacity) { 342 | cardInfo.opacity = parseFloat(card.material.uniforms.opacity.value.toFixed(2)) 343 | } 344 | 345 | cardSetInfo.push(cardInfo) 346 | }) 347 | 348 | const config = {} 349 | config.camera = cameraInfo 350 | config.cards = cardSetInfo 351 | 352 | return config 353 | } 354 | 355 | getScreenPosition(center, w, h) { 356 | const corner = new THREE.Vector3(center.x + w / 2, center.y + h / 2, center.z); 357 | center.project(this.camera.instance); 358 | corner.project(this.camera.instance); 359 | 360 | const x = this.sizes.width * (1 + center.x) / 2; 361 | const y = this.sizes.height * (1 - center.y) / 2; 362 | const wScreen = this.sizes.width * Math.abs(corner.x - center.x); 363 | const hScreen = this.sizes.height * Math.abs(corner.y - center.y); 364 | 365 | return { x, y, width: wScreen, height: hScreen } 366 | } 367 | 368 | getScenePosition(x, y, w, h) { 369 | const mc = new THREE.Vector2() 370 | mc.x = x / this.sizes.width * 2 - 1 371 | mc.y = -(y / this.sizes.height) * 2 + 1 372 | 373 | const me = new THREE.Vector2() 374 | me.x = (x + w / 2) / this.sizes.width * 2 - 1 375 | me.y = -((y + h / 2) / this.sizes.height) * 2 + 1 376 | 377 | const raycaster = new THREE.Raycaster() 378 | raycaster.setFromCamera(mc, this.camera.instance) 379 | const intersectsC = raycaster.intersectObjects([this.whiteBoard.container]) 380 | raycaster.setFromCamera(me, this.camera.instance) 381 | const intersectsE = raycaster.intersectObjects([this.whiteBoard.container]) 382 | if (!intersectsC.length || !intersectsE.length) return 383 | 384 | const center = intersectsC[0].point 385 | const corner = intersectsE[0].point 386 | const wScene = 2 * Math.abs(corner.x - center.x) 387 | const hScene = 2 * Math.abs(corner.y - center.y) 388 | 389 | return { center, width: wScene, height: hScene } 390 | } 391 | 392 | setDOM() { 393 | const cardDOM = document.createElement("div"); 394 | 395 | cardDOM.className = "cardDOM"; 396 | cardDOM.style.backgroundColor = "rgba(0, 0, 0, 0.0)"; 397 | // cardDOM.style.border = "1px solid white"; 398 | cardDOM.style.display = "none"; 399 | cardDOM.style.position = "absolute"; 400 | document.body.appendChild(cardDOM); 401 | 402 | return cardDOM; 403 | } 404 | } 405 | --------------------------------------------------------------------------------