Skip to content

Commit c2db723

Browse files
committed
Add image optimization feature
1 parent c9fd5bd commit c2db723

File tree

9 files changed

+666
-436
lines changed

9 files changed

+666
-436
lines changed

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ Now you can run the project and start making changes to the code! We recommend u
115115

116116
### Running the Project
117117

118-
Once you've cloned the repository, you can begin working on the project. This project requires [Node.js](https://nodejs.org/) and [Rust](https://rust-lang.org/) to be installed on your system. Check the [`.nvmrc`](client/.nvmrc) file for the recommended Node.js version. [Rust](https://rust-lang.org/) is also required to build the project.
118+
Once you've cloned the repository, you can begin working on the project. This project requires [Node.js](https://nodejs.org/) and [Rust](https://rust-lang.org/) to be installed on your system. Check the [`.nvmrc`](client/.nvmrc) file for the recommended Node.js version.
119119

120120
Run all commands from within the `client` directory:
121121

client/html/index.html

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,10 @@ <h1 class="nav-heading">
147147
</button>
148148
</div>
149149
<div class="input-wrapper mobile-hidden">
150-
<label for="exponent">Exponent</label>
150+
<label for="exponent" class="label-with-subtitle">
151+
<span>Exponent</span>
152+
<span class="secondary">Symmetry</span>
153+
</label>
151154
<input type="number" required id="exponent" name="exponent" />
152155
</div>
153156
<div class="checkbox-wrapper mobile-hidden">
@@ -470,6 +473,12 @@ <h1 class="nav-heading">
470473
<label for="imageHeight">Height (px)</label>
471474
<input type="number" required id="imageHeight" name="imageHeight" />
472475
</div>
476+
<div class="checkbox-wrapper">
477+
<input type="checkbox" id="optimizeImage" name="optimizeImage" />
478+
<label for="optimizeImage"
479+
>Reduce file size <span class="secondary">(slow)</span></label
480+
>
481+
</div>
473482
<div class="submit-or-cancel">
474483
<button type="submit" id="saveImageSubmit">Save</button>
475484
<button type="button" id="saveImageCancel" class="underline-button">

client/js/MandelbrotControls.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from "./types";
1515

1616
const DETAILS_STATE_STORAGE_KEY = "mandelbrot-details-state";
17+
const OPTIMIZE_IMAGE_STORAGE_KEY = "mandelbrot-optimize-image";
1718
const SMALL_SCREEN_WIDTH_PX = 800;
1819

