Skip to content

Commit

Permalink
Add coordinate/zoom inputs and share link
Browse files Browse the repository at this point in the history
  • Loading branch information
rosslh committed Feb 9, 2024
1 parent b94ff0f commit 2e39b78
Show file tree
Hide file tree
Showing 12 changed files with 776 additions and 448 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

- [Mandelbrot set code - <code>mandelbrot/src/lib.rs</code>](mandelbrot/src/lib.rs)
- [Rust tests - <code>mandelbrot/src/lib_test.rs</code>](mandelbrot/src/lib_test.rs)
- [Web Worker - <code>client/app/worker.ts</code>](client/app/worker.ts)
- [Web Worker - <code>client/app/worker.js</code>](client/app/worker.js)
- [Leaflet tile generation - <code>client/app/main.ts</code>](client/app/main.ts)

## Features
Expand Down
131 changes: 131 additions & 0 deletions client/app/MandelbrotLayer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import debounce from "lodash/debounce";
import * as L from "leaflet";
import MandelbrotMap from "./MandelbrotMap";
import { config } from "./main";

type Done = (error: null, tile: HTMLCanvasElement) => void;

class MandelbrotLayer extends L.GridLayer {
tileSize: number;
_map: MandelbrotMap;
tilesToGenerate: Array<{
position: L.Coords;
canvas: HTMLCanvasElement;
done: Done;
}> = [];

constructor() {
super({
noWrap: true,
tileSize: 200,
});
}

private getComplexBoundsOfTile(tilePosition: L.Coords) {
const { re: re_min, im: im_min } = this._map.tilePositionToComplexParts(
tilePosition.x,
tilePosition.y,
tilePosition.z
);

const { re: re_max, im: im_max } = this._map.tilePositionToComplexParts(
tilePosition.x + 1,
tilePosition.y + 1,
tilePosition.z
);

const bounds = {
re_min,
re_max,
im_min,
im_max,
};

return bounds;
}

private generateTile(
canvas: HTMLCanvasElement,
tilePosition: L.Coords,
done: Done
) {
const context = canvas.getContext("2d");

const scaledTileSize = config.highDpiTiles
? this.getTileSize().x * Math.max(window.devicePixelRatio || 2, 2)
: this.getTileSize().x;

canvas.width = scaledTileSize;
canvas.height = scaledTileSize;

const bounds = this.getComplexBoundsOfTile(tilePosition);

this._map.pool.queue(async ({ getTile }) => {
getTile({
bounds,
maxIterations: config.iterations,
exponent: config.exponent,
tileSize: scaledTileSize,
colorScheme: config.colorScheme,
reverseColors: config.reverseColors,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}).then((data: any) => {
const imageData = new ImageData(
Uint8ClampedArray.from(data),
scaledTileSize,
scaledTileSize
);
context.putImageData(imageData, 0, 0);
done(null, canvas);
});
});

return canvas;
}

private generateTiles() {
const tilesToGenerate = this.tilesToGenerate.splice(
0,
this.tilesToGenerate.length
);

const mapZoom = this._map.getZoom();

const seenTasks = new Set<string>();
const relevantTasks = tilesToGenerate.filter((task) => {
const positionString = JSON.stringify(task.position);
if (task.position.z === mapZoom && !seenTasks.has(positionString)) {
seenTasks.add(positionString);
return true;
}
return false;
});

relevantTasks.forEach((task) => {
this.generateTile(task.canvas, task.position, task.done);
});
}

debounceTileGeneration = debounce(this.generateTiles, 350);

createTile(tilePosition: L.Coords, done: Done) {
const canvas = L.DomUtil.create(
"canvas",
"leaflet-tile"
) as HTMLCanvasElement;
this.tilesToGenerate.push({ position: tilePosition, canvas, done });
this.debounceTileGeneration();
return canvas;
}

refresh() {
let currentMap: MandelbrotMap | null = null;
if (this._map) {
currentMap = this._map as MandelbrotMap;
this.removeFrom(this._map);
}
this.addTo(currentMap);
}
}

export { MandelbrotLayer };
160 changes: 160 additions & 0 deletions client/app/MandelbrotMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import throttle from "lodash/throttle";
import * as L from "leaflet";
import { saveAs } from "file-saver";
import domToImage from "dom-to-image";
import { Pool, Worker, spawn } from "threads";
import { MandelbrotLayer } from "./MandelbrotLayer";
import { config } from "./main";

