Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix Hue wheel #351

Merged
merged 1 commit into from
Nov 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"compilerOptions": {
"baseUrl": ".",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"moduleResolution": "nodenext",
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VS Code would consistently autocomplete to NodeNext before, but today it is consistently recommending nodenext. Stylistic change from TS itself? Dunno.

"outDir": "./dist/"
},
"include": ["src", "test"]
Expand Down
6 changes: 3 additions & 3 deletions packages/icons/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@
"@rollup/plugin-typescript": "^12.1.1",
"@types/react": "npm:types-react@rc",
"@types/react-dom": "npm:types-react-dom@rc",
"react": "19.0.0-rc-cae764ce-20241025",
"react-dom": "19.0.0-rc-cae764ce-20241025",
"rollup": "^4.26.0",
"react": "19.0.0-rc.1",
"react-dom": "19.0.0-rc.1",
"rollup": "^4.27.2",
"rollup-plugin-import-css": "^3.5.6",
"types-react": "19.0.0-rc.1",
"types-react-dom": "19.0.0-rc.1"
Expand Down
4 changes: 3 additions & 1 deletion packages/icons/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
"extends": "../../tsconfig.react.json",
"compilerOptions": {
"baseUrl": ".",
"outDir": "dist"
"outDir": "dist",
"module": "NodeNext",
"moduleResolution": "nodenext"
},
"include": ["src"]
}
2 changes: 1 addition & 1 deletion packages/plugin-tailwind/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"devDependencies": {
"@terrazzo/cli": "workspace:^",
"@terrazzo/parser": "workspace:^",
"tailwindcss": "^3.4.14",
"tailwindcss": "^3.4.15",
"yaml": "^2.6.0"
}
}
6 changes: 3 additions & 3 deletions packages/react-color-picker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,9 @@
"chokidar-cli": "^3.0.0",
"culori": "^4.0.1",
"jsdom": "^24.1.3",
"react": "19.0.0-rc-cae764ce-20241025",
"react-dom": "19.0.0-rc-cae764ce-20241025",
"rollup": "^4.26.0",
"react": "19.0.0-rc.1",
"react-dom": "19.0.0-rc.1",
"rollup": "^4.27.2",
"rollup-plugin-import-css": "^3.5.6",
"size-limit": "^11.1.6",
"types-react": "19.0.0-rc.1",
Expand Down
22 changes: 11 additions & 11 deletions packages/react-color-picker/src/components/ColorChannelSlider.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { Slider } from '@terrazzo/tiles';
import { COLORSPACES, type ColorOutput, formatCss, type default as useColor } from '@terrazzo/use-color';
import { type ColorOutput, type Oklab, formatCss, type default as useColor, withAlpha } from '@terrazzo/use-color';
import { modeLrgb, modeOklab, modeRgb, useMode } from 'culori';
import { type ReactElement, useMemo } from 'react';
import { calculateBounds } from '../lib/color.js';
import type { WebGLColor } from '../lib/webgl.js';
import HueWheel from './HueWheel.js';
import TrueGradient from './TrueGradient.js';
import './ColorChannelSlider.css';

useMode(modeRgb);
useMode(modeLrgb);
const toOklab = useMode(modeOklab);

/** size, in px, to pad inner track */
export const TRACK_PADDING = 4;
/** CSS class to add to body */
Expand Down Expand Up @@ -84,18 +88,14 @@ function ColorChannelBG({ channel, color, displayMin, displayMax, min, max }: Co
}

