├── .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 | 
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 |
--------------------------------------------------------------------------------