├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── config-overrides.js ├── doc ├── responsive.md └── url.md ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json ├── robots.txt └── screen.png ├── scripts ├── deploy-prod.sh └── deploy.sh ├── src ├── engine │ ├── engine.ts │ ├── fractals.ts │ ├── fractals │ │ ├── burningbird.ts │ │ ├── burningship.ts │ │ ├── example.ts │ │ ├── julia.ts │ │ ├── mandelbrot.ts │ │ ├── mandelbrot3.ts │ │ ├── mandelbrot4.ts │ │ ├── phoenix.ts │ │ └── tippetts.ts │ ├── guide.ts │ ├── math │ │ ├── camera.ts │ │ ├── matrix.ts │ │ └── vector.ts │ ├── painter.ts │ ├── redrawer.ts │ ├── renderer.ts │ ├── scheduler │ │ ├── scheduler.ts │ │ ├── types.ts │ │ ├── worker.ts │ │ └── worker │ │ │ ├── interface.worker.js │ │ │ └── web-worker.ts │ └── tile.ts ├── index.tsx ├── params.ts ├── react-app-env.d.ts ├── redux │ ├── colors.ts │ ├── controller.ts │ ├── guide.ts │ ├── improver.ts │ ├── rdxengine.ts │ ├── reducer.ts │ ├── set.ts │ ├── ui.ts │ └── url.ts ├── serviceWorker.ts ├── ui │ ├── App.scss │ ├── App.tsx │ ├── InfoBox.tsx │ ├── Navigation.tsx │ ├── Share.tsx │ ├── Snackbar.tsx │ ├── Toolbar.tsx │ └── pages │ │ ├── About.tsx │ │ ├── Debug.tsx │ │ ├── Fractal.tsx │ │ ├── Palette.tsx │ │ ├── Settings.tsx │ │ └── Social.tsx └── util │ ├── EventBus.ts │ ├── keybinder.ts │ ├── misc.ts │ └── palette.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "arrowParens": "avoid" 4 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2020, Matthieu Dumas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FractalJS 2 | 3 | FractalJS is a realtime fractal exporer. 4 | 5 | It lets you explore different fractal sets with different color palettes, and share your best discoveries with others. It is a progressive web app running on all your devices. The rendering, which is computationally intensive, is entirely done in multi-threaded Javascript (hence JS in the name). 6 | 7 | ### Click here to [Start FractalJS](http://solendil.github.io/fractaljs/) 8 | ![Start FractalJS](/public/screen.png?raw=true "FractalJS") 9 | 10 | ## Available Fractals 11 | 12 | * [Mandelbrot set](https://en.wikipedia.org/wiki/Mandelbrot_set) 13 | * [Multibrot 3 & 4](https://en.wikipedia.org/wiki/Multibrot_set) 14 | * [Burning Ship](https://en.wikipedia.org/wiki/Burning_Ship_fractal) 15 | * [Burning Bird](http://v.rentalserver.jp/morigon.jp/Repository/SUBI0/SUBI_BurningBird2_e.html) 16 | * [John Tippetts Mandelbrot](http://paulbourke.net/fractals/tippetts/) 17 | 18 | ## History 19 | 20 | This is the third iteration of FractalJS 21 | * [V1](https://solendil.github.io/fractaljs-v1) (June 2015) used Grunt and jQuery 22 | * [V2](https://solendil.github.io/fractaljs-v2) (April 2017) moved to Webpack and Vue.js, and a Material interface 23 | * [V3](https://solendil.github.io/fractaljs) (April 2020) is a mobile-first PWA application, using React and Typescript 24 | 25 | # Technical 26 | 27 | ## Technologies 28 | 29 | * The UX is written with [React](https://reactjs.org/) and [Redux](https://redux-toolkit.js.org/), project is set up using [create-react-app](https://create-react-app.dev/) 30 | * Widgets and mobile capabilities are provided by [Material UI](https://material-ui.com/) 31 | * Touch interface works thanks to [Hammer.js](https://hammerjs.github.io/) 32 | * Both UX and Engine are written in [Typescript](https://www.typescriptlang.org/) 33 | * Fractal are drawn on a [canvas](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API), using mere Javascript code, multi-threaded with [web workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers) 34 | * ...plus lots of code, and lots of :heart: 35 | 36 | ## Project Setup 37 | 38 | ``` 39 | $ git clone https://github.com/solendil/FractalJS.git 40 | $ cd FractalJS 41 | $ npm install 42 | $ npm run start 43 | ``` 44 | 45 | ## Contribute 46 | 47 | Do you want to implement a new fractal set in FractalJS? It couldn't be easier. After the project is set up, just head to `/src/engine/fractals/` copy `example.ts` and write your own fractal function. 48 | 49 | 50 | -------------------------------------------------------------------------------- /config-overrides.js: -------------------------------------------------------------------------------- 1 | module.exports = function override(config, env) { 2 | config.module.rules.push({ 3 | test: /\.worker\.[jt]s$/, 4 | use: { loader: "worker-loader" }, 5 | }); 6 | // const fs = require("fs"); 7 | // fs.writeFileSync("./wpconf.json", JSON.stringify(config, null, 2)); 8 | return config; 9 | }; 10 | -------------------------------------------------------------------------------- /doc/responsive.md: -------------------------------------------------------------------------------- 1 | # Responsive design 2 | 3 | The interface is based on the concept of "sheet". 4 | 5 | A sheet is a vertical "surface" of width **360 to 450px wide**. This accomodates every modern mobile device in portrait mode. This does not fit old iPhone SE and iPhone 5 (320px); too bad for them. A warning might be in order... 6 | 7 | ## If width < 450 (most portrait phones) 8 | 9 | Toolbar is at bottom. 10 | Sheets slide from bottom, with rounded corners, and a handle to indicate "slide to close". 11 | 12 | ## If width > 450 (anything other) 13 | 14 | Toolbar should be along left side... 15 | Sheets are left drawer of forced 360px. 16 | 17 | -------------------------------------------------------------------------------- /doc/url.md: -------------------------------------------------------------------------------- 1 | 2 | # URL schema 3 | 4 | * __t__ : fractalId (mandelbrot, tippets, etc...). Default _mandelbrot_ 5 | * __x, y__ : cpx pos of mid of view 6 | * __w__ : cpx width of view 7 | * __i__ : iteration number 8 | * __fs__ : smooth. Default _true_ 9 | * __va, vb, vc, vd__ : viewport matrix. Default _Matrix.identity_ 10 | * __ct__ : palette id 11 | * __co__ : palette offset (move) 12 | * __cd__ : palette density (stretch) 13 | * __cf__ : palette function ('n': normal, 's': sqrt(iter)) 14 | * __gx, gy__ : pos of guide if present 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fratcaljs", 3 | "version": "3.0.0", 4 | "description": "Browse fractals !", 5 | "private": true, 6 | "author": "Matthieu Dumas", 7 | "license": "MIT", 8 | "homepage": ".", 9 | "dependencies": { 10 | "@material-ui/core": "^4.9.7", 11 | "@reduxjs/toolkit": "^1.3.0", 12 | "@testing-library/jest-dom": "^4.2.4", 13 | "@testing-library/react": "^9.5.0", 14 | "@testing-library/user-event": "^7.2.1", 15 | "@types/hammerjs": "^2.0.36", 16 | "@types/jest": "^24.9.1", 17 | "@types/lodash": "^4.14.149", 18 | "@types/node": "^12.12.31", 19 | "@types/react": "^16.9.25", 20 | "@types/react-dom": "^16.9.5", 21 | "@types/react-redux": "^7.1.7", 22 | "hammerjs": "^2.0.8", 23 | "lodash": "^4.17.19", 24 | "node-sass": "^4.13.1", 25 | "react": "^16.13.1", 26 | "react-dom": "^16.13.1", 27 | "react-github-btn": "^1.1.1", 28 | "react-redux": "^7.2.0", 29 | "react-scripts": "3.4.1", 30 | "source-map-explorer": "^2.4.2", 31 | "typescript": "^3.7.5" 32 | }, 33 | "scripts": { 34 | "analyze": "source-map-explorer 'build/static/js/*.js'", 35 | "start": "react-app-rewired start", 36 | "build": "react-app-rewired build", 37 | "test": "react-app-rewired test --env=jsdom", 38 | "eject": "react-scripts eject" 39 | }, 40 | "eslintConfig": { 41 | "extends": "react-app" 42 | }, 43 | "browserslist": { 44 | "production": [ 45 | ">0.4%", 46 | "not dead", 47 | "not ie 11", 48 | "not op_mini all" 49 | ], 50 | "development": [ 51 | "last 1 chrome version", 52 | "last 1 firefox version", 53 | "last 1 safari version" 54 | ] 55 | }, 56 | "devDependencies": { 57 | "fibers": "^4.0.2", 58 | "react-app-rewired": "^2.1.5", 59 | "sass": "^1.26.3", 60 | "worker-loader": "^2.0.0" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solendil/FractalJS/b5538b0aaa9e5c79faf37508046973589fe52ec1/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | FractalJS 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solendil/FractalJS/b5538b0aaa9e5c79faf37508046973589fe52ec1/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solendil/FractalJS/b5538b0aaa9e5c79faf37508046973589fe52ec1/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "FractalJS", 3 | "name": "FractalJS", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#3f51b5", 24 | "background_color": "#000000" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solendil/FractalJS/b5538b0aaa9e5c79faf37508046973589fe52ec1/public/screen.png -------------------------------------------------------------------------------- /scripts/deploy-prod.sh: -------------------------------------------------------------------------------- 1 | npm run build 2 | rm -fr ../solendil.github.io/fractaljs 3 | cp -R build/ ../solendil.github.io/fractaljs 4 | cd ../solendil.github.io 5 | git add -A 6 | git commit -m "new version" 7 | git push 8 | cd - 9 | -------------------------------------------------------------------------------- /scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | npm run build 2 | rm -fr ../solendil.github.io/fractaljs-test 3 | cp -R build/ ../solendil.github.io/fractaljs-test 4 | cd ../solendil.github.io 5 | git add -A 6 | git commit -m "new version" 7 | git push 8 | cd - 9 | -------------------------------------------------------------------------------- /src/engine/engine.ts: -------------------------------------------------------------------------------- 1 | /* global navigator */ 2 | import EventBus from "../util/EventBus"; 3 | import Renderer from "./renderer"; 4 | import Painter, { PainterArgs } from "./painter"; 5 | import Camera from "./math/camera"; 6 | import Vector from "./math/vector"; 7 | import { DrawParams } from "./scheduler/types"; 8 | import Matrix, { RawMatrix } from "./math/matrix"; 9 | 10 | export interface Context { 11 | readonly canvas: HTMLCanvasElement; 12 | readonly context: CanvasRenderingContext2D; 13 | readonly event: EventBus; 14 | readonly nbThreads: number; 15 | readonly camera: Camera; 16 | smooth: boolean; 17 | iter: number; 18 | fractalId: string; 19 | } 20 | 21 | interface Params { 22 | fractalId: string; 23 | smooth: boolean; 24 | iter: number; 25 | x: number; 26 | y: number; 27 | w: number; 28 | painter: PainterArgs; 29 | viewport: RawMatrix; 30 | } 31 | 32 | export default class Engine { 33 | public readonly ctx: Context; 34 | public readonly painter: Painter; 35 | 36 | private renderer: Renderer; 37 | private paramsFetcher: () => Params; 38 | 39 | constructor(canvas: HTMLCanvasElement, paramsFetcher: () => Params) { 40 | this.paramsFetcher = paramsFetcher; 41 | const params = paramsFetcher(); 42 | let nbThreads = navigator.hardwareConcurrency || 4; 43 | if (nbThreads >= 6) nbThreads--; // sacrifice a thread for responsiveness if we have enough 44 | this.ctx = { 45 | // readonly 46 | canvas: canvas, 47 | context: canvas.getContext("2d") as CanvasRenderingContext2D, 48 | nbThreads, 49 | event: new EventBus(), 50 | camera: new Camera( 51 | new Vector(canvas.width, canvas.height), 52 | new Vector(params.x, params.y), 53 | params.w, 54 | Matrix.fromRaw(params.viewport), 55 | ), 56 | // changeable 57 | smooth: params.smooth, 58 | iter: params.iter, 59 | fractalId: params.fractalId, 60 | }; 61 | this.painter = new Painter(params.painter); 62 | this.renderer = new Renderer(this.ctx, this.painter, this); 63 | } 64 | 65 | get canvas() { 66 | return this.ctx.canvas; 67 | } 68 | 69 | private fetchParams() { 70 | const params = this.paramsFetcher(); 71 | this.ctx.fractalId = params.fractalId; 72 | this.ctx.smooth = params.smooth; 73 | this.ctx.iter = params.iter; 74 | this.painter.set({ 75 | offset: params.painter.offset, 76 | density: params.painter.density, 77 | id: params.painter.id, 78 | fn: params.painter.fn, 79 | }); 80 | // update camera 81 | const cam = this.ctx.camera; 82 | cam.setPos(new Vector(params.x, params.y), params.w); 83 | cam.viewportMatrix = Matrix.fromRaw(params.viewport); 84 | cam.reproject(); 85 | } 86 | 87 | async draw(drawParams?: DrawParams) { 88 | this.fetchParams(); 89 | if (!drawParams) drawParams = { details: "normal" }; 90 | const start = new Date().getTime(); 91 | const res = await this.renderer.draw(drawParams); 92 | const end = new Date().getTime(); 93 | const time = end - start; 94 | console.log(`Frame '${drawParams.details}' drawn in ${time}ms`); 95 | return res; 96 | } 97 | 98 | drawColor() { 99 | this.fetchParams(); 100 | this.renderer.drawColor(); 101 | } 102 | 103 | resize(width: number, height: number) { 104 | this.ctx.camera.resize(width, height); 105 | this.renderer.resize(); 106 | } 107 | 108 | getHistogram() { 109 | return this.renderer.getHistogram(); 110 | } 111 | 112 | getIterationsAt(cpx: Vector) { 113 | return this.renderer.getIterationsAt(cpx); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/engine/fractals.ts: -------------------------------------------------------------------------------- 1 | import { FractalDef } from "./fractals/example"; 2 | 3 | // @ts-ignore 4 | const context = require.context("./fractals/", true, /\.([jt]s)$/); 5 | 6 | const byId: { [key: string]: FractalDef } = {}; 7 | context.keys().forEach((filename: string) => { 8 | const obj = context(filename).default; 9 | byId[obj.fractalId] = obj; 10 | }); 11 | 12 | export const getFunction = (id: string, smooth: boolean) => { 13 | const res = byId[id]; 14 | if (smooth) return res.fn.smooth || res.fn.normal; 15 | return res.fn.normal; 16 | }; 17 | 18 | export const getPreset = (fractalId: string) => { 19 | const res = byId[fractalId]; 20 | return { fractalId, ...res.preset }; 21 | }; 22 | 23 | export const listForUi = () => 24 | Object.values(byId) 25 | .filter(f => !f.hidden) 26 | .sort((a, b) => (a.uiOrder || 0) - (b.uiOrder || 0)); 27 | -------------------------------------------------------------------------------- /src/engine/fractals/burningbird.ts: -------------------------------------------------------------------------------- 1 | import { FractalDef } from "./example"; 2 | 3 | const escape = 4; 4 | var iLog2 = 1.0 / Math.log(2.0); 5 | 6 | export default { 7 | fractalId: "burningbird", 8 | uiOrder: 2.5, 9 | name: "Burning Bird", 10 | preset: { x: -0.46, y: 0.07, w: 3.26, iter: 50 }, 11 | fn: { 12 | normal: (cx, cy, iter) => { 13 | var zx = 0, 14 | zy = 0, 15 | sqx = 0, 16 | sqy = 0, 17 | i = 0, 18 | znx, 19 | zny; 20 | cy = -cy; // this fractal is usually represented upside down 21 | while (true) { 22 | zny = (zx + zx) * zy + cy; 23 | znx = sqx - sqy + cx; 24 | zx = znx; 25 | zy = Math.abs(zny); 26 | if (++i >= iter) break; 27 | sqx = zx * zx; 28 | sqy = zy * zy; 29 | if (sqx + sqy > escape) break; 30 | } 31 | return i; 32 | }, 33 | smooth: (cx, cy, iter) => { 34 | var zx = 0, 35 | zy = 0, 36 | sqx = 0, 37 | sqy = 0, 38 | i = 0, 39 | j, 40 | znx, 41 | zny; 42 | cy = -cy; // this fractal is usually represented upside down 43 | while (true) { 44 | zny = (zx + zx) * zy + cy; 45 | znx = sqx - sqy + cx; 46 | zx = znx; 47 | zy = Math.abs(zny); 48 | if (++i >= iter) break; 49 | sqx = zx * zx; 50 | sqy = zy * zy; 51 | if (sqx + sqy > escape) break; 52 | } 53 | if (i === iter) return i; 54 | for (j = 0; j < 4; ++j) { 55 | zny = (zx + zx) * zy + cy; 56 | znx = sqx - sqy + cx; 57 | zx = znx; 58 | zy = Math.abs(zny); 59 | sqx = zx * zx; 60 | sqy = zy * zy; 61 | } 62 | return 5 + i - Math.log(Math.log(sqx + sqy)) * iLog2; 63 | }, 64 | }, 65 | } as FractalDef; 66 | -------------------------------------------------------------------------------- /src/engine/fractals/burningship.ts: -------------------------------------------------------------------------------- 1 | import { FractalDef } from "./example"; 2 | 3 | const escape = 4; 4 | var iLog2 = 1.0 / Math.log(2.0); 5 | 6 | export default { 7 | fractalId: "burningship", 8 | uiOrder: 2, 9 | name: "Burning Ship", 10 | preset: { x: -0.4, y: 0.55, w: 3, iter: 50 }, 11 | fn: { 12 | normal: (cx, cy, iter) => { 13 | var zx = 0, 14 | zy = 0, 15 | sqx = 0, 16 | sqy = 0, 17 | i = 0, 18 | znx, 19 | zny; 20 | cy = -cy; // this fractal is usually represented upside down 21 | while (true) { 22 | zny = (zx + zx) * zy + cy; 23 | znx = sqx - sqy + cx; 24 | zx = Math.abs(znx); 25 | zy = Math.abs(zny); 26 | if (++i >= iter) break; 27 | sqx = zx * zx; 28 | sqy = zy * zy; 29 | if (sqx + sqy > escape) break; 30 | } 31 | return i; 32 | }, 33 | smooth: (cx, cy, iter) => { 34 | var zx = 0, 35 | zy = 0, 36 | sqx = 0, 37 | sqy = 0, 38 | i = 0, 39 | j, 40 | znx, 41 | zny; 42 | cy = -cy; // this fractal is usually represented upside down 43 | while (true) { 44 | zny = (zx + zx) * zy + cy; 45 | znx = sqx - sqy + cx; 46 | zx = Math.abs(znx); 47 | zy = Math.abs(zny); 48 | if (++i >= iter) break; 49 | sqx = zx * zx; 50 | sqy = zy * zy; 51 | if (sqx + sqy > escape) break; 52 | } 53 | if (i === iter) return i; 54 | for (j = 0; j < 4; ++j) { 55 | zny = (zx + zx) * zy + cy; 56 | znx = sqx - sqy + cx; 57 | zx = Math.abs(znx); 58 | zy = Math.abs(zny); 59 | sqx = zx * zx; 60 | sqy = zy * zy; 61 | } 62 | return 5 + i - Math.log(Math.log(sqx + sqy)) * iLog2; 63 | }, 64 | }, 65 | } as FractalDef; 66 | -------------------------------------------------------------------------------- /src/engine/fractals/example.ts: -------------------------------------------------------------------------------- 1 | export type RenderFn = (cx: number, cy: number, iter: number) => number; 2 | export interface FractalDef { 3 | fractalId: string; 4 | hidden?: boolean; 5 | uiOrder?: number; 6 | name: string; 7 | preset: { 8 | x: number; 9 | y: number; 10 | w: number; 11 | iter: number; 12 | }; 13 | fn: { 14 | normal: RenderFn; 15 | smooth?: RenderFn; 16 | }; 17 | } 18 | 19 | export default { 20 | fractalId: "example", 21 | hidden: true, 22 | uiOrder: -1, 23 | name: "example", 24 | preset: { 25 | x: -0.7, 26 | y: 0.0, 27 | w: 2.5, 28 | iter: 50, 29 | }, 30 | fn: { 31 | normal: (cx, cy, iter) => { 32 | return 0; 33 | }, 34 | smooth: (cx, cy, iter) => { 35 | return 0; 36 | }, 37 | }, 38 | } as FractalDef; 39 | -------------------------------------------------------------------------------- /src/engine/fractals/julia.ts: -------------------------------------------------------------------------------- 1 | import { FractalDef } from "./example"; 2 | 3 | const escape = 4; 4 | const iLog2 = 1.0 / Math.log(2.0); 5 | 6 | const refx = 0.285; 7 | const refy = 0.01; 8 | 9 | export default { 10 | fractalId: "juliaex1", 11 | uiOrder: 5, 12 | name: "Julia", 13 | preset: { 14 | x: 0.0, 15 | y: 0.0, 16 | w: 2.2, 17 | iter: 50, 18 | }, 19 | fn: { 20 | normal: function (cx, cy, iter) { 21 | var znx = cx, 22 | zny = cy, 23 | sqx = cx * cx, 24 | sqy = cy * cy, 25 | i = 0; 26 | for (; i < iter && sqx + sqy <= escape; ++i) { 27 | zny = (znx + znx) * zny + refy; 28 | znx = sqx - sqy + refx; 29 | sqx = znx * znx; 30 | sqy = zny * zny; 31 | } 32 | return i; 33 | }, 34 | smooth: function (cx, cy, iter) { 35 | var znx = cx, 36 | zny = cy, 37 | sqx = cx * cx, 38 | sqy = cy * cy, 39 | i = 0, 40 | j = 0; 41 | for (; i < iter && sqx + sqy <= escape; ++i) { 42 | zny = (znx + znx) * zny + refy; 43 | znx = sqx - sqy + refx; 44 | sqx = znx * znx; 45 | sqy = zny * zny; 46 | } 47 | if (i === iter) { 48 | return i; 49 | } 50 | for (j = 0; j < 4; ++j) { 51 | zny = (znx + znx) * zny + refy; 52 | znx = sqx - sqy + refx; 53 | sqx = znx * znx; 54 | sqy = zny * zny; 55 | } 56 | var res = 5 + i - Math.log(Math.log(sqx + sqy)) * iLog2; 57 | return res; 58 | }, 59 | }, 60 | } as FractalDef; 61 | -------------------------------------------------------------------------------- /src/engine/fractals/mandelbrot.ts: -------------------------------------------------------------------------------- 1 | import { FractalDef } from "./example"; 2 | 3 | const escape = 4; 4 | var iLog2 = 1.0 / Math.log(2.0); 5 | 6 | export default { 7 | fractalId: "mandelbrot", 8 | uiOrder: 0, 9 | name: "Mandelbrot", 10 | preset: { x: -0.7, y: 0.0, w: 2.5, iter: 50 }, 11 | fn: { 12 | normal: (cx, cy, iter) => { 13 | var znx = 0, 14 | zny = 0, 15 | sqx = 0, 16 | sqy = 0, 17 | i = 0; 18 | for (; i < iter && sqx + sqy <= escape; ++i) { 19 | zny = (znx + znx) * zny + cy; 20 | znx = sqx - sqy + cx; 21 | sqx = znx * znx; 22 | sqy = zny * zny; 23 | } 24 | return i; 25 | }, 26 | smooth: (cx, cy, iter) => { 27 | var znx = 0, 28 | zny = 0, 29 | sqx = 0, 30 | sqy = 0, 31 | i = 0, 32 | j = 0; 33 | for (; i < iter && sqx + sqy <= escape; ++i) { 34 | zny = (znx + znx) * zny + cy; 35 | znx = sqx - sqy + cx; 36 | sqx = znx * znx; 37 | sqy = zny * zny; 38 | } 39 | if (i === iter) return i; 40 | for (j = 0; j < 4; ++j) { 41 | zny = (znx + znx) * zny + cy; 42 | znx = sqx - sqy + cx; 43 | sqx = znx * znx; 44 | sqy = zny * zny; 45 | } 46 | return 5 + i - Math.log(Math.log(sqx + sqy)) * iLog2; 47 | }, 48 | }, 49 | } as FractalDef; 50 | -------------------------------------------------------------------------------- /src/engine/fractals/mandelbrot3.ts: -------------------------------------------------------------------------------- 1 | import { FractalDef } from "./example"; 2 | 3 | const escape = 4; 4 | var iLog4 = 1.0 / Math.log(4.0); 5 | 6 | export default { 7 | fractalId: "mandelbrot3", 8 | uiOrder: 3, 9 | name: "Multibrot *3", 10 | preset: { x: 0.0, y: 0.0, w: 3.0, iter: 50 }, 11 | fn: { 12 | normal: (cx, cy, iter) => { 13 | var zx = 0, 14 | zy = 0, 15 | sqx = 0, 16 | sqy = 0, 17 | i = 0, 18 | znx, 19 | zny; 20 | while (true) { 21 | znx = sqx * zx - 3 * zx * sqy + cx; 22 | zny = 3 * sqx * zy - sqy * zy + cy; 23 | zx = znx; 24 | zy = zny; 25 | if (++i >= iter) break; 26 | sqx = zx * zx; 27 | sqy = zy * zy; 28 | if (sqx + sqy > escape) break; 29 | } 30 | return i; 31 | }, 32 | smooth: (cx, cy, iter) => { 33 | var zx = 0, 34 | zy = 0, 35 | sqx = 0, 36 | sqy = 0, 37 | i = 0, 38 | j, 39 | znx, 40 | zny; 41 | while (true) { 42 | znx = sqx * zx - 3 * zx * sqy + cx; 43 | zny = 3 * sqx * zy - sqy * zy + cy; 44 | zx = znx; 45 | zy = zny; 46 | if (++i >= iter) break; 47 | sqx = zx * zx; 48 | sqy = zy * zy; 49 | if (sqx + sqy > escape) break; 50 | } 51 | if (i === iter) return i; 52 | for (j = 0; j < 4; ++j) { 53 | znx = sqx * zx - 3 * zx * sqy + cx; 54 | zny = 3 * sqx * zy - sqy * zy + cy; 55 | zx = znx; 56 | zy = zny; 57 | sqx = zx * zx; 58 | sqy = zy * zy; 59 | } 60 | return 5 + i - Math.log(Math.log(sqx + sqy)) * iLog4; 61 | }, 62 | }, 63 | } as FractalDef; 64 | -------------------------------------------------------------------------------- /src/engine/fractals/mandelbrot4.ts: -------------------------------------------------------------------------------- 1 | import { FractalDef } from "./example"; 2 | 3 | const escape = 4; 4 | var iLog4 = 1.0 / Math.log(4.0); 5 | 6 | export default { 7 | fractalId: "mandelbrot4", 8 | uiOrder: 4, 9 | name: "Multibrot *4", 10 | preset: { x: 0.0, y: 0.0, w: 3.0, iter: 50 }, 11 | fn: { 12 | normal: (cx, cy, iter) => { 13 | var zx = 0, 14 | zy = 0, 15 | sqx = 0, 16 | sqy = 0, 17 | i = 0, 18 | znx, 19 | zny; 20 | while (true) { 21 | znx = sqx * sqx - 6 * sqx * sqy + sqy * sqy + cx; 22 | zny = 4 * sqx * zx * zy - 4 * zx * sqy * zy + cy; 23 | zx = znx; 24 | zy = zny; 25 | if (++i >= iter) break; 26 | sqx = zx * zx; 27 | sqy = zy * zy; 28 | if (sqx + sqy > escape) break; 29 | } 30 | return i; 31 | }, 32 | smooth: (cx, cy, iter) => { 33 | var zx = 0, 34 | zy = 0, 35 | sqx = 0, 36 | sqy = 0, 37 | i = 0, 38 | znx, 39 | zny; 40 | while (true) { 41 | znx = sqx * sqx - 6 * sqx * sqy + sqy * sqy + cx; 42 | zny = 4 * sqx * zx * zy - 4 * zx * sqy * zy + cy; 43 | zx = znx; 44 | zy = zny; 45 | if (++i >= iter) break; 46 | sqx = zx * zx; 47 | sqy = zy * zy; 48 | if (sqx + sqy > escape) break; 49 | } 50 | if (i === iter) return i; 51 | return 5 + i - Math.log(Math.log(sqx + sqy)) * iLog4; 52 | }, 53 | }, 54 | } as FractalDef; 55 | -------------------------------------------------------------------------------- /src/engine/fractals/phoenix.ts: -------------------------------------------------------------------------------- 1 | import { FractalDef } from "./example"; 2 | 3 | const escape = 4; 4 | var iLog2 = 1.0 / Math.log(2.0); 5 | 6 | const refx = 0.5667; 7 | const refy = -0.5; 8 | 9 | export default { 10 | fractalId: "phoenixex1", 11 | uiOrder: 6, 12 | name: "Phoenix", 13 | preset: { x: -0.7, y: -0.05, w: 1.6, iter: 250 }, 14 | fn: { 15 | normal: function (cx, cy, iter) { 16 | var x = -cy, 17 | y = cx, 18 | xm1 = 0, 19 | ym1 = 0; 20 | var sx = 0, 21 | sy = 0, 22 | i = 0; 23 | var xp1; 24 | var yp1; 25 | for (; i < iter && sx + sy <= escape; ++i) { 26 | xp1 = x * x - y * y + refx + refy * xm1; 27 | yp1 = 2 * x * y + refy * ym1; 28 | sx = xp1 * xp1; 29 | sy = yp1 * yp1; 30 | xm1 = x; 31 | ym1 = y; 32 | x = xp1; 33 | y = yp1; 34 | } 35 | return i; 36 | }, 37 | 38 | smooth: function (cx, cy, iter) { 39 | var x = -cy, 40 | y = cx, 41 | xm1 = 0, 42 | ym1 = 0; 43 | var sx = 0, 44 | sy = 0, 45 | i = 0; 46 | var xp1; 47 | var yp1; 48 | for (; i < iter && sx + sy <= escape; ++i) { 49 | xp1 = x * x - y * y + refx + refy * xm1; 50 | yp1 = 2 * x * y + refy * ym1; 51 | sx = xp1 * xp1; 52 | sy = yp1 * yp1; 53 | xm1 = x; 54 | ym1 = y; 55 | x = xp1; 56 | y = yp1; 57 | } 58 | if (i === iter) { 59 | return i; 60 | } 61 | for (var j = 0; j < 4; ++j) { 62 | xp1 = x * x - y * y + refx + refy * xm1; 63 | yp1 = 2 * x * y + refy * ym1; 64 | sx = xp1 * xp1; 65 | sy = yp1 * yp1; 66 | xm1 = x; 67 | ym1 = y; 68 | x = xp1; 69 | y = yp1; 70 | } 71 | var res = 5 + i - Math.log(Math.log(x * x + y * y)) * iLog2; 72 | return res; 73 | }, 74 | }, 75 | } as FractalDef; 76 | -------------------------------------------------------------------------------- /src/engine/fractals/tippetts.ts: -------------------------------------------------------------------------------- 1 | import { FractalDef } from "./example"; 2 | 3 | const escape = 4; 4 | 5 | export default { 6 | fractalId: "tippetts", 7 | uiOrder: 1, 8 | name: "Tippetts", 9 | preset: { x: -0.5, y: 0.0, w: 4, iter: 50 }, 10 | fn: { 11 | normal: (cx, cy, iter) => { 12 | var zx = 0, 13 | zy = 0, 14 | sqx = 0, 15 | sqy = 0, 16 | i = 0; 17 | for (; i < iter && sqx + sqy <= escape; ++i) { 18 | zx = sqx - sqy + cx; 19 | zy = (zx + zx) * zy + cy; 20 | sqx = zx * zx; 21 | sqy = zy * zy; 22 | } 23 | return i; 24 | }, 25 | }, 26 | } as FractalDef; 27 | -------------------------------------------------------------------------------- /src/engine/guide.ts: -------------------------------------------------------------------------------- 1 | import Engine from "./engine"; 2 | import Vector from "./math/vector"; 3 | import { Root } from "../redux/reducer"; 4 | 5 | const cross = (pos: Vector, context: CanvasRenderingContext2D) => { 6 | const size = 20; 7 | context.beginPath(); 8 | context.moveTo(pos.x - size, pos.y); 9 | context.lineTo(pos.x + size, pos.y); 10 | context.moveTo(pos.x, pos.y - size); 11 | context.lineTo(pos.x, pos.y + size); 12 | context.stroke(); 13 | }; 14 | 15 | export default class Guide { 16 | private context: CanvasRenderingContext2D; 17 | 18 | constructor( 19 | public canvas: HTMLCanvasElement, 20 | private engine: Engine, 21 | private getState: () => Root, 22 | ) { 23 | this.context = canvas.getContext("2d") as CanvasRenderingContext2D; 24 | } 25 | 26 | draw() { 27 | const guide = this.getState().guide; 28 | if (!guide.active) { 29 | this.canvas.hidden = true; 30 | return; 31 | } 32 | 33 | this.canvas.hidden = false; 34 | const cam = this.engine.ctx.camera; 35 | const scr = cam.cpx2scr(new Vector(guide.x, guide.y)); 36 | this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); 37 | 38 | this.context.lineWidth = 3; 39 | this.context.strokeStyle = "#000000"; 40 | cross(scr.plus(new Vector(3, 3)), this.context); 41 | this.context.strokeStyle = "#FFFFFF"; 42 | cross(scr, this.context); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/engine/math/camera.ts: -------------------------------------------------------------------------------- 1 | import Matrix from "./matrix"; 2 | import Vector from "./vector"; 3 | 4 | // prettier-ignore 5 | const getSquareToComplexMatrix = (x: number, y: number, w: number) => 6 | Matrix.GetTriangleToTriangle( 7 | 1, 0, 0, 1, 0, 0, 8 | x + w / 2, y, x, y + w / 2, x, y); 9 | 10 | export type AffineTransform = "rotation" | "scale" | "shear"; 11 | 12 | const getScreenToSquareMatrix = ( 13 | viewportTransform: Matrix, 14 | width: number, 15 | height: number, 16 | ) => { 17 | const p = viewportTransform.transform(new Vector(1, -1)); 18 | const q = viewportTransform.transform(new Vector(-1, 1)); 19 | const r = viewportTransform.transform(new Vector(-1, -1)); 20 | if (width >= height) { 21 | const x1 = (width - height) / 2; 22 | const x2 = x1 + height; 23 | // prettier-ignore 24 | return Matrix.GetTriangleToTriangle( 25 | x2, height, x1, 0, x1, height, 26 | p.x, p.y, q.x, q.y, r.x, r.y); 27 | } else { 28 | const y1 = (height - width) / 2; 29 | const y2 = y1 + width; 30 | // prettier-ignore 31 | return Matrix.GetTriangleToTriangle( 32 | width, y2, 0, y1, 0, y2, 33 | p.x, p.y, q.x, q.y, r.x, r.y); 34 | } 35 | }; 36 | 37 | // The camera projects view space on complex space and vice-verse. 38 | // The "square" or Q is the maximum centered square that can be inscribed in the view. 39 | // X and Y are the complex coordinates @ the center of the view 40 | // W is the complex size of the square 41 | // A viewport affine transformation can be applied on the square to rotate/scale/shear the view 42 | export default class Camera { 43 | private matrix_inv: Matrix; 44 | public matrix: Matrix; 45 | public viewportMatrix: Matrix; 46 | public screen: Vector; 47 | public resolutionLimit: number; 48 | public pos: Vector; 49 | public w: number; 50 | 51 | constructor( 52 | screenSize: Vector, 53 | pos: Vector, 54 | w: number, 55 | viewportMatrix = Matrix.identity, 56 | ) { 57 | this.viewportMatrix = viewportMatrix; 58 | this.screen = screenSize; 59 | const extent = screenSize.minVal(); 60 | this.resolutionLimit = extent * 1.11e-15; 61 | this.pos = pos; 62 | this.w = w; 63 | this.matrix = Matrix.identity; 64 | this.matrix_inv = Matrix.identity; 65 | this.reproject(); 66 | } 67 | 68 | reproject() { 69 | this.w = Math.max(this.w, this.resolutionLimit); 70 | const S2Q = getScreenToSquareMatrix( 71 | this.viewportMatrix, 72 | this.screen.x, 73 | this.screen.y, 74 | ); 75 | const Q2C = getSquareToComplexMatrix(this.pos.x, this.pos.y, this.w); 76 | this.matrix = Q2C.multiply(S2Q); 77 | this.matrix_inv = this.matrix.inverse(); 78 | } 79 | 80 | clone() { 81 | return new Camera(this.screen, this.pos, this.w, this.viewportMatrix); 82 | } 83 | 84 | scr2cpx(v: Vector) { 85 | return this.matrix.transform(v); 86 | } 87 | 88 | cpx2scr(v: Vector) { 89 | return this.matrix_inv.transform(v); 90 | } 91 | 92 | getPos() { 93 | return this.pos; 94 | } 95 | 96 | setPos(pos: Vector, w?: number) { 97 | this.pos = pos; 98 | if (w) this.w = w; 99 | } 100 | 101 | resize(width: number, height: number) { 102 | this.screen = new Vector(width, height); 103 | const extent = Math.min(width, height); // extent of the min square 104 | this.resolutionLimit = extent * 1.11e-15; 105 | this.reproject(); 106 | } 107 | 108 | isZoomLimit() { 109 | return this.w <= this.resolutionLimit; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/engine/math/matrix.ts: -------------------------------------------------------------------------------- 1 | import Vector from "./vector"; 2 | 3 | export type RawMatrix = { 4 | a: number; 5 | b: number; 6 | c: number; 7 | d: number; 8 | e: number; 9 | f: number; 10 | }; 11 | 12 | export default class Matrix { 13 | public static readonly identity = new Matrix(1, 0, 0, 1, 0, 0); 14 | 15 | constructor( 16 | public readonly a: number, 17 | public readonly b: number, 18 | public readonly c: number, 19 | public readonly d: number, 20 | public readonly e: number, 21 | public readonly f: number, 22 | ) {} 23 | 24 | static fromRaw(raw: RawMatrix) { 25 | return new Matrix(raw.a, raw.b, raw.c, raw.d, raw.e, raw.f); 26 | } 27 | 28 | transform(v: Vector) { 29 | return new Vector( 30 | v.x * this.a + v.y * this.c + this.e, 31 | v.x * this.b + v.y * this.d + this.f, 32 | ); 33 | } 34 | 35 | isIdentity() { 36 | return ( 37 | this.a === 1 && 38 | this.b === 0 && 39 | this.c === 0 && 40 | this.d === 1 && 41 | this.e === 0 && 42 | this.f === 0 43 | ); 44 | } 45 | 46 | private isInvertible() { 47 | const deter = this.a * this.d - this.b * this.c; 48 | return Math.abs(deter) > 1e-15; 49 | } 50 | 51 | inverse() { 52 | if (!this.isInvertible()) { 53 | return this.inverseGaussJordan(); 54 | } 55 | const dt = this.a * this.d - this.b * this.c; 56 | return new Matrix( 57 | this.d / dt, 58 | -this.b / dt, 59 | -this.c / dt, 60 | this.a / dt, 61 | (this.c * this.f - this.d * this.e) / dt, 62 | -(this.a * this.f - this.b * this.e) / dt, 63 | ); 64 | } 65 | 66 | multiply(o: Matrix) { 67 | return new Matrix( 68 | this.a * o.a + this.c * o.b, 69 | this.b * o.a + this.d * o.b, 70 | this.a * o.c + this.c * o.d, 71 | this.b * o.c + this.d * o.d, 72 | this.a * o.e + this.c * o.f + this.e, 73 | this.b * o.e + this.d * o.f + this.f, 74 | ); 75 | } 76 | 77 | private inverseGaussJordan() { 78 | function gje(M: any, c1i: any, c2i: any, f: any) { 79 | const c1 = M[c1i]; 80 | const c2 = M[c2i]; 81 | for (let i = 0; i < 6; i++) { 82 | c1[i] += c2[i] * f; 83 | } 84 | } 85 | 86 | function gjet(M: any, c1i: any, f: any) { 87 | const c1 = M[c1i]; 88 | for (let i = 0; i < 6; i++) { 89 | c1[i] *= f; 90 | } 91 | } 92 | const M = [ 93 | [this.a, this.c, this.e, 1, 0, 0], 94 | [this.b, this.d, this.f, 0, 1, 0], 95 | [0, 0, 1, 0, 0, 1], 96 | ]; 97 | gje(M, 1, 2, -M[1][2]); // c2 = c2 + c3 * -f 98 | gje(M, 0, 2, -M[0][2]); // c1 = c1 + c3 * -e 99 | gje(M, 1, 0, -M[1][0] / M[0][0]); 100 | gje(M, 0, 1, -M[0][1] / M[1][1]); 101 | gjet(M, 0, 1 / M[0][0]); 102 | gjet(M, 1, 1 / M[1][1]); 103 | return new Matrix(M[0][3], M[1][3], M[0][4], M[1][4], M[0][5], M[1][5]); 104 | } 105 | 106 | toString() { 107 | return `${this.a} ${this.c} ${this.e}\n${this.b} ${this.d} ${this.f}\n0 0 1`; 108 | } 109 | 110 | // prettier-ignore 111 | static GetTriangleToTriangle( 112 | t1px: number, t1py: number, t1qx: number, t1qy: number, t1rx: number, t1ry: number, 113 | t2px: number, t2py: number, t2qx: number, t2qy: number, t2rx: number, t2ry: number 114 | ) { 115 | const STD2T1 = new Matrix(t1px - t1rx, t1py - t1ry, t1qx - t1rx, t1qy - t1ry, t1rx, t1ry); 116 | const STD2T2 = new Matrix(t2px - t2rx, t2py - t2ry, t2qx - t2rx, t2qy - t2ry, t2rx, t2ry); 117 | const T12STD = STD2T1.inverse(); 118 | return STD2T2.multiply(T12STD); 119 | } 120 | 121 | static GetRotationMatrix(angle: number) { 122 | const cos = Math.cos(angle); 123 | const sin = Math.sin(angle); 124 | return new Matrix(cos, sin, -sin, cos, 0, 0); 125 | } 126 | 127 | static GetScaleMatrix(x: number, y: number) { 128 | return new Matrix(x, 0, 0, y, 0, 0); 129 | } 130 | 131 | static GetShearMatrix(x: number, y: number) { 132 | return new Matrix(1, y, x, 1, 0, 0); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/engine/math/vector.ts: -------------------------------------------------------------------------------- 1 | export default class Vector { 2 | public readonly x: number; 3 | public readonly y: number; 4 | 5 | constructor(x: Vector | number, y?: number) { 6 | if (x instanceof Vector) { 7 | this.x = x.x; 8 | this.y = x.y; 9 | } else { 10 | this.x = x; 11 | this.y = y as number; 12 | } 13 | } 14 | 15 | midPoint(v2: Vector) { 16 | return new Vector((v2.x + this.x) / 2, (v2.y + this.y) / 2); 17 | } 18 | 19 | minus(v: Vector) { 20 | return new Vector(this.x - v.x, this.y - v.y); 21 | } 22 | 23 | plus(v: Vector) { 24 | return new Vector(this.x + v.x, this.y + v.y); 25 | } 26 | 27 | times(v: Vector | number) { 28 | return v instanceof Vector 29 | ? new Vector(this.x * v.x, this.y * v.y) 30 | : new Vector(this.x * v, this.y * v); 31 | } 32 | 33 | minVal() { 34 | return Math.min(this.x, this.y); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/engine/painter.ts: -------------------------------------------------------------------------------- 1 | import { getBufferFromId } from "../util/palette"; 2 | import { Tile } from "./tile"; 3 | 4 | export interface PainterArgs { 5 | offset: number; 6 | density: number; 7 | id: number; 8 | fn: "s" | "n"; 9 | } 10 | 11 | export default class Painter { 12 | public id: number = NaN; 13 | public offset: number = 0; 14 | public fn: string = "s"; 15 | public density: number = 0; 16 | private resolution: number = 0; 17 | private buffer: Int32Array = new Int32Array(); 18 | 19 | constructor(p: PainterArgs) { 20 | this.set(p); 21 | } 22 | 23 | set(p: any) { 24 | if ("fn" in p) this.fn = p.fn; 25 | if ("offset" in p) this.offset = p.offset; 26 | if ("density" in p) this.density = p.density; 27 | if (this.id !== p.id) { 28 | this.id = p.id; 29 | this.buffer = getBufferFromId(p.id, 1000); 30 | this.resolution = this.buffer.length; 31 | } 32 | } 33 | 34 | // this function is not pure : it modifies buffer 35 | // this function need maximum speed : it uses only vars 36 | paint(tile: Tile, buffer: Uint32Array, width: number) { 37 | var offset = this.offset * this.resolution; 38 | var density = this.density, 39 | resolution = this.resolution; 40 | var cbuffer = this.buffer; 41 | var tileIndex = 0; 42 | var bufferIndex = 0; 43 | var tx, ty, iter, color; 44 | 45 | if (this.fn === "s") { 46 | density = density * 7; 47 | offset = ((this.offset + 0.85) % 1) * this.resolution; 48 | } 49 | 50 | for (ty = 0; ty < tile.height; ty += 1) { 51 | bufferIndex = (ty + tile.y1) * width + tile.x1; 52 | for (tx = 0; tx < tile.width; tx += 1) { 53 | iter = tile.buffer[tileIndex]; 54 | if (iter === 0) { 55 | color = 0xff000000; 56 | } else { 57 | if (this.fn === "s") { 58 | iter = Math.sqrt(iter); 59 | } 60 | color = cbuffer[~~((iter * density + offset) % resolution)]; 61 | } 62 | buffer[bufferIndex] = color; 63 | tileIndex += 1; 64 | bufferIndex += 1; 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/engine/redrawer.ts: -------------------------------------------------------------------------------- 1 | import Matrix from "./math/matrix"; 2 | import { Context } from "./engine"; 3 | 4 | export default class Redrawer { 5 | private canvas: HTMLCanvasElement; 6 | private offCanvas: HTMLCanvasElement; 7 | private context: CanvasRenderingContext2D; 8 | private lastId: number | null = null; 9 | private lastType: string | null = null; 10 | private lastMatrix: Matrix | null = null; 11 | 12 | constructor(ctx: Context) { 13 | this.canvas = ctx.canvas; 14 | this.context = ctx.context; 15 | this.offCanvas = document.createElement("canvas"); 16 | this.offCanvas.width = this.canvas.width; 17 | this.offCanvas.height = this.canvas.height; 18 | } 19 | 20 | redraw(newMatrix: Matrix, type: string, id: number) { 21 | // if type has changed, don't redraw 22 | if (this.lastType !== type) this.lastMatrix = null; 23 | if (this.lastId === id) return; 24 | let tileSorter; 25 | 26 | if (this.lastMatrix) { 27 | // compute movement matrix 28 | const m = this.lastMatrix 29 | .inverse() 30 | .multiply(newMatrix) 31 | .inverse(); 32 | 33 | // use movement to quickly redraw last picture at new position 34 | const imageData = this.context.getImageData( 35 | 0, 36 | 0, 37 | this.canvas.width, 38 | this.canvas.height, 39 | ); 40 | this.offCanvas.getContext("2d")!.putImageData(imageData, 0, 0); 41 | this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); 42 | this.context.transform(m.a, m.b, m.c, m.d, m.e, m.f); 43 | this.context.drawImage(this.offCanvas, 0, 0); 44 | this.context.setTransform(1, 0, 0, 1, 0, 0); 45 | 46 | // use movement to infer an optimal redrawing order of tiles 47 | // tiles are sorted according to their distance to the tileSorter x, y point 48 | // compute invariant point 49 | /* eslint-disable no-mixed-operators */ 50 | const x = 51 | (m.c * m.f - m.d * m.e + m.e) / (m.a * m.d - m.a - m.b * m.c - m.d + 1); 52 | const y = 53 | (m.a * m.f - m.b * m.e - m.f) / 54 | (m.a * -m.d + m.a + m.b * m.c + m.d - 1); 55 | /* eslint-enable no-mixed-operators */ 56 | 57 | if (m.a > 1.001 && m.d > 1.001) { 58 | tileSorter = { x, y, reverse: false }; 59 | } else if (m.a < 0.99 && m.d < 0.99) { 60 | tileSorter = { x, y, reverse: true }; 61 | } else if (Math.abs(m.e) > 0.01 || Math.abs(m.f) > 0.01) { 62 | tileSorter = { 63 | x: this.canvas.width / 2, 64 | y: this.canvas.height / 2, 65 | reverse: true, 66 | }; 67 | if (m.e < -0.01) tileSorter.x = 0; 68 | if (m.e > 0.01) tileSorter.x = this.canvas.width; 69 | if (m.f < -0.01) tileSorter.y = 0; 70 | if (m.f > 0.01) tileSorter.y = this.canvas.height; 71 | } 72 | } else { 73 | this.lastType = type; 74 | } 75 | 76 | this.lastId = id; 77 | this.lastMatrix = newMatrix; 78 | return tileSorter; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/engine/renderer.ts: -------------------------------------------------------------------------------- 1 | import Scheduler from "./scheduler/scheduler"; 2 | import Redrawer from "./redrawer"; 3 | import { getFunction } from "./fractals"; 4 | import Painter from "./painter"; 5 | import Main, { Context } from "./engine"; 6 | import { 7 | WorkerResponse, 8 | DrawParams, 9 | Model, 10 | DrawOrder, 11 | } from "./scheduler/types"; 12 | import Vector from "./math/vector"; 13 | import { Tile } from "./tile"; 14 | 15 | export default class Renderer { 16 | private readonly ctx: Context; 17 | 18 | private painter: Painter; 19 | private scheduler: Scheduler; 20 | private redrawer: Redrawer; 21 | 22 | private width!: number; 23 | private height!: number; 24 | private imageData!: ImageData; 25 | private imageBuffer!: Uint32Array; 26 | 27 | private tiles!: Tile[]; 28 | 29 | constructor(ctx: Context, painter: Painter, engine: Main) { 30 | this.ctx = ctx; 31 | this.resize(); 32 | this.callback = this.callback.bind(this); 33 | this.scheduler = new Scheduler(ctx, this.callback); 34 | this.painter = painter; 35 | this.redrawer = new Redrawer(ctx); 36 | } 37 | 38 | resize() { 39 | const canvas = this.ctx.canvas; 40 | this.imageData = this.ctx.context.createImageData( 41 | canvas.width, 42 | canvas.height, 43 | ); 44 | this.imageBuffer = new Uint32Array(this.imageData.data.buffer); 45 | this.width = canvas.width; 46 | this.height = canvas.height; 47 | this.tiles = Tile.getTiling(this.width, this.height); 48 | } 49 | 50 | getIterationsAt(cpx: Vector) { 51 | const func = getFunction(this.ctx.fractalId, this.ctx.smooth); 52 | return func(cpx.x, cpx.y, this.ctx.iter); 53 | } 54 | 55 | getHistogram(): number[] { 56 | const histogram = new Array(this.ctx.iter + 1).fill(0); 57 | for (const tile of this.tiles) { 58 | for (let i in tile.buffer) { 59 | const val = Math.round(tile.buffer[i]); 60 | if (val >= 0 && val <= this.ctx.iter) histogram[val] += 1; 61 | } 62 | } 63 | return histogram; 64 | } 65 | 66 | // redraws the current float buffer 67 | drawColor() { 68 | this.ctx.event.notify("draw.redraw", {}); 69 | this.tiles.forEach(tile => { 70 | this.painter.paint(tile, this.imageBuffer, this.width); 71 | }); 72 | this.ctx.context.putImageData( 73 | this.imageData, 74 | 0, 75 | 0, 76 | 0, 77 | 0, 78 | this.width, 79 | this.height, 80 | ); 81 | } 82 | 83 | // performs a full draw: floatbuffer + colors 84 | draw(params: DrawParams) { 85 | this.ctx.event.notify("draw.start", {}); 86 | const redraw = this.redrawer.redraw; 87 | let tileSort: ReturnType; 88 | if (this.redrawer) 89 | tileSort = this.redrawer.redraw( 90 | this.ctx.camera.matrix, 91 | this.ctx.fractalId, 92 | params.id || -1, 93 | ); 94 | const workerModel: Model = Object.assign({}, this.ctx.camera.matrix, { 95 | type: this.ctx.fractalId, 96 | smooth: this.ctx.smooth, 97 | iter: this.ctx.iter, 98 | }); 99 | const orders: DrawOrder[] = this.tiles.map(tile => ({ 100 | action: "draw", 101 | tile, 102 | params, 103 | model: workerModel, 104 | })); 105 | // if redrawer was able to find a tile sorting, sort them 106 | if (tileSort) { 107 | orders.forEach(t => { 108 | t.dist = Math.sqrt( 109 | (t.tile.x - tileSort!.x) ** 2 + (t.tile.y - tileSort!.y) ** 2, 110 | ); 111 | }); 112 | orders.sort((a, b) => 113 | // @ts-ignore 114 | tileSort!.reverse ? b.dist - a.dist : a.dist - b.dist, 115 | ); 116 | } 117 | const schedulerPromise = this.scheduler.schedule(orders); // .catch(() => {}); 118 | return schedulerPromise; 119 | } 120 | 121 | callback(data: WorkerResponse) { 122 | if (data.action !== "end-draw") throw new Error(); 123 | const tile = data.tile; 124 | this.painter.paint(tile, this.imageBuffer, this.width); 125 | this.ctx.context.putImageData( 126 | this.imageData, 127 | 0, 128 | 0, 129 | tile.x1, 130 | tile.y1, 131 | tile.width, 132 | tile.height, 133 | ); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/engine/scheduler/scheduler.ts: -------------------------------------------------------------------------------- 1 | import Worker from "./worker"; 2 | import { DrawOrder, WorkerResponse } from "./types"; 3 | import { Context } from "../engine"; 4 | import _ from "lodash"; 5 | 6 | /* 7 | Maintains the list of webworkers. 8 | Receive list of orders from the renderer and dispatches them to the workers. 9 | Can be interrupted in the middle of a rendering. 10 | Deal with neutered arrays (arrays passed by reference between main thread and workers). 11 | */ 12 | export default class Scheduler { 13 | private scheduleId: number = 0; 14 | private orders: DrawOrder[] = []; 15 | private workers: Worker[] = []; 16 | private upperCallback: (data: WorkerResponse) => void; 17 | public handlers!: { 18 | accept: () => any; 19 | reject: (x: any) => any; 20 | }; 21 | 22 | constructor(ctx: Context, callback: (data: WorkerResponse) => void) { 23 | this.upperCallback = callback; 24 | for (let i = 0; i < ctx.nbThreads; i += 1) { 25 | const callback = this.onWorkerResponse.bind(this); 26 | this.workers.push(new Worker(i, callback)); 27 | } 28 | this.throttledSchedule = _.throttle(this.throttledSchedule.bind(this), 200); 29 | } 30 | 31 | private onWorkerResponse(worker: Worker, data: WorkerResponse) { 32 | if (data.scheduleId === this.scheduleId) this.upperCallback(data); 33 | const order = this.orders.shift(); 34 | if (order) worker.draw(order); 35 | if (!this.isWorking) this.handlers.accept(); 36 | } 37 | 38 | get availableWorkers() { 39 | return this.workers.filter(w => w.available); 40 | } 41 | 42 | get busyWorkers() { 43 | return this.workers.filter(w => !w.available); 44 | } 45 | 46 | get isWorking() { 47 | return this.orders.length || this.busyWorkers.length; 48 | } 49 | 50 | private throttledSchedule(orders: DrawOrder[]) { 51 | // store current draw orders, and assign one to each worker 52 | this.orders = orders.map(o => ({ ...o, scheduleId: this.scheduleId })); 53 | this.busyWorkers.forEach(w => w.cancel()); 54 | this.availableWorkers.forEach(w => { 55 | const order = this.orders.shift(); 56 | if (order) w.draw(order); 57 | }); 58 | } 59 | 60 | // returns a pending promise that will be resolved by either 61 | // - rejection if schedule is called before all orders have been handled 62 | // - acception when everything has been settled 63 | async schedule(orders: DrawOrder[]) { 64 | // increase scheduleId to be able to reject outdated workers answering 65 | this.scheduleId++; 66 | 67 | // if we had previous orders, or busy workers, clear orders, reject 68 | // previous promise 69 | if (this.isWorking) { 70 | this.orders = []; 71 | if (this.handlers) this.handlers.reject("Scheduler interrupted"); 72 | } 73 | 74 | // throttle real work to avoid building/destroying webworkers too often 75 | this.throttledSchedule(orders); 76 | 77 | return new Promise((accept, reject) => { 78 | this.handlers = { accept, reject }; 79 | }); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/engine/scheduler/types.ts: -------------------------------------------------------------------------------- 1 | import { Tile } from "../tile"; 2 | 3 | export interface DrawParams { 4 | details: "supersampling" | "normal" | "subsampling" | "iter-increase"; 5 | size?: number; 6 | id?: number; 7 | } 8 | 9 | export interface Model { 10 | a: number; 11 | b: number; 12 | c: number; 13 | d: number; 14 | e: number; 15 | f: number; 16 | type: string; 17 | smooth: boolean; 18 | iter: number; 19 | } 20 | 21 | export interface DrawOrder { 22 | action: "draw"; 23 | tile: Tile; 24 | params: DrawParams; 25 | model: Model; 26 | scheduleId?: number; 27 | dist?: number; // used when computing draw order 28 | } 29 | interface InitOrder { 30 | action: "init"; 31 | id: number; 32 | } 33 | 34 | export type Order = DrawOrder | InitOrder; 35 | 36 | export type WorkerResponse = { 37 | action: "end-draw"; 38 | tile: Tile; 39 | workerId: string; 40 | scheduleId: number; 41 | }; 42 | -------------------------------------------------------------------------------- /src/engine/scheduler/worker.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import WorkerInterface from "./worker/interface.worker.js"; 3 | import { DrawOrder, Order, WorkerResponse } from "./types"; 4 | import { Tile } from "../tile.js"; 5 | 6 | interface TWorker { 7 | postMessage: (x: Order, y?: any[]) => void; 8 | onmessage: (x: any) => void; 9 | terminate: () => void; 10 | } 11 | type LocalCallback = (worker: Worker, data: WorkerResponse) => void; 12 | 13 | /* 14 | This is a webworker proxy. It handles: 15 | - passing of tile buffer by reference to and from the webworker 16 | - canceling webworkers, and recreating their tile buffer 17 | - handling "available" status as a safeguard 18 | */ 19 | export default class Worker { 20 | public id: number; 21 | public available: boolean = true; 22 | 23 | private tile!: Tile; 24 | private worker!: TWorker; 25 | private callback: LocalCallback; 26 | 27 | constructor(id: number, callback: LocalCallback) { 28 | this.id = id; 29 | this.createWebWorker(); 30 | this.callback = callback; 31 | } 32 | 33 | private createWebWorker() { 34 | this.worker = (new WorkerInterface() as unknown) as TWorker; 35 | this.worker.postMessage({ action: "init", id: this.id }); 36 | this.worker.onmessage = this.onmessage.bind(this); 37 | } 38 | 39 | cancel() { 40 | if (this.available) throw new Error("Canceling an available worker"); 41 | // hard stuff: force terminate and recreate of a worker 42 | this.worker.terminate(); 43 | this.createWebWorker(); 44 | this.tile.buffer = new Float32Array(this.tile.width * this.tile.height); 45 | this.available = true; 46 | } 47 | 48 | draw(order: DrawOrder) { 49 | if (!this.available) throw new Error("Worker already busy"); 50 | this.tile = order.tile; 51 | this.available = false; 52 | this.worker.postMessage(order, [order.tile.buffer.buffer]); 53 | } 54 | 55 | onmessage(event: any) { 56 | const data: WorkerResponse = event.data; 57 | this.tile.buffer = data.tile.buffer; // reassign the buffer to the original tile 58 | this.available = true; 59 | this.callback(this, data); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/engine/scheduler/worker/interface.worker.js: -------------------------------------------------------------------------------- 1 | // a mere javascript file is required as the entry point for the worker 2 | // with my current setup... 3 | import Worker from "./web-worker"; 4 | export default class Dummy {} 5 | -------------------------------------------------------------------------------- /src/engine/scheduler/worker/web-worker.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-globals */ 2 | import { getFunction } from "../../fractals"; 3 | import { Order, Model, WorkerResponse } from "../types"; 4 | import { RenderFn } from "../../fractals/example"; 5 | import { Tile } from "../../tile"; 6 | 7 | const draw = (model: Model, func: RenderFn, tile: Tile) => { 8 | var buffer = tile.buffer; 9 | var dx = 0; 10 | var sx, sy, px, py, piter; 11 | for (sy = tile.y1; sy <= tile.y2; sy++) { 12 | px = (tile.x1 + 0.5) * model.a + (sy + 0.5) * model.c + model.e; 13 | py = (tile.x1 + 0.5) * model.b + (sy + 0.5) * model.d + model.f; 14 | for (sx = tile.x1; sx <= tile.x2; sx++) { 15 | piter = func(px, py, model.iter); 16 | if (piter === model.iter) { 17 | buffer[dx++] = 0; 18 | } else { 19 | buffer[dx++] = piter; 20 | } 21 | px += model.a; 22 | py += model.b; 23 | } 24 | } 25 | }; 26 | 27 | // does not seem to work well, maybe because of a tile ordering problem 28 | // need to sort out tiling before attempting this method again 29 | // a first test shows a 15% speed increase, nice but not decisive 30 | const drawZeroes = (model: Model, func: RenderFn, tile: Tile) => { 31 | var buffer = tile.buffer; 32 | var dx = 0; 33 | var sx, sy, px, py, piter; 34 | for (sy = tile.y1; sy <= tile.y2; sy++) { 35 | px = (tile.x1 + 0.5) * model.a + (sy + 0.5) * model.c + model.e; 36 | py = (tile.x1 + 0.5) * model.b + (sy + 0.5) * model.d + model.f; 37 | for (sx = tile.x1; sx <= tile.x2; sx++) { 38 | if (buffer[dx] === 0) { 39 | piter = func(px, py, model.iter); 40 | if (piter !== model.iter) { 41 | buffer[dx] = piter; 42 | } 43 | px += model.a; 44 | py += model.b; 45 | } 46 | dx++; 47 | } 48 | } 49 | }; 50 | 51 | const drawSubsampled = ( 52 | model: Model, 53 | func: RenderFn, 54 | tile: Tile, 55 | res: number, 56 | ) => { 57 | var buffer = tile.buffer; 58 | var dx = 0, 59 | sx, 60 | sy, 61 | px, 62 | py, 63 | piter; 64 | // first compute sparse grid 65 | for (sy = tile.y1; sy <= tile.y2; sy += res) { 66 | dx = (sy - tile.y1) * tile.width; 67 | for (sx = tile.x1; sx <= tile.x2; sx += res) { 68 | px = (sx + res / 2) * model.a + (sy + res / 2) * model.c + model.e; 69 | py = (sx + res / 2) * model.b + (sy + res / 2) * model.d + model.f; 70 | piter = func(px, py, model.iter); 71 | if (piter === model.iter) { 72 | buffer[dx] = 0; 73 | } else { 74 | buffer[dx] = piter; 75 | } 76 | dx += res; 77 | } 78 | } 79 | // then fill the holes 80 | dx = 0; 81 | for (sy = 0; sy < tile.height; sy++) { 82 | for (sx = 0; sx < tile.width; sx++) { 83 | if (!(sy % res === 0 && sx % res === 0)) { 84 | buffer[dx] = buffer[(sy - (sy % res)) * tile.width + sx - (sx % res)]; 85 | } 86 | dx++; 87 | } 88 | } 89 | }; 90 | 91 | // prettier-ignore 92 | const drawSupersampled = (model: Model, func: RenderFn, tile: Tile, res: number ) => { 93 | var buffer = tile.buffer; 94 | var pixelOnP = Math.sqrt(model.a * model.a + model.b * model.b); 95 | var resq = res * res; 96 | var sss = pixelOnP / res; 97 | var dx = 0, sx, sy, px, py, itersum, pxs, pys, piter, ss; 98 | for (sy = tile.y1; sy <= tile.y2; sy++) { 99 | for (sx = tile.x1; sx <= tile.x2; sx++) { 100 | // must only be activated if we're sure tile contains data from previously computed normal 101 | if (buffer[dx] === 0) { 102 | // if we're not on borders of tile, check if this point is inside set and skip SS 103 | if ( 104 | !(sy === tile.y1 || sy === tile.y2 - 1 || sx === tile.x1 || sx === tile.y1 - 1) 105 | ) { 106 | if ( 107 | buffer[dx + 1] === 0 && 108 | buffer[dx - 1] === 0 && 109 | buffer[dx + tile.width] === 0 && 110 | buffer[dx - tile.width] === 0 111 | ) { 112 | dx++; 113 | continue; 114 | } 115 | } 116 | } 117 | px = sx * model.a + sy * model.c + model.e + sss / 2; 118 | py = sx * model.b + sy * model.d + model.f - sss / 2; 119 | // console.log('---', sx, sy, px, py) 120 | itersum = 0; 121 | for (ss = 0; ss < resq; ss++) { 122 | pxs = px + Math.trunc(ss / res) * sss; 123 | pys = py - (ss % res) * sss; 124 | itersum += func(pxs, pys, model.iter); 125 | // console.log(pxs, pys) 126 | } 127 | piter = itersum / resq; 128 | buffer[dx++] = piter === model.iter ? 0 : piter; 129 | } 130 | } 131 | }; 132 | 133 | let workerId = "worker-?"; 134 | 135 | // @ts-ignore 136 | self.onmessage = (event: any) => { 137 | const data: Order = event.data; 138 | switch (data.action) { 139 | case "init": 140 | workerId = `worker-${data.id}`; 141 | break; 142 | case "draw": { 143 | const func = getFunction(data.model.type, data.model.smooth); 144 | if (data.params.details === "normal") { 145 | draw(data.model, func, data.tile); 146 | } else if (data.params.details === "iter-increase") { 147 | drawZeroes(data.model, func, data.tile); 148 | } else if (data.params.details === "subsampling") { 149 | drawSubsampled(data.model, func, data.tile, data.params.size || 4); 150 | } else if (data.params.details === "supersampling") { 151 | drawSupersampled(data.model, func, data.tile, data.params.size || 4); 152 | } 153 | const answer: WorkerResponse = { 154 | action: "end-draw", 155 | tile: data.tile, 156 | workerId: workerId, 157 | scheduleId: data.scheduleId || -1, 158 | }; 159 | // @ts-ignore 160 | postMessage(answer, [answer.tile.buffer.buffer]); 161 | break; 162 | } 163 | default: 164 | throw new Error("Illegal action"); 165 | } 166 | }; 167 | -------------------------------------------------------------------------------- /src/engine/tile.ts: -------------------------------------------------------------------------------- 1 | import { param } from "../params"; 2 | 3 | let id = 0; 4 | 5 | export class Tile { 6 | public readonly id: number; 7 | public readonly x1: number; 8 | public readonly x2: number; 9 | public readonly y1: number; 10 | public readonly y2: number; 11 | public readonly x: number; 12 | public readonly y: number; 13 | public readonly width: number; 14 | public readonly height: number; 15 | public buffer: Float32Array; 16 | 17 | constructor(x1: number, x2: number, y1: number, y2: number) { 18 | this.id = id++; 19 | this.x1 = x1; 20 | this.x2 = x2; 21 | this.y1 = y1; 22 | this.y2 = y2; 23 | this.x = (x1 + x2) / 2; 24 | this.y = (y1 + y2) / 2; 25 | this.width = x2 - x1 + 1; 26 | this.height = y2 - y1 + 1; 27 | this.buffer = new Float32Array(this.width * this.height); 28 | } 29 | 30 | static getTiling(width: number, height: number) { 31 | // compute tiling 32 | const nbTiles = param.nbTiles; 33 | const ratio = width / height; 34 | const nbY = Math.round(Math.sqrt(nbTiles / ratio)); 35 | const nbX = Math.round(Math.sqrt(nbTiles / ratio) * ratio); 36 | console.log( 37 | `Tiling [${width} x ${height}] with ${nbTiles} tiles --> `, 38 | `[${nbX} * ${nbY}] = ${nbY * nbX} tiles of `, 39 | `~ [${Math.round(width / nbX)} x ${Math.round(height / nbY)}]`, 40 | ); 41 | // instanciate tiles 42 | const res = []; 43 | for (let j = 0; j < nbY; j += 1) { 44 | for (let i = 0; i < nbX; i += 1) { 45 | const x1 = Math.round((i * width) / nbX); 46 | const x2 = Math.round(((i + 1) * width) / nbX) - 1; 47 | const y1 = Math.round((j * height) / nbY); 48 | const y2 = Math.round(((j + 1) * height) / nbY) - 1; 49 | res.push(new Tile(x1, x2, y1, y2)); 50 | } 51 | } 52 | return res; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./ui/App"; 4 | import { ThemeProvider } from "@material-ui/styles"; 5 | import * as serviceWorker from "./serviceWorker"; 6 | import { configureStore } from "@reduxjs/toolkit"; 7 | import reducer from "./redux/reducer"; 8 | import { Provider } from "react-redux"; 9 | import { createMuiTheme } from "@material-ui/core/styles"; 10 | 11 | const store = configureStore({ reducer }); 12 | 13 | const theme = createMuiTheme({ 14 | palette: { 15 | // type: "dark", 16 | }, 17 | breakpoints: { 18 | values: { 19 | xs: 0, 20 | sm: 450, // 'sm' value at 450 (ie mobile portrait width) is our only real point 21 | md: 960, 22 | lg: 1280, 23 | xl: 1920, 24 | }, 25 | }, 26 | }); 27 | 28 | ReactDOM.render( 29 | 30 | 31 | 32 | 33 | , 34 | document.getElementById("root"), 35 | ); 36 | 37 | serviceWorker.register(); 38 | -------------------------------------------------------------------------------- /src/params.ts: -------------------------------------------------------------------------------- 1 | const defaults = { 2 | nbTiles: 100, 3 | supersampling: true, 4 | }; 5 | 6 | const debug = { 7 | // nbTiles: 100, 8 | // supersampling: false, 9 | }; 10 | 11 | export const param = { ...defaults, ...debug }; 12 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/redux/colors.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | import { PainterArgs } from "../engine/painter"; 3 | 4 | const ui = createSlice({ 5 | name: "colors", 6 | initialState: { 7 | offset: 0, 8 | id: 0, 9 | density: 20, // 0.05 - 20 10 | fn: "s", 11 | } as PainterArgs, 12 | reducers: { 13 | setColorId: (state, action) => ({ ...state, id: action.payload }), 14 | setOffset: (state, action) => ({ ...state, offset: action.payload }), 15 | setDensity: (state, action) => ({ ...state, density: action.payload }), 16 | setPaint: (state, action) => ({ ...state, ...action.payload }), 17 | }, 18 | }); 19 | 20 | export const { reducer, actions } = ui; 21 | export const { setOffset, setColorId, setDensity, setPaint } = actions; 22 | -------------------------------------------------------------------------------- /src/redux/controller.ts: -------------------------------------------------------------------------------- 1 | import Vector from "../engine/math/vector"; 2 | import Camera, { AffineTransform } from "../engine/math/camera"; 3 | import { Dispatch } from "@reduxjs/toolkit"; 4 | import { 5 | changeXY, 6 | viewportReset, 7 | viewportTransform, 8 | toggleGuide, 9 | } from "./rdxengine"; 10 | import Hammer from "hammerjs"; 11 | import Matrix from "../engine/math/matrix"; 12 | import { bindKeys } from "../util/keybinder"; 13 | import Engine from "../engine/engine"; 14 | 15 | const ZOOM = 1.3; 16 | const ZOOM_TAP = 2.5; 17 | const PAN = 0.1; 18 | const SCALE = 0.1; // 1+ 19 | const SHEAR = 0.1; 20 | const ANGLE = Math.PI / 18; 21 | 22 | export default class Controller { 23 | private engine: Engine; 24 | private camera: Camera; 25 | 26 | constructor(engine: Engine, private dispatch: Dispatch) { 27 | this.engine = engine; 28 | this.camera = engine.ctx.camera; 29 | this.setupKeyboard(); 30 | this.setupMouse(); 31 | this.setupTouch(); 32 | } 33 | 34 | // pan the screen by the given vector (as a ratio of its size) 35 | pan(scr_vector_delta: Vector) { 36 | const cam = this.camera; 37 | const scr_vector = cam.screen.times(scr_vector_delta); 38 | const cpx_point_0 = cam.scr2cpx(new Vector(0, 0)); 39 | const cpx_point_dest = cam.scr2cpx(scr_vector); 40 | const cpx_vector = cpx_point_0.minus(cpx_point_dest); 41 | const cpx_new_point = cam.getPos().plus(cpx_vector); 42 | this.dispatch(changeXY(cpx_new_point)); 43 | } 44 | 45 | // transforms the current viewport 46 | viewportTransform(type: AffineTransform, valuex: number, valuey?: number) { 47 | this.dispatch(viewportTransform(type, valuex, valuey)); 48 | } 49 | 50 | // zoom the screen at the given screen point, using the given delta ratio 51 | zoom(delta: number, scr_point_arg?: Vector) { 52 | const cam = this.camera; 53 | if (delta < 1 && cam.isZoomLimit()) { 54 | this.engine.ctx.event.notify("zoom.limit"); 55 | return; 56 | } 57 | // zoom @ center of screen by default 58 | const scr_point = scr_point_arg || cam.screen.times(0.5); 59 | const cpx_point = cam.scr2cpx(scr_point); 60 | const cpx_center = cam.getPos(); 61 | // complex vector from point to view center 62 | const cpx_vector = cpx_center.minus(cpx_point); 63 | // scale vector and compute new center 64 | const cpx_new_point = cpx_point.plus(cpx_vector.times(delta)); 65 | this.dispatch(changeXY(cpx_new_point, cam.w * delta)); 66 | } 67 | 68 | setupKeyboard() { 69 | bindKeys("up", (Δ: number) => this.pan(new Vector(0, PAN * Δ))); 70 | bindKeys("down", (Δ: number) => this.pan(new Vector(0, -PAN * Δ))); 71 | bindKeys("right", (Δ: number) => this.pan(new Vector(-PAN * Δ, 0))); 72 | bindKeys("left", (Δ: number) => this.pan(new Vector(PAN * Δ, 0))); 73 | bindKeys("+", (Δ: number) => this.zoom(1 / (ZOOM * Δ))); 74 | bindKeys("-", (Δ: number) => this.zoom(ZOOM * Δ)); 75 | bindKeys("V", () => this.dispatch(viewportReset())); 76 | bindKeys("R left", (Δ: number) => 77 | this.viewportTransform("rotation", -ANGLE * Δ), 78 | ); 79 | bindKeys("R right", (Δ: number) => 80 | this.viewportTransform("rotation", +ANGLE * Δ), 81 | ); 82 | bindKeys("S right", (Δ: number) => 83 | this.viewportTransform("scale", 1 / (1 + SCALE * Δ), 1), 84 | ); 85 | bindKeys("S left", (Δ: number) => 86 | this.viewportTransform("scale", 1 + SCALE * Δ, 1), 87 | ); 88 | bindKeys("S up", (Δ: number) => 89 | this.viewportTransform("scale", 1, 1 / (1 + SCALE * Δ)), 90 | ); 91 | bindKeys("S down", (Δ: number) => 92 | this.viewportTransform("scale", 1, 1 + SCALE * Δ), 93 | ); 94 | bindKeys("H right", (Δ: number) => 95 | this.viewportTransform("shear", -SHEAR * Δ, 0), 96 | ); 97 | bindKeys("H left", (Δ: number) => 98 | this.viewportTransform("shear", SHEAR * Δ, 0), 99 | ); 100 | bindKeys("H up", (Δ: number) => 101 | this.viewportTransform("shear", 0, -SHEAR * Δ), 102 | ); 103 | bindKeys("H down", (Δ: number) => 104 | this.viewportTransform("shear", 0, SHEAR * Δ), 105 | ); 106 | bindKeys("G", () => this.dispatch(toggleGuide())); 107 | } 108 | 109 | setupTouch() { 110 | var hammer = new Hammer(this.engine.canvas, {}); 111 | let isDragging = false; 112 | let dragStart: Vector; 113 | let cameraStart: Camera; 114 | 115 | hammer.get("pan").set({ 116 | direction: Hammer.DIRECTION_ALL, 117 | threshold: 1, 118 | }); 119 | hammer.get("pinch").set({ 120 | enable: true, 121 | }); 122 | 123 | hammer.on("doubletap", evt => { 124 | // console.log("double", evt); 125 | const pos = new Vector(evt.center.x, evt.center.y); 126 | this.zoom(1 / ZOOM_TAP, new Vector(pos)); 127 | }); 128 | 129 | hammer.on("panstart", evt => { 130 | isDragging = true; 131 | dragStart = new Vector(evt.center.x, evt.center.y); 132 | cameraStart = this.camera.clone(); 133 | }); 134 | 135 | hammer.on("panend", evt => { 136 | isDragging = false; 137 | }); 138 | 139 | hammer.on("panmove", evt => { 140 | if (isDragging) { 141 | const pos = new Vector(evt.center.x, evt.center.y); 142 | const scr_vector = pos.minus(dragStart); 143 | const cpx_point_0 = cameraStart.scr2cpx(new Vector(0, 0)); 144 | const cpx_point_dest = cameraStart.scr2cpx(scr_vector); 145 | const cpx_vector = cpx_point_dest.minus(cpx_point_0); 146 | const cpx_new = cameraStart.getPos().minus(cpx_vector); 147 | this.dispatch(changeXY(cpx_new)); 148 | } 149 | }); 150 | 151 | var isPinching = false; 152 | let pinchStart: Vector; 153 | 154 | hammer.on("pinchstart", ev => { 155 | console.log("pinchstart"); 156 | isPinching = true; 157 | pinchStart = new Vector(ev.center.x, ev.center.y); 158 | cameraStart = this.camera.clone(); 159 | }); 160 | 161 | hammer.on("pinchend", function (ev) { 162 | console.log("pinchend"); 163 | isPinching = false; 164 | }); 165 | 166 | hammer.on("pinch", ev => { 167 | if (isPinching) { 168 | // compute matrix that transforms an original triangle to the transformed triangle 169 | var pc1 = cameraStart.scr2cpx(pinchStart); 170 | var pc2 = cameraStart.scr2cpx(new Vector(ev.center.x, ev.center.y)); 171 | var m = Matrix.GetTriangleToTriangle( 172 | pc1.x, 173 | pc1.y, 174 | pc1.x + 1, 175 | pc1.y, 176 | pc1.x, 177 | pc1.y + 1, 178 | pc2.x, 179 | pc2.y, 180 | pc2.x + ev.scale, 181 | pc2.y, 182 | pc2.x, 183 | pc2.y + ev.scale, 184 | ); 185 | 186 | // apply inverse of this matrix to starting point 187 | var pc0A = m.inverse().transform(cameraStart.pos); 188 | 189 | let z = cameraStart.w / m.a; 190 | if (z < this.camera.resolutionLimit) { 191 | this.engine.ctx.event.notify("zoom.limit"); 192 | z = this.camera.resolutionLimit; 193 | } 194 | this.dispatch(changeXY(pc0A, z)); 195 | } 196 | }); 197 | } 198 | 199 | setupMouse() { 200 | const canvas = this.engine.canvas; 201 | 202 | // disable mouse pan because it is handled by hammer 203 | // let isDragging = false; 204 | // let dragStart: Vector; 205 | // let cameraStart: Camera; 206 | 207 | // canvas.addEventListener("mousedown", (e: MouseEvent) => { 208 | // const evt = e || window.event; 209 | // if (evt.button !== 0) return; 210 | // isDragging = true; 211 | // dragStart = new Vector(evt.screenX, evt.screenY); 212 | // cameraStart = this.camera.clone(); 213 | // }); 214 | 215 | // window.addEventListener("mouseup", () => { 216 | // isDragging = false; 217 | // }); 218 | 219 | // window.addEventListener("mousemove", e => { 220 | // const evt = e || window.event; 221 | // if (isDragging) { 222 | // console.log("mousemove", e); 223 | // const pos = new Vector(evt.screenX, evt.screenY); 224 | // const scr_vector = pos.minus(dragStart); 225 | // const cpx_point_0 = cameraStart.scr2cpx(new Vector(0, 0)); 226 | // const cpx_point_dest = cameraStart.scr2cpx(scr_vector); 227 | // const cpx_vector = cpx_point_dest.minus(cpx_point_0); 228 | // const cpx_new = cameraStart.getPos().minus(cpx_vector); 229 | // this.dispatch(changeXY(cpx_new)); 230 | // } 231 | // }); 232 | 233 | const wheelFunction = (e: WheelEvent) => { 234 | const evt = e || window.event; 235 | evt.preventDefault(); 236 | const modifier = evt.shiftKey ? 1 / 10 : 1; 237 | let delta = evt.deltaY; 238 | delta = delta > 0 ? ZOOM * modifier : 1 / (ZOOM * modifier); 239 | const point = new Vector(evt.offsetX, evt.offsetY); 240 | this.zoom(delta, point); 241 | }; 242 | 243 | canvas.addEventListener("wheel", wheelFunction); 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /src/redux/guide.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | const ui = createSlice({ 4 | name: "ui", 5 | initialState: { 6 | active: true, 7 | x: 0, 8 | y: 0, 9 | }, 10 | reducers: { 11 | setGuide: (state, action) => ({ ...state, ...action.payload }), 12 | }, 13 | }); 14 | 15 | export const { reducer, actions } = ui; 16 | export const { setGuide } = actions; 17 | -------------------------------------------------------------------------------- /src/redux/improver.ts: -------------------------------------------------------------------------------- 1 | import { updateSet } from "./set"; 2 | import { Dispatch } from "@reduxjs/toolkit"; 3 | import Engine from "../engine/engine"; 4 | import { Root } from "./reducer"; 5 | import { param } from "../params"; 6 | 7 | /* 8 | The improver hijacks engine to use a more complex rendering; so it's 9 | implemented as a function and a closure instead of a class (whose 'this' 10 | would have been messy) 11 | */ 12 | export default function Improver( 13 | engineArg: Engine, 14 | dispatch: Dispatch, 15 | getState: () => Root, 16 | ) { 17 | const engine = engineArg; 18 | const draw = engine.draw.bind(engine); 19 | let frameId = 0; 20 | let lastState = ""; 21 | 22 | const sleep = (duration: number) => 23 | new Promise(resolve => setTimeout(() => resolve(), duration)); 24 | 25 | // compute number of pixels in 10% fringe and trigger a redrawing if this 26 | // number is above a threshold 27 | const analysePicture2 = () => { 28 | const histo = engine.getHistogram(); 29 | const nbPixels = engine.ctx.camera.screen.x * engine.ctx.camera.screen.y; 30 | const iter = engine.ctx.iter; 31 | let nb = 0; 32 | for (let i = Math.round(iter * 0.9); i <= iter; i += 1) { 33 | nb += histo[i]; 34 | } 35 | const perc = (100 * nb) / nbPixels; 36 | const shouldIncrease = perc > 0.2; 37 | const shouldDecrease = perc < 0.05; 38 | const txt = `10% fringe is ${nb}px or ${perc.toFixed(2)}%`; 39 | return { shouldIncrease, shouldDecrease, txt }; 40 | }; 41 | 42 | // @ts-ignore 43 | engine.draw = async () => { 44 | try { 45 | // ID detects when a rendering is interrupted 46 | frameId += 1; 47 | const id = frameId; 48 | 49 | // state detects when the fractal is drew afresh, needing a coarse rendering first 50 | const state = engine.ctx.fractalId + engine.ctx.smooth; 51 | if (state !== lastState) { 52 | await draw({ details: "subsampling", size: 4 }); 53 | if (frameId !== id) return; 54 | } 55 | lastState = state; 56 | 57 | // perform a normal drawing 58 | await draw({ details: "normal", id }); 59 | if (frameId !== id) return; 60 | 61 | // analyze, then increase iterations if needed 62 | let analysis: any = analysePicture2(); 63 | while (analysis.shouldIncrease) { 64 | const newIter = Math.round(engine.ctx.iter * 1.5); 65 | // console.log(`+ iter ${engine.ctx.iter} -> ${newIter}: ${analysis.txt}`); 66 | dispatch(updateSet({ iter: newIter })); 67 | engine.ctx.iter = newIter; 68 | await draw({ details: "normal", id }); 69 | if (frameId !== id) return; 70 | analysis = analysePicture2(); 71 | } 72 | if (analysis.shouldDecrease) { 73 | const newIter = Math.round(Math.max(50, engine.ctx.iter / 1.5)); 74 | // console.log(`- iter ${engine.ctx.iter} -> ${newIter}: ${analysis.txt}`); 75 | dispatch(updateSet({ iter: newIter })); 76 | engine.ctx.iter = newIter; 77 | } 78 | 79 | // wait one sec, then supersample 80 | if (param.supersampling) { 81 | await sleep(1000); 82 | if (frameId !== id) return; 83 | await draw({ details: "supersampling", size: 4, id }); 84 | } 85 | } catch (err) { 86 | // ignore interrupted frames 87 | } 88 | }; 89 | } 90 | -------------------------------------------------------------------------------- /src/redux/rdxengine.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch } from "@reduxjs/toolkit"; 2 | import { Root } from "./reducer"; 3 | import Engine from "../engine/engine"; 4 | import { updateSet } from "./set"; 5 | import Controller from "./controller"; 6 | import Improver from "./improver"; 7 | import * as url from "./url"; 8 | import throttle from "lodash/throttle"; 9 | import debounce from "lodash/debounce"; 10 | import Vector from "../engine/math/vector"; 11 | import { 12 | setMouseOnCanvas, 13 | setMouseInfo, 14 | setInfobox, 15 | setDrawer, 16 | setSnack, 17 | setNarrowDevice, 18 | setTab, 19 | } from "./ui"; 20 | import * as colorActions from "./colors"; 21 | import { getPreset } from "../engine/fractals"; 22 | import { bindKeys } from "../util/keybinder"; 23 | import Guide from "../engine/guide"; 24 | import { setGuide } from "./guide"; 25 | import Matrix from "../engine/math/matrix"; 26 | import { AffineTransform } from "../engine/math/camera"; 27 | 28 | type D = Dispatch; 29 | 30 | let engine: Engine; 31 | let guide: Guide; 32 | let urlUpdate: () => void; 33 | 34 | export const getEngine = () => engine; 35 | 36 | export const initEngine = ( 37 | canvas: HTMLCanvasElement, 38 | canvasGuide: HTMLCanvasElement, 39 | ): any => (dispatch: D, getState: () => Root) => { 40 | // ---- init global keyboard shortcuts 41 | bindKeys("I", () => dispatch(setInfobox(!getState().ui.infobox))); 42 | bindKeys("esc", () => dispatch(setDrawer(!getState().ui.drawer))); 43 | bindKeys("D", () => { 44 | dispatch(setDrawer(true)); 45 | dispatch(setTab("debug")); 46 | }); 47 | 48 | // ---- init window size & capture resize events 49 | const getWindowSize = () => [window.innerWidth, window.innerHeight]; 50 | [canvas.width, canvas.height] = getWindowSize(); 51 | [canvasGuide.width, canvasGuide.height] = getWindowSize(); 52 | window.addEventListener("resize", () => { 53 | [canvas.width, canvas.height] = getWindowSize(); 54 | [canvasGuide.width, canvasGuide.height] = getWindowSize(); 55 | engine.resize(canvas.width, canvas.height); 56 | engine.draw(); 57 | }); 58 | 59 | // ---- update ui.smallDevice boolean when device (or orientation) changes 60 | const onMediaChange = (media: any) => 61 | dispatch(setNarrowDevice(media.matches)); 62 | const matchMedia = window.matchMedia("(max-width: 450px)"); 63 | onMediaChange(matchMedia); 64 | matchMedia.addListener(onMediaChange); 65 | 66 | window.addEventListener( 67 | "error", 68 | debounce(() => { 69 | console.error("error caught, resetting engine"); 70 | url.readInit(dispatch, true); 71 | engine.draw(); 72 | }, 200), 73 | ); 74 | 75 | // ---- capture canvas enter & leave events for infobox 76 | canvas.addEventListener("mouseenter", () => { 77 | dispatch(setMouseOnCanvas(true)); 78 | }); 79 | canvas.addEventListener("mouseleave", () => { 80 | dispatch(setMouseOnCanvas(false)); 81 | }); 82 | canvas.addEventListener( 83 | "mousemove", 84 | throttle(evt => { 85 | const cpx = engine.ctx.camera.scr2cpx( 86 | new Vector(evt.offsetX, evt.offsetY), 87 | ); 88 | const iter = engine.getIterationsAt(cpx); 89 | dispatch(setMouseInfo({ x: cpx.x, y: cpx.y, iter })); 90 | }, 50), // 20 fps max 91 | ); 92 | 93 | // ---- read URL and infer start params 94 | const paramsFetcher = () => { 95 | // convert current redux state into engine state 96 | const rdx = getState(); 97 | return { 98 | ...rdx.set, 99 | painter: rdx.colors, 100 | }; 101 | }; 102 | url.readInit(dispatch); 103 | engine = new Engine(canvas, paramsFetcher); 104 | // @ts-ignore 105 | window.engine = engine; 106 | 107 | urlUpdate = debounce(() => { 108 | url.update(getState()); 109 | }, 250); 110 | engine.ctx.event.on("draw.start", urlUpdate); 111 | engine.ctx.event.on("draw.redraw", urlUpdate); 112 | engine.ctx.event.on("zoom.limit", () => { 113 | dispatch(setSnack("Sorry, FractalJS cannot zoom further...")); 114 | }); 115 | engine.ctx.event.on( 116 | "zoom.limit", 117 | debounce(() => { 118 | dispatch(setSnack(undefined)); 119 | }, 5000), 120 | ); 121 | 122 | guide = new Guide(canvasGuide, engine, getState); 123 | engine.ctx.event.on("draw.start", () => { 124 | guide.draw(); 125 | }); 126 | 127 | new Controller(engine, dispatch); 128 | Improver(engine, dispatch, getState); // add improvement capabilities 129 | engine.draw(); 130 | }; 131 | 132 | export const changeFractalType = (type: string): any => (dispatch: D) => { 133 | const setValues = getPreset(type); 134 | dispatch(updateSet(setValues)); 135 | dispatch(updateSet({ viewport: { ...Matrix.identity } })); 136 | dispatch(colorActions.setPaint({ density: 20 })); 137 | dispatch(setGuide({ active: false })); 138 | engine.draw(); 139 | }; 140 | 141 | export const changeSmooth = (smooth: boolean): any => (dispatch: D) => { 142 | dispatch(updateSet({ smooth })); 143 | engine.draw(); 144 | }; 145 | 146 | export const changeXY = (pt: Vector, w?: number): any => (dispatch: D) => { 147 | if (w === undefined) dispatch(updateSet({ x: pt.x, y: pt.y })); 148 | else dispatch(updateSet({ x: pt.x, y: pt.y, w })); 149 | engine.draw(); 150 | }; 151 | 152 | export const setColorOffset = (val: number): any => (dispatch: D) => { 153 | dispatch(colorActions.setOffset(val)); 154 | engine.drawColor(); 155 | }; 156 | 157 | export const setColorDensity = (val: number): any => (dispatch: D) => { 158 | dispatch(colorActions.setDensity(val)); 159 | engine.drawColor(); 160 | }; 161 | 162 | export const setColorId = (id: number): any => (dispatch: D) => { 163 | dispatch(colorActions.setPaint({ id, fn: "s" })); 164 | engine.drawColor(); 165 | }; 166 | 167 | export const toggleGuide = (): any => async ( 168 | dispatch: Dispatch, 169 | getState: () => Root, 170 | ) => { 171 | const ui = getState().ui; 172 | if (ui.mouseOnCanvas) { 173 | dispatch(setGuide({ active: true, x: ui.mouse.x, y: ui.mouse.y })); 174 | guide.draw(); 175 | urlUpdate(); 176 | } 177 | }; 178 | 179 | export const viewportReset = (): any => (dispatch: D) => { 180 | dispatch(updateSet({ viewport: { ...Matrix.identity } })); 181 | engine.draw(); 182 | }; 183 | 184 | export const viewportTransform = ( 185 | type: AffineTransform, 186 | valuex: number, 187 | valuey?: number, 188 | ): any => (dispatch: D, getState: () => Root) => { 189 | let transform = Matrix.identity; 190 | switch (type) { 191 | case "rotation": 192 | transform = Matrix.GetRotationMatrix(valuex); 193 | break; 194 | case "shear": 195 | transform = Matrix.GetShearMatrix(valuex, valuey!); 196 | break; 197 | case "scale": 198 | transform = Matrix.GetScaleMatrix(valuex, valuey!); 199 | break; 200 | } 201 | let matrix = Matrix.fromRaw(getState().set.viewport); 202 | matrix = matrix.multiply(transform); 203 | dispatch(updateSet({ viewport: { ...matrix } })); 204 | engine.draw(); 205 | }; 206 | -------------------------------------------------------------------------------- /src/redux/reducer.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "@reduxjs/toolkit"; 2 | import { reducer as ui } from "./ui"; 3 | import { reducer as guide } from "./guide"; 4 | import { reducer as set } from "./set"; 5 | import { reducer as colors } from "./colors"; 6 | 7 | const root = combineReducers({ 8 | ui, 9 | guide, 10 | set, 11 | colors, 12 | }); 13 | 14 | export default root; 15 | export type Root = ReturnType; 16 | -------------------------------------------------------------------------------- /src/redux/set.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | const ui = createSlice({ 4 | name: "set", 5 | initialState: { 6 | fractalId: "mandelbrot", 7 | smooth: true, 8 | x: 0, 9 | y: 0, 10 | w: 0, 11 | iter: 0, 12 | viewport: { 13 | a: 1, 14 | b: 0, 15 | c: 0, 16 | d: 1, 17 | e: 0, 18 | f: 0, 19 | }, 20 | }, 21 | reducers: { 22 | setSet: (state, action) => action.payload, 23 | updateSet: (state, action) => ({ ...state, ...action.payload }), 24 | }, 25 | }); 26 | 27 | export const { reducer, actions } = ui; 28 | export const { setSet, updateSet } = actions; 29 | -------------------------------------------------------------------------------- /src/redux/ui.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | type Tab = "settings" | "fractal" | "palette" | "about" | "social" | "debug"; 4 | interface Int { 5 | drawer: boolean; 6 | infobox: boolean; 7 | mouseOnCanvas: boolean; 8 | narrowDevice: boolean; // less than 450 px 9 | tab: Tab; 10 | snack?: string; 11 | mouse: { 12 | x: number; 13 | y: number; 14 | iter: number; 15 | }; 16 | } 17 | 18 | const ui = createSlice({ 19 | name: "ui", 20 | initialState: { 21 | drawer: false, 22 | mouseOnCanvas: false, 23 | tab: "fractal", 24 | infobox: false, 25 | mouse: { x: 0, y: 0, iter: 0 }, 26 | narrowDevice: false, 27 | orientableDevice: false, 28 | } as Int, 29 | reducers: { 30 | setDrawer: (state, action) => ({ ...state, drawer: action.payload }), 31 | setTab: (state, action) => ({ ...state, tab: action.payload }), 32 | setInfobox: (state, action) => ({ ...state, infobox: action.payload }), 33 | setMouseOnCanvas: (state, action) => ({ 34 | ...state, 35 | mouseOnCanvas: action.payload, 36 | }), 37 | setMouseInfo: (state, action) => ({ ...state, mouse: action.payload }), 38 | setNarrowDevice: (state, action) => ({ 39 | ...state, 40 | narrowDevice: action.payload, 41 | }), 42 | setSnack: (state, action) => ({ ...state, snack: action.payload }), 43 | }, 44 | }); 45 | 46 | export const { reducer, actions } = ui; 47 | export const { 48 | setDrawer, 49 | setTab, 50 | setInfobox, 51 | setMouseOnCanvas, 52 | setMouseInfo, 53 | setSnack, 54 | setNarrowDevice, 55 | } = actions; 56 | -------------------------------------------------------------------------------- /src/redux/url.ts: -------------------------------------------------------------------------------- 1 | import Matrix, { RawMatrix } from "../engine/math/matrix"; 2 | import { Dispatch } from "@reduxjs/toolkit"; 3 | import { setOffset, setDensity, setColorId } from "./colors"; 4 | import { setSet } from "./set"; 5 | import { getPreset } from "../engine/fractals"; 6 | import { PainterArgs } from "../engine/painter"; 7 | import { Root } from "./reducer"; 8 | import { setGuide } from "./guide"; 9 | 10 | interface UrlOutputObject { 11 | painter: PainterArgs; 12 | guide: { 13 | active: boolean; 14 | x: number; 15 | y: number; 16 | }; 17 | desc: { 18 | x: number; 19 | y: number; 20 | w: number; 21 | iter: number; 22 | fractalId: string; 23 | smooth: boolean; 24 | viewport: RawMatrix; 25 | }; 26 | } 27 | 28 | const defaults: any = { 29 | t: "mandelbrot", 30 | i: "50", 31 | fs: "1", 32 | ct: "0", 33 | co: "0", 34 | cd: "20", 35 | cf: "n", 36 | va: "1.0000", 37 | vb: "0.0000", 38 | vc: "0.0000", 39 | vd: "1.0000", 40 | }; 41 | 42 | export const update = (root: Root) => { 43 | try { 44 | const args: any = {}; 45 | // engine 46 | args.t = root.set.fractalId; 47 | args.x = root.set.x; 48 | args.y = root.set.y; 49 | args.w = root.set.w; 50 | args.i = String(root.set.iter); 51 | args.fs = root.set.smooth ? "1" : "0"; 52 | // painter 53 | args.ct = String(root.colors.id); 54 | args.co = String(Math.round(root.colors.offset * 100)); 55 | args.cd = String(+root.colors.density.toFixed(2)); 56 | args.cf = root.colors.fn; 57 | // viewport matrix 58 | args.va = root.set.viewport.a.toFixed(4); 59 | args.vb = root.set.viewport.b.toFixed(4); 60 | args.vc = root.set.viewport.c.toFixed(4); 61 | args.vd = root.set.viewport.d.toFixed(4); 62 | // guide 63 | if (root.guide.active) { 64 | args.gx = root.guide.x; 65 | args.gy = root.guide.y; 66 | } 67 | // remove args with default values 68 | for (let key in args) if (args[key] === defaults[key]) delete args[key]; 69 | // build url 70 | const str = Object.entries(args) 71 | .map(([k, v]) => `${k}_${v}`) 72 | .join("&"); 73 | window.history.replaceState("", "", `#B${str}`); 74 | } catch (e) { 75 | console.error("Could not set URL", e); 76 | } 77 | }; 78 | 79 | function readCurrentScheme(url: string): UrlOutputObject { 80 | // parse url 81 | const str = url.substr(2); 82 | const rawArgs: any = str.split("&").reduce((acc, tuple) => { 83 | const parts = tuple.split("_"); 84 | return Object.assign(acc, { [parts[0]]: parts[1] }); 85 | }, {}); 86 | // add default arguments 87 | const args = { ...defaults, ...rawArgs }; 88 | // build objet 89 | const desc = { 90 | x: parseFloat(args.x), 91 | y: parseFloat(args.y), 92 | w: parseFloat(args.w), 93 | iter: parseInt(args.i), 94 | fractalId: args.t, 95 | smooth: parseInt(args.fs) === 1, 96 | viewport: { ...Matrix.identity }, 97 | }; 98 | desc.viewport = { 99 | a: parseFloat(args.va), 100 | b: parseFloat(args.vb), 101 | c: parseFloat(args.vc), 102 | d: parseFloat(args.vd), 103 | e: 0, 104 | f: 0, 105 | }; 106 | const painter = { 107 | offset: parseInt(args.co) / 100.0, 108 | density: parseFloat(args.cd), 109 | id: parseInt(args.ct, 10), 110 | fn: args.cf, 111 | }; 112 | let guide = { active: false, x: 0, y: 0 }; 113 | if ("gx" in args) 114 | guide = { active: true, x: parseFloat(args.gx), y: parseFloat(args.gy) }; 115 | return { desc, painter, guide }; 116 | } 117 | 118 | export const read = (): UrlOutputObject | null => { 119 | try { 120 | const url = document.location.hash; 121 | if (url.startsWith("#B")) { 122 | return readCurrentScheme(url); 123 | } 124 | } catch (e) { 125 | console.error("Could not read URL", e); 126 | } 127 | return null; 128 | }; 129 | 130 | export const readInit = (dispatch: Dispatch, forceCold = false): void => { 131 | const urlData = read(); 132 | if (!urlData || forceCold) { 133 | // coldstart 134 | let desc = { 135 | ...getPreset("mandelbrot"), 136 | smooth: true, 137 | viewport: { ...Matrix.identity }, 138 | }; 139 | let painter = { offset: 0, density: 20, id: 0, fn: "s" }; 140 | dispatch(setSet(desc)); 141 | dispatch(setColorId(painter.id)); 142 | dispatch(setOffset(0)); 143 | dispatch(setDensity(20)); 144 | } else { 145 | const { desc, painter, guide } = urlData; 146 | dispatch(setSet(desc)); 147 | dispatch(setGuide(guide)); 148 | dispatch(setColorId(painter.id)); 149 | dispatch(setOffset(painter.offset)); 150 | dispatch(setDensity(painter.density)); 151 | } 152 | }; 153 | -------------------------------------------------------------------------------- /src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | type Config = { 24 | onSuccess?: (registration: ServiceWorkerRegistration) => void; 25 | onUpdate?: (registration: ServiceWorkerRegistration) => void; 26 | }; 27 | 28 | export function register(config?: Config) { 29 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 30 | // The URL constructor is available in all browsers that support SW. 31 | const publicUrl = new URL( 32 | process.env.PUBLIC_URL, 33 | window.location.href 34 | ); 35 | if (publicUrl.origin !== window.location.origin) { 36 | // Our service worker won't work if PUBLIC_URL is on a different origin 37 | // from what our page is served on. This might happen if a CDN is used to 38 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 39 | return; 40 | } 41 | 42 | window.addEventListener('load', () => { 43 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 44 | 45 | if (isLocalhost) { 46 | // This is running on localhost. Let's check if a service worker still exists or not. 47 | checkValidServiceWorker(swUrl, config); 48 | 49 | // Add some additional logging to localhost, pointing developers to the 50 | // service worker/PWA documentation. 51 | navigator.serviceWorker.ready.then(() => { 52 | console.log( 53 | 'This web app is being served cache-first by a service ' + 54 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 55 | ); 56 | }); 57 | } else { 58 | // Is not localhost. Just register service worker 59 | registerValidSW(swUrl, config); 60 | } 61 | }); 62 | } 63 | } 64 | 65 | function registerValidSW(swUrl: string, config?: Config) { 66 | navigator.serviceWorker 67 | .register(swUrl) 68 | .then(registration => { 69 | registration.onupdatefound = () => { 70 | const installingWorker = registration.installing; 71 | if (installingWorker == null) { 72 | return; 73 | } 74 | installingWorker.onstatechange = () => { 75 | if (installingWorker.state === 'installed') { 76 | if (navigator.serviceWorker.controller) { 77 | // At this point, the updated precached content has been fetched, 78 | // but the previous service worker will still serve the older 79 | // content until all client tabs are closed. 80 | console.log( 81 | 'New content is available and will be used when all ' + 82 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 83 | ); 84 | 85 | // Execute callback 86 | if (config && config.onUpdate) { 87 | config.onUpdate(registration); 88 | } 89 | } else { 90 | // At this point, everything has been precached. 91 | // It's the perfect time to display a 92 | // "Content is cached for offline use." message. 93 | console.log('Content is cached for offline use.'); 94 | 95 | // Execute callback 96 | if (config && config.onSuccess) { 97 | config.onSuccess(registration); 98 | } 99 | } 100 | } 101 | }; 102 | }; 103 | }) 104 | .catch(error => { 105 | console.error('Error during service worker registration:', error); 106 | }); 107 | } 108 | 109 | function checkValidServiceWorker(swUrl: string, config?: Config) { 110 | // Check if the service worker can be found. If it can't reload the page. 111 | fetch(swUrl, { 112 | headers: { 'Service-Worker': 'script' } 113 | }) 114 | .then(response => { 115 | // Ensure service worker exists, and that we really are getting a JS file. 116 | const contentType = response.headers.get('content-type'); 117 | if ( 118 | response.status === 404 || 119 | (contentType != null && contentType.indexOf('javascript') === -1) 120 | ) { 121 | // No service worker found. Probably a different app. Reload the page. 122 | navigator.serviceWorker.ready.then(registration => { 123 | registration.unregister().then(() => { 124 | window.location.reload(); 125 | }); 126 | }); 127 | } else { 128 | // Service worker found. Proceed as normal. 129 | registerValidSW(swUrl, config); 130 | } 131 | }) 132 | .catch(() => { 133 | console.log( 134 | 'No internet connection found. App is running in offline mode.' 135 | ); 136 | }); 137 | } 138 | 139 | export function unregister() { 140 | if ('serviceWorker' in navigator) { 141 | navigator.serviceWorker.ready 142 | .then(registration => { 143 | registration.unregister(); 144 | }) 145 | .catch(error => { 146 | console.error(error.message); 147 | }); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/ui/App.scss: -------------------------------------------------------------------------------- 1 | 2 | * { 3 | margin: 0; 4 | box-sizing: border-box; 5 | user-select: none; 6 | } 7 | 8 | body { 9 | background-color: black; 10 | } 11 | 12 | canvas { 13 | position:fixed; 14 | left:0; 15 | transition:transform 225ms cubic-bezier(0, 0, 0.2, 1) 0ms; 16 | // allow canvas to move when opening drawer 17 | &.offset-left { 18 | transform: translateX(160px); 19 | } 20 | &.offset-top-fractal { transform: translateY(-130px) } 21 | &.offset-top-palette { transform: translateY(-173px) } 22 | &.offset-top-settings { transform: translateY(-50px) } 23 | &.offset-top-about { transform: translateY(-215px) } 24 | } 25 | 26 | canvas.guide { 27 | pointer-events: none; 28 | } 29 | -------------------------------------------------------------------------------- /src/ui/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | import "./App.scss"; 3 | import { useSelector, useDispatch } from "react-redux"; 4 | import { Root } from "../redux/reducer"; 5 | import Navigation from "./Navigation"; 6 | import useMediaQuery from "@material-ui/core/useMediaQuery"; 7 | import { initEngine } from "../redux/rdxengine"; 8 | import InfoBox from "./InfoBox"; 9 | import Snackbar from "./Snackbar"; 10 | 11 | const App = () => { 12 | const dispatch = useDispatch(); 13 | const canvasRef = useRef(null); 14 | const canvasGuideRef = useRef(null); 15 | const ui = useSelector((state: Root) => state.ui); 16 | const bigDevice = useMediaQuery("(min-width:450px)"); 17 | 18 | React.useEffect(() => { 19 | const canvas = (canvasRef.current as unknown) as HTMLCanvasElement; 20 | const canvasGuide = (canvasGuideRef.current as unknown) as HTMLCanvasElement; 21 | dispatch(initEngine(canvas, canvasGuide)); 22 | }, [dispatch]); 23 | 24 | let canvasClass = ""; 25 | if (ui.drawer) 26 | canvasClass = bigDevice ? "offset-left" : `offset-top-${ui.tab}`; 27 | 28 | return ( 29 |
30 | 31 | 32 | 33 | 34 | 35 |
36 | ); 37 | }; 38 | 39 | export default App; 40 | -------------------------------------------------------------------------------- /src/ui/InfoBox.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Paper from "@material-ui/core/Paper"; 3 | import Box from "@material-ui/core/Box"; 4 | import { makeStyles } from "@material-ui/core/styles"; 5 | import { Typography } from "@material-ui/core"; 6 | import { useSelector } from "react-redux"; 7 | import { Root } from "../redux/reducer"; 8 | 9 | const useStyles = makeStyles((theme) => ({ 10 | box: { 11 | position: "fixed", 12 | bottom: 0, 13 | right: 0, 14 | margin: "16px", 15 | background: "white", 16 | padding: "16px", 17 | }, 18 | })); 19 | 20 | function InfoBox() { 21 | const classes = useStyles(); 22 | const set = useSelector((state: Root) => state.set); 23 | const ui = useSelector((state: Root) => state.ui); 24 | if (ui.narrowDevice) return null; 25 | if (!ui.infobox) return null; 26 | const d: any = {}; 27 | if (ui.mouseOnCanvas) { 28 | d.x = ui.mouse.x; 29 | d.y = ui.mouse.y; 30 | d.iter = ui.mouse.iter; 31 | d.w = set.w; 32 | } else { 33 | d.x = set.x; 34 | d.y = set.y; 35 | d.iter = set.iter; 36 | d.w = set.w; 37 | } 38 | return ( 39 | 40 | 41 | {ui.mouseOnCanvas ? "Mouse" : "Screen"} 42 | 43 | 44 | X: {d.x.toFixed(16)} 45 | Y: {d.y.toFixed(16)} 46 | iterations: {d.iter.toFixed(2)} 47 | extent: {d.w.toExponential(2)} 48 | 49 | 50 | ); 51 | } 52 | 53 | export default InfoBox; 54 | -------------------------------------------------------------------------------- /src/ui/Navigation.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { makeStyles } from "@material-ui/core/styles"; 3 | import SwipeableDrawer from "@material-ui/core/SwipeableDrawer"; 4 | import useMediaQuery from "@material-ui/core/useMediaQuery"; 5 | import Fractal from "./pages/Fractal"; 6 | import Palette from "./pages/Palette"; 7 | import Settings from "./pages/Settings"; 8 | import About from "./pages/About"; 9 | import Debug from "./pages/Debug"; 10 | import { useDispatch, useSelector } from "react-redux"; 11 | import { Root } from "../redux/reducer"; 12 | import { setDrawer } from "../redux/ui"; 13 | import Divider from "@material-ui/core/Divider"; 14 | import MyToolbar from "./Toolbar"; 15 | 16 | export const navigation: { [key: string]: any } = { 17 | fractal: { icon: "home", component: }, 18 | palette: { icon: "invert_colors", component: }, 19 | // social: { icon: "share", component: }, 20 | settings: { icon: "settings", component: }, 21 | about: { icon: "info_outline", component: }, 22 | debug: { hidden: true, component: }, 23 | }; 24 | 25 | const useStyles = makeStyles((theme) => ({ 26 | appBar: { 27 | top: "auto", 28 | bottom: 0, 29 | }, 30 | drawer: { 31 | minWidth: "360px", 32 | [theme.breakpoints.up("sm")]: { 33 | width: "360px", 34 | }, 35 | }, 36 | trans: { 37 | // if we wanna make a transparent background 38 | // backgroundColor: "rgb(255, 255, 255, 0.85)", 39 | // backdropFilter: "blur(5px)", 40 | [theme.breakpoints.only("xs")]: { 41 | borderTopLeftRadius: "10px", 42 | borderTopRightRadius: "10px", 43 | }, 44 | }, 45 | handle: { 46 | width: "33%", 47 | margin: "4px auto -2px auto", 48 | height: "4px", 49 | }, 50 | })); 51 | 52 | const Navigation = () => { 53 | const classes = useStyles(); 54 | const dispatch = useDispatch(); 55 | const { drawer, tab } = useSelector((state: Root) => ({ 56 | drawer: state.ui.drawer, 57 | tab: state.ui.tab, 58 | })); 59 | const bigDevice = useMediaQuery("(min-width:450px)"); 60 | const content = navigation[tab || "fractal"].component; 61 | 62 | return ( 63 | <> 64 | 65 | dispatch(setDrawer(false))} 73 | onOpen={() => dispatch(setDrawer(true))} 74 | > 75 |
76 | {!bigDevice ? : null} 77 | {content} 78 |
79 |
80 | 81 | ); 82 | }; 83 | 84 | export default Navigation; 85 | -------------------------------------------------------------------------------- /src/ui/Share.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { IconButton } from "@material-ui/core"; 3 | 4 | interface Props { 5 | className: string; 6 | } 7 | 8 | // hack typescript which does not know this property 9 | interface ShareNavigator { 10 | share: (props: any) => Promise; 11 | } 12 | 13 | const Share = (props: Props) => { 14 | const nav = (navigator as unknown) as ShareNavigator; 15 | if (!nav.share) return null; 16 | return ( 17 | { 20 | nav.share({ 21 | title: "FractalJS", 22 | text: "Check out this fractal picture !", 23 | url: window.location.href, 24 | }); 25 | }} 26 | > 27 | share 28 | 29 | ); 30 | }; 31 | 32 | export default Share; 33 | -------------------------------------------------------------------------------- /src/ui/Snackbar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useSelector } from "react-redux"; 3 | import { Root } from "../redux/reducer"; 4 | import { makeStyles } from "@material-ui/core"; 5 | import Snackbar from "@material-ui/core/Snackbar"; 6 | 7 | const useStyles = makeStyles((theme) => ({ 8 | snackbar: { 9 | [theme.breakpoints.only("xs")]: { 10 | bottom: 64, 11 | }, 12 | }, 13 | })); 14 | 15 | const MySnackbar = () => { 16 | const ui = useSelector((state: Root) => state.ui); 17 | const classes = useStyles(); 18 | if (!ui.snack) return null; 19 | 20 | return ( 21 | 26 | ); 27 | }; 28 | 29 | export default MySnackbar; 30 | -------------------------------------------------------------------------------- /src/ui/Toolbar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import map from "lodash/map"; 3 | import { makeStyles } from "@material-ui/core/styles"; 4 | import { navigation } from "./Navigation"; 5 | import IconButton from "@material-ui/core/IconButton"; 6 | import { useDispatch } from "react-redux"; 7 | import { setTab, setDrawer } from "../redux/ui"; 8 | import { fade } from "@material-ui/core/styles/colorManipulator"; 9 | import Share from "./Share"; 10 | 11 | const useStyles = makeStyles((theme) => ({ 12 | bar: { 13 | backgroundColor: fade(theme.palette.primary.main, 0.8), 14 | backdropFilter: "blur(8px)", 15 | position: "fixed", 16 | display: "flex", 17 | [theme.breakpoints.only("xs")]: { 18 | width: "100%", 19 | height: "56px", 20 | bottom: 0, 21 | }, 22 | [theme.breakpoints.up("sm")]: { 23 | height: "100%", 24 | flexDirection: "column", 25 | left: 0, 26 | flexWrap: "wrap-reverse", 27 | }, 28 | }, 29 | icons: { 30 | color: "white", 31 | [theme.breakpoints.only("xs")]: { 32 | width: "64px", 33 | }, 34 | [theme.breakpoints.up("sm")]: { 35 | width: "56px", 36 | }, 37 | }, 38 | brand: { 39 | [theme.breakpoints.only("xs")]: { 40 | display: "none", 41 | }, 42 | color: "white", 43 | fontSize: "20px", 44 | lineHeight: "56px", 45 | writingMode: "vertical-rl", 46 | transform: "rotate(180deg)", 47 | fontFamily: "roboto", 48 | paddingBottom: "56px", 49 | paddingTop: "0.5em", 50 | letterSpacing: "3px", 51 | fontWeight: 500, 52 | flexGrow: 1, 53 | textDecoration: "none", 54 | }, 55 | })); 56 | 57 | const ToolBar = () => { 58 | const classes = useStyles(); 59 | const dispatch = useDispatch(); 60 | const buttons = map(navigation, (def, tabId) => 61 | !def.hidden ? ( 62 | { 66 | dispatch(setTab(tabId)); 67 | dispatch(setDrawer(true)); 68 | }} 69 | > 70 | {def.icon} 71 | 72 | ) : null, 73 | ).filter(Boolean); 74 | buttons.push( 75 | 81 | FractalJS 82 | , 83 | ); 84 | buttons.splice(2, 0, ); 85 | 86 | return
{buttons}
; 87 | }; 88 | 89 | export default ToolBar; 90 | -------------------------------------------------------------------------------- /src/ui/pages/About.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Typography } from "@material-ui/core"; 3 | import { createStyles, makeStyles, Theme } from "@material-ui/core/styles"; 4 | import List from "@material-ui/core/List"; 5 | import ListItem from "@material-ui/core/ListItem"; 6 | import ListItemText from "@material-ui/core/ListItemText"; 7 | import ListSubheader from "@material-ui/core/ListSubheader"; 8 | import ListItemAvatar from "@material-ui/core/ListItemAvatar"; 9 | import Avatar from "@material-ui/core/Avatar"; 10 | import { Divider } from "@material-ui/core"; 11 | import GitHubButton from "react-github-btn"; 12 | import { isMobileDevice, isTouchDevice, isMouseDevice } from "../../util/misc"; 13 | 14 | const useStyles = makeStyles((theme: Theme) => 15 | createStyles({ 16 | avatar: { 17 | backgroundColor: theme.palette.primary.main, 18 | }, 19 | inline: { 20 | display: "inline", 21 | }, 22 | keyboard: { 23 | "& > span": { 24 | display: "inline-block", 25 | verticalAlign: "middle", 26 | border: "1px solid #888", 27 | background: "#EEE", 28 | borderRadius: "4px", 29 | padding: "1px 4px", 30 | margin: "2px 0px", 31 | fontSize: "14px", 32 | minWidth: "1.8em", 33 | textAlign: "center", 34 | color: "black", 35 | marginRight: "0.4em", 36 | }, 37 | }, 38 | }), 39 | ); 40 | 41 | const Keys = () => { 42 | const classes = useStyles(); 43 | return ( 44 | 45 | 🠜 46 | 🠝 47 | 🠞 48 | 🠟 : move 49 |
50 | {/* zoom */} 51 | 52 | + 53 | 54 | 55 | - 56 | {" "} 57 | : zoom 58 |
59 | {/* rotate */} 60 | R + 🠜 61 | 🠞 : rotate 62 |
63 | S + 🠜 64 | 🠝 65 | 🠞 66 | 🠟 : scale x or y 67 |
68 | H + 🠜 69 | 🠝 70 | 🠞 71 | 🠟 : shear x or y 72 |
73 | all + shift : smaller movement 74 |
75 | G : set a guide point 76 |
77 | ); 78 | }; 79 | 80 | const Speech = () => { 81 | const classes = useStyles(); 82 | return ( 83 | 89 | FractalJS is open-source. You can report bugs, or add you favorite fractal 90 | set by heading to{" "} 91 | 96 | GitHub 97 | 98 | . 99 | 100 | ); 101 | }; 102 | 103 | export default function SimpleCard() { 104 | const classes = useStyles(); 105 | 106 | return ( 107 | <> 108 | 109 | Controls 110 | {isTouchDevice() ? ( 111 | 112 | 113 | 114 | touch_app 115 | 116 | 117 | 118 | 119 | ) : null} 120 | {isMouseDevice() ? ( 121 | 122 | 123 | 124 | mouse 125 | 126 | 127 | 128 | 129 | ) : null} 130 | {!isMobileDevice() ? ( 131 | 132 | 133 | 134 | keyboard 135 | 136 | 137 | } /> 138 | 139 | ) : null} 140 | 141 | About 142 | 143 | 144 | 145 | 146 | 153 | Star 154 | {" "} 155 | 156 | 157 | 158 | ); 159 | } 160 | -------------------------------------------------------------------------------- /src/ui/pages/Debug.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import List from "@material-ui/core/List"; 3 | import ListItem from "@material-ui/core/ListItem"; 4 | import ListSubheader from "@material-ui/core/ListSubheader"; 5 | import { getEngine } from "../../redux/rdxengine"; 6 | 7 | export default function Debug() { 8 | const engine = getEngine(); 9 | const camera = engine.ctx.camera; 10 | return ( 11 |
12 | 13 | Debug 14 | Threads: {engine.ctx.nbThreads} 15 | 16 | Screen: {camera.screen.x} * {camera.screen.y} 17 | 18 | 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/ui/pages/Fractal.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import List from "@material-ui/core/List"; 3 | import ListItem from "@material-ui/core/ListItem"; 4 | import ListSubheader from "@material-ui/core/ListSubheader"; 5 | import { useSelector, useDispatch } from "react-redux"; 6 | import { changeFractalType } from "../../redux/rdxengine"; 7 | import { Root } from "../../redux/reducer"; 8 | import { listForUi } from "../../engine/fractals"; 9 | import Button from "@material-ui/core/Button"; 10 | import { makeStyles } from "@material-ui/core/styles"; 11 | 12 | const useStyles = makeStyles(theme => ({ 13 | swatches: { 14 | display: "flex", 15 | flexWrap: "wrap", 16 | marginLeft: -theme.spacing(1), 17 | "& > *": { 18 | "& > *": { 19 | textTransform: "none", 20 | width: "100%", 21 | }, 22 | width: "50%", 23 | paddingLeft: theme.spacing(1), 24 | paddingBottom: theme.spacing(1), 25 | }, 26 | }, 27 | })); 28 | 29 | function Fractal() { 30 | const classes = useStyles(); 31 | const dispatch = useDispatch(); 32 | const type = useSelector((state: Root) => state.set.fractalId); 33 | 34 | const buttons = listForUi().map(o => { 35 | return ( 36 |
37 | 44 |
45 | ); 46 | }); 47 | return ( 48 |
49 | Pick a fractal set 53 | } 54 | > 55 | 56 |
{buttons}
57 |
58 |
59 |
60 | ); 61 | } 62 | 63 | export default Fractal; 64 | -------------------------------------------------------------------------------- /src/ui/pages/Palette.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import throttle from "lodash/throttle"; 3 | import Slider from "@material-ui/core/Slider"; 4 | import List from "@material-ui/core/List"; 5 | import ListItem from "@material-ui/core/ListItem"; 6 | import ListSubheader from "@material-ui/core/ListSubheader"; 7 | import { useSelector, useDispatch } from "react-redux"; 8 | import { Root } from "../../redux/reducer"; 9 | import { 10 | setColorOffset, 11 | setColorDensity, 12 | setColorId, 13 | } from "../../redux/rdxengine"; 14 | import { makeStyles } from "@material-ui/core/styles"; 15 | import { getBufferFromId } from "../../util/palette"; 16 | import { Typography } from "@material-ui/core"; 17 | 18 | const DENSITY = (20 * 20) ** (1 / 100); 19 | 20 | const gradients = (() => { 21 | const res: { id: number; dataURL: string }[] = []; 22 | const WIDTH = 100; 23 | const HEIGHT = 50; 24 | const RES = WIDTH; 25 | const canvas = document.createElement("canvas") as HTMLCanvasElement; 26 | const context = canvas.getContext("2d") as CanvasRenderingContext2D; 27 | canvas.width = WIDTH; 28 | canvas.height = HEIGHT; 29 | const imageData = context.createImageData(canvas.width, canvas.height); 30 | const imageBuffer = new Uint32Array(imageData.data.buffer); 31 | [0, 2, 3, 4, 5, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19].forEach((id) => { 32 | const colorBuffer = getBufferFromId(id, RES); 33 | for (let i = 0; i < WIDTH; i += 1) { 34 | for (let j = 0; j < HEIGHT; j += 1) { 35 | imageBuffer[j * WIDTH + i] = 36 | colorBuffer[Math.round(i * 0.6 + j * 0.6) % RES]; 37 | } 38 | } 39 | context.putImageData(imageData, 0, 0, 0, 0, canvas.width, canvas.height); 40 | const dataURL = canvas.toDataURL("image/png"); 41 | res.push({ id, dataURL }); 42 | }); 43 | return res; 44 | })(); 45 | 46 | const useStyles = makeStyles((theme) => ({ 47 | swatches: { 48 | display: "flex", 49 | flexWrap: "wrap", 50 | marginRight: -theme.spacing(1), 51 | "& > *": { 52 | paddingBottom: theme.spacing(1), 53 | paddingRight: theme.spacing(1), 54 | width: "20%", 55 | height: "56px", 56 | position: "relative", 57 | "& > img": { 58 | width: "100%", 59 | height: "100%", 60 | borderRadius: "4px", 61 | }, 62 | "& > i": { 63 | position: "absolute", 64 | bottom: 0, 65 | right: 0, 66 | color: "white", 67 | fontSize: "36px", 68 | padding: "0px", 69 | textShadow: "2px 2px 2px #888", 70 | }, 71 | }, 72 | }, 73 | txt: { 74 | width: "100px", 75 | }, 76 | })); 77 | 78 | function Palette() { 79 | const classes = useStyles(); 80 | const dispatch = useDispatch(); 81 | const colors = useSelector((state: Root) => state.colors); 82 | const swatches = gradients.map((gradient) => ( 83 |
dispatch(setColorId(gradient.id))}> 84 | 85 | {colors.id === gradient.id ? ( 86 | check_circle 87 | ) : null} 88 |
89 | )); 90 | 91 | const densitySlider = Math.log(20 * colors.density) / Math.log(DENSITY); 92 | const getDensity = (val: number) => (1 / 20) * DENSITY ** val; 93 | 94 | return ( 95 |
96 | 97 | 98 | Pick & adjust a color palette 99 | 100 | 101 |
{swatches}
102 |
103 | 104 | Move 105 | dispatch(setColorOffset(v)), 100)} 111 | /> 112 | 113 | 114 | Stretch 115 | dispatch(setColorDensity(getDensity(v))), 122 | 100, 123 | )} 124 | /> 125 | 126 |
127 |
128 | ); 129 | } 130 | 131 | export default Palette; 132 | -------------------------------------------------------------------------------- /src/ui/pages/Settings.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import List from "@material-ui/core/List"; 3 | import ListItem from "@material-ui/core/ListItem"; 4 | import ListItemText from "@material-ui/core/ListItemText"; 5 | import ListSubheader from "@material-ui/core/ListSubheader"; 6 | import ListItemSecondaryAction from "@material-ui/core/ListItemSecondaryAction"; 7 | import Switch from "@material-ui/core/Switch"; 8 | import { useSelector, useDispatch } from "react-redux"; 9 | import { changeSmooth } from "../../redux/rdxengine"; 10 | import { Root } from "../../redux/reducer"; 11 | import { setInfobox } from "../../redux/ui"; 12 | 13 | function Settings() { 14 | const dispatch = useDispatch(); 15 | const smooth = useSelector((state: Root) => state.set.smooth); 16 | const ui = useSelector((state: Root) => state.ui); 17 | 18 | return ( 19 |
20 | Advanced settings 24 | } 25 | > 26 | 27 | 28 | 29 | { 32 | dispatch(changeSmooth(event.target.checked)); 33 | }} 34 | checked={smooth} 35 | color="primary" 36 | /> 37 | 38 | 39 | {ui.narrowDevice ? null : ( 40 | 41 | 42 | 43 | { 46 | dispatch(setInfobox(event.target.checked)); 47 | }} 48 | checked={ui.infobox} 49 | color="primary" 50 | /> 51 | 52 | 53 | )} 54 | 55 |
56 | ); 57 | } 58 | 59 | export default Settings; 60 | -------------------------------------------------------------------------------- /src/ui/pages/Social.tsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import React from "react"; 3 | import List from "@material-ui/core/List"; 4 | import ListItem from "@material-ui/core/ListItem"; 5 | import ListSubheader from "@material-ui/core/ListSubheader"; 6 | 7 | export default function Debug() { 8 | const share = () => { 9 | if (navigator.share) { 10 | navigator 11 | .share({ 12 | title: "web.dev", 13 | text: "Check out web.dev.", 14 | url: "https://web.dev/", 15 | }) 16 | .then(() => console.log("Successful share")) 17 | .catch((error) => console.log("Error sharing", error)); 18 | } 19 | }; 20 | return ( 21 |
22 | 23 | Share 24 | 25 | 26 | 27 | 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/util/EventBus.ts: -------------------------------------------------------------------------------- 1 | export type Callback = (x: any) => void; 2 | 3 | type Events = "draw.redraw" | "draw.start" | "zoom.limit"; 4 | 5 | export default class EventBus { 6 | private listeners: { 7 | [key: string]: Callback[]; 8 | }; 9 | 10 | constructor() { 11 | this.listeners = {}; 12 | } 13 | 14 | on(evt: Events, callback: Callback) { 15 | if (!(evt in this.listeners)) this.listeners[evt] = []; 16 | this.listeners[evt].push(callback); 17 | } 18 | 19 | notify(evt: Events, obj?: any) { 20 | // force the notification to occur from the event loop (always async callback) 21 | setTimeout(() => { 22 | const callbacks = this.listeners[evt] || []; 23 | if (obj) obj.evt = evt; 24 | callbacks.forEach(cb => { 25 | cb(obj); 26 | }); 27 | }, 0); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/util/keybinder.ts: -------------------------------------------------------------------------------- 1 | const map: { [key: string]: number } = { 2 | left: 37, 3 | up: 38, 4 | right: 39, 5 | down: 40, 6 | "+": 107, 7 | "-": 109, 8 | esc: 27, 9 | A: 65, 10 | B: 66, 11 | C: 67, 12 | D: 68, 13 | E: 69, 14 | F: 70, 15 | G: 71, 16 | H: 72, 17 | I: 73, 18 | J: 74, 19 | K: 75, 20 | L: 76, 21 | M: 77, 22 | N: 78, 23 | O: 79, 24 | P: 80, 25 | Q: 81, 26 | R: 82, 27 | S: 83, 28 | T: 84, 29 | U: 85, 30 | V: 86, 31 | W: 87, 32 | X: 88, 33 | Y: 89, 34 | Z: 90, 35 | }; 36 | 37 | type Callback = (mod: number) => void; 38 | interface Binding { 39 | combo: number[]; 40 | func: Callback; 41 | } 42 | 43 | const keymap: boolean[] = []; 44 | const bindings: Binding[] = []; 45 | 46 | document.addEventListener("keyup", evt => { 47 | keymap[evt.keyCode] = false; 48 | }); 49 | 50 | document.addEventListener("keydown", evt => { 51 | keymap[evt.keyCode] = true; 52 | const modifier = evt.shiftKey ? 1 / 10 : 1; 53 | for (const binding of bindings) { 54 | const match = binding.combo.every(k => keymap[k]); 55 | if (match) { 56 | binding.func(modifier); 57 | return; 58 | } 59 | } 60 | }); 61 | 62 | export const bindKeys = (keysStr: string, func: Callback) => { 63 | const keys = keysStr.split(" "); 64 | const keycodes: number[] = []; 65 | for (const key of keys) { 66 | if (!(key in map)) throw new Error(`Unknown key${key}`); 67 | keycodes.push(map[key]); 68 | } 69 | bindings.push({ combo: keycodes, func }); 70 | bindings.sort((a, b) => b.combo.length - a.combo.length); 71 | }; 72 | -------------------------------------------------------------------------------- /src/util/misc.ts: -------------------------------------------------------------------------------- 1 | export const isMobileDevice = () => 2 | typeof window.orientation !== "undefined" || 3 | navigator.userAgent.indexOf("IEMobile") !== -1; 4 | 5 | export const isTouchDevice = () => 6 | "ontouchstart" in window || 7 | navigator.maxTouchPoints > 0 || 8 | navigator.msMaxTouchPoints > 0; 9 | 10 | export const isMouseDevice = () => 11 | matchMedia("(pointer:fine)").matches || matchMedia("(hover:hover)").matches; 12 | -------------------------------------------------------------------------------- /src/util/palette.ts: -------------------------------------------------------------------------------- 1 | const standardGradients: { [key: number]: string } = { 2 | 0: "0#080560;0.2#2969CB;0.40#F1FEFE;0.60#FCA425;0.85#000000", 3 | 1: "0.0775#78591e;0.55#d6e341", // gold 4 | 2: "0#0000FF;0.33#FFFFFF;0.66#FF0000", // bleublancrouge 5 | 3: "0.08#09353e;0.44#1fc3e6;0.77#08173e", // night 6 | 4: "0#000085;0.25#fffff5;0.5#ffb500;0.75#9c0000", // defaultProps 7 | 5: "0#000000;0.25#000000;0.5#7f7f7f;0.75#ffffff;0.975#ffffff", // emboss 8 | // flatUI palettes (http://designmodo.github.io/Flat-UI/) 9 | 10: "0#000000;0.25#16A085;0.5#FFFFFF;0.75#16A085", // green sea 10 | 11: "0#000000;0.25#27AE60;0.5#FFFFFF;0.75#27AE60", // nephritis 11 | 12: "0#000000;0.25#2980B9;0.5#FFFFFF;0.75#2980B9", // nephritis 12 | 13: "0#000000;0.25#8E44AD;0.5#FFFFFF;0.75#8E44AD", // wisteria 13 | 14: "0#000000;0.25#2C3E50;0.5#FFFFFF;0.75#2C3E50", // midnight blue 14 | 15: "0#000000;0.25#F39C12;0.5#FFFFFF;0.75#F39C12", // orange 15 | 16: "0#000000;0.25#D35400;0.5#FFFFFF;0.75#D35400", // pumpkin 16 | 17: "0#000000;0.25#C0392B;0.5#FFFFFF;0.75#C0392B", // pmoegranate 17 | 18: "0#000000;0.25#BDC3C7;0.5#FFFFFF;0.75#BDC3C7", // silver 18 | 19: "0#000000;0.25#7F8C8D;0.5#FFFFFF;0.75#7F8C8D", // asbestos 19 | }; 20 | 21 | /* 22 | * Monotone cubic spline interpolation 23 | * let f = createInterpolant([0, 1, 2, 3, 4], [0, 1, 4, 9, 16]); 24 | * for (let x = 0; x <= 4; x += 0.1) 25 | * let xSquared = f(x); 26 | * https://en.wikipedia.org/wiki/Monotone_cubic_interpolation 27 | */ 28 | function createInterpolant(xs: number[], ys: number[]) { 29 | let i, 30 | length = xs.length; 31 | 32 | // Deal with length issues 33 | if (length !== ys.length) { 34 | throw new Error("Need an equal count of xs and ys."); 35 | } 36 | if (length === 0) { 37 | return function(x: any) { 38 | return 0; 39 | }; 40 | } 41 | if (length === 1) { 42 | // Impl: Precomputing the result prevents problems if ys is mutated later and allows garbage collection of ys 43 | // Impl: Unary plus properly converts values to numbers 44 | const result = +ys[0]; 45 | return function(x: any) { 46 | return result; 47 | }; 48 | } 49 | 50 | // Rearrange xs and ys so that xs is sorted 51 | const indexes = []; 52 | for (i = 0; i < length; i++) { 53 | indexes.push(i); 54 | } 55 | indexes.sort((a, b) => (xs[a] < xs[b] ? -1 : 1)); 56 | let oldXs = xs, 57 | oldYs = ys; 58 | // Impl: Creating new arrays also prevents problems if the input arrays are mutated later 59 | xs = []; 60 | ys = []; 61 | // Impl: Unary plus properly converts values to numbers 62 | for (i = 0; i < length; i++) { 63 | xs.push(+oldXs[indexes[i]]); 64 | ys.push(+oldYs[indexes[i]]); 65 | } 66 | 67 | // Get consecutive differences and slopes 68 | let dys = [], 69 | dxs = [], 70 | ms = []; 71 | for (i = 0; i < length - 1; i++) { 72 | let dx = xs[i + 1] - xs[i], 73 | dy = ys[i + 1] - ys[i]; 74 | dxs.push(dx); 75 | dys.push(dy); 76 | ms.push(dy / dx); 77 | } 78 | 79 | // Get degree-1 coefficients 80 | const c1s = [ms[0]]; 81 | for (i = 0; i < dxs.length - 1; i++) { 82 | let m = ms[i], 83 | mNext = ms[i + 1]; 84 | if (m * mNext <= 0) { 85 | c1s.push(0); 86 | } else { 87 | let dx = dxs[i], 88 | dxNext = dxs[i + 1], 89 | common = dx + dxNext; 90 | c1s.push((3 * common) / ((common + dxNext) / m + (common + dx) / mNext)); 91 | } 92 | } 93 | c1s.push(ms[ms.length - 1]); 94 | 95 | // Get degree-2 and degree-3 coefficients 96 | let cpx2scr: number[] = [], 97 | c3s: number[] = []; 98 | for (i = 0; i < c1s.length - 1; i++) { 99 | let c1 = c1s[i], 100 | m = ms[i], 101 | invDx = 1 / dxs[i], 102 | common = c1 + c1s[i + 1] - m - m; 103 | cpx2scr.push((m - c1 - common) * invDx); 104 | c3s.push(common * invDx * invDx); 105 | } 106 | 107 | // Return interpolant function 108 | return function(x: number) { 109 | // The rightmost point in the dataset should give an exact result 110 | let i = xs.length - 1; 111 | if (x === xs[i]) { 112 | return ys[i]; 113 | } 114 | 115 | // Search for the interval x is in, returning the corresponding y if x is one of the original xs 116 | let low = 0, 117 | mid, 118 | high = c3s.length - 1; 119 | while (low <= high) { 120 | mid = Math.floor(0.5 * (low + high)); 121 | const xHere = xs[mid]; 122 | if (xHere < x) { 123 | low = mid + 1; 124 | } else if (xHere > x) { 125 | high = mid - 1; 126 | } else { 127 | return ys[mid]; 128 | } 129 | } 130 | i = Math.max(0, high); 131 | 132 | // Interpolate 133 | let diff = x - xs[i], 134 | diffSq = diff * diff; 135 | return ys[i] + c1s[i] * diff + cpx2scr[i] * diffSq + c3s[i] * diff * diffSq; 136 | }; 137 | } 138 | 139 | const buildBufferFromStringGradient = ( 140 | resolution: number, 141 | gradient: string, 142 | ) => { 143 | const indices: number[] = []; 144 | const reds: number[] = []; 145 | const greens: number[] = []; 146 | const blues: number[] = []; 147 | 148 | const buildStops = (str: string) => { 149 | str.split(";").forEach(stop => { 150 | const items = stop.split("#"); 151 | indices.push(Number(items[0])); 152 | reds.push(parseInt(items[1].substring(0, 2), 16)); 153 | greens.push(parseInt(items[1].substring(2, 4), 16)); 154 | blues.push(parseInt(items[1].substring(4, 6), 16)); 155 | }); 156 | }; 157 | 158 | const buffer = new Int32Array(resolution); 159 | const buildBuffer = () => { 160 | // loop first stop to end 161 | indices.push(indices[0] + 1); 162 | reds.push(reds[0]); 163 | greens.push(greens[0]); 164 | blues.push(blues[0]); 165 | 166 | const interR = createInterpolant(indices, reds); 167 | const interG = createInterpolant(indices, greens); 168 | const interB = createInterpolant(indices, blues); 169 | 170 | const byteBuffer = new Uint8Array(buffer.buffer); // create an 8-bit view on the buffer 171 | let bufferIndex = 0; 172 | for (let i = 0; i < resolution; i += 1) { 173 | let floatIndex = i / resolution; 174 | if (floatIndex < indices[0]) floatIndex += 1; 175 | byteBuffer[bufferIndex + 0] = interR(floatIndex); 176 | byteBuffer[bufferIndex + 1] = interG(floatIndex); 177 | byteBuffer[bufferIndex + 2] = interB(floatIndex); 178 | byteBuffer[bufferIndex + 3] = 255; 179 | bufferIndex += 4; 180 | } 181 | }; 182 | 183 | buildStops(gradient); 184 | buildBuffer(); 185 | return buffer; 186 | }; 187 | 188 | export const getBufferFromId = (id: number, res = 400) => { 189 | return buildBufferFromStringGradient(res, standardGradients[id]); 190 | }; 191 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | --------------------------------------------------------------------------------