├── src ├── index.ts └── components │ ├── Video │ ├── index.ts │ ├── Video.stories.tsx │ └── Video.tsx │ ├── Canvas │ ├── index.ts │ ├── Canvas.stories.tsx │ └── Canvas.tsx │ ├── UserMedia │ ├── index.ts │ ├── Utility.ts │ ├── useUserMedia.stories.tsx │ └── useUserMedia.ts │ ├── WindowSize │ ├── index.ts │ ├── useWindowSize.stories.tsx │ └── useWindowSize.ts │ ├── RequestAnimationFrame │ ├── index.ts │ └── useRequestAnimationFrame.ts │ ├── mapboxgl │ ├── Utility │ │ ├── index.ts │ │ ├── Point.ts │ │ └── LngLat.ts │ ├── index.ts │ ├── MapContext.ts │ ├── Map.stories.tsx │ ├── LocationInformationDialog.tsx │ ├── Marker.tsx │ ├── Map.tsx │ └── Line.tsx │ ├── WebSocket │ ├── ConnectionClosedError.ts │ ├── index.ts │ ├── WebSocketError.ts │ ├── JsonMessageParsingError.ts │ ├── useJsonMessageClientConnection.ts │ ├── useClientConnection.stories.tsx │ └── useClientConnection.ts │ ├── LocalStorageItem │ ├── index.ts │ ├── useLocalStorageItem.stories.tsx │ └── useLocalStorageItem.ts │ ├── three.js │ ├── index.ts │ ├── Types.ts │ ├── useWebGLRenderer.ts │ ├── Debug.ts │ ├── Utility.ts │ ├── THREEView.stories.tsx │ └── THREEView.tsx │ └── index.ts ├── .prettierrc.json ├── .storybook ├── preview.ts └── main.ts ├── .gitignore ├── .eslintrc.js ├── tsconfig.json ├── .github └── workflows │ ├── dependency-review.yml │ └── codeql.yml ├── LICENSE ├── rollup.config.mjs └── package.json /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './components/' 2 | -------------------------------------------------------------------------------- /src/components/Video/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Video' 2 | -------------------------------------------------------------------------------- /src/components/Canvas/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Canvas' 2 | -------------------------------------------------------------------------------- /src/components/UserMedia/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useUserMedia' 2 | -------------------------------------------------------------------------------- /src/components/WindowSize/index.ts: -------------------------------------------------------------------------------- 1 | export { useWindowSize } from './useWindowSize' 2 | -------------------------------------------------------------------------------- /src/components/RequestAnimationFrame/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useRequestAnimationFrame' 2 | -------------------------------------------------------------------------------- /src/components/mapboxgl/Utility/index.ts: -------------------------------------------------------------------------------- 1 | export * from './LngLat' 2 | export * from './Point' 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "none" 6 | } 7 | -------------------------------------------------------------------------------- /src/components/UserMedia/Utility.ts: -------------------------------------------------------------------------------- 1 | export const stopMediaStream = (stream: MediaStream) => { 2 | stream.getTracks().forEach((track) => { 3 | track.stop() 4 | }) 5 | } 6 | -------------------------------------------------------------------------------- /src/components/WebSocket/ConnectionClosedError.ts: -------------------------------------------------------------------------------- 1 | export class ConnectionClosedError extends Error { 2 | constructor() { 3 | super('Connection closed error') 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/components/LocalStorageItem/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | useLocalStorageItem, 3 | } from './useLocalStorageItem' 4 | 5 | export type { 6 | Props as useLocalStorageItemProps 7 | } from './useLocalStorageItem' -------------------------------------------------------------------------------- /src/components/mapboxgl/index.ts: -------------------------------------------------------------------------------- 1 | export { Map } from './Map' 2 | export { Marker } from './Marker' 3 | export { Line } from './Line' 4 | export { MapContext } from './MapContext' 5 | export * from './Utility' 6 | -------------------------------------------------------------------------------- /src/components/three.js/index.ts: -------------------------------------------------------------------------------- 1 | export { THREEView } from './THREEView' 2 | export { useWebGLRenderer } from './useWebGLRenderer' 3 | export * from './Types' 4 | export * from './Utility' 5 | export * from './Debug' 6 | -------------------------------------------------------------------------------- /src/components/WebSocket/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useClientConnection' 2 | export * from './useJsonMessageClientConnection' 3 | 4 | export * from './ConnectionClosedError' 5 | export * from './JsonMessageParsingError' 6 | export * from './WebSocketError' 7 | -------------------------------------------------------------------------------- /src/components/mapboxgl/Utility/Point.ts: -------------------------------------------------------------------------------- 1 | import { Point } from 'mapbox-gl' 2 | 3 | export const calculateDistanceSquared = (left: Point, right: Point): number => { 4 | return ( 5 | (left.x - right.x) * (left.x - right.x) + 6 | (left.y - right.y) * (left.y - right.y) 7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /src/components/mapboxgl/MapContext.ts: -------------------------------------------------------------------------------- 1 | import { Map } from 'mapbox-gl' 2 | import { createContext } from 'react' 3 | 4 | interface Context { 5 | map: Map | null 6 | mapStyleIsDoneLoading: boolean 7 | } 8 | 9 | export const MapContext = createContext({ 10 | map: null, 11 | mapStyleIsDoneLoading: false 12 | }) 13 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from '@storybook/react' 2 | 3 | const preview: Preview = { 4 | parameters: { 5 | actions: { argTypesRegex: '^on[A-Z].*' }, 6 | controls: { 7 | matchers: { 8 | color: /(background|color)$/i, 9 | date: /Date$/ 10 | } 11 | } 12 | } 13 | } 14 | 15 | export default preview 16 | -------------------------------------------------------------------------------- /src/components/WebSocket/WebSocketError.ts: -------------------------------------------------------------------------------- 1 | export class WebSocketError extends Error { 2 | constructor( 3 | private _connection: WebSocket | undefined, 4 | private _errorEvent: Event 5 | ) { 6 | super('WebSocket error') 7 | } 8 | 9 | get connection(): WebSocket | undefined { 10 | return this._connection 11 | } 12 | 13 | get errorEvent(): Event { 14 | return this._errorEvent 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { Canvas } from './Canvas' 2 | export type { Methods as CanvasMethods } from './Canvas' 3 | export * from './LocalStorageItem' 4 | export * from './RequestAnimationFrame' 5 | export * from './UserMedia' 6 | export { Video } from './Video' 7 | export type { Methods as VideoMethods } from './Video' 8 | export * as WebSocket from './WebSocket' 9 | export * from './WindowSize' 10 | 11 | export * from './three.js' 12 | export * from './mapboxgl' 13 | -------------------------------------------------------------------------------- /src/components/WindowSize/useWindowSize.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Meta } from '@storybook/react' 3 | import { useWindowSize } from './useWindowSize' 4 | 5 | export default { 6 | title: 'useWindowSize' 7 | } as Meta 8 | 9 | export const Default = () => { 10 | const { width, height } = useWindowSize() 11 | return ( 12 | <> 13 |
Window Width: {width}px
14 |
Window Height: {height}px
15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/react-vite' 2 | const config: StorybookConfig = { 3 | stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 4 | addons: [ 5 | '@storybook/addon-links', 6 | '@storybook/addon-essentials', 7 | '@storybook/addon-interactions' 8 | ], 9 | framework: { 10 | name: '@storybook/react-vite', 11 | options: {} 12 | }, 13 | docs: { 14 | autodocs: 'tag' 15 | } 16 | } 17 | export default config 18 | -------------------------------------------------------------------------------- /.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 | # production 12 | /build 13 | /lib 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | /.vscode 26 | /storybook-static 27 | 28 | # environment variables 29 | /.env 30 | -------------------------------------------------------------------------------- /src/components/WebSocket/JsonMessageParsingError.ts: -------------------------------------------------------------------------------- 1 | export class JsonMessageParsingError extends Error { 2 | constructor( 3 | private _connection: WebSocket | undefined, 4 | private _error: unknown, 5 | private _text: string 6 | ) { 7 | super('Json message parsing error') 8 | } 9 | 10 | get connection(): WebSocket | undefined { 11 | return this._connection 12 | } 13 | 14 | get error(): unknown { 15 | return this._error 16 | } 17 | 18 | get text(): string { 19 | return this._text 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module' 6 | }, 7 | plugins: ['react', '@typescript-eslint/eslint-plugin'], 8 | extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', 'plugin:storybook/recommended'], 9 | root: true, 10 | env: { 11 | browser: true 12 | }, 13 | ignorePatterns: ['.eslintrc.js'], 14 | rules: { 15 | '@typescript-eslint/interface-name-prefix': 'off', 16 | '@typescript-eslint/explicit-function-return-type': 'off', 17 | '@typescript-eslint/explicit-module-boundary-types': 'off', 18 | '@typescript-eslint/no-explicit-any': 'off' 19 | } 20 | }; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "outDir": "lib", 5 | "lib": [ 6 | "dom", 7 | "dom.iterable", 8 | "esnext" 9 | ], 10 | "declaration": true, 11 | "declarationDir": "lib", 12 | "allowJs": true, 13 | "skipLibCheck": true, 14 | "esModuleInterop": true, 15 | "allowSyntheticDefaultImports": true, 16 | "strict": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "module": "esnext", 19 | "moduleResolution": "node", 20 | "resolveJsonModule": true, 21 | "isolatedModules": true, 22 | "noEmit": true, 23 | "jsx": "react" 24 | }, 25 | "include": [ 26 | "src" 27 | ], 28 | "exclude": [ 29 | "node_modules", 30 | "lib" 31 | ] 32 | } -------------------------------------------------------------------------------- /src/components/WindowSize/useWindowSize.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | export interface WindowSize { 4 | width: number 5 | height: number 6 | } 7 | 8 | export const useWindowSize = () => { 9 | const [windowSize, setWindowSize] = useState({ 10 | width: window.innerWidth, 11 | height: window.innerHeight 12 | }) 13 | 14 | useEffect(() => { 15 | const handleWindowResize = () => { 16 | setWindowSize({ 17 | width: window.innerWidth, 18 | height: window.innerHeight 19 | }) 20 | } 21 | 22 | window.addEventListener('resize', handleWindowResize) //, false) 23 | 24 | return () => { 25 | window.removeEventListener('resize', handleWindowResize) 26 | } 27 | }, []) 28 | 29 | return windowSize 30 | } 31 | 32 | export default useWindowSize 33 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. 4 | # 5 | # Source repository: https://github.com/actions/dependency-review-action 6 | # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement 7 | name: 'Dependency Review' 8 | on: [pull_request] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | dependency-review: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: 'Checkout Repository' 18 | uses: actions/checkout@v3 19 | - name: 'Dependency Review' 20 | uses: actions/dependency-review-action@v2 21 | -------------------------------------------------------------------------------- /src/components/three.js/Types.ts: -------------------------------------------------------------------------------- 1 | export type FixedSize = { 2 | kind: 'FixedSize' 3 | 4 | width: number 5 | height: number 6 | } 7 | 8 | /** 9 | * Center our render viewport at (0, 0) and fix it's width and height to a specific render space size accounting for the DOM container's ratio of width and height 10 | */ 11 | export type ViewportSize = { 12 | kind: 'ViewportSize' 13 | 14 | /** 15 | * value to fix out viewport's width and height to 16 | */ 17 | viewportSize: number 18 | } 19 | 20 | /** 21 | * Relate our render viewport size to match a number of pixels per window (client/screen) pixel 22 | */ 23 | export type FitWindowSize = { 24 | kind: 'FitWindowSize' 25 | 26 | /** 27 | * ratio of the number of render view port pixels per window pixels 28 | */ 29 | pixelsPerWindowPixel: number 30 | 31 | /** 32 | * whether to resize the renderer when the window resizes to maintain the `pixelsPerWindowPixel` ratio 33 | */ 34 | resizeRendererToFit: boolean 35 | } 36 | 37 | export type SizeProperties = FixedSize | FitWindowSize | ViewportSize 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Adorkable 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/UserMedia/useUserMedia.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { useUserMedia } from './useUserMedia' 4 | 5 | const displayConfig = { 6 | title: 'useUserMedia' 7 | } 8 | export default displayConfig 9 | 10 | interface Props { 11 | audio: boolean 12 | facingMode: string 13 | width: number 14 | height: number 15 | idealFrameRate: number 16 | } 17 | 18 | export const Default = ({ 19 | audio, 20 | facingMode, 21 | width, 22 | height, 23 | idealFrameRate 24 | }: Props) => { 25 | const { stream } = useUserMedia({ 26 | constraints: { 27 | audio, 28 | video: { 29 | facingMode, 30 | width, 31 | height, 32 | frameRate: { 33 | ideal: idealFrameRate 34 | } 35 | } 36 | } 37 | }) 38 | 39 | return ( 40 |
41 | Id: {stream?.id} 42 |
43 | Active: {stream?.active} 44 |
45 | Video Track Count: {stream?.getVideoTracks()?.length} 46 |
47 | ) 48 | } 49 | Default.args = { 50 | audio: false, 51 | facingMode: 'user', 52 | width: 360, 53 | height: 270, 54 | idealFrameRate: 60 55 | } 56 | -------------------------------------------------------------------------------- /src/components/mapboxgl/Utility/LngLat.ts: -------------------------------------------------------------------------------- 1 | import { LngLat } from 'mapbox-gl' 2 | import { clamp } from '@adorkable/eunomia-typescript' 3 | import { Coordinates } from '../Marker' 4 | 5 | export interface MinMax { 6 | minimum: number 7 | maximum: number 8 | } 9 | 10 | export const clampLngLat = ( 11 | lngLat: LngLat, 12 | longitudeMinMax?: MinMax | undefined, 13 | latitudeMinMax?: MinMax | undefined 14 | ): LngLat => { 15 | let clampedLongitude: number 16 | if (longitudeMinMax) { 17 | clampedLongitude = clamp( 18 | lngLat.lng, 19 | longitudeMinMax.minimum, 20 | longitudeMinMax.maximum 21 | ) 22 | } else { 23 | clampedLongitude = lngLat.lng 24 | } 25 | 26 | let clampedLatitude: number 27 | if (latitudeMinMax) { 28 | clampedLatitude = clamp( 29 | lngLat.lat, 30 | latitudeMinMax.minimum, 31 | latitudeMinMax.maximum 32 | ) 33 | } else { 34 | clampedLatitude = lngLat.lat 35 | } 36 | 37 | return new LngLat(clampedLongitude, clampedLatitude) 38 | } 39 | 40 | export const coordinatesToLngLat = (coordinates: Coordinates): LngLat => { 41 | return new LngLat(coordinates.longitude, coordinates.latitude) 42 | } 43 | -------------------------------------------------------------------------------- /src/components/RequestAnimationFrame/useRequestAnimationFrame.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from 'react' 2 | 3 | const InvalidRequestId = -1 4 | 5 | // TODO: properly scope 6 | let requestId: number = InvalidRequestId 7 | export const useRequestAnimationFrame = (perform: (time: number) => void) => { 8 | // const [requestId, setRequestId] = useState(InvalidRequestId) 9 | 10 | const animateFrame = useCallback( 11 | (time: number) => { 12 | if (requestId === InvalidRequestId) { 13 | return 14 | } 15 | 16 | perform(time) 17 | 18 | if (requestAnimationFrame) { 19 | requestId = requestAnimationFrame(animateFrame) 20 | } else { 21 | throw new Error('requestAnimationFrame is not supported') 22 | } 23 | }, 24 | [perform] 25 | ) 26 | 27 | useEffect(() => { 28 | if (requestAnimationFrame) { 29 | requestId = requestAnimationFrame(animateFrame) 30 | } else { 31 | throw new Error('requestAnimationFrame is not supported') 32 | } 33 | 34 | return () => { 35 | if (requestId !== InvalidRequestId) { 36 | cancelAnimationFrame(requestId) 37 | requestId = InvalidRequestId 38 | } 39 | } 40 | }, [animateFrame]) 41 | 42 | return {} 43 | } 44 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import peerDepsExternal from "rollup-plugin-peer-deps-external"; 2 | import resolve from "@rollup/plugin-node-resolve"; 3 | import commonjs from "@rollup/plugin-commonjs"; 4 | import typescript from "@rollup/plugin-typescript"; 5 | import postcss from "rollup-plugin-postcss"; 6 | import dts from "rollup-plugin-dts"; 7 | 8 | // This is required to read package.json file when 9 | // using Native ES modules in Node.js 10 | // https://rollupjs.org/command-line-interface/#importing-package-json 11 | import { createRequire } from 'node:module'; 12 | const requireFile = createRequire(import.meta.url); 13 | const packageJson = requireFile('./package.json'); 14 | 15 | 16 | export default [{ 17 | input: "src/index.ts", 18 | output: [ 19 | { 20 | file: packageJson.main, 21 | format: "cjs", 22 | sourcemap: true 23 | }, 24 | { 25 | file: packageJson.module, 26 | format: "esm", 27 | sourcemap: true 28 | } 29 | ], 30 | plugins: [ 31 | peerDepsExternal(), 32 | resolve(), 33 | commonjs(), 34 | typescript(), 35 | postcss({ 36 | extensions: ['.css'] 37 | }) 38 | ] 39 | }, { 40 | input: 'lib/index.d.ts', 41 | output: [{ file: 'lib/index.d.ts', format: 'es' }], 42 | plugins: [dts()], 43 | external: [/\.css$/] 44 | }] -------------------------------------------------------------------------------- /src/components/LocalStorageItem/useLocalStorageItem.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { Meta } from '@storybook/react' 4 | 5 | import { useLocalStorageItem } from './useLocalStorageItem' 6 | 7 | export default { 8 | title: 'useLocalStorageItem' 9 | } as Meta 10 | 11 | interface Props { 12 | key: string 13 | defaultValue?: string 14 | localChangeCheckSeconds?: number 15 | } 16 | 17 | export const Default = ({ 18 | key, 19 | defaultValue = '', 20 | localChangeCheckSeconds = 1 21 | }: Props) => { 22 | const currentValue = useLocalStorageItem({ 23 | key, 24 | defaultValue, 25 | localChangeCheckSeconds 26 | }) 27 | return ( 28 | <> 29 |
{currentValue}
30 | 40 | 47 | 54 | 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /src/components/UserMedia/useUserMedia.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react' 2 | import { stopMediaStream } from './Utility' 3 | 4 | export interface Props { 5 | constraints?: MediaStreamConstraints | undefined 6 | } 7 | 8 | export const useUserMedia = ({ constraints }: Props) => { 9 | const stream = useRef() 10 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 11 | const [isStreamSet, setIsStreamSet] = useState(false) // Hack to force rerender with new reference returned 12 | const [error, setError] = useState() 13 | 14 | useEffect(() => { 15 | let cancelEnable = false 16 | 17 | navigator.mediaDevices 18 | .getUserMedia(constraints) 19 | .then((result) => { 20 | if (!cancelEnable) { 21 | stream.current = result 22 | setIsStreamSet(true) 23 | } 24 | }) 25 | .catch((reason) => { 26 | if (!cancelEnable) { 27 | setError( 28 | new Error(`Error enabling video stream: ${JSON.stringify(reason)}}`) 29 | ) 30 | } 31 | }) 32 | 33 | return () => { 34 | cancelEnable = true 35 | } 36 | }, [constraints, setIsStreamSet, setError]) 37 | 38 | useEffect(() => { 39 | return () => { 40 | if (stream.current) { 41 | stopMediaStream(stream.current) 42 | } 43 | } 44 | }, [stream]) 45 | 46 | return { stream: stream.current, error } 47 | } 48 | -------------------------------------------------------------------------------- /src/components/mapboxgl/Map.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { Meta } from '@storybook/react' 4 | 5 | import { Map } from './Map' 6 | import 'mapbox-gl/dist/mapbox-gl.css' 7 | import { MinMax } from './Utility/LngLat' 8 | 9 | export default { 10 | title: 'mapboxgl/Map' 11 | } as Meta 12 | 13 | interface Props { 14 | accessToken: string 15 | 16 | latitude: number 17 | longitude: number 18 | latitudeMinMax?: MinMax 19 | longitudeMinMax?: MinMax 20 | 21 | zoom: number 22 | zoomMinMax?: MinMax 23 | 24 | style?: string | undefined 25 | showLocation?: boolean 26 | } 27 | 28 | export const Default = ({ 29 | accessToken, 30 | latitude, 31 | longitude, 32 | latitudeMinMax, 33 | longitudeMinMax, 34 | zoom, 35 | zoomMinMax, 36 | style, 37 | showLocation 38 | }: Props) => { 39 | return ( 40 | 51 | ) 52 | } 53 | 54 | Default.args = { 55 | accessToken: process.env.MAPBOXGL_ACCESS_TOKEN, 56 | latitude: 40.71, 57 | longitude: -73.93, 58 | latitudeMinMax: undefined, 59 | longitudeMinMax: undefined, 60 | zoom: 5, 61 | zoomMinMax: undefined, 62 | style: undefined, 63 | showLocation: false 64 | } 65 | -------------------------------------------------------------------------------- /src/components/three.js/useWebGLRenderer.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { Vector2, WebGLRenderer, WebGLRendererParameters } from 'three' 3 | import { createWebGLRenderer } from './Utility' 4 | 5 | export const useWebGLRenderer = ( 6 | container: HTMLElement | null, 7 | renderSize: Vector2, 8 | renderPixelRatio: number, 9 | options?: WebGLRendererParameters 10 | ): WebGLRenderer | void => { 11 | const [renderer, setRenderer] = useState(undefined) 12 | const [rendererDomElement, setRendererDomElement] = 13 | useState(undefined) 14 | 15 | const attachRenderer = (container: HTMLElement) => { 16 | removeRenderer() 17 | 18 | const newRenderer = createWebGLRenderer( 19 | renderSize.x, 20 | renderSize.y, 21 | renderPixelRatio, 22 | options 23 | ) 24 | 25 | setRendererDomElement(newRenderer.domElement) 26 | container.appendChild(newRenderer.domElement) 27 | setRenderer(newRenderer) 28 | } 29 | 30 | const removeRenderer = () => { 31 | if (rendererDomElement) { 32 | rendererDomElement.remove() 33 | setRendererDomElement(undefined) 34 | } 35 | 36 | if (renderer) { 37 | renderer.dispose() 38 | setRenderer(undefined) 39 | } 40 | } 41 | 42 | useEffect(() => { 43 | if (container) { 44 | attachRenderer(container) 45 | } else { 46 | removeRenderer() 47 | } 48 | }, [container]) 49 | 50 | useEffect(() => { 51 | return () => { 52 | removeRenderer() 53 | } 54 | }, []) 55 | 56 | return renderer 57 | } 58 | -------------------------------------------------------------------------------- /src/components/mapboxgl/LocationInformationDialog.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | interface Props { 4 | longitude: number 5 | latitude: number 6 | zoom: number 7 | 8 | showMouse: boolean 9 | mouseLongitude: number 10 | mouseLatitude: number 11 | 12 | showForCode: boolean 13 | } 14 | 15 | export const LocationInformationDialog = ({ 16 | longitude, 17 | latitude, 18 | zoom, 19 | showMouse, 20 | mouseLongitude, 21 | mouseLatitude, 22 | showForCode 23 | }: Props): React.ReactElement => { 24 | return ( 25 |
40 |
41 |
42 | Longitude: {longitude} | Latitude: {latitude} | Zoom: {zoom} 43 |
44 |
45 | {showForCode 46 | ? `- {"longitude": ${longitude}, "latitude": ${latitude}}` 47 | : null} 48 |
49 |
50 | {showMouse ? ( 51 |
52 |
53 | Mouse ~ Longitude: {mouseLongitude} | Latitude: {mouseLatitude} 54 |
55 |
56 | {showForCode 57 | ? `- {"longitude": ${mouseLongitude}, "latitude": ${mouseLatitude}}` 58 | : null} 59 |
60 |
61 | ) : null} 62 |
63 | ) 64 | } 65 | 66 | export default LocationInformationDialog 67 | -------------------------------------------------------------------------------- /src/components/Canvas/Canvas.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { useCallback, useRef } from 'react' 3 | 4 | import { Canvas, Methods as CanvasMethods } from './Canvas' 5 | 6 | const displayConfig = { 7 | title: 'Canvas' 8 | } 9 | export default displayConfig 10 | 11 | interface Props { 12 | width: number 13 | height: number 14 | 15 | flip: boolean 16 | } 17 | 18 | export const Default = ({ width, height, flip }: Props) => { 19 | const canvas = useRef() 20 | 21 | const draw = useCallback(() => { 22 | if (!canvas || !canvas.current) { 23 | return 24 | } 25 | const context = canvas.current.getContext() 26 | if (!context) { 27 | return 28 | } 29 | let gradient = context.createLinearGradient( 30 | 0, 31 | 0, 32 | canvas.current.width(), 33 | canvas.current.height() 34 | ) 35 | gradient.addColorStop(0, 'green') 36 | gradient.addColorStop(0.5, 'cyan') 37 | gradient.addColorStop(1, 'red') 38 | 39 | context.fillStyle = gradient 40 | context.fillRect(0, 0, canvas.current.width(), canvas.current.height()) 41 | }, []) 42 | 43 | // const drawIntoVideo = useCallback(() => { 44 | // }, []) 45 | 46 | return ( 47 |
48 | (canvas.current = ref)} 50 | width={width} 51 | height={height} 52 | flip={flip} 53 | /> 54 |
55 | Width: {canvas.current?.width()} Height: {canvas.current?.height()} 56 |
57 | 58 | 59 |
60 | ) 61 | } 62 | Default.args = { 63 | width: 640, 64 | height: 480, 65 | 66 | flip: false 67 | } 68 | -------------------------------------------------------------------------------- /src/components/LocalStorageItem/useLocalStorageItem.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | export interface Props { 4 | key: string 5 | 6 | defaultValue?: string | null 7 | 8 | localChangeCheckSeconds?: number | undefined 9 | onChange?: ((newValue: string | null) => void) | undefined 10 | } 11 | 12 | export const useLocalStorageItem = (props: Props) => { 13 | const { key, defaultValue, localChangeCheckSeconds, onChange } = props 14 | 15 | const [currentValue, setCurrentValue] = useState( 16 | localStorage.getItem(key) 17 | ) 18 | 19 | useEffect(() => { 20 | if (!localStorage.getItem(key)) { 21 | localStorage.setItem(key, defaultValue || '') 22 | } 23 | }, [key]) 24 | 25 | useEffect(() => { 26 | if (!localStorage.getItem(key)) { 27 | localStorage.setItem(key, defaultValue || '') 28 | } 29 | }, [defaultValue]) 30 | 31 | useEffect(() => { 32 | if (onChange) { 33 | onChange(currentValue) 34 | } 35 | }, [currentValue]) 36 | 37 | useEffect(() => { 38 | const checkChange = () => { 39 | const checkValue = localStorage.getItem(key) 40 | if (checkValue !== currentValue) { 41 | setCurrentValue(checkValue) 42 | } 43 | } 44 | const callback = (event: any /*Event*/) => { 45 | if (event.storageArea !== localStorage) { 46 | return 47 | } 48 | checkChange() 49 | } 50 | 51 | window.addEventListener('storage', callback) // only fires if other docs change the storage area 52 | 53 | if (!localStorage.getItem(key)) { 54 | localStorage.setItem(key, defaultValue || '') 55 | } 56 | 57 | let intervalSubscription: any = undefined 58 | if (localChangeCheckSeconds) { 59 | intervalSubscription = setInterval( 60 | checkChange, 61 | localChangeCheckSeconds * 1000 62 | ) 63 | } 64 | 65 | return () => { 66 | window.removeEventListener('storage', callback) 67 | if (intervalSubscription) { 68 | clearInterval(intervalSubscription) 69 | } 70 | } 71 | }, []) 72 | 73 | return currentValue 74 | } 75 | 76 | export default useLocalStorageItem 77 | -------------------------------------------------------------------------------- /src/components/WebSocket/useJsonMessageClientConnection.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | import { JsonMessageParsingError } from './JsonMessageParsingError' 3 | import { useClientConnection, DataType } from './useClientConnection' 4 | 5 | interface Props { 6 | url: string 7 | 8 | onOpen?: (connection: WebSocket | undefined, event: Event) => void 9 | onClose?: (connection: WebSocket | undefined, event: CloseEvent) => void 10 | 11 | binaryType?: BinaryType 12 | 13 | onMessage?: (connection: WebSocket | undefined, data: T) => void 14 | 15 | onError?: (connection: WebSocket | undefined, error: Error) => void 16 | 17 | unmountCloseMessage?: string 18 | } 19 | 20 | interface Result { 21 | send: (data: T) => void 22 | readyState: () => number 23 | close: (code?: number, reason?: string) => void 24 | 25 | internal: { 26 | send: (data: DataType) => void 27 | } 28 | } 29 | 30 | export const useJsonMessageClientConnection = ({ 31 | url, 32 | onOpen, 33 | onClose, 34 | 35 | onMessage, 36 | 37 | onError, 38 | 39 | unmountCloseMessage = 'Browsing away' 40 | }: Props): Result => { 41 | // TODO: support binary json sending 42 | 43 | const onMessageDataText = useCallback( 44 | (connection: WebSocket | undefined, text: string) => { 45 | if (!onMessage) { 46 | return 47 | } 48 | try { 49 | const message = JSON.parse(text) 50 | onMessage(connection, message) // TODO: separate and handle with a different try/catch and error report 51 | } catch (error) { 52 | if (onError) { 53 | onError( 54 | connection, 55 | new JsonMessageParsingError(connection, error, text) 56 | ) 57 | } 58 | } 59 | }, 60 | [onMessage, onError] 61 | ) 62 | 63 | const { 64 | send: connectionSend, 65 | close, 66 | readyState 67 | } = useClientConnection({ 68 | url, 69 | onOpen, 70 | onClose, 71 | onMessageDataText, 72 | onError, 73 | unmountCloseMessage 74 | }) 75 | 76 | const send = useCallback((data: T) => { 77 | connectionSend(JSON.stringify(data)) 78 | }, []) 79 | 80 | return { 81 | send, 82 | readyState, 83 | close, 84 | internal: { 85 | send: connectionSend 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/components/three.js/Debug.ts: -------------------------------------------------------------------------------- 1 | // TODO: this can exist in `eunomia-typescript` 2 | import { 3 | Group, 4 | BoxGeometry, 5 | MeshBasicMaterial, 6 | Mesh, 7 | Box2, 8 | WebGLRenderer, 9 | Scene 10 | } from 'three' 11 | 12 | export const createAxisGeometry = (): Group => { 13 | const boxGeometry = new BoxGeometry() 14 | 15 | const redMaterial = new MeshBasicMaterial({ color: 0xff0000 }) 16 | const greenMaterial = new MeshBasicMaterial({ color: 0x00ff00 }) 17 | const blueMaterial = new MeshBasicMaterial({ color: 0x0000ff }) 18 | 19 | const x = new Mesh(boxGeometry, redMaterial) 20 | x.position.x = 0.5 21 | x.scale.y = 0.1 22 | x.scale.z = 0.1 23 | 24 | const y = new Mesh(boxGeometry, greenMaterial) 25 | y.position.y = 0.5 26 | y.scale.x = 0.1 27 | y.scale.z = 0.1 28 | 29 | const z = new Mesh(boxGeometry, blueMaterial) 30 | z.position.z = 0.5 31 | z.scale.x = 0.1 32 | z.scale.y = 0.1 33 | 34 | const result = new Group() 35 | result.add(x) 36 | result.add(y) 37 | result.add(z) 38 | return result 39 | } 40 | 41 | export const createBounds2DGeometry = (bounds: Box2): Group => { 42 | const topLeft = createAxisGeometry() 43 | topLeft.position.x = bounds.min.x 44 | topLeft.position.y = bounds.min.y 45 | 46 | const topRight = createAxisGeometry() 47 | topRight.position.x = bounds.max.x 48 | topRight.position.y = bounds.min.y 49 | 50 | const bottomLeft = createAxisGeometry() 51 | bottomLeft.position.x = bounds.min.x 52 | bottomLeft.position.y = bounds.max.y 53 | 54 | const bottomRight = createAxisGeometry() 55 | bottomRight.position.x = bounds.max.x 56 | bottomRight.position.y = bounds.max.y 57 | 58 | const result = new Group() 59 | result.add(topLeft) 60 | result.add(topRight) 61 | result.add(bottomLeft) 62 | result.add(bottomRight) 63 | return result 64 | } 65 | 66 | // eslint-disable-next-line @typescript-eslint/ban-types 67 | declare let setupThreejsDevTool: Function | void 68 | 69 | export const attachThreejsDevTool = ( 70 | renderer: WebGLRenderer, 71 | scene: Scene, 72 | THREE: any 73 | ) => { 74 | if (typeof setupThreejsDevTool === 'function') { 75 | setupThreejsDevTool({ 76 | renderer, 77 | scene, 78 | THREE 79 | }) 80 | } else { 81 | throw new Error('setupThreejsDevTool is not a function') 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/components/Video/Video.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { Video, Methods as VideoMethods } from './Video' 4 | 5 | import { useUserMedia } from '../UserMedia' 6 | import { useCallback, useRef } from 'react' 7 | 8 | const displayConfig = { 9 | title: 'Video' 10 | } 11 | export default displayConfig 12 | 13 | interface Props { 14 | autoplay: boolean 15 | muted: boolean 16 | controls: boolean 17 | crossOrigin: string | null 18 | loop: boolean 19 | playbackRate: number 20 | playsInline: boolean 21 | volume: number 22 | } 23 | 24 | export const Default = ({ 25 | autoplay, 26 | muted, 27 | controls, 28 | crossOrigin, 29 | loop, 30 | playbackRate, 31 | playsInline, 32 | volume 33 | }: Props) => { 34 | const constraints = { 35 | audio: false, 36 | video: { 37 | facingMode: 'user', 38 | width: 360, 39 | height: 270, 40 | frameRate: { 41 | ideal: 60 42 | } 43 | } 44 | } 45 | const { stream } = useUserMedia({ constraints }) 46 | const video = useRef() 47 | 48 | const play = useCallback(() => { 49 | if (!video || !video.current) { 50 | return 51 | } 52 | video.current.play() 53 | }, [video]) 54 | 55 | const pause = useCallback(() => { 56 | if (!video || !video.current) { 57 | return 58 | } 59 | video.current.pause() 60 | }, [video]) 61 | 62 | return ( 63 |
64 |
84 | ) 85 | } 86 | Default.args = { 87 | autoplay: true, 88 | muted: true, 89 | controls: false, 90 | crossOrigin: null, 91 | loop: false, 92 | playbackRate: 1, 93 | playsInline: true, 94 | volume: 1 95 | } 96 | -------------------------------------------------------------------------------- /src/components/WebSocket/useClientConnection.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from 'react' 2 | 3 | import { Meta } from '@storybook/react' 4 | 5 | import { useClientConnection } from './useClientConnection' 6 | 7 | import { Server } from 'mock-socket' 8 | 9 | export default { 10 | title: 'WebSocket/useClientConnection' 11 | } as Meta 12 | 13 | interface Props { 14 | sendStringToServer: string 15 | sendStringToClient: string 16 | } 17 | 18 | let server: Server | null = null 19 | 20 | export const Default = ({ sendStringToServer, sendStringToClient }: Props) => { 21 | const [clientReceivedMessage, setClientReceivedMessage] = useState('') 22 | const [serverReceivedMessage, setServerReceivedMessage] = useState('') 23 | 24 | const url = 'ws://localhost:3050' 25 | 26 | const serverOnMessage = useCallback( 27 | (data: string | Blob | ArrayBuffer | ArrayBufferView) => { 28 | const update = 29 | typeof data === 'string' 30 | ? `string: '${data}'` 31 | : data instanceof ArrayBuffer 32 | ? `ArrayBuffer: ${data.byteLength} bytes` 33 | : `${typeof data}` 34 | setServerReceivedMessage(update) 35 | }, 36 | [setServerReceivedMessage] 37 | ) 38 | 39 | if (!server) { 40 | try { 41 | server = new Server(url) 42 | 43 | server.on('connection', (socket) => { 44 | socket.on('message', serverOnMessage) 45 | }) 46 | } catch (e) { 47 | console.error(e) 48 | } 49 | } 50 | 51 | const { send: clientSend } = useClientConnection({ 52 | url, 53 | onMessageDataText: (_, data: string) => { 54 | setClientReceivedMessage(`string: '${data}'`) 55 | } 56 | }) 57 | 58 | return ( 59 | <> 60 |
Client Received: {clientReceivedMessage}
61 |
Server Received: {serverReceivedMessage}
62 |
63 | 70 | 71 | 81 | 82 | ) 83 | } 84 | Default.args = { 85 | sendStringToServer: 'Hello from client', 86 | sendStringToClient: 'Hello from server' 87 | } 88 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@adorkable/eunomia-typescript-react", 3 | "version": "0.5.1", 4 | "description": "The team's go-to utilities for Typescript and React, named for the Greek goddess of green pastures, law and legislation", 5 | "main": "lib/index.js", 6 | "module": "lib/index.esm.js", 7 | "types": "lib/index.d.ts", 8 | "scripts": { 9 | "prepare": "npm run build", 10 | "build": "rollup -c", 11 | "test": "echo \"Error: no test specified\" && exit 1", 12 | "storybook": "storybook dev -p 6006", 13 | "build-storybook": "storybook build", 14 | "deploy-storybook": "storybook-to-ghpages" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/Adorkable/eunomia-typescript-react.git" 19 | }, 20 | "keywords": [ 21 | "typescript", 22 | "react", 23 | "eunomia", 24 | "adorkable" 25 | ], 26 | "author": "Ian G ", 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/Adorkable/eunomia-typescript-react/issues" 30 | }, 31 | "homepage": "https://github.com/Adorkable/eunomia-typescript-react", 32 | "devDependencies": { 33 | "@rollup/plugin-commonjs": "^25.0.2", 34 | "@rollup/plugin-node-resolve": "^15.1.0", 35 | "@rollup/plugin-typescript": "^11.1.2", 36 | "@storybook/addon-essentials": "^7.0.26", 37 | "@storybook/addon-interactions": "^7.0.26", 38 | "@storybook/addon-links": "^7.0.26", 39 | "@storybook/blocks": "^7.0.26", 40 | "@storybook/react": "^7.0.26", 41 | "@storybook/react-vite": "^7.0.26", 42 | "@storybook/storybook-deployer": "^2.8.16", 43 | "@storybook/testing-library": "^0.0.14-next.2", 44 | "@types/mapbox-gl": "^2.7.11", 45 | "@types/react": "^18.2.14", 46 | "@types/react-dom": "^18.2.6", 47 | "@types/three": "^0.153.0", 48 | "eslint-plugin-storybook": "^0.6.12", 49 | "mock-socket": "^9.2.1", 50 | "postcss": "^8.4.25", 51 | "prop-types": "^15.8.1", 52 | "react": "^18.2.0", 53 | "react-dom": "^18.2.0", 54 | "rollup": "^3.26.2", 55 | "rollup-plugin-dts": "^5.3.0", 56 | "rollup-plugin-peer-deps-external": "^2.2.4", 57 | "rollup-plugin-postcss": "^4.0.2", 58 | "storybook": "^7.0.26", 59 | "typescript": "^5.1.6" 60 | }, 61 | "peerDependencies": { 62 | "mapbox-gl": "^2.7.0", 63 | "react": "^18.2.0", 64 | "react-dom": "^18.2.0", 65 | "three": "^0.138.0" 66 | }, 67 | "dependencies": { 68 | "@adorkable/eunomia-typescript": "^0.4.2" 69 | }, 70 | "files": [ 71 | "lib" 72 | ], 73 | "publishConfig": { 74 | "registry": "https://npm.pkg.github.com/" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '25 11 * * 0' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | with: 74 | category: "/language:${{matrix.language}}" 75 | -------------------------------------------------------------------------------- /src/components/three.js/Utility.ts: -------------------------------------------------------------------------------- 1 | // TODO: some of these can exist in `eunomia-typescript` 2 | 3 | import { 4 | WebGLRenderer, 5 | Box2, 6 | OrthographicCamera, 7 | Scene, 8 | Vector2, 9 | WebGLRendererParameters 10 | } from 'three' 11 | import { SizeProperties } from './Types' 12 | 13 | export const physicalToWindowPixelRatio = (): number => { 14 | return window.devicePixelRatio ? window.devicePixelRatio : 1 15 | } 16 | 17 | export const createZeroCenteredBox = (width: number, height: number): Box2 => { 18 | const widthHalf = width / 2.0 19 | const heightHalf = height / 2.0 20 | return new Box2( 21 | new Vector2(widthHalf * -1, heightHalf * -1), 22 | new Vector2(widthHalf, heightHalf) 23 | ) 24 | } 25 | 26 | export const createOrthographicCamera = ( 27 | viewPortBox: Box2, 28 | nearClippingPlane?: number, 29 | farClippingPlane?: number 30 | ): OrthographicCamera => { 31 | const orthographicCamera = new OrthographicCamera( 32 | viewPortBox.min.x, 33 | viewPortBox.max.x, 34 | viewPortBox.min.y, 35 | viewPortBox.max.y, 36 | nearClippingPlane, 37 | farClippingPlane 38 | ) 39 | return orthographicCamera 40 | } 41 | 42 | export const createWebGLRenderer = ( 43 | width: number, 44 | height: number, 45 | pixelRatio: number, 46 | options?: WebGLRendererParameters 47 | ): WebGLRenderer => { 48 | const result = new WebGLRenderer( 49 | options || { 50 | antialias: true, 51 | preserveDrawingBuffer: true, 52 | alpha: true 53 | } 54 | ) 55 | result.setPixelRatio(pixelRatio) 56 | result.setSize(width, height) 57 | result.autoClear = false 58 | result.setClearColor(0x000000, 0.0) 59 | return result 60 | } 61 | 62 | export const clearScene = (scene: Scene) => { 63 | while (scene.children.length) { 64 | scene.remove(scene.children[0]) 65 | } 66 | } 67 | 68 | export const calculateRenderSize = ( 69 | sizeProperties: SizeProperties, 70 | renderWindow: HTMLDivElement 71 | ): Vector2 => { 72 | let newRenderSize: Vector2 73 | if (sizeProperties.kind === 'FixedSize') { 74 | newRenderSize = new Vector2(sizeProperties.width, sizeProperties.height) 75 | } else if (renderWindow) { 76 | newRenderSize = new Vector2( 77 | renderWindow.offsetWidth, 78 | renderWindow.offsetHeight 79 | ) 80 | } else { 81 | newRenderSize = new Vector2(window.innerWidth, window.innerHeight) 82 | } 83 | 84 | return newRenderSize 85 | } 86 | 87 | export const createViewPortBox = ( 88 | sizeProperties: SizeProperties, 89 | renderContainer: HTMLDivElement, 90 | renderSize: Vector2 91 | ) => { 92 | let newViewPortBox: Box2 93 | 94 | if (sizeProperties.kind === 'ViewportSize') { 95 | if (!renderContainer) { 96 | throw new Error('Render window not available') 97 | } 98 | 99 | newViewPortBox = createZeroCenteredBox( 100 | sizeProperties.viewportSize, 101 | (sizeProperties.viewportSize * renderContainer.offsetHeight) / 102 | renderContainer.offsetWidth 103 | ) 104 | } else { 105 | newViewPortBox = createZeroCenteredBox(renderSize.x, renderSize.y) 106 | } 107 | 108 | return newViewPortBox 109 | } 110 | -------------------------------------------------------------------------------- /src/components/Canvas/Canvas.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' 4 | 5 | export interface Methods { 6 | drawContext: (video: HTMLVideoElement) => void 7 | clearContext: () => void 8 | getContext: () => CanvasRenderingContext2D | null | undefined 9 | 10 | width: () => number 11 | height: () => number 12 | } 13 | 14 | interface Props { 15 | width: number 16 | height: number 17 | 18 | flip?: boolean 19 | 20 | style?: React.CSSProperties 21 | className?: string 22 | } 23 | 24 | export const Canvas = forwardRef( 25 | ({ width, height, flip = false, style, className }: Props, forwardedRef) => { 26 | const ref = useRef() 27 | const contextRef = useRef() 28 | 29 | useEffect(() => { 30 | if (!ref.current) { 31 | return 32 | } 33 | ref.current.width = width 34 | }, [ref, width]) 35 | 36 | useEffect(() => { 37 | if (!ref.current) { 38 | return 39 | } 40 | ref.current.height = height 41 | }, [ref, height]) 42 | 43 | useEffect(() => { 44 | if (!contextRef.current) { 45 | return 46 | } 47 | if (flip) { 48 | contextRef.current.translate(width, 0) //result.video.videoWidth, 0) 49 | contextRef.current.scale(-1, 1) 50 | } 51 | return () => { 52 | if (!contextRef.current) { 53 | return 54 | } 55 | if (flip) { 56 | contextRef.current.scale(1, 1) 57 | contextRef.current.translate(-width, 0) //result.video.videoWidth, 0) 58 | } 59 | } 60 | }, [ref, width, flip]) 61 | 62 | useEffect(() => { 63 | if (!ref.current) { 64 | return 65 | } 66 | // TODO: support configurable context 67 | contextRef.current = ref.current.getContext('2d') 68 | 69 | return () => { 70 | contextRef.current = null 71 | } 72 | }, [ref]) 73 | 74 | useImperativeHandle(forwardedRef, () => ({ 75 | drawContext: (video: HTMLVideoElement) => { 76 | if (!contextRef.current) { 77 | return 78 | } 79 | contextRef.current.drawImage( 80 | video, 81 | 0, 82 | 0, 83 | video.videoWidth, 84 | video.videoHeight 85 | ) 86 | }, 87 | clearContext: () => { 88 | if (!ref.current || !contextRef.current) { 89 | return 90 | } 91 | contextRef.current.clearRect( 92 | 0, 93 | 0, 94 | ref.current.width, 95 | ref.current.height 96 | ) 97 | }, 98 | getContext: () => { 99 | return contextRef.current 100 | }, 101 | width: () => { 102 | if (!ref.current) { 103 | return 0 104 | } 105 | return ref.current.width 106 | }, 107 | height: () => { 108 | if (!ref.current) { 109 | return 0 110 | } 111 | return ref.current.height 112 | } 113 | })) 114 | 115 | return ( 116 | { 118 | ref.current = newRef 119 | }} 120 | style={style} 121 | className={className} 122 | /> 123 | ) 124 | } 125 | ) 126 | Canvas.displayName = 'Canvas' 127 | -------------------------------------------------------------------------------- /src/components/three.js/THREEView.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react' 2 | 3 | import { Meta } from '@storybook/react' 4 | 5 | import { 6 | SizePropertiesDefault, 7 | THREEView, 8 | THREEViewHandlers 9 | } from './THREEView' 10 | 11 | import { Box2, PerspectiveCamera, Scene, Vector2 } from 'three' 12 | import { FitWindowSize, FixedSize, SizeProperties, ViewportSize } from './Types' 13 | import { createAxisGeometry, createBounds2DGeometry } from './Debug' 14 | 15 | export default { 16 | title: 'three.js/THREEView', 17 | component: THREEView 18 | } as Meta 19 | 20 | export const DefaultSizeProperties = ({ size }: { size: SizeProperties }) => { 21 | var frameRequest: number = -1 22 | 23 | const camera = new PerspectiveCamera( 24 | 75, 25 | window.innerWidth / window.innerHeight, 26 | 0.1, 27 | 1000 28 | ) 29 | camera.position.z = 5 30 | 31 | const scene = new Scene() 32 | scene.add(camera) 33 | 34 | const axis = createAxisGeometry() 35 | scene.add(axis) 36 | 37 | const bounds = createBounds2DGeometry( 38 | new Box2(new Vector2(-3, -3), new Vector2(3, 3)) 39 | ) 40 | scene.add(bounds) 41 | 42 | const initialValue: { 43 | ref: THREEViewHandlers | undefined 44 | threejsSetup: boolean 45 | } = { 46 | ref: undefined, 47 | threejsSetup: false 48 | } 49 | 50 | const ref = useRef(null) 51 | const [threejsSetup, setThreejsSetup] = useState(false) 52 | 53 | const startRenders = (_threeView: THREEViewHandlers) => { 54 | stopRenders() 55 | 56 | animate() 57 | } 58 | 59 | const stopRenders = () => { 60 | if (frameRequest !== -1) { 61 | cancelAnimationFrame(frameRequest) 62 | frameRequest = -1 63 | } 64 | } 65 | 66 | const animate = () => { 67 | frameRequest = requestAnimationFrame(animate) 68 | 69 | axis.rotation.x += 0.01 70 | axis.rotation.y += 0.01 71 | 72 | if (!ref.current) { 73 | return 74 | } 75 | try { 76 | const renderer = ref.current.getRenderer() 77 | renderer.clear() 78 | renderer.render(scene, camera) 79 | } catch (error) { 80 | cancelAnimationFrame(frameRequest) 81 | throw error 82 | } 83 | } 84 | 85 | useEffect(() => { 86 | if (!threejsSetup || !ref.current) { 87 | return 88 | } 89 | 90 | startRenders(ref.current) 91 | }, [ref.current, threejsSetup]) 92 | 93 | useEffect(() => { 94 | return stopRenders 95 | }, []) 96 | 97 | return ( 98 | { 102 | setThreejsSetup(true) 103 | }} 104 | /> 105 | ) 106 | } 107 | DefaultSizeProperties.args = { 108 | size: SizePropertiesDefault 109 | } 110 | 111 | export const FixedSizeProperties = ({ 112 | width, 113 | height 114 | }: { 115 | width: number 116 | height: number 117 | }) => { 118 | const fixedSize: FixedSize = { 119 | kind: 'FixedSize', 120 | width, 121 | height 122 | } 123 | return DefaultSizeProperties({ size: fixedSize }) 124 | } 125 | FixedSizeProperties.args = { 126 | width: 10, 127 | height: 10 128 | } 129 | 130 | export const ViewPortSizeProperties = ({ 131 | viewportSize 132 | }: { 133 | viewportSize: number 134 | }) => { 135 | const viewportSizeProperty: ViewportSize = { 136 | kind: 'ViewportSize', 137 | viewportSize 138 | } 139 | return DefaultSizeProperties({ size: viewportSizeProperty }) 140 | } 141 | ViewPortSizeProperties.args = { 142 | viewportSize: 10 143 | } 144 | 145 | export const FitWindowSizeProperties = ({ 146 | pixelsPerWindowPixel, 147 | resizeRendererToFit 148 | }: { 149 | pixelsPerWindowPixel: number 150 | resizeRendererToFit: boolean 151 | }) => { 152 | const size: FitWindowSize = { 153 | kind: 'FitWindowSize', 154 | pixelsPerWindowPixel, 155 | resizeRendererToFit 156 | } 157 | return DefaultSizeProperties({ size }) 158 | } 159 | FitWindowSizeProperties.args = { 160 | pixelsPerWindowPixel: 1, 161 | resizeRendererToFit: true 162 | } 163 | -------------------------------------------------------------------------------- /src/components/three.js/THREEView.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | forwardRef, 3 | useEffect, 4 | useImperativeHandle, 5 | useRef, 6 | useState 7 | } from 'react' 8 | 9 | import { ViewportSize, SizeProperties } from './Types' 10 | 11 | import { 12 | physicalToWindowPixelRatio, 13 | calculateRenderSize, 14 | createViewPortBox 15 | } from './Utility' 16 | 17 | import { WebGLRenderer, Vector2, Box2 } from 'three' 18 | import useWindowSize from '../WindowSize/useWindowSize' 19 | import { useWebGLRenderer } from './useWebGLRenderer' 20 | 21 | export type Props = { 22 | size?: SizeProperties | void 23 | 24 | /** 25 | * Callback called when everything is set up 26 | */ 27 | onSetUp: () => void 28 | } 29 | 30 | export interface THREEViewHandlers { 31 | getDOMElementSize: () => Vector2 32 | getViewPort: () => Box2 33 | 34 | getRenderer: () => WebGLRenderer 35 | 36 | clear: () => void 37 | 38 | saveToImageData: () => string 39 | } 40 | 41 | export const SizePropertiesDefault: ViewportSize = { 42 | kind: 'ViewportSize', 43 | 44 | viewportSize: 10 45 | } 46 | 47 | export const ContainerReferenceNotAvailableError = 48 | 'Container reference not available' 49 | export const RendererNotAvailableError = 'Renderer not available' 50 | 51 | export const THREEView = forwardRef( 52 | (props: Props, ref) => { 53 | const containerRef = useRef(null) 54 | 55 | const [sizeProperties] = useState( 56 | props.size || SizePropertiesDefault 57 | ) 58 | 59 | const windowSize = useWindowSize() 60 | 61 | const [renderPixelRatio, setRenderPixelRatio] = useState(1) 62 | 63 | useEffect(() => { 64 | const newValue = ((): number => { 65 | if (sizeProperties.kind === 'FitWindowSize') { 66 | return sizeProperties.pixelsPerWindowPixel 67 | } 68 | return physicalToWindowPixelRatio() 69 | })() 70 | setRenderPixelRatio(newValue) 71 | }, [sizeProperties]) 72 | 73 | const [renderSize, setRenderSize] = useState(new Vector2(1, 1)) 74 | 75 | const renderer = useWebGLRenderer( 76 | containerRef.current, 77 | renderSize, 78 | renderPixelRatio 79 | ) 80 | 81 | useEffect(() => { 82 | if (!sizeProperties || !containerRef.current) { 83 | return 84 | } 85 | 86 | const newValue = calculateRenderSize(sizeProperties, containerRef.current) 87 | setRenderSize(newValue) 88 | }, [sizeProperties, containerRef.current]) 89 | 90 | useEffect(() => { 91 | if (!renderer) { 92 | return 93 | } 94 | 95 | props.onSetUp() 96 | }, [renderer]) 97 | 98 | useEffect(() => { 99 | if (!renderer) { 100 | return 101 | } 102 | console.log('Render size', renderSize) 103 | renderer.setSize(renderSize.x, renderSize.y) 104 | }, [renderer, renderSize]) 105 | 106 | useEffect(() => { 107 | if (!renderer) { 108 | return 109 | } 110 | renderer.setPixelRatio(renderPixelRatio) 111 | }, [renderer, renderPixelRatio]) 112 | 113 | useEffect(() => { 114 | if (sizeProperties.kind === 'FitWindowSize') { 115 | if (sizeProperties.resizeRendererToFit && renderer) { 116 | setRenderSize(new Vector2(windowSize.width, windowSize.height)) 117 | } 118 | } 119 | }, [windowSize]) 120 | 121 | useImperativeHandle( 122 | ref, 123 | (): THREEViewHandlers => ({ 124 | getDOMElementSize: () => { 125 | if (!containerRef.current) { 126 | throw new Error(ContainerReferenceNotAvailableError) 127 | } 128 | // TODO: useEffect cache 129 | return new Vector2( 130 | containerRef.current.offsetWidth, 131 | containerRef.current.offsetHeight 132 | ) 133 | }, 134 | getViewPort: () => { 135 | if (!containerRef.current) { 136 | throw new Error(ContainerReferenceNotAvailableError) 137 | } 138 | // TODO: useEffect cache 139 | return createViewPortBox( 140 | sizeProperties, 141 | containerRef.current, 142 | renderSize 143 | ) 144 | }, 145 | getRenderer: () => { 146 | if (!renderer) { 147 | throw new Error(RendererNotAvailableError) 148 | } 149 | return renderer 150 | }, 151 | clear: () => { 152 | if (renderer) { 153 | renderer.clear() 154 | } 155 | }, 156 | saveToImageData: () => { 157 | if (!renderer) { 158 | throw new Error(RendererNotAvailableError) 159 | } 160 | const imageMime = 'image/png' 161 | // TODO: do we have to use the dom element? can't we copy from the renderer? 162 | return renderer.domElement.toDataURL(imageMime) 163 | } 164 | }) 165 | ) 166 | 167 | return ( 168 |
185 | ) 186 | } 187 | ) 188 | 189 | export default THREEView 190 | -------------------------------------------------------------------------------- /src/components/Video/Video.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { 4 | forwardRef, 5 | MutableRefObject, 6 | useCallback, 7 | useEffect, 8 | useImperativeHandle, 9 | useRef 10 | } from 'react' 11 | 12 | export interface Methods { 13 | play: () => void 14 | pause: () => void 15 | 16 | readyState: () => number 17 | isReady: () => boolean 18 | waitUntilReady: () => Promise 19 | 20 | element: () => HTMLVideoElement | null | undefined 21 | width: () => number 22 | height: () => number 23 | 24 | // currentTime: () => number 25 | // paused: () => boolean 26 | // fastSeek: (time: number) => void 27 | // duration: () => number 28 | // ended: () => boolean 29 | // error: () => MediaError | null 30 | } 31 | 32 | interface Props { 33 | autoplay?: boolean 34 | muted?: boolean 35 | controls?: boolean 36 | crossOrigin?: string | null 37 | loop?: boolean 38 | playbackRate?: number 39 | playsInline?: boolean 40 | volume?: number 41 | 42 | style?: React.CSSProperties 43 | className?: string 44 | 45 | stream?: MediaStream | null 46 | } 47 | 48 | type VideoRef = MutableRefObject 49 | 50 | const readyState = (ref: VideoRef): number => { 51 | if (!ref.current) { 52 | return HTMLMediaElement.HAVE_NOTHING 53 | } 54 | return ref.current.readyState 55 | } 56 | 57 | const isReady = (ref: VideoRef): boolean => { 58 | const readyStateValue = readyState(ref) 59 | return ( 60 | readyStateValue === HTMLMediaElement.HAVE_CURRENT_DATA || 61 | readyStateValue === HTMLMediaElement.HAVE_ENOUGH_DATA || 62 | readyStateValue === HTMLMediaElement.HAVE_FUTURE_DATA 63 | ) 64 | } 65 | 66 | const waitUntilReady = async (ref: VideoRef): Promise => { 67 | const isReadyValue = isReady(ref) 68 | if (isReadyValue) { 69 | return Promise.resolve() 70 | } 71 | const refInstance = ref.current 72 | if (!refInstance) { 73 | throw new Error('Video element is not available yet') 74 | } 75 | await new Promise((resolve) => { 76 | refInstance.onloadeddata = () => { 77 | resolve(null) 78 | } 79 | }) 80 | } 81 | 82 | const setVideoElementSettings = ( 83 | video: HTMLVideoElement, 84 | { 85 | autoplay = true, 86 | muted = true, 87 | controls = false, 88 | crossOrigin = null, 89 | loop = false, 90 | playbackRate = 1, 91 | playsInline = true, 92 | volume = 1, 93 | stream = null 94 | }: Props 95 | ) => { 96 | video.srcObject = stream 97 | video.autoplay = autoplay 98 | video.controls = controls 99 | video.crossOrigin = crossOrigin 100 | video.loop = loop 101 | video.playbackRate = playbackRate 102 | video.playsInline = playsInline 103 | video.volume = volume 104 | video.muted = muted 105 | } 106 | 107 | export const Video = forwardRef( 108 | ( 109 | { 110 | autoplay = true, 111 | muted = true, 112 | controls = false, 113 | crossOrigin = null, 114 | loop = false, 115 | playbackRate = 1, 116 | playsInline = true, 117 | volume = 1, 118 | style, 119 | className, 120 | stream = null 121 | }: Props, 122 | forwardedRef 123 | ) => { 124 | const ref = useRef() 125 | 126 | const onRefSet = useCallback( 127 | (refInstance: HTMLVideoElement | null) => { 128 | if (refInstance) { 129 | setVideoElementSettings(refInstance, { 130 | autoplay, 131 | controls, 132 | crossOrigin, 133 | loop, 134 | muted, 135 | playbackRate, 136 | playsInline, 137 | volume, 138 | stream 139 | }) 140 | 141 | ref.current = refInstance 142 | } else { 143 | if (ref.current) { 144 | ref.current.srcObject = null 145 | ref.current = null 146 | } 147 | } 148 | }, 149 | [ 150 | stream, 151 | autoplay, 152 | controls, 153 | crossOrigin, 154 | loop, 155 | muted, 156 | playbackRate, 157 | playsInline, 158 | volume 159 | ] 160 | ) 161 | 162 | useEffect(() => { 163 | if (ref.current) { 164 | setVideoElementSettings(ref.current, { 165 | autoplay, 166 | controls, 167 | crossOrigin, 168 | loop, 169 | muted, 170 | playbackRate, 171 | playsInline, 172 | volume, 173 | stream 174 | }) 175 | } 176 | 177 | return () => { 178 | if (ref.current) { 179 | ref.current.srcObject = null 180 | } 181 | } 182 | }, [ 183 | stream, 184 | autoplay, 185 | controls, 186 | crossOrigin, 187 | loop, 188 | muted, 189 | playbackRate, 190 | playsInline, 191 | volume 192 | ]) 193 | 194 | useImperativeHandle(forwardedRef, () => ({ 195 | play: () => { 196 | if (!ref.current) { 197 | return 198 | } 199 | ref.current.play() 200 | }, 201 | pause: () => { 202 | if (!ref.current) { 203 | return 204 | } 205 | ref.current.pause() 206 | }, 207 | readyState: () => { 208 | return readyState(ref) 209 | }, 210 | isReady: () => { 211 | return isReady(ref) 212 | }, 213 | waitUntilReady: async () => { 214 | await waitUntilReady(ref) 215 | }, 216 | element: () => { 217 | return ref.current 218 | }, 219 | width: () => { 220 | if (!ref.current) { 221 | return 0 222 | } 223 | return ref.current.width 224 | }, 225 | height: () => { 226 | if (!ref.current) { 227 | return 0 228 | } 229 | return ref.current.height 230 | } 231 | })) 232 | 233 | return ( 234 |