diff --git a/package.json b/package.json index f50823a..9ff8cf6 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest" }, "devDependencies": { "@radix-ui/colors": "2.1.0", diff --git a/src/color/color-chromajs.ts b/src/color/color-chromajs.ts index 2218e54..9c02ba2 100644 --- a/src/color/color-chromajs.ts +++ b/src/color/color-chromajs.ts @@ -1,14 +1,28 @@ import chroma from "chroma-js"; -import { BasePalette, Palette, RGB } from "."; +import { BasePalette, Palette, RGB, clampedPaletteParams } from "."; +import { safeParseInt } from "@/math"; -class ChromaJsPalette extends BasePalette { - colorConstructor: (string | chroma.Color)[]; +export class ChromaJsPalette extends BasePalette { + colorConstructor: string[]; colors: chroma.Color[] = []; - constructor(colorConstructor: (string | chroma.Color)[], length: number) { - super(length); + constructor( + colorConstructor: string[], + length: number, + mirrored = true, + offset = 0, + ) { + const { colorLength, offsetIndex } = clampedPaletteParams(length, offset); - this.colorConstructor = colorConstructor; + super(colorLength, mirrored, offsetIndex); + + if (colorConstructor.length === 0) { + this.colorConstructor = ["black", "white"]; + } else if (colorConstructor.length > 16) { + this.colorConstructor = colorConstructor.slice(0, 16); + } else { + this.colorConstructor = colorConstructor; + } this.buildColors(); } @@ -22,6 +36,33 @@ class ChromaJsPalette extends BasePalette { .scale(this.colorConstructor) .colors(this.colorLength, null); } + + serialize(): string { + const result = ["chroma-js"]; + result.push(`${this.colorConstructor.length}`); + result.push(...this.colorConstructor); + result.push(`${this.mirrored ? 1 : 0}`); + result.push(`${this.colorLength}`); + result.push(`${this.offsetIndex}`); + + return result.join(","); + } + + static deserialize(serialized: string): ChromaJsPalette { + const parts = serialized.split(","); + const colorNum = safeParseInt(parts[1], 0); + + const colorConstructor = parts.slice(2, colorNum + 2); + const mirrored = parts[2 + colorNum] === "1"; + const colorLength = safeParseInt(parts[2 + colorNum + 1], 16); + const offset = safeParseInt(parts[2 + colorNum + 2], 0); + + return new ChromaJsPalette(colorConstructor, colorLength, mirrored, offset); + } + + static defaultPalette(): ChromaJsPalette { + return new ChromaJsPalette(["lightblue", "navy", "white"], 128); + } } export const chromaJsPalettes = [ diff --git a/src/color/color-d3-chromatic.ts b/src/color/color-d3-chromatic.ts index a23f92e..78c7929 100644 --- a/src/color/color-d3-chromatic.ts +++ b/src/color/color-d3-chromatic.ts @@ -1,4 +1,4 @@ -import { BasePalette, Palette, RGB, buildRGB } from "."; +import { BasePalette, Palette, RGB, buildRGB, clampedPaletteParams } from "."; import { samples } from "culori"; import { interpolateInferno, @@ -7,16 +7,53 @@ import { interpolateTurbo, } from "d3-scale-chromatic"; import { color } from "d3-color"; +import { safeParseInt } from "@/math"; type D3Interpolator = (t: number) => string; type D3Color = ReturnType; -class D3ChromaticPalette extends BasePalette { +const getInterpolatorFromName = (name: string): D3Interpolator => { + switch (name) { + case "Inferno": + return interpolateInferno; + case "RdYlBlu": + return interpolateRdYlBu; + case "Turbo": + return interpolateTurbo; + case "Sinebow": + return interpolateSinebow; + default: + return interpolateRdYlBu; + } +}; + +const getInterpolatorName = (interpolator: D3Interpolator): string => { + if (interpolator === interpolateInferno) { + return "Inferno"; + } else if (interpolator === interpolateRdYlBu) { + return "RdYlBlu"; + } else if (interpolator === interpolateTurbo) { + return "Turbo"; + } else if (interpolator === interpolateSinebow) { + return "Sinebow"; + } else { + return "RdYlBlu"; + } +}; + +export class D3ChromaticPalette extends BasePalette { interpolator: D3Interpolator; colors: D3Color[] = []; - constructor(interpolator: D3Interpolator, length: number) { - super(length); + constructor( + interpolator: D3Interpolator, + length: number, + mirrored = true, + offset = 0, + ) { + const { colorLength, offsetIndex } = clampedPaletteParams(length, offset); + + super(colorLength, mirrored, offsetIndex); this.interpolator = interpolator; @@ -32,6 +69,29 @@ class D3ChromaticPalette extends BasePalette { .map((t) => color(this.interpolator(t))) .filter((v): v is NonNullable => v != null); } + + serialize(): string { + const result = ["d3-chromatic"]; + result.push(getInterpolatorName(this.interpolator)); + result.push(this.mirrored ? "1" : "0"); + result.push(`${this.colorLength}`); + result.push(`${this.offsetIndex}`); + + return result.join(","); + } + + static deserialize(serialized: string): D3ChromaticPalette { + const [, rawInterpolate, rawMirrored, rawLength, rawOffset] = + serialized.split(","); + + const length = safeParseInt(rawLength); + const offset = safeParseInt(rawOffset); + const mirrored = rawMirrored === "1"; + + const interpolator = getInterpolatorFromName(rawInterpolate); + + return new D3ChromaticPalette(interpolator, length, mirrored, offset); + } } export const d3ChromaticPalettes = [ diff --git a/src/color/color-others.ts b/src/color/color-others.ts new file mode 100644 index 0000000..24b6dec --- /dev/null +++ b/src/color/color-others.ts @@ -0,0 +1,103 @@ +import { Hsv, convertHsvToRgb, samples } from "culori"; +import { + BasePalette, + Palette, + RGB, + buildRGB32Byte, + clampedPaletteParams, +} from "."; +import { safeParseInt } from "@/math"; + +type OthersInterpolator = (t: number) => Hsv; + +const interpolators: Record = { + hue360: (t) => { + // hue 0~360 + const hue = Math.floor(t * 360); + return { mode: "hsv", h: hue, s: 0.75, v: 1 }; + }, + monochrome: (t) => { + // monochrome + const brightness = t * 0.8 + 0.2; + return { mode: "hsv", s: 0, v: brightness }; + }, + fire: (t) => { + // fire + const brightness = t * 0.7 + 0.3; + const hue = Math.floor(t * 90) - 30; + return { mode: "hsv", h: hue, s: 0.9, v: brightness }; + }, +}; + +const getInterpolatorFromName = (name: string): ((t: number) => Hsv) => { + const interpolator = interpolators[name]; + return interpolator ?? interpolators.hue360; +}; + +const getInterpolatorName = (interpolator: OthersInterpolator): string => { + for (const [name, func] of Object.entries(interpolators)) { + if (func === interpolator) { + return name; + } + } + return "hue360"; +}; + +export class OthersPalette extends BasePalette { + private interpolator: (t: number) => Hsv; + colors: Hsv[] = []; + + constructor( + length: number, + interpolator: (t: number) => Hsv, + mirrored = true, + offset = 0, + ) { + const { colorLength, offsetIndex } = clampedPaletteParams(length, offset); + + super(colorLength, mirrored, offsetIndex); + + this.interpolator = interpolator; + + this.buildColors(); + } + + buildColors(): void { + this.colors = samples(this.colorLength) + .map((t) => this.interpolator(t)) + .filter((v): v is NonNullable => v != null); + } + + getRGBFromColorIndex(index: number): RGB { + return buildRGB32Byte(convertHsvToRgb(this.colors[index])); + } + + serialize(): string { + const result = ["others"]; + result.push(getInterpolatorName(this.interpolator)); + result.push(`${this.mirrored ? 1 : 0}`); + result.push(`${this.colorLength}`); + result.push(`${this.offsetIndex}`); + + return result.join(","); + } + + static deserialize(serialized: string): OthersPalette { + const [, rawInterpolate, rawMirrored, rawLength, rawOffset] = + serialized.split(","); + + const length = safeParseInt(rawLength); + const offset = safeParseInt(rawOffset); + const mirrored = rawMirrored === "1"; + + const interpolator = getInterpolatorFromName(rawInterpolate); + + return new OthersPalette(length, interpolator, mirrored, offset); + } +} + +export const othersPalettes = [ + new OthersPalette(128, interpolators.hue360), + new OthersPalette(128, interpolators.monochrome), + new OthersPalette(128, interpolators.fire), +] satisfies Palette[]; diff --git a/src/color/color-p5js.ts b/src/color/color-p5js.ts deleted file mode 100644 index de1a3a3..0000000 --- a/src/color/color-p5js.ts +++ /dev/null @@ -1,70 +0,0 @@ -import p5 from "p5"; -import { BasePalette, Palette, RGB } from "."; - -const posterize = ( - p: p5, - value: number, - numberOfTones: number, - lower: number, - upper: number, -) => { - const paletteLength = numberOfTones * 2; - const v = value % paletteLength; - - if (v < numberOfTones) { - return p.map(Math.floor(v % numberOfTones), 0, numberOfTones, lower, upper); - } else { - return p.map(Math.floor(v % numberOfTones), 0, numberOfTones, upper, lower); - } -}; - -const extractRGB = (p: p5, color: p5.Color): RGB => { - return [p.red(color), p.green(color), p.blue(color)] satisfies RGB; -}; - -class P5JsPalette extends BasePalette { - private p5Instance: p5; - private f: (index: number, colorLength: number) => p5.Color; - - constructor( - p: p5, - length: number, - f: (index: number, colorLength: number) => p5.Color, - ) { - super(length); - - this.p5Instance = p; - this.f = f; - - this.buildColors(); - } - - buildColors(): void { - // do nothing - } - - getRGBFromColorIndex(index: number): RGB { - const color = this.p5Instance.color(this.f(index, this.colorLength)); - return extractRGB(this.p5Instance, color); - } -} - -export const p5jsPalettes = (p: p5) => - [ - new P5JsPalette(p, 128, (idx, length) => { - // hue 0~360 - const hue = posterize(p, idx, length, 0, 360); - return p.color(hue, 75, 100); - }), - new P5JsPalette(p, 128, (idx, length) => { - // monochrome - const brightness = posterize(p, idx, length, 20, 100); - return p.color(0, 0, brightness); - }), - new P5JsPalette(p, 128, (idx, length) => { - // fire - const brightness = posterize(p, idx, length, 30, 100); - const hue = posterize(p, idx, length, -30, 60); - return p.color(hue, 90, brightness); - }), - ] satisfies Palette[]; diff --git a/src/color/deserializer.ts b/src/color/deserializer.ts new file mode 100644 index 0000000..412831d --- /dev/null +++ b/src/color/deserializer.ts @@ -0,0 +1,19 @@ +import { Palette } from "."; +import { ChromaJsPalette } from "./color-chromajs"; +import { D3ChromaticPalette } from "./color-d3-chromatic"; +import { OthersPalette } from "./color-others"; + +export const deserializePalette = (serialized: string): Palette => { + const [type] = serialized.split(","); + + switch (type) { + case "chroma-js": + return ChromaJsPalette.deserialize(serialized); + case "d3-chromatic": + return D3ChromaticPalette.deserialize(serialized); + case "others": + return OthersPalette.deserialize(serialized); + default: + throw new Error(`Unknown palette type: ${type}`); + } +}; diff --git a/src/color/index.test.ts b/src/color/index.test.ts new file mode 100644 index 0000000..a5e905c --- /dev/null +++ b/src/color/index.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { ChromaJsPalette } from "./color-chromajs"; +import { deserializePalette } from "./deserializer"; +import { repeatUntil } from "@/math"; +import { D3ChromaticPalette } from "./color-d3-chromatic"; +import { interpolateRdYlBu } from "d3-scale-chromatic"; +import { OthersPalette, othersPalettes } from "./color-others"; + +describe("chroma-js", () => { + it("不正な入力に対してデフォルト値を適用する", () => { + const palette = new ChromaJsPalette([], -1); + const serialized = palette.serialize(); + expect(serialized).toBe("chroma-js,2,black,white,1,1,0"); + }); + + it("serializeできる", () => { + const palette = new ChromaJsPalette(["lightblue", "navy", "white"], 128); + const serialized = palette.serialize(); + expect(serialized).toBe("chroma-js,3,lightblue,navy,white,1,128,0"); + }); + + it("deserializeできる", () => { + const serialized = "chroma-js,3,lightblue,navy,white,1,128,0"; + const palette = deserializePalette(serialized); + const serialized2 = palette.serialize(); + expect(serialized2).toBe(serialized); + }); + + it("deserialize時にlength, offsetの不正な入力は丸められる", () => { + const serialized = "chroma-js,3,lightblue,navy,white,-1,128128128,-42"; + const palette = deserializePalette(serialized); + const serialized2 = palette.serialize(); + expect(serialized2).toBe("chroma-js,3,lightblue,navy,white,0,8192,0"); + }); + + it("deserialize時に16以上の長さのpalette指定は切り捨てられる", () => { + const colors = repeatUntil(["black", "red", "yellow"], 16); + const overColors = [...colors, "white"]; + + const serialized = `chroma-js,17,${overColors.join(",")},1,128,0`; + const palette = deserializePalette(serialized); + const serialized2 = palette.serialize(); + expect(serialized2).toBe(`chroma-js,16,${colors.join(",")},1,128,0`); + }); + + it("deserialize時に不正な入力を与えられた場合はデフォルトのパレットを返す", () => { + const serialized = "chroma-js,asd,asd,qwe,wer,ert,rty,xcv,sdf,asd"; + const palette = deserializePalette(serialized); + const serialized2 = palette.serialize(); + expect(serialized2).toBe("chroma-js,2,black,white,0,16,0"); + }); +}); + +describe("d3-chromatic", () => { + it("不正な入力に対してデフォルト値を適用する", () => { + const palette = new D3ChromaticPalette(interpolateRdYlBu, -1, true, -512); + const serialized = palette.serialize(); + expect(serialized).toBe("d3-chromatic,RdYlBlu,1,1,0"); + }); + + it("serializeできる", () => { + const palette = new D3ChromaticPalette(interpolateRdYlBu, 128, true, 8); + expect(palette.serialize()).toBe("d3-chromatic,RdYlBlu,1,128,8"); + }); + + it("deserializeできる", () => { + const serialized = "d3-chromatic,RdYlBlu,1,128,8"; + const palette = deserializePalette(serialized); + const serialized2 = palette.serialize(); + expect(serialized2).toBe(serialized); + }); + + it("deserialize時に不正な入力を与えられた場合はデフォルトのパレットを返す", () => { + const serialized = "d3-chromatic,asd,asd,asd,asd,asd"; + const palette = deserializePalette(serialized); + const serialized2 = palette.serialize(); + expect(serialized2).toBe("d3-chromatic,RdYlBlu,0,1,0"); + }); +}); + +describe("othersPalette", () => { + it("不正な入力に対してデフォルト値を適用する", () => { + const palette = new OthersPalette( + -1, + () => ({ mode: "hsv", h: 0, s: 0, v: 0 }), + true, + -512, + ); + const serialized = palette.serialize(); + expect(serialized).toBe("others,hue360,1,1,0"); + }); + + it("serializeできる", () => { + const palette = othersPalettes[0]; + const serialized = palette.serialize(); + expect(serialized).toBe("others,hue360,1,128,0"); + }); + + it("deserializeできる", () => { + const serialized = "others,hue360,1,128,0"; + const palette = deserializePalette(serialized); + const serialized2 = palette.serialize(); + expect(serialized2).toBe(serialized); + }); + + it("deserialize時に不正な入力を与えられた場合はデフォルトのパレットを返す", () => { + const serialized = "others,asd,asd,asd,asd,asd,asd,asd"; + const palette = deserializePalette(serialized); + const serialized2 = palette.serialize(); + expect(serialized2).toBe("others,hue360,0,1,0"); + }); +}); diff --git a/src/color/index.ts b/src/color/index.ts index c48e2eb..99fc1ad 100644 --- a/src/color/index.ts +++ b/src/color/index.ts @@ -1,3 +1,5 @@ +import { clamp } from "@/math"; + export type RGB = [number, number, number]; export const buildRGB = ({ @@ -12,6 +14,18 @@ export const buildRGB = ({ return [r, g, b]; }; +export const buildRGB32Byte = ({ + r, + g, + b, +}: { + r: number; + g: number; + b: number; +}): RGB => { + return [r * 255, g * 255, b * 255]; +}; + export type Palette = { rgb(index: number): RGB; @@ -25,6 +39,8 @@ export type Palette = { cycleOffset(step?: number): void; setLength(length: number): void; setMirrored(mirrored: boolean): void; + + serialize(): string; }; export class BasePalette implements Palette { @@ -35,8 +51,10 @@ export class BasePalette implements Palette { mirrored = true; colorLength; - constructor(length: number) { + constructor(length: number, mirrored = true, offsetIndex = 0) { this.colorLength = length; + this.mirrored = mirrored; + this.offsetIndex = offsetIndex; this.resetCache(); } @@ -54,6 +72,10 @@ export class BasePalette implements Palette { throw new Error("Not implemented"); } + serialize(): string { + throw new Error("Not implemented"); + } + public rgb(index: number): RGB { const colorIndex = this.getColorIndex(index); @@ -144,3 +166,10 @@ export class BasePalette implements Palette { this.cacheInitialized[index] = true; } } + +export const clampedPaletteParams = (length: number, offset: number) => { + return { + colorLength: clamp(length, 1, 8192), + offsetIndex: clamp(offset, 0, length * 2 - 1), + }; +}; diff --git a/src/main.tsx b/src/main.tsx index dc552df..9226673 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -16,7 +16,7 @@ import { } from "./camera"; import { setP5 } from "./canvas-reference"; import { chromaJsPalettes } from "./color/color-chromajs"; -import { p5jsPalettes } from "./color/color-p5js"; +import { othersPalettes } from "./color/color-others"; import { calcVars, cycleMode, @@ -110,7 +110,7 @@ const sketch = (p: p5) => { const { width, height } = getCanvasSize(); addPalettes(...d3ChromaticPalettes); - addPalettes(...p5jsPalettes(p)); + addPalettes(...othersPalettes); addPalettes(...chromaJsPalettes); p.createCanvas(width, height); diff --git a/src/math.ts b/src/math.ts index 70a9fc0..bfaa8f1 100644 --- a/src/math.ts +++ b/src/math.ts @@ -200,3 +200,20 @@ export function generateLowResDiffSequence( return { xDiffs, yDiffs }; } + +export function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)); +} + +export function repeatUntil(base: T[], length: number) { + const result = []; + for (let i = 0; i < length; i++) { + result.push(base[i % base.length]); + } + return result; +} + +export function safeParseInt(value: string, defaultValue = 0): number { + const parsed = parseInt(value, 10); + return isNaN(parsed) ? defaultValue : parsed; +} diff --git a/vite.config.ts b/vite.config.ts index 42396fd..4b239ac 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,6 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; -import path from "path"; +import path from "node:path"; export default defineConfig({ base: process.env.CLOUDFLARE_BUILD === "true" ? "/" : "/p5mandelbrot/", diff --git a/vitest.config.ts b/vitest.config.ts index 77a73cf..cead258 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,11 @@ +import path from "node:path"; import { defineConfig } from "vitest/config"; export default defineConfig({ test: {}, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, });