1920
class MandelbrotControls {
@@ -434,6 +435,9 @@ class MandelbrotControls {
434435
) as HTMLInputElement;
435436
const saveImageSubmitButton = document.getElementById("saveImageSubmit");
436437
const closeModalButton = document.getElementById("saveImageCancel");
438+
const optimizeImageCheckbox = document.getElementById(
439+
"optimizeImage",
440+
) as HTMLInputElement;
437441

438442
const ignoreSubmitListener: EventListener = (event) =>
439443
event.preventDefault();
@@ -449,7 +453,11 @@ class MandelbrotControls {
449453
saveImageSubmitButton.removeAttribute("disabled");
450454
saveImageForm.removeEventListener("submit", ignoreSubmitListener);
451455
closeModalButton.removeAttribute("disabled");
456+
457+
const currentOptimizeState = optimizeImageCheckbox.checked;
452458
saveImageForm.reset();
459+
optimizeImageCheckbox.checked = currentOptimizeState;
460+
453461
if (saveImageDialog.open) {
454462
saveImageDialog.close();
455463
} else {
@@ -463,6 +471,18 @@ class MandelbrotControls {
463471
}
464472
};
465473

474+
const savedOptimizeState = localStorage.getItem(OPTIMIZE_IMAGE_STORAGE_KEY);
475+
if (savedOptimizeState !== null) {
476+
optimizeImageCheckbox.checked = JSON.parse(savedOptimizeState);
477+
}
478+
479+
optimizeImageCheckbox.onchange = () => {
480+
localStorage.setItem(
481+
OPTIMIZE_IMAGE_STORAGE_KEY,
482+
JSON.stringify(optimizeImageCheckbox.checked),
483+
);
484+
};
485+
466486
if (typeof Blob !== "undefined") {
467487
saveImageButton.onclick = (e) => {
468488
e.stopPropagation();
@@ -495,8 +515,20 @@ class MandelbrotControls {
495515

496516
this.logEvent("imageSave");
497517

518+
saveImageSubmitButton.innerText = "Generating...";
519+
const shouldOptimize = optimizeImageCheckbox.checked;
520+
498521
this.map
499-
.saveVisibleImage(width, height)
522+
.saveVisibleImage(
523+
width,
524+
height,
525+
shouldOptimize,
526+
shouldOptimize
527+
? () => {
528+
saveImageSubmitButton.innerText = "Optimizing...";
529+
}
530+
: undefined,
531+
)
500532
.catch((error) => {
501533
alert("Error saving image\n\n" + error);
502534
console.error(error);

client/js/MandelbrotLayer.ts

Lines changed: 42 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -37,24 +37,28 @@ class MandelbrotLayer extends L.GridLayer {
3737
canvas.width = imageWidth;
3838
canvas.height = imageHeight;
3939

40-
this._map.pool.queue(async (getTile) => {
40+
this._map.pool.queue(async (workerTask) => {
4141
try {
42-
const data = await getTile({
43-
bounds,
44-
iterations: this._map.config.iterations,
45-
exponent: this._map.config.exponent,
46-
imageWidth,
47-
imageHeight,
48-
colorScheme: this._map.config.colorScheme,
49-
reverseColors: this._map.config.reverseColors,
50-
lightenAmount: this._map.config.lightenAmount,
51-
saturateAmount: this._map.config.saturateAmount,
52-
shiftHueAmount: this._map.config.shiftHueAmount,
53-
colorSpace: this._map.config.colorSpace,
54-
smoothColoring: this._map.config.smoothColoring,
55-
paletteMinIter: this._map.config.paletteMinIter,
56-
paletteMaxIter: this._map.config.paletteMaxIter,
57-
});
42+
const request = {
43+
type: "calculate" as const,
44+
payload: {
45+
bounds,
46+
iterations: this._map.config.iterations,
47+
exponent: this._map.config.exponent,
48+
imageWidth,
49+
imageHeight,
50+
colorScheme: this._map.config.colorScheme,
51+
reverseColors: this._map.config.reverseColors,
52+
lightenAmount: this._map.config.lightenAmount,
53+
saturateAmount: this._map.config.saturateAmount,
54+
shiftHueAmount: this._map.config.shiftHueAmount,
55+
colorSpace: this._map.config.colorSpace,
56+
smoothColoring: this._map.config.smoothColoring,
57+
paletteMinIter: this._map.config.paletteMinIter,
58+
paletteMaxIter: this._map.config.paletteMaxIter,
59+
},
60+
};
61+
const data = (await workerTask(request)) as Uint8Array;
5862

5963
const imageData = new ImageData(
6064
Uint8ClampedArray.from(data),
@@ -153,23 +157,27 @@ class MandelbrotLayer extends L.GridLayer {
153157
? crypto.randomUUID()
154158
: Date.now().toString();
155159

156-
const tileTask = this._map.pool.queue(async (getTile) => {
157-
const data = await getTile({
158-
bounds,
159-
iterations: this._map.config.iterations,
160-
exponent: this._map.config.exponent,
161-
imageWidth: scaledTileSize,
162-
imageHeight: scaledTileSize,
163-
colorScheme: this._map.config.colorScheme,
164-
reverseColors: this._map.config.reverseColors,
165-
lightenAmount: this._map.config.lightenAmount,
166-
saturateAmount: this._map.config.saturateAmount,
167-
shiftHueAmount: this._map.config.shiftHueAmount,
168-
colorSpace: this._map.config.colorSpace,
169-
smoothColoring: this._map.config.smoothColoring,
170-
paletteMinIter: this._map.config.paletteMinIter,
171-
paletteMaxIter: this._map.config.paletteMaxIter,
172-
});
160+
const tileTask = this._map.pool.queue(async (workerTask) => {
161+
const request = {
162+
type: "calculate" as const,
163+
payload: {
164+
bounds,
165+
iterations: this._map.config.iterations,
166+
exponent: this._map.config.exponent,
167+
imageWidth: scaledTileSize,
168+
imageHeight: scaledTileSize,
169+
colorScheme: this._map.config.colorScheme,
170+
reverseColors: this._map.config.reverseColors,
171+
lightenAmount: this._map.config.lightenAmount,
172+
saturateAmount: this._map.config.saturateAmount,
173+
shiftHueAmount: this._map.config.shiftHueAmount,
174+
colorSpace: this._map.config.colorSpace,
175+
smoothColoring: this._map.config.smoothColoring,
176+
paletteMinIter: this._map.config.paletteMinIter,
177+
paletteMaxIter: this._map.config.paletteMaxIter,
178+
},
179+
};
180+
const data = (await workerTask(request)) as Uint8Array;
173181

174182
const imageData = new ImageData(
175183
Uint8ClampedArray.from(data),

client/js/MandelbrotMap.ts

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,28 @@ type MapWithResetView = MandelbrotMap & {
1010
_resetView: (center: L.LatLng | [number, number], zoom: number) => void;
1111
};
1212

13-
type MandelbrotThread = FunctionThread<[WasmRequestPayload], Uint8Array>;
13+
type MandelbrotRequest = { type: "calculate"; payload: WasmRequestPayload };
14+
type OptimisePayload = { buffer: ArrayBuffer };
15+
type OptimiseRequest = { type: "optimise"; payload: OptimisePayload };
16+
type WorkerRequest = MandelbrotRequest | OptimiseRequest;
17+
18+
type MandelbrotResponse = Uint8Array;
19+
type OptimiseResponse = ArrayBuffer;
20+
type WorkerResponse = MandelbrotResponse | OptimiseResponse;
21+
22+
type TaskThread = FunctionThread<[WorkerRequest], WorkerResponse>;
1423

1524
class MandelbrotMap extends L.Map {
1625
mandelbrotLayer: MandelbrotLayer;
1726
mapId: string;
1827
controls: MandelbrotControls;
1928
initialConfig: MandelbrotConfig;
2029
config: MandelbrotConfig;
21-
pool: Pool<MandelbrotThread>;
30+
pool: Pool<TaskThread>;
2231
queuedTileTasks: {
2332
id: string;
2433
position: L.Coords;
25-
task: QueuedTask<MandelbrotThread, void>;
34+
task: QueuedTask<TaskThread, void>;
2635
}[] = [];
2736

2837
constructor({
@@ -178,7 +187,12 @@ class MandelbrotMap extends L.Map {
178187
}
179188
}
180189

181-
async saveVisibleImage(totalWidth: number, totalHeight: number) {
190+
async saveVisibleImage(
191+
totalWidth: number,
192+
totalHeight: number,
193+
optimize: boolean,
194+
onStartOptimizing?: () => void,
195+
) {
182196
const bounds = this.adjustBoundsForAspectRatio(
183197
this.mapBoundsAsComplexParts,
184198
totalWidth,
@@ -194,7 +208,7 @@ class MandelbrotMap extends L.Map {
194208
totalWidth,
195209
totalHeight,
196210
);
197-
this.saveCanvasAsImage(finalCanvas);
211+
await this.saveCanvasAsImage(finalCanvas, optimize, onStartOptimizing);
198212
}
199213

200214
private adjustBoundsForAspectRatio(
@@ -265,15 +279,40 @@ class MandelbrotMap extends L.Map {
265279
return finalCanvas;
266280
}
267281

268-
private saveCanvasAsImage(canvas: HTMLCanvasElement) {
269-
canvas.toBlob((blob) => {
270-
saveAs(
271-
blob,
272-
`mandelbrot${Date.now()}_r${this.config.re}_im${this.config.im}_z${
273-
this.config.zoom
274-
}.png`,
275-
);
276-
});
282+
private async saveCanvasAsImage(
283+
canvas: HTMLCanvasElement,
284+
optimize: boolean,
285+
onStartOptimizing?: () => void,
286+
) {
287+
const ctx = canvas.getContext("2d");
288+
if (!ctx) {
289+
console.error("Could not get canvas context");
290+
return;
291+
}
292+
const dataUrl = canvas.toDataURL("image/png");
293+
const response = await fetch(dataUrl);
294+
const rawPngBuffer = await response.arrayBuffer();
295+
296+
let finalBuffer = rawPngBuffer;
297+
298+
if (optimize) {
299+
onStartOptimizing?.();
300+
finalBuffer = (await this.pool.queue((worker) =>
301+
worker({
302+
type: "optimise",
303+
payload: { buffer: rawPngBuffer },
304+
}),
305+
)) as ArrayBuffer;
306+
}
307+
308+
const blob = new Blob([finalBuffer], { type: "image/png" });
309+
310+
saveAs(
311+
blob,
312+
`mandelbrot${Date.now()}_r${this.config.re}_im${this.config.im}_z${
313+
this.config.zoom
314+
}.png`,
315+
);
277316
}
278317

279318
getShareUrl() {

client/js/worker.js

Lines changed: 63 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,67 @@
11
import { expose } from "threads/worker";
2+
import initOxipngST, {
3+
optimise as optimiseST,
4+
} from "../node_modules/@jsquash/oxipng/codec/pkg/squoosh_oxipng.js";
25

3-
import("../../mandelbrot/pkg").then((wasm) => {
4-
wasm.init();
6+
let oxipngInitialized = false;
57

6-
const getTile = (params) =>
7-
wasm.get_mandelbrot_set_image(
8-
params.bounds.reMin,
9-
params.bounds.reMax,
10-
params.bounds.imMin,
11-
params.bounds.imMax,
12-
params.iterations,
13-
params.exponent,
14-
params.imageWidth,
15-
params.imageHeight,
16-
params.colorScheme,
17-
params.reverseColors,
18-
params.shiftHueAmount,
19-
params.saturateAmount,
20-
params.lightenAmount,
21-
params.colorSpace,
22-
params.smoothColoring,
23-
params.paletteMinIter,
24-
params.paletteMaxIter,
25-
);
8+
async function ensureOxipngInitialized() {
9+
if (!oxipngInitialized) {
10+
await initOxipngST();
11+
oxipngInitialized = true;
12+
}
13+
}
2614

27-
expose(getTile);
28-
});
15+
import("../../mandelbrot/pkg")
16+
.then(async (wasm) => {
17+
wasm.init();
18+
19+
const getTile = (params) =>
20+
wasm.get_mandelbrot_set_image(
21+
params.bounds.reMin,
22+
params.bounds.reMax,
23+
params.bounds.imMin,
24+
params.bounds.imMax,
25+
params.iterations,
26+
params.exponent,
27+
params.imageWidth,
28+
params.imageHeight,
29+
params.colorScheme,
30+
params.reverseColors,
31+
params.shiftHueAmount,
32+
params.saturateAmount,
33+
params.lightenAmount,
34+
params.colorSpace,
35+
params.smoothColoring,
36+
params.paletteMinIter,
37+
params.paletteMaxIter,
38+
);
39+
40+
const optimiseImage = async (payload) => {
41+
await ensureOxipngInitialized();
42+
const result = optimiseST(
43+
new Uint8Array(payload.buffer),
44+
2,
45+
false,
46+
false,
47+
);
48+
return result.buffer;
49+
};
50+
51+
expose(async (request) => {
52+
switch (request.type) {
53+
case "calculate":
54+
return getTile(request.payload);
55+
case "optimise":
56+
return await optimiseImage(request.payload);
57+
default:
58+
throw new Error(`Unknown worker request type: ${request.type}`);
59+
}
60+
});
61+
})
62+
.catch((err) => {
63+
console.error("Error loading WASM module in worker:", err);
64+
expose(() => {
65+
throw new Error("Worker initialization failed");
66+
});
67+
});

0 commit comments

Comments
 (0)