Skip to content

Commit 76845f8

Browse files
committed
Add ImageSaver class for saving generated images and integrate with MandelbrotControls
1 parent 17cdee8 commit 76845f8

File tree

5 files changed

+306
-282
lines changed

5 files changed

+306
-282
lines changed

client/js/ImageSaver.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { saveAs } from "file-saver";
2+
import { FunctionThread, Pool } from "threads";
3+
import type MandelbrotMap from "./MandelbrotMap";
4+
import type MandelbrotLayer from "./MandelbrotLayer";
5+
import {
6+
ComplexBounds,
7+
OptimiseRequest,
8+
OptimiseResponse,
9+
WorkerRequest,
10+
WorkerResponse,
11+
} from "./MandelbrotMap";
12+
13+
type TaskThread = FunctionThread<[WorkerRequest], WorkerResponse>;
14+
15+
class ImageSaver {
16+
private map: MandelbrotMap;
17+
private pool: Pool<TaskThread>;
18+
private mandelbrotLayer: MandelbrotLayer;
19+
20+
constructor(
21+
map: MandelbrotMap,
22+
pool: Pool<TaskThread>,
23+
mandelbrotLayer: MandelbrotLayer,
24+
) {
25+
this.map = map;
26+
this.pool = pool;
27+
this.mandelbrotLayer = mandelbrotLayer;
28+
}
29+
30+
async saveVisibleImage(
31+
totalWidth: number,
32+
totalHeight: number,
33+
optimize: boolean,
34+
onStartOptimizing?: () => void,
35+
) {
36+
const bounds = this.adjustBoundsForAspectRatio(
37+
this.map.mapBoundsAsComplexParts,
38+
totalWidth,
39+
totalHeight,
40+
);
41+
const imageCanvases = await this.generateImageColumns(
42+
bounds,
43+
totalWidth,
44+
totalHeight,
45+
);
46+
const finalCanvas = this.combineImageColumns(
47+
imageCanvases,
48+
totalWidth,
49+
totalHeight,
50+
);
51+
await this.saveCanvasAsImage(finalCanvas, optimize, onStartOptimizing);
52+
}
53+
54+
private adjustBoundsForAspectRatio(
55+
bounds: ComplexBounds,
56+
totalWidth: number,
57+
totalHeight: number,
58+
): ComplexBounds {
59+
const imageAspectRatio = totalWidth / totalHeight;
60+
const complexAspectRatio =
61+
(bounds.reMax - bounds.reMin) / (bounds.imMax - bounds.imMin);
62+
63+
const adjustedBounds = { ...bounds };
64+
65+
if (imageAspectRatio < complexAspectRatio) {
66+
const newImHeight = (bounds.reMax - bounds.reMin) / imageAspectRatio;
67+
const imCenter = (bounds.imMin + bounds.imMax) / 2;
68+
adjustedBounds.imMin = imCenter - newImHeight / 2;
69+
adjustedBounds.imMax = imCenter + newImHeight / 2;
70+
} else if (imageAspectRatio > complexAspectRatio) {
71+
const newReWidth = (bounds.imMax - bounds.imMin) * imageAspectRatio;
72+
const reCenter = (bounds.reMin + bounds.reMax) / 2;
73+
adjustedBounds.reMin = reCenter - newReWidth / 2;
74+
adjustedBounds.reMax = reCenter + newReWidth / 2;
75+
}
76+
77+
return adjustedBounds;
78+
}
79+
80+
private async generateImageColumns(
81+
bounds: ComplexBounds,
82+
totalWidth: number,
83+
totalHeight: number,
84+
): Promise<HTMLCanvasElement[]> {
85+
const numColumns = 24;
86+
const columnWidth = Math.ceil(totalWidth / numColumns);
87+
const reDiff = bounds.reMax - bounds.reMin;
88+
const reDiffPerColumn = reDiff * (columnWidth / totalWidth);
89+
90+
const imagePromises: Promise<HTMLCanvasElement>[] = [];
91+
for (let i = 0; i < numColumns; i++) {
92+
const subBounds = {
93+
...bounds,
94+
reMin: bounds.reMin + reDiffPerColumn * i,
95+
reMax: bounds.reMin + reDiffPerColumn * (i + 1),
96+
};
97+
imagePromises.push(
98+
this.mandelbrotLayer.getImage(subBounds, columnWidth, totalHeight),
99+
);
100+
}
101+
102+
return Promise.all(imagePromises);
103+
}
104+
105+
private combineImageColumns(
106+
imageCanvases: HTMLCanvasElement[],
107+
totalWidth: number,
108+
totalHeight: number,
109+
): HTMLCanvasElement {
110+
const finalCanvas = document.createElement("canvas");
111+
finalCanvas.width = totalWidth;
112+
finalCanvas.height = totalHeight;
113+
const ctx = finalCanvas.getContext("2d");
114+
115+
if (!ctx) {
116+
throw new Error("Could not get canvas context for combining columns");
117+
}
118+
119+
let xOffset = 0;
120+
imageCanvases.forEach((canvas) => {
121+
ctx.drawImage(canvas, xOffset, 0);
122+
xOffset += canvas.width;
123+
});
124+
125+
return finalCanvas;
126+
}
127+
128+
private async saveCanvasAsImage(
129+
canvas: HTMLCanvasElement,
130+
optimize: boolean,
131+
onStartOptimizing?: () => void,
132+
): Promise<void> {
133+
const ctx = canvas.getContext("2d");
134+
if (!ctx) {
135+
console.error("Could not get canvas context for saving image");
136+
return;
137+
}
138+
const dataUrl = canvas.toDataURL("image/png");
139+
const response = await fetch(dataUrl);
140+
const rawPngBuffer = await response.arrayBuffer();
141+
142+
let finalBuffer = rawPngBuffer;
143+
144+
if (optimize) {
145+
onStartOptimizing?.();
146+
const optimiseRequest: OptimiseRequest = {
147+
type: "optimise",
148+
payload: { buffer: rawPngBuffer },
149+
};
150+
finalBuffer = (await this.pool.queue((worker) =>
151+
worker(optimiseRequest),
152+
)) as OptimiseResponse;
153+
}
154+
155+
const blob = new Blob([finalBuffer], { type: "image/png" });
156+
157+
saveAs(
158+
blob,
159+
`mandelbrot${Date.now()}_r${this.map.config.re}_im${
160+
this.map.config.im
161+
}_z${this.map.config.zoom}.png`,
162+
);
163+
}
164+
}
165+
166+
export default ImageSaver;