class MandelbrotMap extends L.Map {
mandelbrotLayer: MandelbrotLayer;
mapId: string;
defaultPosition: [number, number];
defaultZoom: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pool: Pool<any>;

constructor({ htmlId: mapId }: { htmlId: string }) {
super(mapId, {
attributionControl: false,
maxZoom: 32,
zoomAnimationThreshold: 32,
});

this.createPool();
this.mapId = mapId;
this.mandelbrotLayer = new MandelbrotLayer().addTo(this);
this.defaultPosition = [0, 0];
this.defaultZoom = 3;
this.setView(this.defaultPosition, this.defaultZoom);

this.on("drag", function () {
this.mandelbrotLayer.debounceTileGeneration.flush();
});
this.on("click", this.handleMapClick);

this.on("load", this.throttleSetDomElementValues);
this.on("move", this.throttleSetDomElementValues);
this.on("moveend", this.throttleSetDomElementValues);
this.on("zoomend", this.throttleSetDomElementValues);
this.on("viewreset", this.throttleSetDomElementValues);
this.on("resize", this.throttleSetDomElementValues);
}

handleMapClick = (e: L.LeafletMouseEvent) => {
if (e.originalEvent.altKey) {
this.setView(e.latlng, this.getZoom());
}
};

tilePositionToComplexParts(
x: number,
y: number,
z: number
): { re: number; im: number } {
const scaleFactor = this.mandelbrotLayer.getTileSize().x / 128;
const d = 2 ** (z - 2);
const re = (x / d) * scaleFactor - 4;
const im = (y / d) * scaleFactor - 4;
return { re, im };
}

complexPartsToTilePosition(re: number, im: number, z: number) {
const scaleFactor = this.mandelbrotLayer.getTileSize().x / 128;
const d = 2 ** (z - 2);
const x = ((re + 4) * d) / scaleFactor;
const y = ((im + 4) * d) / scaleFactor;
return { x, y };
}

private setDomElementValues = () => {
const tileSize = [
this.mandelbrotLayer.getTileSize().x,
this.mandelbrotLayer.getTileSize().y,
];
const point = this.project(this.getCenter(), this.getZoom()).unscaleBy(
new L.Point(tileSize[0], tileSize[1])
);

const position = { ...point, z: this.getZoom() };

const { re, im } = this.tilePositionToComplexParts(
position.x,
position.y,
position.z
);

config.re = re;
(<HTMLInputElement>document.querySelector("#re")).value = String(re);

config.im = im;
(<HTMLInputElement>document.querySelector("#im")).value = String(im);

config.zoom = position.z;
(<HTMLInputElement>document.querySelector("#zoom")).value = String(
position.z
);

(<HTMLAnchorElement>(
document.querySelector("#shareLink")
)).href = `?re=${re}&im=${im}&z=${position.z}&i=${config.iterations}&e=${config.exponent}&c=${config.colorScheme}&r=${config.reverseColors}&sharing=true`;
};

private complexPartsToLatLng(re: number, im: number, z: number) {
const tileSize = [
this.mandelbrotLayer.getTileSize().x,
this.mandelbrotLayer.getTileSize().y,
];

const { x, y } = this.complexPartsToTilePosition(re, im, z);

const latLng = this.unproject(
L.point(x, y).scaleBy(new L.Point(tileSize[0], tileSize[1])),
z
);

return latLng;
}

throttleSetDomElementValues = throttle(this.setDomElementValues, 200);

async createPool() {
if (this.pool) {
this.pool.terminate(true);
}

this.pool = Pool(() => spawn(new Worker("./worker.js")));
}

async refresh(resetView = false) {
await this.createPool();
if (resetView) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this as any)._resetView(this.defaultPosition, this.defaultZoom);
} else {
const pointToCenter = this.complexPartsToLatLng(
config.re,
config.im,
config.zoom
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this as any)._resetView(pointToCenter, config.zoom);
}
}

async saveImage() {
const zoomControl = this.zoomControl;
const mapElement = document.getElementById(this.mapId);
const width = mapElement.offsetWidth;
const height = mapElement.offsetHeight;
this.removeControl(zoomControl);
const blob = await domToImage.toBlob(mapElement, { width, height });
this.addControl(zoomControl);
saveAs(
blob,
`mandelbrot${Date.now()}r${config.re}im${config.im}z${config.zoom}.png`
);
}
}

export default MandelbrotMap;
Loading

0 comments on commit 2e39b78

Please sign in to comment.