sendou.ink/app/components/layout/DrawingSection.tsx
2022-06-10 01:02:59 +03:00

387 lines
9.6 KiB
TypeScript

import clsx from "clsx";
import randomColor from "randomcolor";
import { useState } from "react";
import { atOrError } from "~/utils/arrays";
interface HSL {
h: number;
s: number;
l: number;
}
class Color {
public r: number;
public g: number;
public b: number;
constructor(r: number, g: number, b: number) {
this.r = this.clamp(r);
this.g = this.clamp(g);
this.b = this.clamp(b);
}
toString() {
return `rgb(${Math.round(this.r)}, ${Math.round(this.g)}, ${Math.round(
this.b
)})`;
}
set(r: number, g: number, b: number) {
this.r = this.clamp(r);
this.g = this.clamp(g);
this.b = this.clamp(b);
}
hueRotate(angle = 0) {
angle = (angle / 180) * Math.PI;
const sin = Math.sin(angle);
const cos = Math.cos(angle);
this.multiply([
0.213 + cos * 0.787 - sin * 0.213,
0.715 - cos * 0.715 - sin * 0.715,
0.072 - cos * 0.072 + sin * 0.928,
0.213 - cos * 0.213 + sin * 0.143,
0.715 + cos * 0.285 + sin * 0.14,
0.072 - cos * 0.072 - sin * 0.283,
0.213 - cos * 0.213 - sin * 0.787,
0.715 - cos * 0.715 + sin * 0.715,
0.072 + cos * 0.928 + sin * 0.072,
]);
}
grayscale(value = 1) {
this.multiply([
0.2126 + 0.7874 * (1 - value),
0.7152 - 0.7152 * (1 - value),
0.0722 - 0.0722 * (1 - value),
0.2126 - 0.2126 * (1 - value),
0.7152 + 0.2848 * (1 - value),
0.0722 - 0.0722 * (1 - value),
0.2126 - 0.2126 * (1 - value),
0.7152 - 0.7152 * (1 - value),
0.0722 + 0.9278 * (1 - value),
]);
}
sepia(value = 1) {
this.multiply([
0.393 + 0.607 * (1 - value),
0.769 - 0.769 * (1 - value),
0.189 - 0.189 * (1 - value),
0.349 - 0.349 * (1 - value),
0.686 + 0.314 * (1 - value),
0.168 - 0.168 * (1 - value),
0.272 - 0.272 * (1 - value),
0.534 - 0.534 * (1 - value),
0.131 + 0.869 * (1 - value),
]);
}
saturate(value = 1) {
this.multiply([
0.213 + 0.787 * value,
0.715 - 0.715 * value,
0.072 - 0.072 * value,
0.213 - 0.213 * value,
0.715 + 0.285 * value,
0.072 - 0.072 * value,
0.213 - 0.213 * value,
0.715 - 0.715 * value,
0.072 + 0.928 * value,
]);
}
multiply(matrix: number[]) {
const newR = this.clamp(
this.r * atOrError(matrix, 0) +
this.g * atOrError(matrix, 1) +
this.b * atOrError(matrix, 2)
);
const newG = this.clamp(
this.r * atOrError(matrix, 3) +
this.g * atOrError(matrix, 4) +
this.b * atOrError(matrix, 5)
);
const newB = this.clamp(
this.r * atOrError(matrix, 6) +
this.g * atOrError(matrix, 7) +
this.b * atOrError(matrix, 8)
);
this.r = newR;
this.g = newG;
this.b = newB;
}
brightness(value = 1) {
this.linear(value);
}
contrast(value = 1) {
this.linear(value, -(0.5 * value) + 0.5);
}
linear(slope = 1, intercept = 0) {
this.r = this.clamp(this.r * slope + intercept * 255);
this.g = this.clamp(this.g * slope + intercept * 255);
this.b = this.clamp(this.b * slope + intercept * 255);
}
invert(value = 1) {
this.r = this.clamp((value + (this.r / 255) * (1 - 2 * value)) * 255);
this.g = this.clamp((value + (this.g / 255) * (1 - 2 * value)) * 255);
this.b = this.clamp((value + (this.b / 255) * (1 - 2 * value)) * 255);
}
hsl(): HSL {
// Code taken from https://stackoverflow.com/a/9493060/2688027, licensed under CC BY-SA.
const r = this.r / 255;
const g = this.g / 255;
const b = this.b / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
}
h /= 6;
}
return {
h: h * 100,
s: s * 100,
l: l * 100,
};
}
clamp(value: number): number {
if (value > 255) {
value = 255;
} else if (value < 0) {
value = 0;
}
return value;
}
}
interface Solution {
loss: number;
values: number[];
}
class Solver {
private target: Color;
private targetHSL: HSL;
private reusedColor: Color;
constructor(target: Color) {
this.target = target;
this.targetHSL = target.hsl();
this.reusedColor = new Color(0, 0, 0);
}
solve() {
const result = this.solveNarrow(this.solveWide());
return {
values: result.values,
loss: result.loss,
filter: this.css(result.values),
};
}
solveWide(): Solution {
const A = 5;
const c = 15;
const a = [60, 180, 18000, 600, 1.2, 1.2];
let best = { loss: Infinity, values: [] as number[] };
for (let i = 0; best.loss > 25 && i < 3; i++) {
const initial = [50, 20, 3750, 50, 100, 100];
const result = this.spsa(A, a, c, initial, 1000);
if (result.loss < best.loss) {
best = result;
}
}
return best;
}
solveNarrow(wide: Solution) {
const A = wide.loss;
const c = 2;
const A1 = A + 1;
const a = [0.25 * A1, 0.25 * A1, A1, 0.25 * A1, 0.2 * A1, 0.2 * A1];
return this.spsa(A, a, c, wide.values, 500);
}
spsa(
A: number,
a: number[],
c: number,
values: number[],
iters: number
): Solution {
const alpha = 1;
const gamma = 0.16666666666666666;
let best = [] as number[];
let bestLoss = Infinity;
const deltas = new Array(6);
const highArgs = new Array<number>(6);
const lowArgs = new Array<number>(6);
for (let k = 0; k < iters; k++) {
const ck = c / Math.pow(k + 1, gamma);
for (let i = 0; i < 6; i++) {
deltas[i] = Math.random() > 0.5 ? 1 : -1;
highArgs[i] = atOrError(values, i) + ck * deltas[i];
lowArgs[i] = atOrError(values, i) - ck * deltas[i];
}
const lossDiff = this.loss(highArgs) - this.loss(lowArgs);
for (let i = 0; i < 6; i++) {
const g = (lossDiff / (2 * ck)) * deltas[i];
const ak = atOrError(a, i) / Math.pow(A + k + 1, alpha);
values[i] = fix(atOrError(values, i) - ak * g, i);
}
const loss = this.loss(values);
if (loss < bestLoss) {
best = values.slice(0);
bestLoss = loss;
}
}
return { values: best, loss: bestLoss };
function fix(value: number, idx: number): number {
let max = 100;
if (idx === 2 /* saturate */) {
max = 7500;
} else if (idx === 4 /* brightness */ || idx === 5 /* contrast */) {
max = 200;
}
if (idx === 3 /* hue-rotate */) {
if (value > max) {
value %= max;
} else if (value < 0) {
value = max + (value % max);
}
} else if (value < 0) {
value = 0;
} else if (value > max) {
value = max;
}
return value;
}
}
loss(filters: number[]) {
// Argument is array of percentages.
const color = this.reusedColor;
color.set(0, 0, 0);
color.invert(atOrError(filters, 0) / 100);
color.sepia(atOrError(filters, 1) / 100);
color.saturate(atOrError(filters, 2) / 100);
color.hueRotate(atOrError(filters, 3) * 3.6);
color.brightness(atOrError(filters, 4) / 100);
color.contrast(atOrError(filters, 5) / 100);
const colorHSL = color.hsl();
return (
Math.abs(color.r - this.target.r) +
Math.abs(color.g - this.target.g) +
Math.abs(color.b - this.target.b) +
Math.abs(colorHSL.h - this.targetHSL.h) +
Math.abs(colorHSL.s - this.targetHSL.s) +
Math.abs(colorHSL.l - this.targetHSL.l)
);
}
css(filters: number[]) {
function fmt(idx: number, multiplier = 1) {
return Math.round(atOrError(filters, idx) * multiplier);
}
return `invert(${fmt(0)}%) sepia(${fmt(1)}%) saturate(${fmt(
2
)}%) hue-rotate(${fmt(3, 3.6)}deg) brightness(${fmt(4)}%) contrast(${fmt(
5
)}%)`;
}
}
type RGB = [number, number, number];
function hexToRgb(hex: string): RGB {
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
hex = hex.replace(shorthandRegex, (_m, r: string, g: string, b: string) => {
return r + r + g + g + b + b;
});
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (result) {
return [
parseInt(atOrError(result, 1), 16),
parseInt(atOrError(result, 2), 16),
parseInt(atOrError(result, 3), 16),
];
}
throw new Error("Error parsing hex: " + hex);
}
function getFilters(hex: string) {
let rgb = [255, 255, 255];
rgb = hexToRgb(hex);
const color = new Color(
atOrError(rgb, 0),
atOrError(rgb, 1),
atOrError(rgb, 2)
);
const solver = new Solver(color);
const result = solver.solve();
return result.filter;
}
export function DrawingSection({ type }: { type: "girl" | "boy" }) {
const [hexCode, setHexCode] = useState(randomColor());
const handleColorChange = () => setHexCode(randomColor());
return (
<div
className={clsx("menu__img-container", type)}
onClick={handleColorChange}
onMouseEnter={handleColorChange}
>
<img
className={clsx("menu__img", type)}
src={`/img/layout/new_${type}_dark.png`}
alt=""
/>
<img
className={clsx("menu__img-bg", type)}
src={`/img/layout/new_${type}_bg.png`}
style={{ filter: getFilters(hexCode) }}
alt=""
/>
</div>
);
}
export default DrawingSection;