client/js/MandelbrotControls.ts

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,46 @@ import debounce from "lodash/debounce";
33
import throttle from "lodash/throttle";
44
import snakeCase from "lodash/snakeCase";
55
import type MandelbrotMap from "./MandelbrotMap";
6+
import { MandelbrotConfig } from "./MandelbrotMap";
67
import * as api from "./api";
7-
import {
8-
NumberInput,
9-
SelectInput,
10-
CheckboxInput,
11-
SliderInput,
12-
MandelbrotConfig,
13-
ResetButtonConfig,
14-
} from "./types";
8+
9+
type NumberInput = {
10+
id:
11+
| "iterations"
12+
| "exponent"
13+
| "re"
14+
| "im"
15+
| "zoom"
16+
| "paletteMinIter"
17+
| "paletteMaxIter";
18+
minValue: number;
19+
maxValue: number;
20+
resetView?: boolean;
21+
allowFraction?: boolean;
22+
};
23+
24+
type SelectInput = {
25+
id: "colorScheme" | "colorSpace";
26+
};
27+
28+
type CheckboxInput = {
29+
id:
30+
| "reverseColors"
31+
| "highDpiTiles"
32+
| "smoothColoring"
33+
| "scaleWithIterations";
34+
};
35+
36+
type SliderInput = {
37+
id: "lightenAmount" | "saturateAmount" | "shiftHueAmount";
38+
};
39+
40+
type ResetButtonConfig = {
41+
buttonId: string;
42+
configKeys: Array<keyof MandelbrotConfig>;
43+
specialHandling?: (oldIterations?: number) => void;
44+
checkDiff?: () => boolean;
45+
};
1546

1647
const DETAILS_STATE_STORAGE_KEY = "mandelbrot-details-state";
1748
const OPTIMIZE_IMAGE_STORAGE_KEY = "mandelbrot-optimize-image";
@@ -518,7 +549,7 @@ class MandelbrotControls {
518549
saveImageSubmitButton.innerText = "Generating...";
519550
const shouldOptimize = optimizeImageCheckbox.checked;
520551

521-
this.map
552+
this.map.imageSaver
522553
.saveVisibleImage(
523554
width,
524555
height,
@@ -529,7 +560,7 @@ class MandelbrotControls {
529560
}
530561
: undefined,
531562
)
532-
.catch((error) => {
563+
.catch((error: unknown) => {
533564
alert("Error saving image\n\n" + error);
534565
console.error(error);
535566
})

client/js/MandelbrotLayer.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,33 @@
11
import debounce from "lodash/debounce";
22
import * as L from "leaflet";
33
import type MandelbrotMap from "./MandelbrotMap";
4+
import {
5+
ComplexBounds,
6+
MandelbrotConfig,
7+
WorkerRequest,
8+
} from "./MandelbrotMap";
49

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

12+
export type WasmRequestPayload = Omit<
13+
MandelbrotConfig,
14+
"zoom" | "highDpiTiles" | "re" | "im" | "scaleWithIterations"
15+
> & {
16+
bounds: ComplexBounds;
17+
imageWidth: number;
18+
imageHeight: number;
19+
};
20+
21+
type TileGenerationTask = {
22+
position: L.Coords;
23+
canvas: HTMLCanvasElement;
24+
done: Done;
25+
};
26+
727
class MandelbrotLayer extends L.GridLayer {
828
tileSize: number;
929
_map: MandelbrotMap;
10-
tilesToGenerate: Array<{
11-
position: L.Coords;
12-
canvas: HTMLCanvasElement;
13-
done: Done;
14-
}> = [];
30+
tilesToGenerate: TileGenerationTask[] = [];
1531

1632
constructor() {
1733
super({
@@ -39,7 +55,7 @@ class MandelbrotLayer extends L.GridLayer {
3955

4056
this._map.pool.queue(async (workerTask) => {
4157
try {
42-
const request = {
58+
const request: WorkerRequest = {
4359
type: "calculate" as const,
4460
payload: {
4561
bounds,
@@ -113,7 +129,7 @@ class MandelbrotLayer extends L.GridLayer {
113129
this.addTo(currentMap);
114130
}
115131

116-
private getComplexBoundsOfTile(tilePosition: L.Coords) {
132+
private getComplexBoundsOfTile(tilePosition: L.Coords): ComplexBounds {
117133
const { re: reMin, im: imMin } = this._map.tilePositionToComplexParts(
118134
tilePosition.x,
119135
tilePosition.y,
@@ -158,7 +174,7 @@ class MandelbrotLayer extends L.GridLayer {
158174
: Date.now().toString();
159175

160176
const tileTask = this._map.pool.queue(async (workerTask) => {
161-
const request = {
177+
const request: WorkerRequest = {
162178
type: "calculate" as const,
163179
payload: {
164180
bounds,

0 commit comments

Comments
 (0)