Compare commits
No commits in common. "main" and "test-cdn" have entirely different histories.
1
.eslintignore
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
**/*.js
|
||||||
28
.eslintrc.json
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"root": true,
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es2020": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:@typescript-eslint/eslint-recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"prettier",
|
||||||
|
"prettier/@typescript-eslint"
|
||||||
|
],
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 11,
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"plugins": ["unused-imports", "@typescript-eslint", "prettier"],
|
||||||
|
"rules": {
|
||||||
|
"prettier/prettier": "error",
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"unused-imports/no-unused-imports-ts": "error",
|
||||||
|
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/explicit-module-boundary-types": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
name: Check & deploy
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ['main']
|
|
||||||
pull_request:
|
|
||||||
branches: ['main']
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: 'pages'
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: docker
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version-file: '.nvmrc'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Copy build to host pages mount (sdf2d)
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
uses: http://forgejo:3000/andras/ci-actions/deploy-pages@main
|
|
||||||
with:
|
|
||||||
source: dist
|
|
||||||
target: sdf2d
|
|
||||||
|
|
||||||
- name: Copy build to host pages mount (sdf-2d)
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
uses: http://forgejo:3000/andras/ci-actions/deploy-pages@main
|
|
||||||
with:
|
|
||||||
source: dist
|
|
||||||
target: sdf-2d
|
|
||||||
2
.gitignore
vendored
|
|
@ -1,4 +1,4 @@
|
||||||
dist
|
dist
|
||||||
node_modules
|
node_modules
|
||||||
|
package-lock.json
|
||||||
.vscode
|
.vscode
|
||||||
.firebase
|
|
||||||
|
|
|
||||||
1
.nvmrc
|
|
@ -1 +0,0 @@
|
||||||
22.13.0
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<h1>
|
<img src="static/logo-colored.svg" width=128 height=128 alt="logo" />
|
||||||
<img src="static/logo-colored.svg" width="64" height="64" alt="SDF-2D logo" />
|
|
||||||
SDF-2D demo
|
# SDF-2D demo
|
||||||
</h1>
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import js from '@eslint/js';
|
|
||||||
import tseslint from 'typescript-eslint';
|
|
||||||
import prettierConfig from 'eslint-config-prettier';
|
|
||||||
import prettierPlugin from 'eslint-plugin-prettier';
|
|
||||||
import unusedImports from 'eslint-plugin-unused-imports';
|
|
||||||
import globals from 'globals';
|
|
||||||
|
|
||||||
export default tseslint.config(
|
|
||||||
{ ignores: ['dist/', 'node_modules/', '**/*.js'] },
|
|
||||||
js.configs.recommended,
|
|
||||||
...tseslint.configs.recommended,
|
|
||||||
prettierConfig,
|
|
||||||
{
|
|
||||||
files: ['src/**/*.ts'],
|
|
||||||
languageOptions: {
|
|
||||||
globals: { ...globals.browser },
|
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
sourceType: 'module',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
'unused-imports': unusedImports,
|
|
||||||
prettier: prettierPlugin,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
'prettier/prettier': 'error',
|
|
||||||
'no-unused-vars': 'off',
|
|
||||||
'@typescript-eslint/no-unused-vars': 'off',
|
|
||||||
'unused-imports/no-unused-imports': 'error',
|
|
||||||
'unused-imports/no-unused-vars': [
|
|
||||||
'warn',
|
|
||||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
|
||||||
],
|
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
|
||||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
7007
package-lock.json
generated
65
package.json
|
|
@ -1,12 +1,13 @@
|
||||||
{
|
{
|
||||||
"name": "sdf-2d-demo",
|
"name": "sdf-2d-demo",
|
||||||
|
"version": "0.0.0",
|
||||||
"description": "Some simple demos to showcase the possibilities of this library.",
|
"description": "Some simple demos to showcase the possibilities of this library.",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.html",
|
"main": "index.html",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "webpack serve --mode development",
|
"start": "webpack-dev-server --mode development",
|
||||||
"lint": "npx eslint --fix \"src/**/*.ts\" && npx prettier --write \"src/**/*.ts\"",
|
"lint": "npx eslint --fix \"src/**/*.ts\" && npx prettier --write \"src/**/*.ts\"",
|
||||||
"build": "webpack --mode production && rm -f dist/*.js dist/*.css"
|
"build": "rm -rf dist/* && webpack --mode production && find dist -type f -not -regex \"dist\\/.*\\.\\(html\\|png\\|ico\\|svg\\|jpg\\)\" | xargs rm && sed -i 's/^\\/\\/#.*.map//' dist/index.html"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "András Schmelczer <andras@schmelczer.dev> (https://schmelczer.dev/)",
|
"author": "András Schmelczer <andras@schmelczer.dev> (https://schmelczer.dev/)",
|
||||||
|
|
@ -23,35 +24,35 @@
|
||||||
"*.scss"
|
"*.scss"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@typescript-eslint/eslint-plugin": "^3.10.1",
|
||||||
"@plausible-analytics/tracker": "^0.4.5",
|
"@typescript-eslint/parser": "^3.10.1",
|
||||||
"autoprefixer": "^10.5.0",
|
"@types/gl-matrix": "^2.4.5",
|
||||||
"css-loader": "^7.1.4",
|
"gl-matrix": "^3.3.0",
|
||||||
"css-minimizer-webpack-plugin": "^8.0.0",
|
"autoprefixer": "^9.8.6",
|
||||||
"eslint": "^10.4.1",
|
"css-loader": "^3.5.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint": "^7.9.0",
|
||||||
"eslint-plugin-prettier": "^5.5.6",
|
"eslint-config-prettier": "^6.11.0",
|
||||||
"eslint-plugin-unused-imports": "^4.4.1",
|
"eslint-plugin-import": "^2.22.0",
|
||||||
"gl-matrix": "^3.4.4",
|
"eslint-plugin-prettier": "^3.1.4",
|
||||||
"globals": "^17.6.0",
|
"eslint-plugin-unused-imports": "^0.1.3",
|
||||||
"html-inline-css-webpack-plugin": "^1.11.2",
|
"html-webpack-inline-source-plugin": "0.0.10",
|
||||||
"html-inline-script-webpack-plugin": "^3.2.1",
|
"html-webpack-plugin": "^3.2.0",
|
||||||
"html-loader": "^5.1.0",
|
"mini-css-extract-plugin": "^0.9.0",
|
||||||
"html-webpack-plugin": "^5.6.7",
|
"optimize-css-assets-webpack-plugin": "^5.0.4",
|
||||||
"mini-css-extract-plugin": "^2.10.2",
|
"postcss-loader": "^3.0.0",
|
||||||
"postcss": "^8.5.15",
|
"prettier": "^2.1.2",
|
||||||
"postcss-loader": "^8.2.1",
|
"raw-loader": "^4.0.1",
|
||||||
"prettier": "^3.8.3",
|
"resolve-url-loader": "^3.1.1",
|
||||||
"resolve-url-loader": "^5.0.0",
|
"sass": "^1.26.3",
|
||||||
"sass": "^1.100.0",
|
"sass-loader": "^9.0.3",
|
||||||
"sass-loader": "^17.0.0",
|
"sdf-2d": "^0.1.0-alpha",
|
||||||
"sdf-2d": "^0.8.0",
|
"source-map-loader": "^1.1.0",
|
||||||
"source-map-loader": "^5.0.0",
|
"svg-url-loader": "^6.0.0",
|
||||||
"ts-loader": "^9.6.0",
|
"terser-webpack-plugin": "^2.3.8",
|
||||||
"typescript": "^6.0.3",
|
"ts-loader": "^8.0.3",
|
||||||
"typescript-eslint": "^8.60.1",
|
"typescript": "^3.9.7",
|
||||||
"webpack": "^5.107.2",
|
"webpack": "^4.44.1",
|
||||||
"webpack-cli": "^7.0.3",
|
"webpack-cli": "^3.3.11",
|
||||||
"webpack-dev-server": "^5.2.4"
|
"webpack-dev-server": "^3.10.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
import {
|
|
||||||
init as plausibleInit,
|
|
||||||
track as plausibleTrack,
|
|
||||||
type PlausibleEventOptions,
|
|
||||||
} from '@plausible-analytics/tracker';
|
|
||||||
|
|
||||||
const ANALYTICS_AUTO_CAPTURE_PAGEVIEWS = true;
|
|
||||||
const ANALYTICS_DOMAIN = 'schmelczer.dev/sdf2d';
|
|
||||||
const ANALYTICS_ENDPOINT = 'https://stats.schmelczer.dev/status';
|
|
||||||
const ANALYTICS_LOGGING = process.env.NODE_ENV !== 'production';
|
|
||||||
|
|
||||||
let isInitialized = false;
|
|
||||||
|
|
||||||
export const track = (eventName: string, options: PlausibleEventOptions = {}) => {
|
|
||||||
try {
|
|
||||||
plausibleTrack(eventName, options);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Could not track analytics event "${eventName}".`, error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const initAnalytics = () => {
|
|
||||||
if (isInitialized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
plausibleInit({
|
|
||||||
domain: ANALYTICS_DOMAIN,
|
|
||||||
endpoint: ANALYTICS_ENDPOINT,
|
|
||||||
autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS,
|
|
||||||
logging: ANALYTICS_LOGGING,
|
|
||||||
});
|
|
||||||
isInitialized = true;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Could not initialize analytics.', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
|
||||||
|
|
||||||
export class Circle {
|
|
||||||
constructor(
|
|
||||||
public center: vec2,
|
|
||||||
public radius: number
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public distance(target: vec2): number {
|
|
||||||
return vec2.distance(this.center, target) - this.radius;
|
|
||||||
}
|
|
||||||
|
|
||||||
public distanceBetween(target: Circle): number {
|
|
||||||
return vec2.distance(target.center, this.center) - this.radius - target.radius;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import { RendererInfo } from 'sdf-2d/lib/src/graphics/rendering/renderer/renderer-info';
|
|
||||||
|
|
||||||
export const extractInsights = (
|
|
||||||
insights?: RendererInfo
|
|
||||||
): {
|
|
||||||
vendor?: string;
|
|
||||||
renderer?: string;
|
|
||||||
fps?: number;
|
|
||||||
renderScale?: number;
|
|
||||||
lightScale?: number;
|
|
||||||
version?: string;
|
|
||||||
} => ({
|
|
||||||
fps: insights?.fps,
|
|
||||||
vendor: insights?.vendor,
|
|
||||||
renderer: insights?.renderer,
|
|
||||||
renderScale: insights?.renderPasses.distance.renderScale,
|
|
||||||
lightScale: insights?.renderPasses.lights.renderScale,
|
|
||||||
version: insights?.sdf2dVersion,
|
|
||||||
});
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
export const handleFullScreen = (
|
|
||||||
minimizeButton: HTMLElement,
|
|
||||||
maximizeButton: HTMLElement,
|
|
||||||
target: HTMLElement
|
|
||||||
) => {
|
|
||||||
if (!document.fullscreenEnabled) {
|
|
||||||
minimizeButton.style.visibility = 'hidden';
|
|
||||||
maximizeButton.style.visibility = 'hidden';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isInFullScreen = (): boolean => document.fullscreenElement !== null;
|
|
||||||
|
|
||||||
const showButtons = () => {
|
|
||||||
minimizeButton.style.visibility = isInFullScreen() ? 'visible' : 'hidden';
|
|
||||||
maximizeButton.style.visibility = isInFullScreen() ? 'hidden' : 'visible';
|
|
||||||
};
|
|
||||||
|
|
||||||
showButtons();
|
|
||||||
|
|
||||||
let currentWindowHeight = innerHeight;
|
|
||||||
|
|
||||||
const followToggle = () => {
|
|
||||||
showButtons();
|
|
||||||
currentWindowHeight = innerHeight;
|
|
||||||
};
|
|
||||||
|
|
||||||
const triggerToggle = async () => {
|
|
||||||
await (isInFullScreen() ? document.exitFullscreen() : target.requestFullscreen());
|
|
||||||
followToggle();
|
|
||||||
};
|
|
||||||
|
|
||||||
addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'F11') {
|
|
||||||
triggerToggle();
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
addEventListener('resize', () => {
|
|
||||||
if (isInFullScreen && currentWindowHeight > innerHeight) {
|
|
||||||
followToggle();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
maximizeButton.addEventListener('click', triggerToggle);
|
|
||||||
minimizeButton.addEventListener('click', triggerToggle);
|
|
||||||
};
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { track } from '../analytics';
|
|
||||||
|
|
||||||
export const handleInsights = async (initialData: any): Promise<(data: any) => void> => {
|
|
||||||
track('Session Insights', { props: toStringProps(initialData) });
|
|
||||||
|
|
||||||
let performanceReported = false;
|
|
||||||
return (data) => {
|
|
||||||
if (performanceReported) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
performanceReported = true;
|
|
||||||
track('Performance Insights', { props: toStringProps(data) });
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const toStringProps = (data: Record<string, unknown>): Record<string, string> =>
|
|
||||||
Object.entries(data).reduce<Record<string, string>>((props, [key, value]) => {
|
|
||||||
if (value !== undefined && value !== null) {
|
|
||||||
props[key] = String(value);
|
|
||||||
}
|
|
||||||
return props;
|
|
||||||
}, {});
|
|
||||||
1
src/helper/mix.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export const mix = (from: number, to: number, q: number) => from + (to - from) * q;
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export const prettyPrint = (o: any): string =>
|
export const prettyPrint = (o: any): string =>
|
||||||
JSON.stringify(o, (_, v) => (v?.toFixed ? Number(v.toFixed(3)) : v), ' ')
|
JSON.stringify(o, (_, v) => (v.toFixed ? Number(v.toFixed(3)) : v), ' ')
|
||||||
.replace(/("|,|{|^\n)/g, '')
|
.replace(/("|,|{|^\n)/g, '')
|
||||||
.replace(/(\W*}\n?)+/g, '\n\n');
|
.replace(/(\W*}\n?)+/g, '\n\n');
|
||||||
|
|
|
||||||
3
src/helper/rgb.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { vec3 } from 'gl-matrix';
|
||||||
|
|
||||||
|
export const rgb = (r: number, g: number, b: number): vec3 => vec3.fromValues(r, g, b);
|
||||||
4
src/helper/rgb255.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { vec3 } from 'gl-matrix';
|
||||||
|
|
||||||
|
export const rgb255 = (r: number, g: number, b: number): vec3 =>
|
||||||
|
vec3.fromValues(r / 255, g / 255, b / 255);
|
||||||
|
|
@ -4,33 +4,33 @@
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>SDF-2D demo</title>
|
<title>SDF-2D demo</title>
|
||||||
|
|
||||||
|
<meta property="og:image:width" content="1438" />
|
||||||
|
<meta property="og:image:height" content="638" />
|
||||||
<meta property="og:url" content="https://sdf2d.schmelczer.dev" />
|
<meta property="og:url" content="https://sdf2d.schmelczer.dev" />
|
||||||
<meta property="og:image" content="https://sdf2d.schmelczer.dev/og-image.png" />
|
<meta property="og:image" content="https://sdf2d.schmelczer.dev/og-image.jpg" />
|
||||||
<meta property="og:image:width" content="1607" />
|
|
||||||
<meta property="og:image:height" content="880" />
|
|
||||||
<meta
|
|
||||||
property="og:image:alt"
|
|
||||||
content="Merging vibrant red blobs lit from below by two flashlights."
|
|
||||||
/>
|
|
||||||
<meta name="theme-color" content="#103783" />
|
<meta name="theme-color" content="#103783" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Some simple demos to showcase the possibilities of the sdf-2d library. Click on the title to find out more."
|
content="Some simple demos to showcase the possibilities of the sdf-2d library library. Click on the title to find out more."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png" />
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png" />
|
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png" />
|
||||||
|
|
||||||
<meta name="viewport" content="width=device-width" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>
|
<noscript>Javascript is required for this website.</noscript>
|
||||||
<h1>Javascript is required for this website.</h1>
|
|
||||||
</noscript>
|
|
||||||
|
|
||||||
<main id="canvas-container">
|
<a id="info" href="https://github.com/schmelczerandras/sdf-2d" target="_BLANK">
|
||||||
<canvas></canvas>
|
<p>SDF-2D</p>
|
||||||
|
<img src="static/logo-white.svg" alt="logo" />
|
||||||
|
</a>
|
||||||
|
<code id="overlay"></code>
|
||||||
|
<button id="toggle-text"></button>
|
||||||
|
|
||||||
|
<canvas id="main"></canvas>
|
||||||
|
|
||||||
<div id="errors-container">
|
<div id="errors-container">
|
||||||
<div id="errors">
|
<div id="errors">
|
||||||
|
|
@ -38,32 +38,5 @@
|
||||||
<p id="error-text"></p>
|
<p id="error-text"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<img
|
|
||||||
src="../static/minimize.svg"
|
|
||||||
alt="minimize"
|
|
||||||
id="minimize"
|
|
||||||
class="full-screen-control"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
src="../static/maximize.svg"
|
|
||||||
alt="maximize"
|
|
||||||
id="maximize"
|
|
||||||
class="full-screen-control"
|
|
||||||
/>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<a id="info" href="https://github.com/schmelczerandras/sdf-2d" target="_BLANK">
|
|
||||||
<p>SDF-2D</p>
|
|
||||||
<img
|
|
||||||
class="logo"
|
|
||||||
width="64"
|
|
||||||
height="64"
|
|
||||||
src="../static/logo-white.svg"
|
|
||||||
alt="logo"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
<code id="overlay"></code>
|
|
||||||
<button id="toggle-text">Loading…</button>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
109
src/index.ts
|
|
@ -1,97 +1,74 @@
|
||||||
import { glMatrix } from 'gl-matrix';
|
import { glMatrix } from 'gl-matrix';
|
||||||
import { compile } from 'sdf-2d';
|
import '../static/favicons/apple-touch-icon.png';
|
||||||
|
import '../static/favicons/favicon-16x16.png';
|
||||||
|
import '../static/favicons/favicon-32x32.png';
|
||||||
|
import '../static/favicons/favicon.ico';
|
||||||
import '../static/logo-white.svg';
|
import '../static/logo-white.svg';
|
||||||
import '../static/no-change/404.html';
|
import '../static/og-image.jpg';
|
||||||
import '../static/no-change/favicons/apple-touch-icon.png';
|
import { DeltaTimeCalculator } from './helper/delta-time-calculator';
|
||||||
import '../static/no-change/favicons/favicon-16x16.png';
|
|
||||||
import '../static/no-change/favicons/favicon-32x32.png';
|
|
||||||
import '../static/no-change/favicons/favicon.ico';
|
|
||||||
import '../static/no-change/og-image.png';
|
|
||||||
import '../static/no-change/robots.txt';
|
|
||||||
import { initAnalytics } from './analytics';
|
|
||||||
import { extractInsights } from './helper/extract-insights';
|
|
||||||
import { handleFullScreen } from './helper/handle-full-screen';
|
|
||||||
import { handleInsights } from './helper/handle-insights';
|
|
||||||
import { Random } from './helper/random';
|
|
||||||
import { removeUnnecessaryOutlines } from './helper/remove-unnecessary-outlines';
|
import { removeUnnecessaryOutlines } from './helper/remove-unnecessary-outlines';
|
||||||
import { BlobScene } from './scenes/blob/blob-scene';
|
import { BlobScene } from './scenes/blob/blob-scene';
|
||||||
import { MetaballScene } from './scenes/metaball/metaball-scene';
|
|
||||||
import { OrbitScene } from './scenes/orbit/orbit-scene';
|
|
||||||
import { RainScene } from './scenes/rain/rain-scene';
|
import { RainScene } from './scenes/rain/rain-scene';
|
||||||
|
import { Scene } from './scenes/scene';
|
||||||
import { TunnelScene } from './scenes/tunnel-scene';
|
import { TunnelScene } from './scenes/tunnel-scene';
|
||||||
import './styles/index.scss';
|
import './styles/index.scss';
|
||||||
|
|
||||||
const scenes = [TunnelScene, MetaballScene, RainScene, BlobScene, OrbitScene];
|
const scenes = [TunnelScene, RainScene, BlobScene];
|
||||||
Random.seed = 2;
|
const sceneIntervalInSeconds = 8;
|
||||||
|
|
||||||
glMatrix.setMatrixArrayType(Array);
|
glMatrix.setMatrixArrayType(Array);
|
||||||
removeUnnecessaryOutlines();
|
removeUnnecessaryOutlines();
|
||||||
initAnalytics();
|
|
||||||
|
|
||||||
|
const deltaTimeCalculator = new DeltaTimeCalculator();
|
||||||
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
|
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
|
||||||
const logo = document.querySelector('#info') as HTMLElement;
|
const info = document.querySelector('#info') as HTMLDivElement;
|
||||||
const canvasContainer = document.querySelector('#canvas-container') as HTMLCanvasElement;
|
const errorText = document.querySelector('#error-text') as HTMLParamElement;
|
||||||
const errorText = document.querySelector('#error-text') as HTMLParagraphElement;
|
const errors = document.querySelector('#errors') as HTMLDivElement;
|
||||||
const errorsContainer = document.querySelector('#errors-container') as HTMLDivElement;
|
const errorsContainer = document.querySelector('#errors-container') as HTMLDivElement;
|
||||||
const toggleButton = document.querySelector('#toggle-text') as HTMLElement;
|
const toggleButton = document.querySelector('#toggle-text');
|
||||||
const minimizeButton = document.querySelector('#minimize') as HTMLElement;
|
|
||||||
const maximizeButton = document.querySelector('#maximize') as HTMLElement;
|
|
||||||
const overlay = document.querySelector('#overlay') as HTMLDivElement;
|
const overlay = document.querySelector('#overlay') as HTMLDivElement;
|
||||||
|
|
||||||
let textVisible = true;
|
let textVisible = false;
|
||||||
const handleTextToggle = () => {
|
const handleTextToggle = () => {
|
||||||
|
[info, overlay, errors].forEach(
|
||||||
|
(e) => (e.style.visibility = textVisible ? 'hidden' : 'inherit')
|
||||||
|
);
|
||||||
textVisible = !textVisible;
|
textVisible = !textVisible;
|
||||||
overlay.style.visibility = textVisible ? 'visible' : 'hidden';
|
toggleButton.innerHTML = textVisible ? 'Hide text' : 'Show text';
|
||||||
toggleButton.innerHTML = textVisible ? 'Hide insights' : 'Show insights';
|
};
|
||||||
if (textVisible) {
|
toggleButton.addEventListener('click', handleTextToggle);
|
||||||
toggleButton.classList.remove('off');
|
handleTextToggle();
|
||||||
|
|
||||||
|
const handleScene = async (SceneConstructor: new () => Scene) => {
|
||||||
|
const scene = new SceneConstructor();
|
||||||
|
await scene.initialize(canvas, overlay);
|
||||||
|
|
||||||
|
let triggerIsOver: () => void;
|
||||||
|
const isOver = new Promise((resolve) => (triggerIsOver = resolve));
|
||||||
|
let timeSinceStart = 0;
|
||||||
|
|
||||||
|
const handleFrame = (currentTime: DOMHighResTimeStamp) => {
|
||||||
|
const deltaTime = deltaTimeCalculator.getNextDeltaTime(currentTime);
|
||||||
|
|
||||||
|
scene.drawNextFrame(currentTime, deltaTime);
|
||||||
|
|
||||||
|
if ((timeSinceStart += deltaTime) > sceneIntervalInSeconds * 1000) {
|
||||||
|
triggerIsOver();
|
||||||
} else {
|
} else {
|
||||||
toggleButton.classList.add('off');
|
requestAnimationFrame(handleFrame);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isInsidePortfolio =
|
requestAnimationFrame(handleFrame);
|
||||||
new URLSearchParams(location.search).get('portfolioView') !== null;
|
await isOver;
|
||||||
if (!isInsidePortfolio) {
|
scene.destroy();
|
||||||
toggleButton.style.visibility = 'visible';
|
|
||||||
logo.style.visibility = 'visible';
|
|
||||||
toggleButton.addEventListener('click', handleTextToggle);
|
|
||||||
}
|
|
||||||
handleTextToggle();
|
|
||||||
|
|
||||||
const startInsightsSession = async (): Promise<(data: any) => unknown> => {
|
|
||||||
const dummyRenderer = await compile(document.createElement('canvas'), []);
|
|
||||||
const { vendor, renderer, version } = extractInsights(dummyRenderer.insights);
|
|
||||||
dummyRenderer.destroy();
|
|
||||||
|
|
||||||
return await handleInsights({
|
|
||||||
vendor,
|
|
||||||
renderer,
|
|
||||||
referrer: document.referrer,
|
|
||||||
connection: (navigator as any)?.connection?.effectiveType,
|
|
||||||
devicePixelRatio: devicePixelRatio,
|
|
||||||
height: innerHeight,
|
|
||||||
width: innerWidth,
|
|
||||||
version,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const main = async () => {
|
const main = async () => {
|
||||||
const sendFramePromise = startInsightsSession();
|
|
||||||
handleFullScreen(minimizeButton, maximizeButton, canvasContainer);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let i = 0;
|
let i = 0;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const currentScene = new scenes[i++ % scenes.length]();
|
await handleScene(scenes[i++ % scenes.length]);
|
||||||
await currentScene.run(canvas, overlay);
|
|
||||||
|
|
||||||
const { fps, renderScale, lightScale } = extractInsights(currentScene.insights);
|
|
||||||
(await sendFramePromise)({
|
|
||||||
fps,
|
|
||||||
renderScale,
|
|
||||||
lightScale,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
|
|
||||||
|
|
@ -1,80 +1,78 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2, vec3 } from 'gl-matrix';
|
||||||
import { CircleFactory, CircleLight, Renderer, rgb, rgb255, runAnimation } from 'sdf-2d';
|
import { Circle, CircleLight, compile, InvertedTunnel, Renderer } from 'sdf-2d';
|
||||||
import { prettyPrint } from '../../helper/pretty-print';
|
import { prettyPrint } from '../../helper/pretty-print';
|
||||||
import { settings } from '../../settings';
|
|
||||||
import { Scene } from '../scene';
|
import { Scene } from '../scene';
|
||||||
import { Blob } from './blob';
|
import { Blob } from './blob';
|
||||||
|
|
||||||
const Circle = CircleFactory(rgb255(119, 143, 120));
|
|
||||||
|
|
||||||
export class BlobScene implements Scene {
|
export class BlobScene implements Scene {
|
||||||
|
private renderer: Renderer;
|
||||||
private canvas: HTMLCanvasElement;
|
private canvas: HTMLCanvasElement;
|
||||||
private overlay: HTMLDivElement;
|
private overlay: HTMLDivElement;
|
||||||
public insights?: any;
|
|
||||||
|
|
||||||
public async run(canvas: HTMLCanvasElement, overlay: HTMLDivElement): Promise<void> {
|
private tunnels: Array<InvertedTunnel> = [];
|
||||||
|
private lights: Array<CircleLight> = [];
|
||||||
|
|
||||||
|
public async initialize(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
overlay: HTMLDivElement
|
||||||
|
): Promise<void> {
|
||||||
this.canvas = canvas;
|
this.canvas = canvas;
|
||||||
this.overlay = overlay;
|
this.overlay = overlay;
|
||||||
|
this.renderer = await compile(
|
||||||
const { width, height } = this.canvas.getBoundingClientRect();
|
|
||||||
const length = vec2.length([width, height]);
|
|
||||||
|
|
||||||
this.blob = new Blob([width / 2, -length / 4 + length / 2]);
|
|
||||||
|
|
||||||
await runAnimation(
|
|
||||||
canvas,
|
canvas,
|
||||||
[
|
[
|
||||||
Circle.descriptor,
|
Circle.descriptor,
|
||||||
{
|
{
|
||||||
...CircleLight.descriptor,
|
...CircleLight.descriptor,
|
||||||
shaderCombinationSteps: [0, 1, 2],
|
shaderCombinationSteps: [1, 2],
|
||||||
},
|
},
|
||||||
Blob.descriptor,
|
Blob.descriptor,
|
||||||
],
|
],
|
||||||
this.drawNextFrame.bind(this),
|
[
|
||||||
{
|
vec3.fromValues(0, 0, 0),
|
||||||
ambientLight: rgb255(89, 25, 115),
|
vec3.fromValues(224 / 255, 96 / 255, 126 / 255),
|
||||||
enableHighDpiRendering: true,
|
vec3.fromValues(119 / 255, 173 / 255, 120 / 255),
|
||||||
colorPalette: [
|
]
|
||||||
rgb255(0, 0, 0),
|
|
||||||
rgb255(119, 143, 120),
|
|
||||||
rgb255(119, 143, 120),
|
|
||||||
rgb255(224, 96, 126),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.renderer.setRuntimeSettings({
|
||||||
|
ambientLight: vec3.fromValues(0.35, 0.1, 0.45),
|
||||||
|
shadowLength: 800,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { width, height } = this.canvas.getBoundingClientRect();
|
||||||
|
const length = vec2.length([width, height]);
|
||||||
|
|
||||||
|
this.blob = new Blob([width / 2, -length / 4 + length / 2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private blob: Blob;
|
private blob: Blob;
|
||||||
|
|
||||||
private drawNextFrame(
|
private deltaSinceStart = 0;
|
||||||
renderer: Renderer,
|
public drawNextFrame(
|
||||||
currentTime: DOMHighResTimeStamp,
|
currentTime: DOMHighResTimeStamp,
|
||||||
_: DOMHighResTimeStamp
|
deltaTime: DOMHighResTimeStamp
|
||||||
): boolean {
|
): void {
|
||||||
this.insights = renderer.insights;
|
const { width, height } = this.canvas.getBoundingClientRect();
|
||||||
|
this.deltaSinceStart += deltaTime;
|
||||||
|
this.renderer.setViewArea([0, height], [width, height]);
|
||||||
|
|
||||||
const width = renderer.canvasSize.x;
|
this.renderer.autoscaleQuality(deltaTime);
|
||||||
const height = renderer.canvasSize.y;
|
this.overlay.innerText = prettyPrint(this.renderer.insights);
|
||||||
renderer.setViewArea([0, height], [width, height]);
|
|
||||||
|
|
||||||
this.overlay.innerText = prettyPrint(renderer.insights);
|
|
||||||
|
|
||||||
const length = vec2.length([width, height]);
|
const length = vec2.length([width, height]);
|
||||||
|
|
||||||
const q = (currentTime % 8000) / 4300;
|
const q = (this.deltaSinceStart % 8000) / 4300;
|
||||||
this.blob.animate(currentTime);
|
this.blob.animate(this.deltaSinceStart);
|
||||||
this.blob.position = [width / 2, -length / 4 + length / 2.5];
|
|
||||||
|
|
||||||
[
|
[
|
||||||
new Circle([width / 2, -length / 4], length / 2.5),
|
new Circle([width / 2, -length / 4], length / 2),
|
||||||
this.blob,
|
this.blob,
|
||||||
new CircleLight(
|
new CircleLight(
|
||||||
[
|
[
|
||||||
(Math.cos((1 - q) * Math.PI) * length) / 2 + width / 2,
|
(Math.cos((1 - q) * Math.PI) * length) / 2 + width / 2,
|
||||||
(Math.sin((1 - q) * Math.PI) * length) / 2,
|
(Math.sin((1 - q) * Math.PI) * length) / 2,
|
||||||
],
|
],
|
||||||
rgb(1, 0.8, 0),
|
[1, 0.8, 0],
|
||||||
1
|
1
|
||||||
),
|
),
|
||||||
new CircleLight(
|
new CircleLight(
|
||||||
|
|
@ -82,11 +80,15 @@ export class BlobScene implements Scene {
|
||||||
(Math.cos(-q * Math.PI) * length) / 2 + width / 2,
|
(Math.cos(-q * Math.PI) * length) / 2 + width / 2,
|
||||||
(Math.sin(-q * Math.PI) * length) / 2,
|
(Math.sin(-q * Math.PI) * length) / 2,
|
||||||
],
|
],
|
||||||
rgb(0, 0.8, 1),
|
[0, 0.8, 1],
|
||||||
1
|
1
|
||||||
),
|
),
|
||||||
].forEach((d) => renderer.addDrawable(d));
|
].forEach((d) => this.renderer.addDrawable(d));
|
||||||
|
|
||||||
return currentTime < settings.sceneTimeInMilliseconds;
|
this.renderer.renderDrawables();
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this.renderer.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,18 @@
|
||||||
import { mat2d, vec2 } from 'gl-matrix';
|
import { mat2d, vec2 } from 'gl-matrix';
|
||||||
import { Drawable, DrawableDescriptor } from 'sdf-2d';
|
import { Circle, Drawable, DrawableDescriptor } from 'sdf-2d';
|
||||||
import { Circle } from '../../helper/circle';
|
|
||||||
|
|
||||||
export class Blob extends Drawable {
|
export class Blob extends Drawable {
|
||||||
public static descriptor: DrawableDescriptor = {
|
public static descriptor: DrawableDescriptor = {
|
||||||
sdf: {
|
sdf: {
|
||||||
shader: `
|
shader: `
|
||||||
uniform vec2 headCenters[BLOB_COUNT];
|
uniform struct {
|
||||||
uniform vec2 leftFootCenters[BLOB_COUNT];
|
vec2 headCenter;
|
||||||
uniform vec2 rightFootCenters[BLOB_COUNT];
|
vec2 leftFootCenter;
|
||||||
uniform float headRadii[BLOB_COUNT];
|
vec2 rightFootCenter;
|
||||||
uniform float footRadii[BLOB_COUNT];
|
float headRadius;
|
||||||
|
float footRadius;
|
||||||
|
float k;
|
||||||
|
}[BLOB_COUNT] blobs;
|
||||||
|
|
||||||
float smoothMin(float a, float b)
|
float smoothMin(float a, float b)
|
||||||
{
|
{
|
||||||
|
|
@ -19,18 +21,15 @@ export class Blob extends Drawable {
|
||||||
return -log2( res )/k;
|
return -log2( res )/k;
|
||||||
}
|
}
|
||||||
|
|
||||||
float circleDistance(vec2 circleCenter, float radius, vec2 target) {
|
float circleDistance(vec2 circleCenter, float radius) {
|
||||||
return distance(target, circleCenter) - radius;
|
return distance(position, circleCenter) - radius;
|
||||||
}
|
}
|
||||||
|
|
||||||
float blobMinDistance(vec2 target, out vec4 color) {
|
void blobMinDistance(inout float minDistance, inout float color) {
|
||||||
float minDistance = 1000.0;
|
|
||||||
color = readFromPalette(3);
|
|
||||||
|
|
||||||
for (int i = 0; i < BLOB_COUNT; i++) {
|
for (int i = 0; i < BLOB_COUNT; i++) {
|
||||||
float headDistance = circleDistance(headCenters[i], headRadii[i], target);
|
float headDistance = circleDistance(blobs[i].headCenter, blobs[i].headRadius);
|
||||||
float leftFootDistance = circleDistance(leftFootCenters[i], footRadii[i], target);
|
float leftFootDistance = circleDistance(blobs[i].leftFootCenter, blobs[i].footRadius);
|
||||||
float rightFootDistance = circleDistance(rightFootCenters[i], footRadii[i], target);
|
float rightFootDistance = circleDistance(blobs[i].rightFootCenter, blobs[i].footRadius);
|
||||||
|
|
||||||
float res = min(
|
float res = min(
|
||||||
smoothMin(headDistance, leftFootDistance),
|
smoothMin(headDistance, leftFootDistance),
|
||||||
|
|
@ -38,22 +37,15 @@ export class Blob extends Drawable {
|
||||||
);
|
);
|
||||||
|
|
||||||
minDistance = min(minDistance, res);
|
minDistance = min(minDistance, res);
|
||||||
|
color = mix(1.0, color, step(distanceNdcPixelSize + SURFACE_OFFSET, res));
|
||||||
}
|
}
|
||||||
|
|
||||||
return minDistance;
|
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
distanceFunctionName: 'blobMinDistance',
|
distanceFunctionName: 'blobMinDistance',
|
||||||
},
|
},
|
||||||
propertyUniformMapping: {
|
uniformName: 'blobs',
|
||||||
footRadius: 'footRadii',
|
|
||||||
headRadius: 'headRadii',
|
|
||||||
rightFootCenter: 'rightFootCenters',
|
|
||||||
leftFootCenter: 'leftFootCenters',
|
|
||||||
headCenter: 'headCenters',
|
|
||||||
},
|
|
||||||
uniformCountMacroName: 'BLOB_COUNT',
|
uniformCountMacroName: 'BLOB_COUNT',
|
||||||
shaderCombinationSteps: [0, 1],
|
shaderCombinationSteps: [1],
|
||||||
empty: new Blob(vec2.fromValues(0, 0)),
|
empty: new Blob(vec2.fromValues(0, 0)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -120,10 +112,12 @@ export class Blob extends Drawable {
|
||||||
Math.sin((q > 1 ? q - 1 : 0) * Math.PI) * 10
|
Math.sin((q > 1 ? q - 1 : 0) * Math.PI) * 10
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.position = this.center;
|
||||||
}
|
}
|
||||||
|
|
||||||
public minDistance(target: vec2): number {
|
public minDistance(target: vec2): number {
|
||||||
return this.boundingCircle.distance(target);
|
return this.boundingCircle.minDistance(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getObjectToSerialize(transform2d: mat2d, transform1d: number): any {
|
protected getObjectToSerialize(transform2d: mat2d, transform1d: number): any {
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
|
||||||
import { Flashlight, Renderer, rgb, rgb255, runAnimation } from 'sdf-2d';
|
|
||||||
import { prettyPrint } from '../../helper/pretty-print';
|
|
||||||
import { Random } from '../../helper/random';
|
|
||||||
import { settings } from '../../settings';
|
|
||||||
import { Scene } from '../scene';
|
|
||||||
import { Metaball, MetaCircle } from './metaball';
|
|
||||||
|
|
||||||
export class MetaballScene implements Scene {
|
|
||||||
private circles: Array<Metaball> = [];
|
|
||||||
|
|
||||||
private overlay: HTMLDivElement;
|
|
||||||
public insights?: any;
|
|
||||||
|
|
||||||
public async run(canvas: HTMLCanvasElement, overlay: HTMLDivElement): Promise<void> {
|
|
||||||
this.overlay = overlay;
|
|
||||||
|
|
||||||
for (let i = 0; i < 16; i++) {
|
|
||||||
this.circles.push(
|
|
||||||
new Metaball(
|
|
||||||
vec2.fromValues(
|
|
||||||
Random.getRandomInRange(0.3, 0.6),
|
|
||||||
Random.getRandomInRange(0.3, 0.6)
|
|
||||||
),
|
|
||||||
vec2.fromValues(
|
|
||||||
Random.getRandomInRange(0.05, 0.5),
|
|
||||||
Random.getRandomInRange(0.05, 0.5)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await runAnimation(
|
|
||||||
canvas,
|
|
||||||
[
|
|
||||||
{
|
|
||||||
...Flashlight.descriptor,
|
|
||||||
shaderCombinationSteps: [0, 2],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...MetaCircle.descriptor,
|
|
||||||
shaderCombinationSteps: [0, 1, 2, 4, 8, 12, 16],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
this.drawNextFrame.bind(this),
|
|
||||||
{
|
|
||||||
ambientLight: rgb(0.1, 0.1, 0.3),
|
|
||||||
enableHighDpiRendering: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private drawNextFrame(
|
|
||||||
renderer: Renderer,
|
|
||||||
currentTime: DOMHighResTimeStamp,
|
|
||||||
_: DOMHighResTimeStamp
|
|
||||||
): boolean {
|
|
||||||
this.insights = renderer.insights;
|
|
||||||
|
|
||||||
const width = renderer.canvasSize.x;
|
|
||||||
const height = renderer.canvasSize.y;
|
|
||||||
|
|
||||||
const viewAreaWidth = width / Math.max(width, height);
|
|
||||||
const viewAreaHeight = height / Math.max(width, height);
|
|
||||||
renderer.setViewArea(
|
|
||||||
vec2.fromValues(0, viewAreaHeight),
|
|
||||||
vec2.fromValues(viewAreaWidth, viewAreaHeight)
|
|
||||||
);
|
|
||||||
|
|
||||||
this.overlay.innerText = prettyPrint(renderer.insights);
|
|
||||||
|
|
||||||
this.circles.forEach((c) => {
|
|
||||||
c.animate(currentTime / 2000, viewAreaWidth, viewAreaHeight);
|
|
||||||
renderer.addDrawable(c.shape);
|
|
||||||
});
|
|
||||||
|
|
||||||
const sweep = Math.sin(currentTime / 1500) * 0.5;
|
|
||||||
const light1 = new Flashlight(
|
|
||||||
vec2.fromValues(-0.05, -0.05),
|
|
||||||
rgb255(104, 171, 212),
|
|
||||||
0.02,
|
|
||||||
vec2.fromValues(Math.cos(Math.PI / 4 + sweep), Math.sin(Math.PI / 4 + sweep))
|
|
||||||
);
|
|
||||||
const light2 = new Flashlight(
|
|
||||||
vec2.fromValues(viewAreaWidth + 0.05, -0.05),
|
|
||||||
rgb255(226, 90, 102),
|
|
||||||
0.02,
|
|
||||||
vec2.fromValues(
|
|
||||||
Math.cos((Math.PI * 3) / 4 - sweep),
|
|
||||||
Math.sin((Math.PI * 3) / 4 - sweep)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
renderer.addDrawable(light1);
|
|
||||||
renderer.addDrawable(light2);
|
|
||||||
|
|
||||||
return currentTime < settings.sceneTimeInMilliseconds;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
|
||||||
import { MetaCircleFactory, rgb255 } from 'sdf-2d';
|
|
||||||
import { Random } from '../../helper/random';
|
|
||||||
|
|
||||||
export const MetaCircle = MetaCircleFactory(rgb255(186, 59, 70));
|
|
||||||
|
|
||||||
export class Metaball {
|
|
||||||
public shape = new MetaCircle(vec2.create(), Random.getRandomInRange(0.025, 0.075));
|
|
||||||
|
|
||||||
private direction = Random.getRandom() > 0.5 ? 1 : -1;
|
|
||||||
private speed = Random.getRandomInRange(0.5, 2);
|
|
||||||
constructor(
|
|
||||||
private readonly center: vec2,
|
|
||||||
private readonly size: vec2
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public animate(currentTime: DOMHighResTimeStamp, width: number, height: number) {
|
|
||||||
vec2.set(
|
|
||||||
this.shape.center,
|
|
||||||
this.size.x * Math.cos(currentTime * this.speed * this.direction) +
|
|
||||||
this.center.x * width,
|
|
||||||
this.size.y * Math.sin(currentTime * this.speed * this.direction) +
|
|
||||||
this.center.y * height
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,215 +0,0 @@
|
||||||
import { mat2d, vec2 } from 'gl-matrix';
|
|
||||||
import { Drawable, DrawableDescriptor } from 'sdf-2d';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every visual style a body can take. The style selects both the outline
|
|
||||||
* (smooth disc, jagged asteroid, ring annulus) and the per-fragment surface
|
|
||||||
* colouring inside the SDF shader — gradients, bands, continents — instead of
|
|
||||||
* a single flat palette colour.
|
|
||||||
*/
|
|
||||||
export const BodyStyle = {
|
|
||||||
sun: 0, // radial white-hot → gold → ember gradient with simmering granules
|
|
||||||
rocky: 1, // two-tone continent blotches
|
|
||||||
banded: 2, // wavy gas-giant stripes
|
|
||||||
ice: 3, // swirling azure sheen
|
|
||||||
asteroid: 4, // jagged outline, speckled surface
|
|
||||||
rings: 5, // two concentric annuli (drawn around a planet)
|
|
||||||
moon: 6, // small cratered grey
|
|
||||||
star: 7, // tiny twinkle, colour boosted far above 1 to stay bright after
|
|
||||||
// being multiplied by the dim ambient lighting
|
|
||||||
} as const;
|
|
||||||
export type BodyStyleName = keyof typeof BodyStyle;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One celestial body: sun, planet, moon, ring system, asteroid, comet
|
|
||||||
* particle, or background star. A single descriptor renders all of them.
|
|
||||||
*
|
|
||||||
* Each instance carries two palette colour indices (mixed per fragment by its
|
|
||||||
* style), a `seed` that animates its surface, and `litAngle` — the direction
|
|
||||||
* towards the sun — used to shade a day/night terminator. Outline roughness is
|
|
||||||
* derived from the style inside the shader.
|
|
||||||
*/
|
|
||||||
export class Body extends Drawable {
|
|
||||||
// The outline harmonics can push the surface outward by at most
|
|
||||||
// 0.12 × roughness, and the roughest style (asteroid) uses 2.6.
|
|
||||||
private static readonly MAX_OUTLINE_GROWTH = 1.45;
|
|
||||||
|
|
||||||
public static descriptor: DrawableDescriptor = {
|
|
||||||
sdf: {
|
|
||||||
shader: `
|
|
||||||
uniform vec2 bodyCenters[BODY_COUNT];
|
|
||||||
uniform float bodyRadii[BODY_COUNT];
|
|
||||||
uniform float bodyColors[BODY_COUNT];
|
|
||||||
uniform float bodySeeds[BODY_COUNT];
|
|
||||||
uniform float bodyStyles[BODY_COUNT];
|
|
||||||
uniform float bodyLitAngles[BODY_COUNT];
|
|
||||||
|
|
||||||
float bodyNoise(vec2 p) {
|
|
||||||
return sin(p.x) * sin(p.y);
|
|
||||||
}
|
|
||||||
|
|
||||||
vec3 bodySurfaceColor(
|
|
||||||
int style,
|
|
||||||
vec2 d,
|
|
||||||
float radius,
|
|
||||||
float seed,
|
|
||||||
float litAngle,
|
|
||||||
vec3 colorA,
|
|
||||||
vec3 colorB
|
|
||||||
) {
|
|
||||||
vec2 q = d / radius;
|
|
||||||
float r01 = length(q);
|
|
||||||
|
|
||||||
if (style == ${BodyStyle.sun}) {
|
|
||||||
// Three drifting interference lattices; the first two sit at
|
|
||||||
// nearby frequencies and slide in opposite directions, so the
|
|
||||||
// convection cells continuously merge, split and boil instead of
|
|
||||||
// gliding across the disc as one rigid pattern.
|
|
||||||
float granules =
|
|
||||||
0.8 * bodyNoise(q * 8.0 + vec2(seed, -seed * 0.6)) +
|
|
||||||
0.55 * bodyNoise(q * 8.9 - vec2(seed * 0.8, seed * 1.1)) +
|
|
||||||
0.45 * bodyNoise(q * 15.0 + vec2(seed * 1.4, -seed * 0.9));
|
|
||||||
vec3 c = mix(vec3(1.4, 1.32, 1.1), colorA, smoothstep(0.0, 0.6, r01));
|
|
||||||
c = mix(c, colorB, smoothstep(0.5, 1.0, r01 + granules * 0.15));
|
|
||||||
return c * (1.0 + 0.2 * granules);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (style == ${BodyStyle.star}) {
|
|
||||||
return colorA * (3.75 + 1.75 * sin(seed));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every world shares one recipe: its surface coordinates rotate
|
|
||||||
// with the body's spin (seed grows over time), so stripes and
|
|
||||||
// blotches visibly roll around the disc, then a single field —
|
|
||||||
// radial ripples for rings, stripes for the banded and ice giants,
|
|
||||||
// blotches for everything rocky — mixes the two colours. The
|
|
||||||
// terminator and limb shading below use the unrotated coordinates,
|
|
||||||
// keeping the lighting locked to the sun while the surface turns.
|
|
||||||
vec2 qr = vec2(
|
|
||||||
q.x * cos(seed) - q.y * sin(seed),
|
|
||||||
q.x * sin(seed) + q.y * cos(seed)
|
|
||||||
);
|
|
||||||
float field;
|
|
||||||
if (style == ${BodyStyle.rings}) {
|
|
||||||
field = sin(r01 * 26.0 + seed);
|
|
||||||
} else if (style == ${BodyStyle.banded} || style == ${BodyStyle.ice}) {
|
|
||||||
field = sin(qr.y * 5.0 + seed * 0.3);
|
|
||||||
} else {
|
|
||||||
field = bodyNoise(qr * 3.4) + 0.6 * bodyNoise(qr * 7.3 + vec2(2.7, 1.3));
|
|
||||||
}
|
|
||||||
vec3 c = mix(colorB, colorA, smoothstep(-0.6, 0.6, field));
|
|
||||||
|
|
||||||
vec2 toSun = vec2(cos(litAngle), sin(litAngle));
|
|
||||||
float dayside = smoothstep(-0.85, 0.55, dot(q / max(r01, 1e-4), toSun));
|
|
||||||
c *= mix(0.22, 1.1, dayside);
|
|
||||||
if (style != ${BodyStyle.rings}) {
|
|
||||||
c *= 1.0 - 0.45 * smoothstep(0.5, 1.0, r01);
|
|
||||||
}
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
|
|
||||||
float bodyMinDistance(vec2 target, out vec4 color) {
|
|
||||||
color = vec4(0.0, 0.0, 0.0, 1.0);
|
|
||||||
float minDistance = 1000.0;
|
|
||||||
|
|
||||||
for (int i = 0; i < BODY_COUNT; i++) {
|
|
||||||
vec2 d = target - bodyCenters[i];
|
|
||||||
float radius = max(bodyRadii[i], 1e-5);
|
|
||||||
int style = int(bodyStyles[i] + 0.5);
|
|
||||||
float seed = bodySeeds[i];
|
|
||||||
float dist;
|
|
||||||
|
|
||||||
if (style == ${BodyStyle.rings}) {
|
|
||||||
float len = length(d);
|
|
||||||
dist = min(
|
|
||||||
abs(len - radius * 0.68) - radius * 0.1,
|
|
||||||
abs(len - radius * 0.9) - radius * 0.06
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
float angle = atan(d.y, d.x);
|
|
||||||
float roughness = style == ${BodyStyle.sun}
|
|
||||||
? 0.5
|
|
||||||
: style == ${BodyStyle.asteroid}
|
|
||||||
? 2.6
|
|
||||||
: style == ${BodyStyle.rocky}
|
|
||||||
? 0.25
|
|
||||||
: style == ${BodyStyle.moon}
|
|
||||||
? 0.35
|
|
||||||
: 0.06;
|
|
||||||
float wobble =
|
|
||||||
sin(angle * 3.0 + seed) * 0.06 +
|
|
||||||
sin(angle * 7.0 + seed * 1.7) * 0.04 +
|
|
||||||
sin(angle * 17.0 - seed * 0.5) * 0.02;
|
|
||||||
dist = length(d) - radius * (1.0 + wobble * roughness);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dist < minDistance) {
|
|
||||||
minDistance = dist;
|
|
||||||
float packedColor = bodyColors[i];
|
|
||||||
float indexA = floor(packedColor / 32.0);
|
|
||||||
vec3 colorA = readFromPalette(int(indexA + 0.5)).rgb;
|
|
||||||
vec3 colorB = readFromPalette(int(packedColor - indexA * 32.0 + 0.5)).rgb;
|
|
||||||
color = vec4(
|
|
||||||
bodySurfaceColor(
|
|
||||||
style, d, radius, seed, bodyLitAngles[i], colorA, colorB
|
|
||||||
),
|
|
||||||
1.0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return minDistance;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
distanceFunctionName: 'bodyMinDistance',
|
|
||||||
},
|
|
||||||
propertyUniformMapping: {
|
|
||||||
center: 'bodyCenters',
|
|
||||||
radius: 'bodyRadii',
|
|
||||||
colors: 'bodyColors',
|
|
||||||
seed: 'bodySeeds',
|
|
||||||
style: 'bodyStyles',
|
|
||||||
litAngle: 'bodyLitAngles',
|
|
||||||
},
|
|
||||||
uniformCountMacroName: 'BODY_COUNT',
|
|
||||||
shaderCombinationSteps: [0, 4, 8, 16, 32],
|
|
||||||
empty: new Body(vec2.create(), 0, 'star', 0, 0, 0),
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
public center: vec2,
|
|
||||||
public radius: number,
|
|
||||||
public style: BodyStyleName,
|
|
||||||
public colorA: number,
|
|
||||||
public colorB: number,
|
|
||||||
public seed: number,
|
|
||||||
public litAngle = 0
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
public minDistance(target: vec2): number {
|
|
||||||
// Conservative: bound by the most outward the bumpiest outline can reach
|
|
||||||
// so a body is never culled from a tile its surface might poke into.
|
|
||||||
return vec2.dist(this.center, target) - this.radius * Body.MAX_OUTLINE_GROWTH;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getObjectToSerialize(transform2d: mat2d, transform1d: number): any {
|
|
||||||
// litAngle is a world-space direction: push it through the linear part of
|
|
||||||
// the transform so it survives the view mapping's scale and y-flip.
|
|
||||||
const dx = Math.cos(this.litAngle);
|
|
||||||
const dy = Math.sin(this.litAngle);
|
|
||||||
|
|
||||||
return {
|
|
||||||
center: vec2.transformMat2d(vec2.create(), this.center, transform2d),
|
|
||||||
radius: this.radius * transform1d,
|
|
||||||
colors: this.colorA * 32 + this.colorB,
|
|
||||||
seed: this.seed,
|
|
||||||
style: BodyStyle[this.style],
|
|
||||||
litAngle: Math.atan2(
|
|
||||||
transform2d[1] * dx + transform2d[3] * dy,
|
|
||||||
transform2d[0] * dx + transform2d[2] * dy
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,299 +0,0 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
|
||||||
import { CircleLight, hsl, Renderer, rgb, runAnimation } from 'sdf-2d';
|
|
||||||
import { prettyPrint } from '../../helper/pretty-print';
|
|
||||||
import { Random } from '../../helper/random';
|
|
||||||
import { settings } from '../../settings';
|
|
||||||
import { Scene } from '../scene';
|
|
||||||
import { Body, BodyStyleName } from './body';
|
|
||||||
|
|
||||||
// Every colour the scene uses; bodies reference entries by name and mix their
|
|
||||||
// two colours per fragment in the shader, so nothing renders as a flat fill.
|
|
||||||
const palette = {
|
|
||||||
white: rgb(1, 1, 1),
|
|
||||||
sunGold: hsl(38, 100, 62),
|
|
||||||
sunEmber: hsl(14, 95, 46),
|
|
||||||
scorchedTan: hsl(32, 22, 60),
|
|
||||||
scorchedBrown: hsl(20, 25, 38),
|
|
||||||
venusCream: hsl(46, 85, 78),
|
|
||||||
venusAmber: hsl(33, 75, 58),
|
|
||||||
earthLand: hsl(135, 50, 45),
|
|
||||||
earthOcean: hsl(213, 85, 52),
|
|
||||||
marsRust: hsl(16, 80, 55),
|
|
||||||
marsShadow: hsl(22, 65, 34),
|
|
||||||
jovianCream: hsl(36, 70, 75),
|
|
||||||
jovianRust: hsl(16, 65, 52),
|
|
||||||
saturnSand: hsl(45, 65, 76),
|
|
||||||
saturnDust: hsl(36, 50, 58),
|
|
||||||
ringGold: hsl(44, 40, 70),
|
|
||||||
ringShadow: hsl(40, 28, 42),
|
|
||||||
iceAzure: hsl(200, 90, 68),
|
|
||||||
iceDeep: hsl(232, 70, 50),
|
|
||||||
moonGrey: hsl(220, 10, 68),
|
|
||||||
moonShadow: hsl(225, 12, 44),
|
|
||||||
};
|
|
||||||
type PaletteColor = keyof typeof palette;
|
|
||||||
const paletteIndex = (name: PaletteColor): number => Object.keys(palette).indexOf(name);
|
|
||||||
|
|
||||||
interface MoonSpec {
|
|
||||||
orbit: number; // around the parent planet, as a fraction of the system radius
|
|
||||||
size: number;
|
|
||||||
speed: number; // rad/s
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PlanetSpec {
|
|
||||||
orbit: number; // fraction of the system radius
|
|
||||||
size: number;
|
|
||||||
style: BodyStyleName;
|
|
||||||
colors: [PaletteColor, PaletteColor];
|
|
||||||
spin: number; // how fast the surface pattern drifts
|
|
||||||
ringSize?: number;
|
|
||||||
moons?: Array<MoonSpec>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A miniature solar system: small rocky worlds inside, then the giants —
|
|
||||||
// banded, ringed, and an ice giant — spread roughly evenly from the sun's
|
|
||||||
// doorstep to the screen edge. Planets all orbit prograde at Kepler speeds
|
|
||||||
// (∝ orbit⁻¹·⁵).
|
|
||||||
const PLANETS: Array<PlanetSpec> = [
|
|
||||||
{
|
|
||||||
orbit: 0.165,
|
|
||||||
size: 0.026,
|
|
||||||
style: 'rocky',
|
|
||||||
colors: ['scorchedTan', 'scorchedBrown'],
|
|
||||||
spin: 0.55,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
orbit: 0.27,
|
|
||||||
size: 0.036,
|
|
||||||
style: 'banded',
|
|
||||||
colors: ['venusCream', 'venusAmber'],
|
|
||||||
spin: 0.4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
orbit: 0.385,
|
|
||||||
size: 0.04,
|
|
||||||
style: 'rocky',
|
|
||||||
colors: ['earthLand', 'earthOcean'],
|
|
||||||
spin: 0.45,
|
|
||||||
moons: [{ orbit: 0.06, size: 0.01, speed: 2.4 }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
orbit: 0.49,
|
|
||||||
size: 0.03,
|
|
||||||
style: 'rocky',
|
|
||||||
colors: ['marsRust', 'marsShadow'],
|
|
||||||
spin: 0.5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
orbit: 0.7,
|
|
||||||
size: 0.06,
|
|
||||||
style: 'banded',
|
|
||||||
colors: ['jovianCream', 'jovianRust'],
|
|
||||||
spin: 0.3,
|
|
||||||
moons: [
|
|
||||||
{ orbit: 0.085, size: 0.011, speed: 1.8 },
|
|
||||||
{ orbit: 0.108, size: 0.009, speed: 1.2 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
orbit: 0.825,
|
|
||||||
size: 0.042,
|
|
||||||
style: 'banded',
|
|
||||||
colors: ['saturnSand', 'saturnDust'],
|
|
||||||
spin: 0.32,
|
|
||||||
ringSize: 0.082,
|
|
||||||
},
|
|
||||||
{ orbit: 0.95, size: 0.036, style: 'ice', colors: ['iceAzure', 'iceDeep'], spin: 0.45 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ORBITAL_SPEED = 0.26; // rad/s at orbit = 1; scaled by Kepler's third law
|
|
||||||
|
|
||||||
interface OrbitingBody {
|
|
||||||
drawable: Body;
|
|
||||||
orbit: number; // around the sun — or around `parent`, for moons and rings
|
|
||||||
phase: number;
|
|
||||||
speed: number;
|
|
||||||
size: number;
|
|
||||||
spin: number;
|
|
||||||
seedPhase: number;
|
|
||||||
parent?: OrbitingBody;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OrbitScene implements Scene {
|
|
||||||
private overlay: HTMLDivElement;
|
|
||||||
public insights?: any;
|
|
||||||
|
|
||||||
// The sun's visible disc carries its own white-hot → ember gradient; these
|
|
||||||
// lights add the blown-out core, the warm corona reflecting off the lifted
|
|
||||||
// background, and the sunlight (plus shadows) on everything orbiting.
|
|
||||||
private sunCore = new CircleLight(vec2.create(), rgb(1.0, 0.96, 0.88), 0.1);
|
|
||||||
private sunGlow = new CircleLight(vec2.create(), rgb(1.35, 0.58, 0.18), 0.5);
|
|
||||||
|
|
||||||
private sun: OrbitingBody = {
|
|
||||||
drawable: new Body(
|
|
||||||
vec2.create(),
|
|
||||||
0,
|
|
||||||
'sun',
|
|
||||||
paletteIndex('sunGold'),
|
|
||||||
paletteIndex('sunEmber'),
|
|
||||||
0
|
|
||||||
),
|
|
||||||
orbit: 0,
|
|
||||||
phase: 0,
|
|
||||||
speed: 0,
|
|
||||||
size: 0.115,
|
|
||||||
spin: 0.9,
|
|
||||||
seedPhase: Random.getRandom() * 10,
|
|
||||||
};
|
|
||||||
|
|
||||||
private bodies: Array<OrbitingBody> = [];
|
|
||||||
|
|
||||||
public async run(canvas: HTMLCanvasElement, overlay: HTMLDivElement): Promise<void> {
|
|
||||||
this.overlay = overlay;
|
|
||||||
|
|
||||||
this.bodies.push(this.sun);
|
|
||||||
|
|
||||||
PLANETS.forEach((spec) => {
|
|
||||||
const planet = this.createOrbitingBody(
|
|
||||||
spec.orbit,
|
|
||||||
spec.size,
|
|
||||||
spec.style,
|
|
||||||
spec.colors,
|
|
||||||
spec.spin
|
|
||||||
);
|
|
||||||
this.bodies.push(planet);
|
|
||||||
|
|
||||||
if (spec.ringSize) {
|
|
||||||
this.bodies.push({
|
|
||||||
...this.createOrbitingBody(
|
|
||||||
0,
|
|
||||||
spec.ringSize,
|
|
||||||
'rings',
|
|
||||||
['ringGold', 'ringShadow'],
|
|
||||||
0
|
|
||||||
),
|
|
||||||
parent: planet,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
spec.moons?.forEach((moon) => {
|
|
||||||
this.bodies.push({
|
|
||||||
...this.createOrbitingBody(
|
|
||||||
moon.orbit,
|
|
||||||
moon.size,
|
|
||||||
'moon',
|
|
||||||
['moonGrey', 'moonShadow'],
|
|
||||||
0.8
|
|
||||||
),
|
|
||||||
speed: moon.speed,
|
|
||||||
parent: planet,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const bodyCount = this.bodies.length;
|
|
||||||
|
|
||||||
await runAnimation(
|
|
||||||
canvas,
|
|
||||||
[
|
|
||||||
{
|
|
||||||
...Body.descriptor,
|
|
||||||
shaderCombinationSteps: [...new Set([0, 4, 8, 16, 32, bodyCount])],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...CircleLight.descriptor,
|
|
||||||
shaderCombinationSteps: [0, 2],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
this.drawNextFrame.bind(this),
|
|
||||||
{
|
|
||||||
enableHighDpiRendering: true,
|
|
||||||
motionBlur: 0.5,
|
|
||||||
lightPenetrationRatio: 0.8,
|
|
||||||
ambientLight: rgb(0.07, 0.075, 0.145),
|
|
||||||
backgroundColor: rgb(0.035, 0.035, 0.07),
|
|
||||||
colorPalette: Object.values(palette),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private createOrbitingBody(
|
|
||||||
orbit: number,
|
|
||||||
size: number,
|
|
||||||
style: BodyStyleName,
|
|
||||||
colors: [PaletteColor, PaletteColor],
|
|
||||||
spin: number
|
|
||||||
): OrbitingBody {
|
|
||||||
return {
|
|
||||||
drawable: new Body(
|
|
||||||
vec2.create(),
|
|
||||||
0,
|
|
||||||
style,
|
|
||||||
paletteIndex(colors[0]),
|
|
||||||
paletteIndex(colors[1]),
|
|
||||||
0
|
|
||||||
),
|
|
||||||
orbit,
|
|
||||||
phase: Random.getRandom() * Math.PI * 2,
|
|
||||||
speed: orbit > 0 ? ORBITAL_SPEED / Math.pow(orbit, 1.5) : 0,
|
|
||||||
size,
|
|
||||||
spin,
|
|
||||||
seedPhase: Random.getRandom() * 10,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private drawNextFrame(
|
|
||||||
renderer: Renderer,
|
|
||||||
currentTime: DOMHighResTimeStamp,
|
|
||||||
_: DOMHighResTimeStamp
|
|
||||||
): boolean {
|
|
||||||
this.insights = renderer.insights;
|
|
||||||
|
|
||||||
const width = renderer.canvasSize.x;
|
|
||||||
const height = renderer.canvasSize.y;
|
|
||||||
const maxSide = Math.max(width, height);
|
|
||||||
|
|
||||||
const viewAreaWidth = width / maxSide;
|
|
||||||
const viewAreaHeight = height / maxSide;
|
|
||||||
renderer.setViewArea(
|
|
||||||
vec2.fromValues(0, viewAreaHeight),
|
|
||||||
vec2.fromValues(viewAreaWidth, viewAreaHeight)
|
|
||||||
);
|
|
||||||
|
|
||||||
this.overlay.innerText = prettyPrint(renderer.insights);
|
|
||||||
|
|
||||||
const center = vec2.fromValues(viewAreaWidth / 2, viewAreaHeight / 2);
|
|
||||||
const maxRadius = Math.min(viewAreaWidth, viewAreaHeight) / 2;
|
|
||||||
const time = currentTime / 1000;
|
|
||||||
|
|
||||||
vec2.copy(this.sunCore.center, center);
|
|
||||||
vec2.copy(this.sunGlow.center, center);
|
|
||||||
this.sunGlow.intensity = 0.5 + 0.05 * Math.sin(time * 1.7);
|
|
||||||
|
|
||||||
// Parents are pushed before their moons and rings, so each parent's
|
|
||||||
// position is already up to date when its satellites read it.
|
|
||||||
this.bodies.forEach((body) => {
|
|
||||||
const angle = body.phase + time * body.speed;
|
|
||||||
const origin = body.parent?.drawable.center ?? center;
|
|
||||||
vec2.set(
|
|
||||||
body.drawable.center,
|
|
||||||
origin[0] + Math.cos(angle) * body.orbit * maxRadius,
|
|
||||||
origin[1] + Math.sin(angle) * body.orbit * maxRadius
|
|
||||||
);
|
|
||||||
// The sun breathes gently; everything else keeps its size.
|
|
||||||
const pulse = body === this.sun ? 1 + 0.015 * Math.sin(time * 2.4) : 1;
|
|
||||||
body.drawable.radius = body.size * maxRadius * pulse;
|
|
||||||
body.drawable.seed = body.seedPhase + time * body.spin;
|
|
||||||
body.drawable.litAngle = Math.atan2(
|
|
||||||
center[1] - body.drawable.center[1],
|
|
||||||
center[0] - body.drawable.center[0]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.bodies.forEach((body) => renderer.addDrawable(body.drawable));
|
|
||||||
renderer.addDrawable(this.sunCore);
|
|
||||||
renderer.addDrawable(this.sunGlow);
|
|
||||||
|
|
||||||
return currentTime < settings.sceneTimeInMilliseconds;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +1,38 @@
|
||||||
import { ReadonlyVec2, vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
import { DropletFactory, rgb255 } from 'sdf-2d';
|
import { Tunnel } from 'sdf-2d';
|
||||||
import { Random } from '../../helper/random';
|
import { Random } from '../../helper/random';
|
||||||
|
|
||||||
export const Droplet = DropletFactory(rgb255(122, 122, 255));
|
export class Droplet {
|
||||||
export class DropletWrapper {
|
public readonly drawable: Tunnel;
|
||||||
public readonly drawable: InstanceType<typeof Droplet>;
|
|
||||||
|
|
||||||
private speed = Random.getRandom() * 0.45 + 0.45;
|
private speed = Random.getRandom() * 0.2 + 0.2;
|
||||||
private position = vec2.fromValues(
|
private position = vec2.fromValues(
|
||||||
Random.getRandomInRange(0.1, 0.9),
|
Random.getRandomInRange(0.1, 0.9),
|
||||||
Random.getRandom()
|
Random.getRandom()
|
||||||
);
|
);
|
||||||
private length = Random.getRandom() * 14 + 8;
|
private length = Random.getRandom() * 20 + 4;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const size = Random.getRandom() * 1.2 + 1;
|
const size = Random.getRandom() * 2 + 2;
|
||||||
|
|
||||||
this.drawable = new Droplet(
|
this.drawable = new Tunnel(
|
||||||
vec2.create(),
|
vec2.create(),
|
||||||
vec2.create(),
|
vec2.create(),
|
||||||
size + Random.getRandom() + 1,
|
size + Random.getRandom() * 2 + 2,
|
||||||
size
|
size
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public animate(currentTime: number, viewAreaSize: ReadonlyVec2, windSlant: number) {
|
public animate(currentTime: number, viewAreaSize: vec2) {
|
||||||
const heightOffset = 100;
|
const heightOffset = 100;
|
||||||
const fall = this.speed * currentTime;
|
|
||||||
|
|
||||||
vec2.set(
|
vec2.set(
|
||||||
this.drawable.from,
|
this.drawable.from,
|
||||||
(this.position.x * viewAreaSize.x + fall * windSlant) % viewAreaSize.x,
|
this.position.x * viewAreaSize.x,
|
||||||
viewAreaSize.y -
|
viewAreaSize.y -
|
||||||
((this.position.y * viewAreaSize.y + fall) % (viewAreaSize.y + heightOffset))
|
((this.position.y * viewAreaSize.y + this.speed * currentTime) %
|
||||||
|
(viewAreaSize.y + heightOffset))
|
||||||
);
|
);
|
||||||
|
|
||||||
// the tail points opposite to the direction of motion
|
vec2.add(this.drawable.to, this.drawable.from, vec2.fromValues(0, this.length));
|
||||||
const norm = this.length / Math.sqrt(1 + windSlant * windSlant);
|
|
||||||
vec2.add(
|
|
||||||
this.drawable.to,
|
|
||||||
this.drawable.from,
|
|
||||||
vec2.fromValues(-windSlant * norm, norm)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,91 +1,74 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2, vec3 } from 'gl-matrix';
|
||||||
import { CircleLight, Renderer, rgb, rgb255, runAnimation } from 'sdf-2d';
|
import { CircleLight, compile, Renderer, Tunnel } from 'sdf-2d';
|
||||||
import { prettyPrint } from '../../helper/pretty-print';
|
import { prettyPrint } from '../../helper/pretty-print';
|
||||||
import { settings } from '../../settings';
|
import { rgb255 } from '../../helper/rgb255';
|
||||||
import { Scene } from '../scene';
|
import { Scene } from '../scene';
|
||||||
import { Droplet, DropletWrapper } from './droplet';
|
import { Droplet } from './droplet';
|
||||||
|
|
||||||
const WIND_SLANT = 0.2;
|
|
||||||
|
|
||||||
export class RainScene implements Scene {
|
export class RainScene implements Scene {
|
||||||
private droplets: Array<DropletWrapper> = [];
|
private droplets: Array<Droplet> = [];
|
||||||
// The moon: a single pale, cold light hanging high in the sky and the scene's
|
private light1: CircleLight = new CircleLight(vec2.create(), rgb255(184, 41, 255), 2);
|
||||||
// only light source — the falling rain only shows where its streaks catch it.
|
private light2: CircleLight = new CircleLight(vec2.create(), rgb255(255, 31, 109), 2);
|
||||||
private moon = new CircleLight(vec2.create(), rgb255(200, 214, 255), 2.5);
|
|
||||||
|
|
||||||
|
private renderer: Renderer;
|
||||||
|
private canvas: HTMLCanvasElement;
|
||||||
private overlay: HTMLDivElement;
|
private overlay: HTMLDivElement;
|
||||||
public insights?: any;
|
|
||||||
|
|
||||||
public async run(canvas: HTMLCanvasElement, overlay: HTMLDivElement): Promise<void> {
|
public async initialize(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
overlay: HTMLDivElement
|
||||||
|
): Promise<void> {
|
||||||
|
this.canvas = canvas;
|
||||||
this.overlay = overlay;
|
this.overlay = overlay;
|
||||||
for (
|
this.renderer = await compile(
|
||||||
let i = 0;
|
|
||||||
i < Math.max(100, (canvas.getBoundingClientRect().width / 800) * 100);
|
|
||||||
i++
|
|
||||||
) {
|
|
||||||
this.droplets.push(new DropletWrapper());
|
|
||||||
}
|
|
||||||
|
|
||||||
await runAnimation(
|
|
||||||
canvas,
|
canvas,
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
...Droplet.descriptor,
|
...Tunnel.descriptor,
|
||||||
// Tiles that contain more droplets than the largest step have no
|
shaderCombinationSteps: [0, 1, 2, 4, 8, 12, 16, 24],
|
||||||
// compiled shader to fall back on and flicker; together with the
|
|
||||||
// raised tileMultiplier, 48 keeps the worst-case tile comfortably
|
|
||||||
// covered.
|
|
||||||
shaderCombinationSteps: [0, 2, 4, 8, 16, 32, 48],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...CircleLight.descriptor,
|
...CircleLight.descriptor,
|
||||||
shaderCombinationSteps: [0, 1],
|
shaderCombinationSteps: [2],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
this.drawNextFrame.bind(this),
|
[vec3.fromValues(0.4, 1, 0.6), vec3.fromValues(1, 1, 0), vec3.fromValues(0.3, 1, 1)]
|
||||||
{
|
|
||||||
// A dark night sky so the moon is the dominant light.
|
|
||||||
backgroundColor: rgb(0.09, 0.12, 0.19),
|
|
||||||
ambientLight: rgb(0.2, 0.22, 0.28),
|
|
||||||
enableHighDpiRendering: true,
|
|
||||||
// Keep most of the previous frame each render so the droplets smear
|
|
||||||
// into long streaks of rain.
|
|
||||||
motionBlur: 0.9,
|
|
||||||
// More, smaller tiles keep the droplet count per tile low.
|
|
||||||
tileMultiplier: 12,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.renderer.setRuntimeSettings({
|
||||||
|
ambientLight: vec3.fromValues(0.2, 0.2, 0.2),
|
||||||
|
tileMultiplier: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < (canvas.getBoundingClientRect().width / 1000) * 20; i++) {
|
||||||
|
this.droplets.push(new Droplet());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawNextFrame(
|
public drawNextFrame(
|
||||||
renderer: Renderer,
|
|
||||||
currentTime: DOMHighResTimeStamp,
|
currentTime: DOMHighResTimeStamp,
|
||||||
_: DOMHighResTimeStamp
|
deltaTime: DOMHighResTimeStamp
|
||||||
): boolean {
|
): void {
|
||||||
this.insights = renderer.insights;
|
const { width, height } = this.canvas.getBoundingClientRect();
|
||||||
|
this.renderer.setViewArea(vec2.fromValues(0, height), vec2.fromValues(width, height));
|
||||||
|
this.renderer.autoscaleQuality(deltaTime);
|
||||||
|
this.overlay.innerText = prettyPrint(this.renderer.insights);
|
||||||
|
|
||||||
const width = renderer.canvasSize.x;
|
const viewAreaSize = this.renderer.viewAreaSize;
|
||||||
const height = renderer.canvasSize.y;
|
|
||||||
|
|
||||||
renderer.setViewArea(vec2.fromValues(0, height), vec2.fromValues(width, height));
|
vec2.set(this.light1.center, 0, viewAreaSize.y / 2);
|
||||||
this.overlay.innerText = prettyPrint(renderer.insights);
|
vec2.set(this.light2.center, viewAreaSize.x, viewAreaSize.y / 2);
|
||||||
|
|
||||||
const viewAreaSize = renderer.viewAreaSize;
|
this.droplets.forEach((d) => d.animate(currentTime, viewAreaSize));
|
||||||
|
|
||||||
// The moon drifts back and forth along the top edge; the rain falls past
|
[...this.droplets.map((d) => d.drawable), this.light1, this.light2].forEach((d) =>
|
||||||
// it and lights up.
|
this.renderer.addDrawable(d)
|
||||||
vec2.set(
|
|
||||||
this.moon.center,
|
|
||||||
viewAreaSize.x * (0.5 + 0.5 * Math.sin(currentTime / 2500)),
|
|
||||||
viewAreaSize.y * 0.95
|
|
||||||
);
|
);
|
||||||
|
|
||||||
this.droplets.forEach((d) => d.animate(currentTime, viewAreaSize, WIND_SLANT));
|
this.renderer.renderDrawables();
|
||||||
|
}
|
||||||
|
|
||||||
[...this.droplets.map((d) => d.drawable), this.moon].forEach((d) =>
|
public destroy(): void {
|
||||||
renderer.addDrawable(d)
|
this.renderer.destroy();
|
||||||
);
|
|
||||||
|
|
||||||
return currentTime < settings.sceneTimeInMilliseconds;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export interface Scene {
|
export interface Scene {
|
||||||
run(canvas: HTMLCanvasElement, overlay: HTMLDivElement): Promise<void>;
|
initialize(canvas: HTMLCanvasElement, overlay: HTMLDivElement): Promise<void>;
|
||||||
readonly insights?: any;
|
drawNextFrame(currentTime: DOMHighResTimeStamp, deltaTime: DOMHighResTimeStamp): void;
|
||||||
|
destroy(): void;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,49 @@
|
||||||
import { vec2, vec3 } from 'gl-matrix';
|
import { vec2, vec3 } from 'gl-matrix';
|
||||||
import {
|
import { CircleLight, compile, InvertedTunnel, Renderer } from 'sdf-2d';
|
||||||
CircleLight,
|
|
||||||
FilteringOptions,
|
|
||||||
InvertedTunnelFactory,
|
|
||||||
Renderer,
|
|
||||||
renderNoise,
|
|
||||||
rgb,
|
|
||||||
runAnimation,
|
|
||||||
WrapOptions,
|
|
||||||
} from 'sdf-2d';
|
|
||||||
import { clamp } from '../helper/clamp';
|
import { clamp } from '../helper/clamp';
|
||||||
import { last } from '../helper/last';
|
import { last } from '../helper/last';
|
||||||
import { prettyPrint } from '../helper/pretty-print';
|
import { prettyPrint } from '../helper/pretty-print';
|
||||||
import { Random } from '../helper/random';
|
import { Random } from '../helper/random';
|
||||||
import { settings } from '../settings';
|
import { rgb } from '../helper/rgb';
|
||||||
import { Scene } from './scene';
|
import { Scene } from './scene';
|
||||||
|
|
||||||
const InvertedTunnel = InvertedTunnelFactory(3);
|
|
||||||
|
|
||||||
export class TunnelScene implements Scene {
|
export class TunnelScene implements Scene {
|
||||||
|
private renderer: Renderer;
|
||||||
|
private canvas: HTMLCanvasElement;
|
||||||
private overlay: HTMLDivElement;
|
private overlay: HTMLDivElement;
|
||||||
private tunnels: Array<InstanceType<typeof InvertedTunnel>> = [];
|
|
||||||
|
private tunnels: Array<InvertedTunnel> = [];
|
||||||
private lights: Array<CircleLight> = [];
|
private lights: Array<CircleLight> = [];
|
||||||
public insights?: any;
|
|
||||||
|
|
||||||
private generateTunnel() {
|
private generateTunnel() {
|
||||||
let previousEnd = vec2.fromValues(0, 0.5);
|
const canvasSize = this.canvas.getBoundingClientRect();
|
||||||
let previousRadius = 0.1;
|
|
||||||
|
let previousEnd = vec2.fromValues(0, 200);
|
||||||
|
let previousRadius = 50;
|
||||||
|
|
||||||
if (this.tunnels.length > 0) {
|
if (this.tunnels.length > 0) {
|
||||||
previousEnd = last(this.tunnels).to;
|
previousEnd = last(this.tunnels).to;
|
||||||
previousRadius = last(this.tunnels).toRadius;
|
previousRadius = last(this.tunnels).toRadius;
|
||||||
}
|
}
|
||||||
|
|
||||||
let height = previousEnd.y + Random.getRandomInRange(-0.4, 0.4);
|
let height =
|
||||||
height = clamp(height, 0.2, 0.8);
|
previousEnd.y +
|
||||||
|
Random.getRandomInRange(-canvasSize.height / 3, canvasSize.height / 3);
|
||||||
|
|
||||||
const currentEnd = vec2.fromValues(this.tunnels.length * 0.25, height);
|
height = clamp(
|
||||||
const currentToRadius = Random.getRandom() * 0.1 + 0.1;
|
height,
|
||||||
|
canvasSize.height / 6 + 50,
|
||||||
|
canvasSize.height - canvasSize.height / 6 + 50
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentEnd = vec2.fromValues(this.tunnels.length * 300, height);
|
||||||
|
const currentToRadius = (Random.getRandom() * canvasSize.height) / 6 + 50;
|
||||||
|
|
||||||
this.tunnels.push(
|
this.tunnels.push(
|
||||||
new InvertedTunnel(previousEnd, currentEnd, previousRadius, currentToRadius)
|
new InvertedTunnel(previousEnd, currentEnd, previousRadius, currentToRadius)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (this.tunnels.length % 4 == 0) {
|
if (this.tunnels.length % 3 == 0) {
|
||||||
this.lights.push(
|
this.lights.push(
|
||||||
new CircleLight(
|
new CircleLight(
|
||||||
previousEnd,
|
previousEnd,
|
||||||
|
|
@ -52,22 +52,19 @@ export class TunnelScene implements Scene {
|
||||||
Random.getRandom(),
|
Random.getRandom(),
|
||||||
Random.getRandom(),
|
Random.getRandom(),
|
||||||
]),
|
]),
|
||||||
0.00025
|
0.35
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async run(canvas: HTMLCanvasElement, overlay: HTMLDivElement): Promise<void> {
|
public async initialize(
|
||||||
const noiseTexture = await renderNoise([1024, 1], 15, 0.5);
|
canvas: HTMLCanvasElement,
|
||||||
|
overlay: HTMLDivElement
|
||||||
|
): Promise<void> {
|
||||||
|
this.canvas = canvas;
|
||||||
this.overlay = overlay;
|
this.overlay = overlay;
|
||||||
|
this.renderer = await compile(
|
||||||
for (let i = 0; i < 30; i++) {
|
|
||||||
this.generateTunnel();
|
|
||||||
}
|
|
||||||
|
|
||||||
await runAnimation(
|
|
||||||
canvas,
|
canvas,
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
|
@ -76,52 +73,51 @@ export class TunnelScene implements Scene {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...CircleLight.descriptor,
|
...CircleLight.descriptor,
|
||||||
shaderCombinationSteps: [0, 1, 2, 3, 4, 5, 6, 7],
|
shaderCombinationSteps: [1, 2, 3, 4, 5, 6, 7],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
this.drawNextFrame.bind(this),
|
[rgb(0.4, 1, 0.6), rgb(1, 1, 0), rgb(0.3, 1, 1)]
|
||||||
{
|
|
||||||
lightPenetrationRatio: 0.5,
|
|
||||||
isWorldInverted: true,
|
|
||||||
enableHighDpiRendering: true,
|
|
||||||
ambientLight: rgb(0.35, 0.1, 0.45),
|
|
||||||
colorPalette: [rgb(0.4, 0.5, 0.6), rgb(0, 0, 0), rgb(0, 0, 0), rgb(0, 0, 0)],
|
|
||||||
textures: {
|
|
||||||
noiseTexture: {
|
|
||||||
source: noiseTexture,
|
|
||||||
overrides: {
|
|
||||||
maxFilter: FilteringOptions.LINEAR,
|
|
||||||
wrapS: WrapOptions.MIRRORED_REPEAT,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.renderer.setRuntimeSettings({
|
||||||
|
isWorldInverted: true,
|
||||||
|
ambientLight: vec3.fromValues(0.35, 0.1, 0.45),
|
||||||
|
shadowLength: 550,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
this.generateTunnel();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private deltaSinceStart = 0;
|
private deltaSinceStart = 0;
|
||||||
private drawNextFrame(
|
public drawNextFrame(
|
||||||
renderer: Renderer,
|
|
||||||
currentTime: DOMHighResTimeStamp,
|
currentTime: DOMHighResTimeStamp,
|
||||||
deltaTime: DOMHighResTimeStamp
|
deltaTime: DOMHighResTimeStamp
|
||||||
): boolean {
|
): void {
|
||||||
this.insights = renderer.insights;
|
const { width, height } = this.canvas.getBoundingClientRect();
|
||||||
|
|
||||||
this.deltaSinceStart += deltaTime;
|
this.deltaSinceStart += deltaTime;
|
||||||
const startX = this.deltaSinceStart / 4 / 1000;
|
const startX = this.deltaSinceStart / 3;
|
||||||
const width = renderer.canvasSize.x / renderer.canvasSize.y;
|
const endX = startX + width;
|
||||||
renderer.setViewArea(vec2.fromValues(startX, 1), vec2.fromValues(width, 1));
|
this.renderer.setViewArea(
|
||||||
|
vec2.fromValues(startX, height),
|
||||||
|
vec2.fromValues(width, height)
|
||||||
|
);
|
||||||
|
|
||||||
this.overlay.innerText = prettyPrint(renderer.insights);
|
this.renderer.autoscaleQuality(deltaTime);
|
||||||
|
this.overlay.innerText = prettyPrint(this.renderer.insights);
|
||||||
|
|
||||||
[
|
[
|
||||||
...this.tunnels.filter(
|
...this.tunnels.filter(
|
||||||
(t) =>
|
(t) => startX < t.to.x + t.toRadius && t.from.x - t.fromRadius <= endX
|
||||||
startX < t.to.x + t.toRadius && t.from.x - t.fromRadius <= startX + width * 1
|
|
||||||
),
|
),
|
||||||
...this.lights,
|
...this.lights,
|
||||||
].forEach((d) => renderer.addDrawable(d));
|
].forEach((d) => this.renderer.addDrawable(d));
|
||||||
|
|
||||||
return currentTime < settings.sceneTimeInMilliseconds;
|
this.renderer.renderDrawables();
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this.renderer.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export const settings = {
|
|
||||||
sceneTimeInMilliseconds: 8000,
|
|
||||||
};
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
@use './mixins' as *;
|
@import './mixins';
|
||||||
|
|
||||||
|
$bg: linear-gradient(45deg, #103783, #9bafd9);
|
||||||
$breakpoint: 800px;
|
$breakpoint: 800px;
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|
@ -10,14 +11,15 @@ $breakpoint: 800px;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body,
|
||||||
|
canvas#main {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
background-color: #103783;
|
background: $bg;
|
||||||
|
|
||||||
@media (max-width: $breakpoint) {
|
@media (max-width: $breakpoint) {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
|
|
@ -40,7 +42,7 @@ body {
|
||||||
#info {
|
#info {
|
||||||
@include card();
|
@include card();
|
||||||
@include center-children();
|
@include center-children();
|
||||||
visibility: hidden;
|
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
||||||
|
|
@ -48,7 +50,7 @@ body {
|
||||||
padding-top: 2px;
|
padding-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
img {
|
||||||
@include square(64px);
|
@include square(64px);
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
@ -56,66 +58,35 @@ body {
|
||||||
@media (max-width: $breakpoint) {
|
@media (max-width: $breakpoint) {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
|
|
||||||
.logo {
|
img {
|
||||||
@include square(40px);
|
@include square(40px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#canvas-container {
|
|
||||||
&,
|
|
||||||
canvas {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.full-screen-control {
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
box-sizing: content-box;
|
|
||||||
@include square(40px);
|
|
||||||
padding: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#overlay {
|
#overlay {
|
||||||
right: 0;
|
right: 0;
|
||||||
white-space: pre-wrap;
|
font-size: 0.75rem;
|
||||||
overflow-wrap: break-word;
|
white-space: pre;
|
||||||
font-family: 'Lucida Console', Monaco, monospace;
|
font-family: 'Lucida Console', Monaco, monospace;
|
||||||
|
|
||||||
font-size: 0.75rem;
|
|
||||||
max-width: 400px;
|
|
||||||
@media (max-width: $breakpoint) {
|
@media (max-width: $breakpoint) {
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
max-width: 200px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#toggle-text {
|
#toggle-text {
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
@include card();
|
@include card();
|
||||||
visibility: hidden;
|
|
||||||
|
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
width: 150px;
|
width: 120px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.2em 0.5rem;
|
padding: 0.2em 0.5rem;
|
||||||
background: none;
|
background: none;
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
&.off:not(:hover) {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
transition: opacity 200ms;
|
|
||||||
|
|
||||||
@media (max-width: $breakpoint) {
|
@media (max-width: $breakpoint) {
|
||||||
width: 110px;
|
width: 80px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,8 @@
|
||||||
@mixin card {
|
@mixin card {
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
border: 2px solid white;
|
border: 2px solid white;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|
||||||
backdrop-filter: blur(24px);
|
|
||||||
@supports not (backdrop-filter: blur(24px)) {
|
|
||||||
background-color: rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border: 4px solid white;
|
border: 4px solid white;
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 440 B After Width: | Height: | Size: 440 B |
|
Before Width: | Height: | Size: 978 B After Width: | Height: | Size: 978 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
|
@ -1,4 +1,4 @@
|
||||||
<svg viewBox="0 0 100 100" width="64" height="64" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="bg">
|
<linearGradient id="bg">
|
||||||
<stop offset="0%" stop-color="#103783" />
|
<stop offset="0%" stop-color="#103783" />
|
||||||
|
|
@ -7,18 +7,18 @@
|
||||||
|
|
||||||
<g id="ray-a">
|
<g id="ray-a">
|
||||||
<rect x="18" y="-3" height="6" rx="3">
|
<rect x="18" y="-3" height="6" rx="3">
|
||||||
<animate attributeName="width" values="20;25;20" dur="5s" repeatCount="indefinite" />
|
<animate attributeName="width" values="20;25;20" dur="4s" repeatCount="indefinite" />
|
||||||
</rect>
|
</rect>
|
||||||
<circle cy="0" r="3">
|
<circle cy="0" r="3">
|
||||||
<animate attributeName="cx" values="47;40;47" dur="5s" repeatCount="indefinite" />
|
<animate attributeName="cx" values="47;40;47" dur="4s" repeatCount="indefinite" />
|
||||||
</circle>
|
</circle>
|
||||||
</g>
|
</g>
|
||||||
<g id="ray-b">
|
<g id="ray-b">
|
||||||
<rect x="18" y="-3" height="6" rx="3">
|
<rect x="18" y="-3" height="6" rx="3">
|
||||||
<animate attributeName="width" values="25;20;25" dur="5s" repeatCount="indefinite" />
|
<animate attributeName="width" values="25;20;25" dur="4s" repeatCount="indefinite" />
|
||||||
</rect>
|
</rect>
|
||||||
<circle cy="0" r="3">
|
<circle cy="0" r="3">
|
||||||
<animate attributeName="cx" values="40;47;40" dur="5s" repeatCount="indefinite" />
|
<animate attributeName="cx" values="40;47;40" dur="4s" repeatCount="indefinite" />
|
||||||
</circle>
|
</circle>
|
||||||
</g>
|
</g>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
attributeName="transform"
|
attributeName="transform"
|
||||||
type="rotate"
|
type="rotate"
|
||||||
values="0 50 50;90 50 50"
|
values="0 50 50;90 50 50"
|
||||||
dur="10s"
|
dur="6s"
|
||||||
repeatCount="indefinite"
|
repeatCount="indefinite"
|
||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
|
@ -1,4 +1,4 @@
|
||||||
<svg viewBox="0 0 100 100" width="64" height="64" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||||
<defs>
|
<defs>
|
||||||
<g id="ray-a">
|
<g id="ray-a">
|
||||||
<rect x="18" y="-3" height="6" rx="3">
|
<rect x="18" y="-3" height="6" rx="3">
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
|
@ -1,7 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60" viewBox="0 0 24 24" stroke-width="1.5" stroke="#FFFFFF" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
|
||||||
<path d="M4 8v-2a2 2 0 0 1 2 -2h2" />
|
|
||||||
<path d="M4 16v2a2 2 0 0 0 2 2h2" />
|
|
||||||
<path d="M16 4h2a2 2 0 0 1 2 2v2" />
|
|
||||||
<path d="M16 20h2a2 2 0 0 0 2 -2v-2" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 399 B |
|
|
@ -1,7 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60" viewBox="0 0 24 24" stroke-width="1.5" stroke="#FFFFFF" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
|
||||||
<path d="M15 19v-2a2 2 0 0 1 2 -2h2" />
|
|
||||||
<path d="M15 5v2a2 2 0 0 0 2 2h2" />
|
|
||||||
<path d="M5 15h2a2 2 0 0 1 2 2v2" />
|
|
||||||
<path d="M5 9h2a2 2 0 0 0 2 -2v-2" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 399 B |
|
|
@ -1,36 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
|
||||||
|
|
||||||
<title>Not found</title>
|
|
||||||
<meta name="theme-color" content="#b7455e" />
|
|
||||||
<meta name="viewport" content="initial-scale=1.0" />
|
|
||||||
<style>
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
background-color: #b7455e;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-family: "Roboto", "Helvetica Neue", sans-serif;
|
|
||||||
font-weight: 100;
|
|
||||||
font-size: 3rem;
|
|
||||||
color: white;
|
|
||||||
text-align: center;
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>The requested resource cannot be found.</h1>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
Before Width: | Height: | Size: 615 KiB |
|
|
@ -1,2 +0,0 @@
|
||||||
User-agent: *
|
|
||||||
Allow: /
|
|
||||||
BIN
static/og-image.jpg
Normal file
|
After Width: | Height: | Size: 35 KiB |
|
|
@ -1,18 +1,14 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist/",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"noImplicitAny": false,
|
"noImplicitAny": false,
|
||||||
"strict": false,
|
"target": "es6",
|
||||||
"target": "ES2017",
|
"downlevelIteration": true,
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "node10",
|
|
||||||
"ignoreDeprecations": "6.0",
|
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"esModuleInterop": true,
|
"moduleResolution": "Node",
|
||||||
"lib": ["ES2017", "DOM"],
|
"module": "es6",
|
||||||
"skipLibCheck": true
|
"lib": ["es2016", "dom"]
|
||||||
},
|
}
|
||||||
"include": ["src"],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,49 @@
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||||
|
const TerserJSPlugin = require('terser-webpack-plugin');
|
||||||
|
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
|
||||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||||
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
|
const HtmlWebpackInlineSourcePlugin = require('html-webpack-inline-source-plugin');
|
||||||
const HtmlInlineScriptPlugin = require('html-inline-script-webpack-plugin');
|
const Sass = require('sass');
|
||||||
const HTMLInlineCSSWebpackPlugin =
|
|
||||||
require('html-inline-css-webpack-plugin').default;
|
|
||||||
|
|
||||||
const PATHS = {
|
const PATHS = {
|
||||||
entryPoint: path.resolve(__dirname, 'src/index.ts'),
|
entryPoint: path.resolve(__dirname, 'src/index.ts'),
|
||||||
entryHtml: path.resolve(__dirname, 'src/index.html'),
|
|
||||||
bundles: path.resolve(__dirname, 'dist'),
|
bundles: path.resolve(__dirname, 'dist'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Inline the UI SVGs referenced via <img src> into the HTML (replacing the
|
module.exports = {
|
||||||
// abandoned html-webpack-inline-svg-plugin). Scope html-loader to <img src> so
|
|
||||||
// the favicon <link href> tags are left to resolve against the emitted files.
|
|
||||||
const htmlLoaderOptions = JSON.stringify({
|
|
||||||
sources: { list: [{ tag: 'img', attribute: 'src', type: 'src' }] },
|
|
||||||
minimize: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = (env, argv) => ({
|
|
||||||
entry: {
|
entry: {
|
||||||
index: PATHS.entryPoint,
|
index: PATHS.entryPoint,
|
||||||
},
|
},
|
||||||
target: 'web',
|
target: 'web',
|
||||||
output: {
|
output: {
|
||||||
|
filename: '[name].[contenthash].js',
|
||||||
path: PATHS.bundles,
|
path: PATHS.bundles,
|
||||||
// The bundle is inlined into index.html (no <script src>), so webpack 5's
|
|
||||||
// default publicPath:'auto' cannot derive a path from document.currentScript
|
|
||||||
// and throws "Automatic publicPath is not supported". A static value (assets
|
|
||||||
// sit next to index.html at the dist root) avoids the runtime auto-detection.
|
|
||||||
publicPath: '',
|
|
||||||
clean: true,
|
|
||||||
},
|
},
|
||||||
//devtool: 'source-map',
|
devtool: 'source-map',
|
||||||
watchOptions: {
|
watchOptions: {
|
||||||
aggregateTimeout: 600,
|
aggregateTimeout: 600,
|
||||||
ignored: /node_modules/,
|
ignored: /node_modules/,
|
||||||
},
|
},
|
||||||
devServer: {
|
devServer: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 9999,
|
disableHostCheck: true,
|
||||||
allowedHosts: 'all',
|
},
|
||||||
|
optimization: {
|
||||||
|
minimize: true,
|
||||||
|
usedExports: true,
|
||||||
|
minimizer: [
|
||||||
|
new TerserJSPlugin({
|
||||||
|
sourceMap: true,
|
||||||
|
test: /\.js$/,
|
||||||
|
}),
|
||||||
|
new OptimizeCSSAssetsPlugin({}),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
new MiniCssExtractPlugin(),
|
|
||||||
new HtmlWebpackPlugin({
|
new HtmlWebpackPlugin({
|
||||||
xhtml: true,
|
xhtml: true,
|
||||||
template: `!!html-loader?${htmlLoaderOptions}!${PATHS.entryHtml}`,
|
template: './src/index.html',
|
||||||
// The bundle gets inlined, and `defer` is ignored on inline <script>s, so
|
|
||||||
// it must sit at the end of <body> (not head) to run after the DOM exists.
|
|
||||||
inject: 'body',
|
|
||||||
scriptLoading: 'blocking',
|
|
||||||
minify: {
|
minify: {
|
||||||
collapseWhitespace: true,
|
collapseWhitespace: true,
|
||||||
removeComments: true,
|
removeComments: true,
|
||||||
|
|
@ -61,72 +52,72 @@ module.exports = (env, argv) => ({
|
||||||
removeStyleLinkTypeAttributes: true,
|
removeStyleLinkTypeAttributes: true,
|
||||||
useShortDoctype: true,
|
useShortDoctype: true,
|
||||||
},
|
},
|
||||||
|
inlineSource: '.(js|css)$',
|
||||||
|
}),
|
||||||
|
new HtmlWebpackInlineSourcePlugin(),
|
||||||
|
new MiniCssExtractPlugin({
|
||||||
|
filename: '[name].[contenthash].css',
|
||||||
|
chunkFilename: '[id].[contenthash].css',
|
||||||
}),
|
}),
|
||||||
...(argv.mode === 'production'
|
|
||||||
? [new HtmlInlineScriptPlugin(), new HTMLInlineCSSWebpackPlugin()]
|
|
||||||
: []),
|
|
||||||
],
|
],
|
||||||
module: {
|
module: {
|
||||||
rules: [
|
rules: [
|
||||||
{
|
|
||||||
test: /\.ts$/,
|
|
||||||
exclude: /node_modules/,
|
|
||||||
// transpileOnly mirrors the project's prior de-facto behaviour: the old
|
|
||||||
// ts-config-webpack-plugin ran type-checking in a separate fork-ts-checker
|
|
||||||
// process that crashes on Node 22, so types were never actually enforced.
|
|
||||||
// (`npm run lint` still type-aware-lints; full type-checking can be
|
|
||||||
// re-enabled separately once the pre-existing type errors are addressed.)
|
|
||||||
use: {
|
|
||||||
loader: 'ts-loader',
|
|
||||||
options: { transpileOnly: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
test: /\.scss$/,
|
test: /\.scss$/,
|
||||||
use: [
|
use: [
|
||||||
MiniCssExtractPlugin.loader,
|
MiniCssExtractPlugin.loader,
|
||||||
'css-loader',
|
'css-loader',
|
||||||
'postcss-loader',
|
'postcss-loader',
|
||||||
// resolve-url-loader v5 always retains the query/hash (the old
|
{
|
||||||
// `keepQuery` option was removed), so no options are needed.
|
loader: 'resolve-url-loader',
|
||||||
'resolve-url-loader',
|
options: {
|
||||||
|
keepQuery: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
loader: 'sass-loader',
|
loader: 'sass-loader',
|
||||||
options: {
|
options: {
|
||||||
sourceMap: true,
|
sourceMap: true,
|
||||||
implementation: require('sass'),
|
implementation: Sass,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Favicons, og-image, 404.html and robots.txt: emit verbatim to the
|
test: /\.ts$/,
|
||||||
// dist root (imported for their side effect in src/index.ts).
|
use: {
|
||||||
// sideEffects:true keeps these bare imports from being tree-shaken away
|
loader: 'ts-loader',
|
||||||
// under the package's "sideEffects": ["*.scss"] declaration.
|
},
|
||||||
test: /no-change.*$/i,
|
exclude: /node_modules/,
|
||||||
type: 'asset/resource',
|
},
|
||||||
sideEffects: true,
|
{
|
||||||
generator: {
|
test: /\.(ico|png|jpg)$/,
|
||||||
filename: '[name][ext]',
|
use: {
|
||||||
|
loader: 'file-loader',
|
||||||
|
query: {
|
||||||
|
outputPath: '/',
|
||||||
|
name: '[name].[ext]',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
test: /\.svg$/,
|
test: /\.(svg)$/,
|
||||||
type: 'asset/inline',
|
use: {
|
||||||
|
loader: 'file-loader',
|
||||||
|
query: {
|
||||||
|
outputPath: '/static',
|
||||||
|
name: '[name].[ext]',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
test: /\.js$/,
|
test: /\.js$/,
|
||||||
enforce: 'pre',
|
enforce: 'pre',
|
||||||
exclude: /node_modules/,
|
|
||||||
use: ['source-map-loader'],
|
use: ['source-map-loader'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
optimization: {
|
|
||||||
minimizer: ['...', new CssMinimizerPlugin()],
|
|
||||||
},
|
|
||||||
resolve: {
|
resolve: {
|
||||||
extensions: ['.ts', '.js'],
|
extensions: ['.ts', '.js'],
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
|
||||||