const range = (displayMax ?? max) - (displayMin ?? min);
let leftColor = { ...color.original, [channel]: min, alpha: 1 } as WebGLColor;
if (!RGB_COLORSPACES.includes(color.original.mode)) {
leftColor = COLORSPACES.rec2020.converter(leftColor);
}
let rightColor = { ...color.original, [channel]: max, alpha: 1 } as WebGLColor;
if (!RGB_COLORSPACES.includes(color.original.mode)) {
rightColor = COLORSPACES.rec2020.converter(rightColor);
}
const leftColor = { ...color.original, [channel]: displayMin ?? min };
const rightColor = { ...color.original, [channel]: displayMax ?? max };
const leftOklab = useMemo(() => withAlpha(toOklab(leftColor) as Oklab) as Oklab, [color.css]);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useColor() hook I made because I’M sO sMArT could be better, because adjustments like this are hard (specifically, this nonsense is because the original color may be in another space, so we want to adjust the color before converting, which isn’t currently possible.

I may just ditch it in favor of raw Culori methods, but for now this is a bandaid to not refactor that.

I’m honestly really happy with the performance of useColor as it’s designed to be used. My problem is I need more from its design 😓

const rightOklab = useMemo(() => withAlpha(toOklab(rightColor) as Oklab) as Oklab, [color.css]);

return (
<div className='tz-color-channel-slider-bg-wrapper'>
<TrueGradient className='tz-color-channel-slider-bg' start={leftColor} end={rightColor} />
<TrueGradient className='tz-color-channel-slider-bg' start={leftOklab} end={rightOklab} />
{typeof displayMin === 'number' && displayMin < min && (
<div
className='tz-color-channel-slider-overlay tz-color-channel-slider-overlay__min'
Expand Down
17 changes: 9 additions & 8 deletions packages/react-color-picker/src/components/TrueGradient.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
import type { Oklab } from '@terrazzo/use-color';
import { type ComponentProps, useEffect, useRef, useState } from 'react';
import { GradientRGB, type WebGLColor } from '../lib/webgl.js';
import { GradientOklab } from '../lib/webgl.js';

export interface TrueGradientProps extends ComponentProps<'canvas'> {
start: WebGLColor;
end: WebGLColor;
start: Oklab;
end: Oklab;
}

function TrueGradient({ start, end, ...rest }: TrueGradientProps) {
function TrueGradient({ start, end, ...props }: TrueGradientProps) {
const canvasEl = useRef<HTMLCanvasElement>(null);
const [webgl, setWebgl] = useState<GradientRGB | undefined>();
const [webgl, setWebgl] = useState<GradientOklab | undefined>();

// initialize
useEffect(() => {
if (webgl || !canvasEl.current) {
return;
}
setWebgl(new GradientRGB({ canvas: canvasEl.current, startColor: start, endColor: end }));
}, [canvasEl.current, webgl]);
setWebgl(new GradientOklab({ canvas: canvasEl.current, startColor: start, endColor: end }));
}, [webgl]);

// update color
useEffect(() => {
Expand All @@ -25,7 +26,7 @@ function TrueGradient({ start, end, ...rest }: TrueGradientProps) {
}
}, [start, end, webgl]);

return <canvas ref={canvasEl} {...rest} />;
return <canvas {...props} ref={canvasEl} />;
}

export default TrueGradient;
4 changes: 3 additions & 1 deletion packages/react-color-picker/src/lib/rgb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ vec4 linear_rgb_to_srgb(vec4 linear_rgb) {
return vec4(srgb_transfer_fn(linear_rgb.x), srgb_transfer_fn(linear_rgb.y), srgb_transfer_fn(linear_rgb.z), linear_rgb.w);
}

// Blend 2 vec4 colors together
vec4 avg_vec4(vec4 a, vec4 b, float w) {
return a * (1.0 - w) + b * w;
float _w = 1.0 - w;
return (a * _w) + (b * w);
}

vec4 blend_srgb(vec4 a, vec4 b, float w) {
Expand Down
51 changes: 17 additions & 34 deletions packages/react-color-picker/src/lib/webgl.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import type { A98, Lrgb, P3, Prophoto, Rec2020, Rgb } from '@terrazzo/use-color';
import type { Oklab } from '@terrazzo/use-color';
import { OKLAB } from './oklab.js';
import { LINEAR_RGB } from './rgb.js';

/** RGB-based colorspaces */
export type WebGLColor = A98 | Lrgb | Rgb | P3 | Rec2020 | Prophoto;

/** create a WebGL2 rendering context and throw errors if needed */
export function createRenderingContext(canvas: HTMLCanvasElement): WebGL2RenderingContext {
// init GL
Expand Down Expand Up @@ -96,17 +93,21 @@ in vec4 v_end_color;
out vec4 f_color;

${LINEAR_RGB}
${OKLAB}

void main() {
float a = vec2(gl_FragCoord.xy / v_resolution).x;
f_color = blend_srgb(v_start_color, v_end_color, a);
f_color = linear_rgb_to_srgb(avg_vec4(oklab_to_linear_rgb(v_start_color), oklab_to_linear_rgb(v_end_color), a));
}
`;

export class GradientRGB {
/**
* Create a gradient from A to B, blended in Oklab space.
*/
export class GradientOklab {
gl: WebGL2RenderingContext;
startColor: WebGLColor;
endColor: WebGLColor;
startColor: Oklab;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another design problem of the WebGL code was trying to make it magically handle all color spaces, all gamuts. This is far too much responsibility for it, and also isn’t necessary since at any given time, we’re only operating in one colorspace!

Instead, if we simply restrict the WebGL code to operate in the Oklab space alone, we don’t lose any performance benefits. It does what it does best—performs crazy-fast translations between Oklab <> Linear RGB. And the only time we have to calculate colorspaces on the main thread in JS is just 2 times—start and end color, which is trivial. Keeping WebGL in Oklab still reaps all the benefits but sheds a ton of complexity and potential bugs.

endColor: Oklab;
program: WebGLProgram;
attr: Record<keyof typeof GRADIENT_RGB_SHADERS.attrs, number> = {
a_position: -1,
Expand All @@ -118,11 +119,7 @@ export class GradientRGB {

private lastFrame: number | undefined;

constructor({
canvas,
startColor,
endColor,
}: { canvas: HTMLCanvasElement; startColor: WebGLColor; endColor: WebGLColor }) {
constructor({ canvas, startColor, endColor }: { canvas: HTMLCanvasElement; startColor: Oklab; endColor: Oklab }) {
this.gl = createRenderingContext(canvas);
this.program = createProgram({
gl: this.gl,
Expand Down Expand Up @@ -153,26 +150,13 @@ export class GradientRGB {
this.render();
}

setColors(startColor: WebGLColor, endColor: WebGLColor) {
setColors(startColor: Oklab, endColor: Oklab) {
this.startColor = startColor;
this.endColor = endColor;
// note: `drawingBufferColorSpace` is ignored in Firefox, but it shouldn’t throw an error
if (
endColor.mode === 'a98' ||
endColor.mode === 'p3' ||
endColor.mode === 'rec2020' ||
endColor.mode === 'prophoto' ||
startColor.mode === 'a98' ||
startColor.mode === 'p3' ||
startColor.mode === 'rec2020' ||
startColor.mode === 'prophoto'
) {
this.gl.drawingBufferColorSpace = 'display-p3';
} else {
this.gl.drawingBufferColorSpace = 'srgb';
}
this.gl.vertexAttrib4f(this.attr.a_start_color, startColor.r, startColor.g, startColor.b, 1);
this.gl.vertexAttrib4f(this.attr.a_end_color, endColor.r, endColor.g, endColor.b, 1);
this.gl.drawingBufferColorSpace = 'display-p3';
this.gl.vertexAttrib4f(this.attr.a_start_color, startColor.l, startColor.a, startColor.b, 1);
this.gl.vertexAttrib4f(this.attr.a_end_color, endColor.l, endColor.a, endColor.b, 1);
this.render();
}

Expand Down Expand Up @@ -206,7 +190,7 @@ export class GradientRGB {
}

/**
* Generate a perfect rainbow hue wheel in Oklab colorspace with WebGL
* Generate a perceptually-uniform rainbow gradient in the Oklab space.
*/
export const HUE_SHADERS = {
attrs: { a_position: 'a_position', a_resolution: 'a_resolution' },
Expand Down Expand Up @@ -243,12 +227,12 @@ void main() {
// 3 = projection toward point, hue dependent
// 4 = adaptive Lightness, hue independent
// 5 = adaptive Lightness, hue dependent
int clamp_mode = 2;
int clamp_mode = 3;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are quite a few changes in the WebGL code, but this was basically a fix—a one-liner. I was halfway to experimenting with some alternate methods of generating the hue wheel, but ultimately we should use the thing that looks best, and that’s changing the clamp method


float hue_norm = vec2(gl_FragCoord.xy / v_resolution).x;
float hue = 360.0 * hue_norm;

f_color = oklch_to_srgb(vec4(0.8, 0.4, hue, 1.0), clamp_mode);
f_color = oklch_to_srgb(vec4(0.7, 0.4, hue, 1.0), clamp_mode);
}
`;

Expand Down Expand Up @@ -288,7 +272,6 @@ export class HueWheel {
if (gamut !== 'srgb' && gamut !== 'p3') {
throw new Error(`Unsupported gamut: "${gamut}"`);
}
// this.gl.drawingBufferColorSpace = gamut === 'p3' ? 'display-p3' : 'srgb';
this.gl.drawingBufferColorSpace = 'display-p3';
}

Expand Down
4 changes: 4 additions & 0 deletions packages/react-color-picker/tsconfig.build.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "nodenext"
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bug with the Rollup TS plugin I’m too lazy to fix right now—it doesn’t do a good job of tracing the extends to resolve the final TSConfig. This is redundant, but is easier than fixing the bug upstream.

},
"exclude": ["**/__test__/**", "**/*.test.*"]
}
5 changes: 4 additions & 1 deletion packages/react-color-picker/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
"extends": "../../tsconfig.react.json",
"compilerOptions": {
"baseUrl": ".",
"outDir": "dist"
"outDir": "dist",
"module": "NodeNext",
"moduleResolution": "nodenext",
"skipLibCheck": true
},
"include": ["src"]
}
16 changes: 8 additions & 8 deletions packages/storybook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@
"culori": "^4.0.1"
},
"devDependencies": {
"@storybook/addon-essentials": "^8.3.6",
"@storybook/react": "^8.3.6",
"@storybook/react-vite": "^8.3.6",
"@storybook/test": "^8.3.6",
"@storybook/addon-essentials": "^8.4.4",
"@storybook/react": "^8.4.4",
"@storybook/react-vite": "^8.4.4",
"@storybook/test": "^8.4.4",
"@terrazzo/fonts": "workspace:^",
"@terrazzo/icons": "workspace:^",
"@terrazzo/react-color-picker": "workspace:^",
Expand All @@ -31,12 +31,12 @@
"@types/react": "npm:types-react@rc",
"@types/react-dom": "npm:types-react-dom@rc",
"@vitejs/plugin-react-swc": "^3.7.1",
"react": "19.0.0-rc-cae764ce-20241025",
"react-dom": "19.0.0-rc-cae764ce-20241025",
"storybook": "^8.3.6",
"react": "19.0.0-rc.1",
"react-dom": "19.0.0-rc.1",
"storybook": "^8.4.4",
"types-react": "19.0.0-rc.1",
"types-react-dom": "19.0.0-rc.1",
"vite": "^5.4.10"
"vite": "^5.4.11"
},
"overrides": {
"@types/react": "npm:types-react@rc",
Expand Down
12 changes: 6 additions & 6 deletions packages/storybook/src/TrueGradient.stories.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import { TrueGradient } from '@terrazzo/react-color-picker';
import { modeP3, modeRgb, useMode } from 'culori/fn';

const toRgb = useMode(modeRgb);
useMode(modeP3);
import useColor from '@terrazzo/use-color';

export default {
title: 'Components/Display/TrueGradient',
Expand All @@ -18,14 +15,17 @@ export const Overview = {
end: 'color(srgb 0 1 0)',
},
render(args) {
const [start] = useColor(args.start);
const [end] = useColor(args.end);

return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
Good
<TrueGradient start={toRgb(args.start)} end={toRgb(args.end)} style={{ width: '16rem', height: '1.5rem' }} />
<TrueGradient start={start.oklab} end={end.oklab} style={{ width: '16rem', height: '1.5rem' }} />
Bad
<div
style={{
background: `linear-gradient(to right, ${args.start}, ${args.end})`,
background: `linear-gradient(to right, ${start.css}, ${end.css})`,
width: '16rem',
height: '1.5rem',
}}
Expand Down
8 changes: 4 additions & 4 deletions packages/tiles/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"@use-gesture/react": "^10.3.1",
"clsx": "^2.1.1",
"culori": "^4.0.1",
"shiki": "^1.22.2",
"shiki": "^1.23.0",
"vite": "^5.4.11"
},
"devDependencies": {
Expand All @@ -52,9 +52,9 @@
"@types/react-dom": "npm:types-react-dom@rc",
"@vitejs/plugin-react-swc": "^3.7.1",
"chokidar-cli": "^3.0.0",
"react": "19.0.0-rc-cae764ce-20241025",
"react-dom": "19.0.0-rc-cae764ce-20241025",
"rollup": "^4.26.0",
"react": "19.0.0-rc.1",
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RC gettin closer! 🎉

"react-dom": "19.0.0-rc.1",
"rollup": "^4.27.2",
"rollup-plugin-import-css": "^3.5.6",
"size-limit": "^11.1.6",
"types-react": "19.0.0-rc.1",
Expand Down
4 changes: 4 additions & 0 deletions packages/tiles/tsconfig.build.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "nodenext"
},
"exclude": ["**/__test__/**", "**/*.test.*"]
}
5 changes: 4 additions & 1 deletion packages/tiles/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
"extends": "../../tsconfig.react.json",
"compilerOptions": {
"baseUrl": ".",
"outDir": "dist"
"outDir": "dist",
"module": "NodeNext",
"moduleResolution": "nodenext",
"skipLibCheck": true
},
"include": ["src"]
}
Loading