17 |
18 |
19 | ## Table of Contents
20 | * [Stack](#stack)
21 | * [Notes](#notes)
22 | * [Available Scripts](#available-scripts)
23 | * [Old Architecture](#old-architecture)
24 | * [New Architecture](#new-architecture)
25 | * [Optimizations](#optimizations)
26 | * [Performance Evaluation](#performance-evaluation)
27 |
28 | ## Stack
29 |
30 | * [file-saver](https://github.com/eligrey/FileSaver.js/) (writing out a binary file)
31 | * [jsqrcode](https://github.com/LazarSoft/jsqrcode) (reading in qr codes)
32 | * [qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator) (generating qr codes)
33 | * (other original dependencies have been removed for optimization)
34 |
35 | ## Notes
36 |
37 | * the [qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator) library doesn't support multipart QR codes (at all), we will be using the patched version of it by Thulinma instead available [here](https://github.com/Thulinma/ACNLPatternTool/blob/master/qrcode.js).
38 |
39 | * the [jsqrcode](https://github.com/LazarSoft/jsqrcode) library still has trouble recognizing the QR codes and there are still some errors to be fixed in the original library. We will be using Thulinma's patched version at [his fork's branch](https://github.com/Thulinma/jsqrcode/tree/finder_fix_mini).
40 |
41 | ## Available Scripts
42 |
43 | In the project directory, you can run:
44 |
45 | ### `npm start`
46 |
47 | Runs the app in the development mode.
48 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
49 |
50 | The page will reload if you make edits.
51 | You will also see any lint errors in the console.
52 |
53 |
54 | ## Old Architecture
55 |
56 | The architecture for original application (not this one) was unorganized. Both the controller and model directly rendered parts of the view on their own. It wasn't very clear how it should be handled.
57 |
58 | `acnl.js`
59 | ```javascript
60 |
61 | function setColor() {
62 | ...
63 | for (var i in canvasses){
64 | drawPixel(canvasses[i], x, y, c, getZoom(canvasses[i].canvas));
65 | }
66 | }
67 |
68 | ```
69 |
70 | `page.js`
71 | ```javascript
72 | .mousemove(function(event) {
73 | ...
74 | ACNL.setColor(x, y, chosen_color)
75 | })
76 | ```
77 |
78 | ## New Architecture
79 |
80 | The new architecture uses an MVC model. The controller interacts with the model and is responsible for rendering the visual representation of the model. The controller now consists of several parts.
81 |
82 | * Editor
83 | * Canvas
84 | * Swatch
85 | * Palette
86 | * QR Code Generator
87 |
88 | Note: the term model will refer to the `ACNL` class from `acnl.js` which represents the ACNL file format used to save/render QR codes in Animal Crossing New Leaf (ACNL).
89 |
90 | Note: the term pixel in this write-up refers to a pattern pixel drawn onto the canvas (as the pattern size is 32x32 pixels and up to 4 patterns can exist on a canvas), not a physical or css pixel.
91 |
92 | The editor is the parent of the canvas, palette, swatch, and qr code generator. It acts as the main center of control. Components cannot update each other directly, but must now communicate with the editor component in order to update other components and the model respectively. Figuratively, the 'editor' is a user that can manipulate both the model and the view. The 'editor' holds onto user information (e.g. current drawing color).
93 |
94 | The components themselves are now modular, easily allowing for additional modifications to be added. For example, pixel tools can be added in the form of a module. All they have to do is return a list of pixels that need to be colored in for the editor to handle via `updatePixelBuffer([pixels...[x, y]])` (read into optimizations on the pixel buffer). It is now possible to introduce pen sizes and bucket tools by simplying extending the EditorTools module.
95 |
96 | ## Optimizations
97 |
98 | ### 1. The Pixel Buffer
99 | When the user is drawing on the canvas, there are two operations that need to be executed:
100 |
101 | 1. the canvas needs to update itself to color in the pixel
102 | 2. the ACNL file needs to have update itself to have the color of the pixel in the data changed
103 |
104 | Since React will force a re-render (redrawing the entire pattern from scratch) when we modify the file and the render is expensive, this application now uses a `pixelBuffer` to store the necessary file modifications. The drawing is performed live while the changed pixels are cached to be applied to the file at a later time. `updatePixelBuffer(x, y)` will handle both the drawing and caching of the pixel that needs to be modified. It will also schedule the application of the file modifications in the `pixelBuffer` to a time when the user is free.
105 |
106 | The `pixelBuffer` is specific to the chosen drawing color and will force the file to update when the chosen drawing color has changed.
107 |
108 | The `pixelBuffer` also prevents the additions of pixels that match the last added pixel in the buffer. This is useful when the user is slowly drawing and the `mousemove` will generate draws on the same pixel more than once. Although this helps reduce duplicates, it will not prevent duplicate pixels from existing in the buffer.
109 |
110 | ### 2. More Caching
111 |
112 | Expensive operations such as `getBoundingClientRect()` and `getContext("2d")` have all been cached into the canvas components and can now conditionally update when necessary (via resize/scrolling or re-rendering respectively).
113 |
114 | ### 3. Controlling Renders
115 |
116 | Re-renders have also been further reduced by manually controlling component update conditions via `shouldComponentUpdate()` on all components.
117 |
118 | The qr code generator no longer probes for the `typeNumber` and uses hardcoded `typeNumbers` to reduce runtime. The qr code generator now only updates the qr codes that have been affected by data changes and not all qr codes.
119 |
120 | ## Performance Evaluation
121 |
122 | ### Overall
123 |
124 | Even though the application has been restructured to fit into the React.js framework with optimizations added and dependencies (e.g. jquery) removed, the overall performance (for drawing) of the application turned out worst than the original (in some respects). Let's get down to the specifics. We'll discuss just drawing and event handling since these are two most important. The specific use case we'll be discussing is drawing on pattern continuously without lifting the mouse. The statistical test was done via Chrome's performance profiles at varying performance throttling levels (1x, 4x, 6x).
125 |
126 | ### Pattern Rendering
127 |
128 | [Reference on Painting and Compositing.](https://medium.com/outsystems-experts/how-to-achieve-60-fps-animations-with-css3-db7b98610108)
129 |
130 | Both tools take the same approach to drawing. They only overwrite the color of pixel that's being drawn on instead of fully redrawing the pattern with the changed pixel. However, the React.js port was able to both match and beat out the original tool in terms of painting and compositing at the time it was forked (the first commit on github). The original tool redraws the entire pattern unecessarily several times:
131 |
132 | 1. when the user starts to click
133 | 2. when the user releases click
134 |
135 | Both cases have been entirely removed in this port. Full refreshes are deferred to a scheduler that will sync the pattern with its data equivalent when the user is not trying to draw with the chosen swatch color.
136 |
137 | This port also caches expensive operations such as `DOMelement.getBoundingClientRect()` and `canvas.getContext()`. While jquery uses `offset()`, in the background it is making calls to `getBoudingClientRect()` which causes [layout thrashing](http://wilsonpage.co.uk/preventing-layout-thrashing/). This problem is removed entirely in this port since each canvas's `boundingClientRect` is cached alongside `canvas.getContext()` into the component and updated only when necessary. Both functions are used quite frequently in the drawing process, so caching them slightly improved performance.
138 |
139 | ### Event Handling
140 |
141 | Our port loses out in this race, even with the optimizations. If we were to compare raw event handler functions (`draw` in `Editor.jsx` vs `setColor` in `acnl.js` from the original) performance used for drawing against each other, this version does much, much better than the original (by almost `300%` in fact). How is it possible that we still lose in overall event handling?
142 |
143 | React uses a [synthetic event handler](https://reactjs.org/docs/events.html) instead of native event handlers to account for browser compatibility. The synthetic event handler comes with a lot of overhead, both from being passed around and from deep encapsulation.
144 |
145 | When drawing for `10s` straight, the port's total raw handling time went from `31.0ms` to a total synthetic event handling time of `567.1ms`. The original tool, using jquery to handle events, went from a raw `96.0ms` to `215.1ms`. While we beat the original tool by a wide margin in raw event handling (due to the `pixelBuffer` caching the file operations), we were not able to beat the original tool in overall event handling due to React's synthetic wrapper.
146 |
147 | ### Conclusion
148 |
149 | So what did we learn with all this effort? Don't use React.js when you have to constantly handle events like in a drawing application. It's a bad idea due to the overhead costs associated with the synethetic event wrapper created by React.
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-pattern-tool",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "file-saver": "^2.0.0",
7 | "react": "^16.6.3",
8 | "react-dom": "^16.6.3",
9 | "react-scripts": "2.1.1"
10 | },
11 | "scripts": {
12 | "start": "react-scripts start",
13 | "build": "react-scripts build",
14 | "test": "react-scripts test",
15 | "eject": "react-scripts eject"
16 | },
17 | "eslintConfig": {
18 | "extends": "react-app"
19 | },
20 | "browserslist": [
21 | ">0.2%",
22 | "not dead",
23 | "not ie <= 11",
24 | "not op_mini all"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DamSenViet/react-acnl-pattern-tool/8353cc7efe6c6c6d5e4d8565c290eaf8352bf800/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
22 | React Animal Crossing Pattern Tool
23 |
24 |
25 |
28 |
29 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/screenshots/desktop.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DamSenViet/react-acnl-pattern-tool/8353cc7efe6c6c6d5e4d8565c290eaf8352bf800/screenshots/desktop.png
--------------------------------------------------------------------------------
/src/Editor.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import EditorCanvas from './EditorCanvas.jsx';
3 | import EditorPalette from './EditorPalette.jsx';
4 | import EditorSwatch from './EditorSwatch.jsx';
5 | import EditorMetadata from './EditorMetadata.jsx';
6 | import EditorImporter from './EditorImporter.jsx';
7 | import EditorQrGenerator from './EditorQrGenerator.jsx';
8 | import * as EditorTools from './EditorTools.js';
9 |
10 | // regular js imports
11 | import ACNL from './acnl.js';
12 |
13 | // control center for all editor things
14 | // maintains control for drawing and data
15 | // using async callback patterns (b/c setState is async)
16 | class Editor extends React.Component {
17 | constructor(props) {
18 | super(props);
19 | this.state = {
20 | acnl: new ACNL(),
21 | chosenColor: 0,
22 | chosenTool: new EditorTools.Pen(),
23 | isDrawing: false,
24 | // buffers pixel operations for a SINGLE chosen color
25 | pixelBuffer: [],
26 | pixelRefreshTimer: null,
27 | // keep track of canvases so I can make later calls to their draws
28 | canvases: [
29 | React.createRef(),
30 | React.createRef(),
31 | React.createRef()
32 | ],
33 | shouldQrCodeUpdate: false,
34 | qrRefreshTimer: null,
35 | };
36 | }
37 |
38 | // canvas will need this to draw and drag across and between multiple canvases
39 | setIsDrawing(isDrawing) {
40 | if (isDrawing !== this.state.isDrawing) {
41 | this.setState({
42 | isDrawing: isDrawing,
43 | });
44 | }
45 | }
46 |
47 | selectSwatchColor(newChosenColor) {
48 | // before switching colors, need to empty pixelBuffer
49 | // each pixelBuffer is specific to the chosen color
50 | // gotta make sure pixels in buffer are colored with old color, not new one
51 | this.refreshPixels(() => {
52 | let chosenColor = this.state.chosenColor;
53 | if (chosenColor !== newChosenColor) {
54 | // not setting Qr timer here, because just changing colors
55 | this.setState({
56 | chosenColor: newChosenColor,
57 | shouldQrCodeUpdate: false,
58 | });
59 | }
60 | });
61 | }
62 |
63 | // any time we make modifications to acnl data, need to reset qr timer
64 | selectPaletteColor(newBinColor) {
65 | this.refreshPixels(() => {
66 | let acnl = this.state.acnl.clone();
67 | let chosenColor = this.state.chosenColor;
68 | let chosenBinColor = acnl.swatch[chosenColor];
69 | if (chosenBinColor !== newBinColor) {
70 | acnl.setSwatchColor(chosenColor, newBinColor);
71 | this.setState(
72 | {
73 | acnl: acnl,
74 | shouldQrCodeUpdate: false,
75 | },
76 | () => this.setQrCodeTimer()
77 | );
78 | }
79 | });
80 | }
81 |
82 | // store changes
83 | // need to guarantee a pixel refresh (complete update to ACNL file) sometime
84 | // support for multi-pixel drawing tools e.g. bucket, bigger pen sizes
85 | // by adding specific pixels
86 | updatePixelBuffer(pixelsToAdd) {
87 | this.clearQrCodeTimer();
88 |
89 | // mousemove might be called "too quickly" and add the last pixel twice
90 | // do not handle duplicate pixels in the last pos of the buffer
91 | let pixelBuffer = this.state.pixelBuffer.slice();
92 | let chosenTool = this.state.chosenTool;
93 | if (chosenTool.willUpdatePixelBuffer(pixelsToAdd, pixelBuffer)) {
94 | this.clearPixelRefreshTimer();
95 | let chosenColor = this.state.chosenColor;
96 |
97 | // update context before performing operations
98 | for (let i = 0; i < this.state.canvases.length; ++i) {
99 | this.state.canvases.forEach(ref => {
100 | // KEEP CONTEXT CACHED for full re-render speed
101 | // losing context here, update context right before drawing
102 | // not much time spent updating context anyway
103 | ref.current.updateContext();
104 | });
105 | }
106 |
107 | // add each pixel to the buffer and color it in
108 | for (let i = 0; i < pixelsToAdd.length; ++i) {
109 | let pixel = pixelsToAdd[i];
110 | let x = pixel[0];
111 | let y = pixel[1];
112 | pixelBuffer.push([x, y]);
113 | for (let i = 0; i < this.state.canvases.length; ++i) {
114 | this.state.canvases[i].current.drawPixel(x, y, chosenColor);
115 | }
116 | }
117 |
118 | this.setState(
119 | {
120 | pixelBuffer: pixelBuffer,
121 | shouldQrCodeUpdate: false,
122 | },
123 | () => this.setPixelRefreshTimer()
124 | );
125 | }
126 | }
127 |
128 | // batch apply changes in pixel buffer
129 | refreshPixels(callback) {
130 | // e.g. selectPaletteColor & setSwatchColor, some don't need to set qr timer
131 | // callback needs to manually set qr timer (to prevent duplicate setTimers)
132 | this.clearQrCodeTimer();
133 | let pixelBuffer = this.state.pixelBuffer.slice();
134 | // if there's nothing in the buffer, no need to update
135 | if (pixelBuffer.length === 0) {
136 | if (callback) callback();
137 | return;
138 | }
139 |
140 | let acnl = this.state.acnl.clone();
141 | let chosenColor = this.state.chosenColor;
142 | for (let i = 0; i < pixelBuffer.length; ++i) {
143 | let x = pixelBuffer[i][0];
144 | let y = pixelBuffer[i][1];
145 | acnl.colorPixel(x, y, chosenColor);
146 | }
147 |
148 | // empty pixel buffer and update acnl
149 | this.setState(
150 | {
151 | acnl: acnl,
152 | pixelBuffer: [],
153 | shouldQrCodeUpdate: false,
154 | },
155 | () => {
156 | if (callback) callback();
157 | }
158 | );
159 | }
160 |
161 | clearPixelRefreshTimer() {
162 | // no need to check existence, since this will be called too many times
163 | window.clearTimeout(this.state.pixelRefreshTimer);
164 | }
165 |
166 | setPixelRefreshTimer() {
167 | let pixelRefreshTimer = window.setTimeout(() => {
168 | this.refreshPixels(() => {
169 | this.setQrCodeTimer()
170 | });
171 | }, 500);
172 | this.setState({
173 | pixelRefreshTimer: pixelRefreshTimer
174 | });
175 | }
176 |
177 | refreshQrCode() {
178 | this.setState({
179 | shouldQrCodeUpdate: true,
180 | pixelRefreshTimer: null
181 | });
182 | // console.log("trigger Qr refresh");
183 | }
184 |
185 | clearQrCodeTimer() {
186 | if (this.state.qrRefreshTimer) {
187 | window.clearTimeout(this.state.qrRefreshTimer);
188 | this.setState({
189 | qrRefreshTimer: null,
190 | });
191 | }
192 | }
193 |
194 | setQrCodeTimer() {
195 | let qrRefreshTimer = window.setTimeout(() => {
196 | this.refreshQrCode();
197 | }, 2500);
198 | this.setState({
199 | qrRefreshTimer: qrRefreshTimer,
200 | });
201 | }
202 |
203 |
204 | // deal with metadata
205 |
206 | updatePatternTitle(title) {
207 | this.clearQrCodeTimer();
208 |
209 | let acnl = this.state.acnl.clone();
210 | if (acnl.patternTitle !== title) {
211 | acnl.patternTitle = title;
212 | this.setState(
213 | {
214 | acnl: acnl,
215 | shouldQrCodeUpdate: false,
216 | },
217 | () => this.setQrCodeTimer()
218 | );
219 | }
220 | }
221 |
222 | updateUserName(name) {
223 | this.clearQrCodeTimer();
224 |
225 | let acnl = this.state.acnl.clone();
226 | if (acnl.userName !== name) {
227 | acnl.userName = name;
228 | this.setState(
229 | {
230 | acnl: acnl,
231 | shouldQrCodeUpdate: false,
232 | },
233 | () => this.setQrCodeTimer()
234 | );
235 | }
236 | }
237 |
238 | updateUserID(id) {
239 | this.clearQrCodeTimer();
240 |
241 | let acnl = this.state.acnl.clone();
242 | if (acnl.userID !== id) {
243 | acnl.userID = id;
244 | this.setState(
245 | {
246 | acnl: acnl,
247 | shouldQrCodeUpdate: false,
248 | },
249 | () => this.setQrCodeTimer()
250 | );
251 | }
252 | }
253 |
254 | updateTownName(name) {
255 | this.clearQrCodeTimer();
256 |
257 | let acnl = this.state.acnl.clone();
258 | if (acnl.townName !== name) {
259 | acnl.townName = name;
260 | this.setState(
261 | {
262 | acnl: acnl,
263 | shouldQrCodeUpdate: false,
264 | },
265 | () => this.setQrCodeTimer()
266 | );
267 | }
268 | }
269 |
270 | updateTownID(id) {
271 | this.clearQrCodeTimer();
272 |
273 | let acnl = this.state.acnl.clone();
274 | if (acnl.townID !== id) {
275 | acnl.townID = id;
276 | this.setState(
277 | {
278 | acnl: acnl,
279 | pixelRefreshTimer: null,
280 | shouldQrCodeUpdate: false,
281 | },
282 | () => this.setQrCodeTimer()
283 | );
284 | }
285 | }
286 |
287 | // replace entire acnl and state
288 | import(acnlData) {
289 | this.clearPixelRefreshTimer();
290 | this.clearQrCodeTimer();
291 | this.setState(
292 | {
293 | acnl: new ACNL(acnlData),
294 | chosenColor: 0,
295 | isDrawing: false,
296 | pixelBuffer: [],
297 | pixelRefreshTimer: null,
298 | shouldQrCodeUpdate: false,
299 | qrRefreshTimer: null,
300 | },
301 | () => this.setQrCodeTimer()
302 | );
303 | }
304 |
305 |
306 | // perform import and convert according to setting
307 | // we are just replacing the swatch, pattern data
308 | convert(imgData, convSet) {
309 | this.clearPixelRefreshTimer();
310 | this.clearQrCodeTimer();
311 |
312 | let acnl = this.state.acnl.clone();
313 | // turn into a standard pattern
314 | acnl.toStandardPattern();
315 |
316 | // select the palette
317 | if (convSet === "top") this.usePaletteTop(acnl, imgData);
318 | else if (convSet === "lowest") this.usePaletteLowest(acnl, imgData);
319 | else if (convSet === "grey") this.usePaletteGrey(acnl);
320 | else if (convSet === "sepia") this.usePaletteSepia(acnl);
321 |
322 | this.drawImage(acnl, imgData);
323 |
324 | this.setState(
325 | {
326 | acnl: acnl,
327 | chosenColor: 0,
328 | isDrawing: false,
329 | pixelBuffer: [],
330 | pixelRefreshTimer: null,
331 | shouldQrCodeUpdate: false,
332 | qrRefreshTimer: null,
333 | },
334 | () => this.setQrCodeTimer()
335 | );
336 | }
337 |
338 | /* CONVERT HELPER START */
339 | // pick palette from 15 most used, inaccurate
340 | usePaletteTop(acnl, imgData) {
341 | let palette = [];
342 | for (let i = 0; i < 256; ++i) {
343 | palette.push({
344 | binColor: i,
345 | score: 0,
346 | });
347 | }
348 |
349 | let scorePaletteColor = (r, g, b) => {
350 | let best = 120;
351 | let bestPaletteColor = 0;
352 | for (let i = 0; i < 256; i++) {
353 | let toMatch = ACNL.paletteBinToHex[i];
354 | if (toMatch === undefined) continue;
355 | let x = parseInt(toMatch.substr(1, 2), 16);
356 | let y = parseInt(toMatch.substr(3, 2), 16);
357 | let z = parseInt(toMatch.substr(5, 2), 16);
358 | let matchDegree = Math.abs(x - r) + Math.abs(y - g) + Math.abs(z - b);
359 | if (matchDegree < best) {
360 | best = matchDegree;
361 | bestPaletteColor = i;
362 | }
363 | }
364 | // increment score for its occurence
365 | palette[bestPaletteColor].score++;
366 | }
367 |
368 | // accumulate scores
369 | for (let i = 0; i < 4096; i += 4) {
370 | scorePaletteColor(
371 | imgData.data[i],
372 | imgData.data[i + 1],
373 | imgData.data[i + 2]
374 | );
375 | }
376 |
377 | // sort by palette occurences, decreasing order
378 | palette.sort((a, b) => {
379 | if (a.score > b.score) return -1;
380 | if (a.score < b.score) return 1;
381 | return 0;
382 | });
383 |
384 | // rebinding, cut the palette, leaving only binColors
385 | let swatchBinColors = palette.slice(0, 15)
386 | .map((palObj) => palObj.binColor);
387 |
388 | for (let i = 0; i < swatchBinColors.length; ++i) {
389 | acnl.setSwatchColor(i, swatchBinColors[i]);
390 | }
391 | }
392 |
393 | // choosing palette from top 40 colors, accurate
394 | usePaletteLowest(acnl, imgData) {
395 | let palette = [];
396 | let prepixels = [];
397 | for (let i = 0; i < 256; ++i) {
398 | palette.push({
399 | binColor: i,
400 | score: 0,
401 | });
402 | }
403 |
404 | let scorePalette = (pixel, r, g, b) => {
405 | let matches = {};
406 | let best = 120;
407 | let bestPaletteColor = 0;
408 | for (let i = 0; i < 256; ++i) {
409 | let toMatch = ACNL.paletteBinToHex[i];
410 | if (toMatch === undefined) continue;
411 | let x = parseInt(toMatch.substr(1, 2), 16);
412 | let y = parseInt(toMatch.substr(3, 2), 16);
413 | let z = parseInt(toMatch.substr(5, 2), 16);
414 | let matchDegree = Math.abs(x - r) + Math.abs(y - g) + Math.abs(z - b);
415 | if (matchDegree < best) {
416 | best = matchDegree;
417 | bestPaletteColor = i;
418 | }
419 | if (matchDegree < 120) {
420 | matches[i.toString()] = matchDegree;
421 | }
422 | }
423 | palette[bestPaletteColor].score++;
424 | prepixels[pixel] = matches;
425 | }
426 |
427 | for (let i = 0; i < 4096; i += 4) {
428 | scorePalette(
429 | i / 4,
430 | imgData.data[i],
431 | imgData.data[i + 1],
432 | imgData.data[i + 2]
433 | );
434 | }
435 |
436 | // sort by to decreasing score
437 | palette.sort((a, b) => {
438 | if (a.score > b.score) return -1;
439 | if (a.score < b.score) return 1;
440 | return 0;
441 | });
442 |
443 | palette = palette.slice(0, 40);
444 | let bestBinColors = [];
445 | let bestScore = 0x200000; // can always do better than this
446 |
447 | // not using alert here, prevent alert from blocking thread
448 | console.log("optimizing color palette...");
449 | for (let i = 0; i < 4000 && palette.length > 16; ++i) {
450 | let chosenBinColors = [];
451 |
452 | // pick random colors out of top 40
453 | while (chosenBinColors.length < 15 && chosenBinColors < palette.length) {
454 | let next = palette[Math.floor(Math.random() * palette.length)].binColor;
455 | if (chosenBinColors.includes(next)) continue;
456 | chosenBinColors.push(next);
457 | }
458 |
459 | // score random selection
460 | let currentScore = 0;
461 |
462 | // stop at first score that meets the criteria
463 | for (let pixel in prepixels) {
464 | let lowPixel = 750;
465 | for (let m in prepixels[pixel]) {
466 | if (!chosenBinColors.includes(parseInt(m))) continue;
467 | if (prepixels[pixel][m] < lowPixel) {
468 | lowPixel = prepixels[pixel][m];
469 | }
470 | }
471 | currentScore += lowPixel;
472 | if (currentScore >= bestScore) break;
473 | }
474 |
475 | if (currentScore < bestScore) {
476 | bestScore = currentScore;
477 | bestBinColors = chosenBinColors;
478 | }
479 | }
480 |
481 | for (let i = 0; i < 15 && i < bestBinColors.length; ++i) {
482 | acnl.setSwatchColor(i, bestBinColors[i]);
483 | }
484 | }
485 |
486 | usePaletteGrey(acnl) {
487 | for (let i = 0; i < 15; i++) {
488 | acnl.setSwatchColor(i, 0x10 * i + 0xF);
489 | }
490 | }
491 |
492 | usePaletteSepia(acnl) {
493 | for (let i = 0; i < 9; i++) {
494 | acnl.setSwatchColor(i, 0x30 + i);
495 | }
496 | for (let i = 9; i < 15; i++) {
497 | acnl.setSwatchColor(i, 0x60 + i - 6);
498 | }
499 | }
500 |
501 |
502 | // AKA previously "recolorize"
503 | // draw image onto pattern based on palette
504 | drawImage(acnl, imgData) {
505 | // for a given rgb color, find the closest matching color in the swatch
506 | let matchedSwatchColor = (r, g, b) => {
507 | let best = 255 * 3;
508 | let bestSwatchColor = 0;
509 | for (let i = 0; i < 15; ++i) {
510 | let swatchColor = acnl.getSwatchColor(i);
511 | let toMatch = ACNL.paletteBinToHex[swatchColor];
512 | let x = parseInt(toMatch.substr(1, 2), 16);
513 | let y = parseInt(toMatch.substr(3, 2), 16);
514 | let z = parseInt(toMatch.substr(5, 2), 16);
515 | let matchDegree = Math.abs(x - r) + Math.abs(y - g) + Math.abs(z - b);
516 | if (matchDegree < best) {
517 | best = matchDegree;
518 | bestSwatchColor = i;
519 | }
520 | }
521 | return bestSwatchColor;
522 | }
523 |
524 | for (let i = 0; i < 4096; i += 4) {
525 | let x = Math.floor(i / 4) % 32;
526 | let y = Math.floor(Math.floor(i / 4) / 32);
527 | acnl.colorPixel(x, y, matchedSwatchColor(
528 | imgData.data[i],
529 | imgData.data[i + 1],
530 | imgData.data[i + 2]
531 | ));
532 | }
533 | }
534 | /* CONVERT HELPER END */
535 |
536 | shouldComponentUpdate(nextProps, nextState) {
537 | // only render after refreshing pixels
538 | if (nextState.pixelBuffer.length === 0) return true;
539 | else return false;
540 | }
541 |
542 | render() {
543 | let acnl = this.state.acnl;
544 | let chosenColor = this.state.chosenColor;
545 | let chosenTool = this.state.chosenTool;
546 | let isDrawing = this.state.isDrawing;
547 | let canvases = this.state.canvases;
548 | let canvasSizes = [64, 128, 512];
549 | // perform actualZoom calculations
550 | let actualZooms = canvasSizes.map((size) => {
551 | if (acnl.isProPattern()) return size / 64;
552 | else return size / 32;
553 | });
554 | let shouldQrCodeUpdate = this.state.shouldQrCodeUpdate;
555 |
556 |
557 | return (
558 |
559 |
560 |
574 |
575 |
589 |
590 |
591 |
592 |
606 |
607 |
608 |
612 |
613 |
618 |
619 |
620 |
621 |
634 |
635 |
639 |
640 |
645 |
646 | );
647 | }
648 | }
649 |
650 | export default Editor;
651 |
--------------------------------------------------------------------------------
/src/EditorCanvas.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ACNL from './acnl.js';
3 |
4 | // this component will attempt to supress updates to minimize full re-renders
5 | class EditorCanvas extends React.Component {
6 | constructor(props) {
7 | super(props);
8 | this.canvas = React.createRef();
9 |
10 | // not using state for these since these are technically static
11 | // to be updated as the DOM model updates/moves
12 | // cannot afford to be asynchronous, since these need to always be current
13 | // e.g. boundingClientRect or context
14 | // caching rect to prevent reflows and save cpu
15 | // also cache context for speed on full-redraws
16 | this.boundingClientRect = null;
17 | this.context = null;
18 | }
19 |
20 | updateContext() {
21 | let context = this.canvas.current.getContext("2d");
22 | this.context = context;
23 | }
24 |
25 | updateBoundingClientRect(){
26 | let boundingClientRect = this.canvas.current.getBoundingClientRect();
27 | this.boundingClientRect = boundingClientRect;
28 | }
29 |
30 | draw(event) {
31 | let actualZoom = this.props.actualZoom;
32 | let boundingClientRect = this.boundingClientRect;
33 | let x = event.pageX - boundingClientRect.left - window.scrollX;
34 | let y = event.pageY - boundingClientRect.top - window.scrollY;
35 |
36 | x = Math.floor(x / actualZoom);
37 | y = Math.floor(y / actualZoom);
38 |
39 | // browser will attempt to dump mousemove event before it completes
40 | // if handler is not fast enough, need to ensure speed, using buffers
41 | // updatePixelBuffer will command all canvases to draw the pixels too
42 | // console.log(x, y);
43 | let pattern = this.props.pattern;
44 | let chosenTool = this.props.chosenTool;
45 | this.props.updatePixelBuffer(chosenTool.transform(x, y, pattern));
46 | }
47 |
48 |
49 | // occurs as the last event in a click-n-drag, if it completes
50 | // refresh, and kill timers to force refresh
51 | onClick(event) {
52 | // console.log("mouse click");
53 | this.draw(event);
54 | }
55 |
56 | onMouseDown(event) {
57 | this.props.setIsDrawing(true);
58 | // console.log("started drawing");
59 | }
60 |
61 | onMouseMove(event) {
62 | let isDrawing = this.props.isDrawing;
63 | if (isDrawing && event.buttons === 1) {
64 | this.draw(event);
65 | }
66 | }
67 |
68 | // WILL NOT TRIGGER IF MOUSEUP OUTSIDE OF CANVAS ELEMENTS
69 | // timer exists in the editor to force refresh
70 | // mouseup triggers before mouseclick
71 | onMouseUp(event) {
72 | // console.log("mouse up");
73 | this.props.setIsDrawing(false);
74 | }
75 |
76 | drawPatterns() {
77 | // adjust zoom factor for pattern size
78 | let patterns = this.props.patterns;
79 | for (let i = 0; i < patterns.length; ++i) {
80 | let pixelPair = patterns.charCodeAt(i);
81 | // get pixel binColors
82 | let firstColor = pixelPair & 0x0F;
83 | let secondColor = pixelPair >> 4;
84 | this.drawOffset(i * 2, firstColor);
85 | this.drawOffset(i * 2 + 1, secondColor);
86 | }
87 | }
88 |
89 | drawOffset(offset, chosenColor) {
90 | let x = (offset % 32);
91 | let y = Math.floor(offset / 32);
92 | this.drawPixel(x, y, chosenColor);
93 | }
94 |
95 | drawPixel(x, y, chosenColor) {
96 | let context = this.context;
97 | let zoom = this.props.actualZoom;
98 |
99 | if (y > 63) {
100 | y-= 64; x+= 32;
101 | }
102 |
103 | context.fillStyle = ACNL.paletteBinToHex[this.props.swatch[chosenColor]];
104 | context.fillRect(x * zoom, y * zoom, zoom, zoom);
105 | // draw the grid lines if zoom is large enough
106 | if (zoom > 5) {
107 | context.fillStyle = "#AAAAAA";
108 | context.fillRect(x * zoom + zoom - 1, y * zoom, 1, zoom);
109 | context.fillRect(x * zoom, y * zoom + zoom - 1, zoom, 1);
110 | }
111 | }
112 |
113 | // can't call draw inside render b/c on the first render
114 | // there's no reference to the actual node
115 | // create reference
116 | componentDidMount() {
117 | this.updateContext();
118 | this.updateBoundingClientRect();
119 | this.drawPatterns();
120 | // attaching event handlers
121 | window.addEventListener("scroll", this.updateBoundingClientRect.bind(this));
122 | window.addEventListener("resize", this.updateBoundingClientRect.bind(this));
123 | }
124 |
125 | // only fully re-render when pattern is updated or swatch colors change
126 | // only occurs after editor applies refresh changes
127 | shouldComponentUpdate(nextProps, nextState) {
128 | if (this.props.patterns !== nextProps.patterns) return true;
129 |
130 | // manually check swatch b/c of object instance comparison
131 | // if swatch has changed, colors have changed
132 | // doesn't work for some reason, throwing error for context
133 | for (let i = 0; i < 15; ++i) {
134 | if (this.props.swatch[i] !== nextProps.swatch[i]) return true;
135 | }
136 |
137 | return false;
138 | }
139 |
140 | componentDidUpdate(prevProps, prevState, snapshot) {
141 | // upon re-rendering, update the context and bounds since technically new
142 | // canvas, redraw patterns too
143 | this.updateContext();
144 | this.updateBoundingClientRect();
145 | this.drawPatterns();
146 | }
147 |
148 | componentWillUnmount() {
149 | window.removeEventListener("scroll", this.updateBoundingClientRect.bind(this));
150 | window.removeEventListener("resize", this.updateBoundingClientRect.bind(this));
151 | }
152 |
153 | render() {
154 | // console.log("rendered canvas");
155 | let size = this.props.size;
156 | let canvasNumber = this.props.canvasNumber;
157 |
158 | let className = "canvas";
159 | if (canvasNumber === 0) void(0);
160 | else if (canvasNumber === 1) className += "-zoom";
161 | else if (canvasNumber === 2) className += "-zoomier";
162 |
163 | return (
164 |
175 | );
176 | }
177 | }
178 |
179 | export default EditorCanvas;
180 |
--------------------------------------------------------------------------------
/src/EditorImporter.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import qrcode from "./jsqrcode.js";
3 |
4 | // handles all imports for the tool
5 | // will do both qr detection and image conversion
6 | class EditorImporter extends React.Component {
7 | constructor(props) {
8 | super(props);
9 | // reads qr code from image or loads .acnl file
10 | this.loader = React.createRef();
11 | // converts image to qr code
12 | this.converter = React.createRef();
13 | this.converterSetting = React.createRef();
14 |
15 | this.state = {
16 | loaderPart: 1,
17 | loaderData : "",
18 | }
19 | }
20 |
21 | onLoad() {
22 | let fileReader = new FileReader();
23 | // image of some kind (find the qr code in the image)
24 | if (/image./.test(this.loader.current.files[0].type)) {
25 | fileReader.onload = (event) => {
26 | qrcode.callback = (qrData) => {
27 | if (qrData.length < 0x21C) {
28 | window.alert(`Could not recognize QR code.\nQR Code too short: ${qrData.length}`);
29 | }
30 |
31 | // part of a pro pattern
32 | else if (qrData.length === 0x21C) {
33 | let loaderPart = this.state.loaderPart;
34 | let loaderData = this.state.loaderData.slice();
35 | loaderData += qrData;
36 |
37 | // last one, load it
38 | if (loaderPart === 4) {
39 | this.props.import(loaderData);
40 |
41 | // technically don't even need this since import will cause
42 | // entire Editor to rerender
43 | this.setState({
44 | loaderPart: 1,
45 | loaderData: "",
46 | });
47 | }
48 |
49 | else {
50 | loaderPart += 1;
51 | this.setState({
52 | loaderPart: loaderPart,
53 | loaderData: loaderData,
54 | });
55 |
56 | // can't trigger click for next upload
57 | // chrome is blocking the qr code
58 | // tell user about next qr code
59 | window.alert(`Please add in the next QR code ${loaderPart}/4`);
60 | }
61 | }
62 |
63 | // regular pattern
64 | else if (qrData.length === 0x26C) {
65 | this.props.import(qrData);
66 | }
67 |
68 | };
69 | qrcode.decode(event.target.result);
70 | }
71 | fileReader.readAsDataURL(this.loader.current.files[0]);
72 | }
73 |
74 | // acnl file type (already checks for valid extension and file name)
75 | // doesn't interfere with qr loading if you're interleaving import methods
76 | else if (/.\.acnl$/.test(this.loader.current.files[0].name)) {
77 | fileReader.onload = (event) => {
78 | this.props.import(event.target.result);
79 | };
80 | fileReader.readAsBinaryString(this.loader.current.files[0]);
81 | }
82 |
83 | // invalid file type
84 | else {
85 | window.alert("Chosen file was not valid.");
86 | // do nothing
87 | }
88 |
89 | // reset input so filename isn't logged into the input forever
90 | this.loader.current.value = "";
91 | }
92 |
93 | onConvert() {
94 | // only if import is an image
95 | if (/image./.test(this.converter.current.files[0].type)) {
96 | let fileReader = new FileReader();
97 | fileReader.onload = (event) => {
98 | let img = new Image();
99 | img.onload = () => {
100 | // using canvas to convert
101 | let canvasEle = document.createElement('canvas');
102 | canvasEle.width = 32;
103 | canvasEle.height = 32;
104 |
105 | let context = canvasEle.getContext("2d");
106 |
107 | // canvas will automatically scale image down for us to desired pixel grid
108 | context.drawImage(img, 0, 0, 32, 32);
109 |
110 | let imgData = context.getImageData(0, 0, 32, 32);
111 |
112 | let convSet = this.converterSetting.current.value;
113 |
114 | // determine conversion method, pass it up
115 | this.props.convert(imgData, convSet);
116 | };
117 | img.src = event.target.result;
118 | };
119 | fileReader.readAsDataURL(this.converter.current.files[0]);
120 | }
121 | // reset input
122 | this.converter.current.value = "";
123 | }
124 |
125 | shouldComponentUpdate() {
126 | // no need to update
127 | return false;
128 | }
129 |
130 | render() {
131 | return (
132 |