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

Show gamut in sliders #226

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
45 changes: 45 additions & 0 deletions src/lib/components/GamutSelect.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<script lang="ts">
import { GAMUTS } from '$lib/constants';
import { gamut } from '$lib/stores';
</script>

<div data-field="color-gamut">
<label for="color-gamut" data-label>Gamut</label>
<select name="color-gamut" id="color-gamut" bind:value={$gamut}>
{#each GAMUTS as gamut (gamut.format)}
{#if gamut}
<option value={gamut.format}>{gamut.name}</option>
{/if}
{/each}
</select>
</div>

<style lang="scss">
@use 'config';

[data-field='color-gamut'] {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is copy pasted from SpaceSelect- should we make this a pattern somewhere?

align-items: center;
column-gap: var(--gutter);
display: grid;
grid-template:
'format-label' auto
'format-input' auto / 1fr;
justify-content: end;

@include config.above('sm-page-break') {
grid-template: 'format-label format-input' auto / 1fr minmax(10rem, auto);
}
}

label {
grid-area: format-label;

@include config.above('sm-page-break') {
text-align: right;
}
}

select {
grid-area: format-input;
}
</style>
2 changes: 2 additions & 0 deletions src/lib/components/Header.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts">
import GamutSelect from '$lib/components/GamutSelect.svelte';
import SpaceSelect from '$lib/components/SpaceSelect.svelte';
import Icon from '$lib/components/util/Icon.svelte';
</script>
Expand All @@ -9,6 +10,7 @@
<span class="sr-only">OddContrast</span>
</h1>
<SpaceSelect />
<GamutSelect />
</header>

<style lang="scss">
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/SpaceSelect.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
</script>

<div data-field="color-format">
<label for="color-format" data-label>Color Format</label>
<label for="color-format" data-label>Format</label>
<select name="color-format" id="color-format" bind:value={$format}>
{#each spaces as space (space.id)}
{#if space}
Expand Down
30 changes: 26 additions & 4 deletions src/lib/components/colors/Sliders.svelte
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<script lang="ts">
import type { PlainColorObject } from 'colorjs.io/fn';
import throttle from 'lodash/throttle';
import type { Writable } from 'svelte/store';

import { type ColorFormatId, SLIDERS } from '$lib/constants';
import { ColorSpace } from '$lib/stores';
import { ColorSpace, gamut } from '$lib/stores';
import { getSpaceFromFormatId, sliderGradient } from '$lib/utils';

interface Props {
Expand All @@ -16,11 +17,22 @@

let targetSpace = $derived(getSpaceFromFormatId(format));
let spaceObject = $derived(ColorSpace.get(targetSpace));

// Create a throttled value for each channel
const throttled = $derived(
SLIDERS[format].map(() => throttle(sliderGradient, 50)),
);
Comment on lines +22 to +24
Copy link
Member

Choose a reason for hiding this comment

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

@jamesnw Without making this $derived, it wasn't re-calculating these fns on initial page load when the format is read from the has. This seems to fix it.

I also bumped the throttle back to 50, which still seems to have decent performance for me. 🤷


let sliders = $derived(
SLIDERS[format].map((id) => {
SLIDERS[format].map((id, index) => {
const coord = spaceObject.coords[id];
const range = coord?.range ?? coord?.refRange ?? [0, 1];
const gradient = sliderGradient($color, id, range);
const gradient = throttled[index]!({
color: $color,
channel: id,
range: range,
gamut: $gamut,
});
return {
id,
name: coord?.name ?? '',
Expand All @@ -37,7 +49,12 @@
);

let alphaGradient = $derived(
sliderGradient($color, 'alpha', [0, $color.alpha]),
sliderGradient({
color: $color,
channel: 'alpha',
range: [0, $color.alpha],
gamut: $gamut,
}),
);

const handleInput = (
Expand Down Expand Up @@ -95,6 +112,7 @@
style={`--stops: ${alphaGradient}`}
value={$color.alpha}
oninput={(e) => handleInput(e)}
data-channel="alpha"
/>
</div>
</div>
Expand All @@ -105,6 +123,10 @@
display: block;
appearance: none;
background: linear-gradient(to right, var(--stops));
&[data-channel='alpha'] {
background: linear-gradient(to right, var(--stops)),
url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60"><rect fill="%23e8e8e8" width="30" height="30"/><rect x="30" y="30" width="30" height="30" fill="%23e8e8e8"/></svg>');
}
}

[data-group~='sliders'] {
Expand Down
10 changes: 10 additions & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ export const FORMATS: ColorFormatId[] = [
'srgb',
];

export type ColorGamutId = 'srgb' | 'p3' | 'rec2020' | null;

export const GAMUTS: { name: string; format: ColorGamutId }[] = [
{ name: 'None', format: null },
{ name: 'sRGB', format: 'srgb' },
{ name: 'P3', format: 'p3' },
{ name: 'Rec2020', format: 'rec2020' },
];
export const GAMUT_IDS = GAMUTS.map((gamut) => gamut.format);

export interface FormatGroup {
name: string;
formats: ColorFormatId[];
Expand Down
4 changes: 3 additions & 1 deletion src/lib/stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { writable } from 'svelte/store';

// eslint-disable-next-line import/no-unresolved
import { browser, dev } from '$app/environment';
import type { ColorFormatId } from '$lib/constants';
import type { ColorFormatId, ColorGamutId } from '$lib/constants';

// Register supported color spaces
ColorSpace.register(HSL);
Expand All @@ -32,6 +32,7 @@ export { ColorSpace };

export const INITIAL_VALUES = {
format: 'p3' as ColorFormatId,
gamut: null as ColorGamutId,
bg_coord: [0.0967, 0.167, 0.4494] as [number, number, number],
fg_coord: [0.951, 0.675, 0.7569] as [number, number, number],
alpha: 1,
Expand All @@ -49,6 +50,7 @@ const INITIAL_FG = {
};

export const format = writable<ColorFormatId>(INITIAL_VALUES.format);
export const gamut = writable<ColorGamutId>(INITIAL_VALUES.gamut);
jgerigmeyer marked this conversation as resolved.
Show resolved Hide resolved
export const bg = writable<PlainColorObject>(INITIAL_BG);
export const fg = writable<PlainColorObject>(INITIAL_FG);

Expand Down
76 changes: 64 additions & 12 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,84 @@
import {
clone,
display,
inGamut,
type PlainColorObject,
serialize,
set,
steps,
to,
} from 'colorjs.io/fn';

import { type ColorFormatId, FORMATS } from '$lib/constants';
import {
type ColorFormatId,
type ColorGamutId,
FORMATS,
GAMUT_IDS,
} from '$lib/constants';

export const getSpaceFromFormatId = (formatId: ColorFormatId) =>
formatId === 'hex' ? 'srgb' : formatId;

export const sliderGradient = (
color: PlainColorObject,
channel: string,
range: [number, number],
) => {
export const sliderGradient = ({
color,
channel,
range,
gamut,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is somewhat computationally slow. I toyed around with caching results, but the cache quickly grew to 12 Mbs in about 30 seconds of heavy usage 🫨 . I could likely do some more optimization if it is slow on slower machines.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, I noticed that too. My first attempt would probably be to try simple debouncing -- I'm not sure we need these to update immediately while dragging a slider? Or possible use a Web Worker to do the inGamut checks?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added a throttle, and it's improved. I'm tempted to try it in a web worker, but unsure if it's worth it. Ideally it would be buttery smooth... but that may not be realistic.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, this is much better IMO. I might play with a Web Worker just as a tutorial sometime, but I think this is fine.

}: {
color: PlainColorObject;
channel: string;
range: [number, number];
gamut: ColorGamutId;
}) => {
const start = clone(color);
const end = clone(color);
if (channel === 'alpha') {
start.alpha = range[0];
end.alpha = range[1];
start.alpha = 0;
end.alpha = 1;
} else {
set(start, channel, range[0]);
start.alpha = 1;
set(end, channel, range[1]);
end.alpha = 1;
Comment on lines +40 to +42
Copy link
Member

Choose a reason for hiding this comment

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

I'm curious why we're forcing the alpha here -- is it required somehow? It seems helpful to me for the sliders to represent alpha, even if we're also showing the gamut limitations.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This change isn't required, but it does mirror patterns I see in other color pickers- I checked the built in Apple one, and oklch.com. I'm fine going either way on it.

Copy link
Member

Choose a reason for hiding this comment

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

Oh, interesting! I'd be curious what @stacyk and @SondraE think as users.

Copy link
Contributor

Choose a reason for hiding this comment

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

@jgerigmeyer @jamesnw If I remember right, we chatted about how to do this with Miriam, played with other pickers together, and kinda group-designed it. I liked how it worked/looked when I tried it just now (though I don't come to it impartially).

Copy link
Member

Choose a reason for hiding this comment

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

I liked how it worked/looked when I tried it just now

@SondraE Were you trying it on this deploy preview (https://deploy-preview-226--oddcontrast.netlify.app/), or on the current production app (https://www.oddcontrast.com/)? The question is about a change in this PR.

Currently in production, when you lower the alpha value (e.g. this example), the background color of the other sliders also adjust to factor in your alpha value. The change @jamesnw is proposing is to have the other slider background colors always show with full alpha (e.g. this example), which makes their values clearer but also not representative of the current color.

Current production:

Screenshot 2025-01-31 at 11 14 44 AM

Proposal in this PR:

Screenshot 2025-01-31 at 11 15 07 AM

}

const gradientSteps = steps(start, end, {
steps: 10,
space: color.space,
hue: 'raw',
// Smaller values will take longer, larger will be less precise and
// produce fuzzy edges. This magic number seems to balance that.
maxDeltaE: 10,
jgerigmeyer marked this conversation as resolved.
Show resolved Hide resolved
});
let wasInGamut = true;
const inGamutSteps: string[] = [];
const stepWidth = 100 / (gradientSteps.length - 1);

if (channel === 'alpha' || gamut === null) {
return gradientSteps.map((c) => display(c)).join(', ');
}

// Create a linear gradient string, mapping gradientSteps to 0%-100% by
// multiplying its index with `stepWidth`.
gradientSteps.forEach((step, index) => {
if (inGamut(step, gamut)) {
if (wasInGamut === false) {
// Coming back into gamut. Add a transparent gradient step for a crisp
// edge.
inGamutSteps.push(`transparent ${stepWidth * index}%`);
}
wasInGamut = true;
inGamutSteps.push(`${display(step)} ${stepWidth * index}%`);
} else if (wasInGamut === true) {
// Leaving gamut. Add a transparent gradient step at the same percent as
// the previous in gamut step for a crisp edge.
inGamutSteps.push(`transparent ${stepWidth * (index - 1)}%`);

return gradientSteps.map((c) => display(c)).join(', ');
wasInGamut = false;
}
});

return inGamutSteps.join(', ');
};

function decodeColor(colorHash: string, format: ColorFormatId) {
Expand Down Expand Up @@ -63,11 +107,13 @@ export const hashToStoreValues = (
bg: PlainColorObject;
fg: PlainColorObject;
format: ColorFormatId;
gamut: ColorGamutId;
} | void => {
if (hash === '') return;
hash = decodeURIComponent(hash);

const [formatValue, bgValue, fgValue] = hash.split('__') as [
const [formatValue, bgValue, fgValue, gamutValue] = hash.split('__') as [
string,
string,
string,
string,
Expand All @@ -77,20 +123,26 @@ export const hashToStoreValues = (
if (!FORMATS.includes(formatValue as ColorFormatId)) return;
const format = formatValue as ColorFormatId;

const gamut = GAMUT_IDS.includes(gamutValue as ColorGamutId)
? (gamutValue as ColorGamutId)
: null;

const bg = decodeColor(bgValue, format);
if (!bg) return;
const fg = decodeColor(fgValue, format);
if (!fg) return;

return { bg, fg, format };
return { bg, fg, format, gamut };
};

export const storeValuesToHash = (
bg: PlainColorObject,
fg: PlainColorObject,
format: ColorFormatId,
gamut: ColorGamutId,
) => {
const bgParam = encodeColor(bg, format);
const fgParam = encodeColor(fg, format);
return encodeURIComponent(`${format}__${bgParam}__${fgParam}`);
const gamutParam = gamut ? `__${gamut}` : '';
return encodeURIComponent(`${format}__${bgParam}__${fgParam}${gamutParam}`);
};
6 changes: 4 additions & 2 deletions src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import Footer from '$lib/components/Footer.svelte';
import Header from '$lib/components/Header.svelte';
import Ratio from '$lib/components/ratio/index.svelte';
import { bg, fg, format } from '$lib/stores';
import { bg, fg, format, gamut } from '$lib/stores';
import { hashToStoreValues, storeValuesToHash } from '$src/lib/utils';

let bg_fallback = $derived(display($bg));
Expand All @@ -20,10 +20,11 @@
bg.subscribe(debouncedColorsToHash);
fg.subscribe(debouncedColorsToHash);
format.subscribe(debouncedColorsToHash);
gamut.subscribe(debouncedColorsToHash);
});

function colorsToHash() {
const hashString = storeValuesToHash($bg, $fg, $format);
const hashString = storeValuesToHash($bg, $fg, $format, $gamut);
replaceState(`#${hashString}`, {});
}

Expand All @@ -38,6 +39,7 @@
bg.set(result.bg);
fg.set(result.fg);
format.set(result.format);
gamut.set(result.gamut ?? null);
}
</script>

Expand Down
2 changes: 1 addition & 1 deletion src/sass/initial/_layout.scss
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ body {
display: grid;
gap: var(--shim) var(--double-gutter);
grid-area: header;
grid-template: 'logo colorspace' auto / auto 1fr;
grid-template: 'logo colorspace gamut' auto / auto 1fr;

@include config.above('sm-page-break') {
gap: var(--double-gutter);
Expand Down
22 changes: 22 additions & 0 deletions test/lib/components/GamutSelect.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { fireEvent, render } from '@testing-library/svelte';
import { get } from 'svelte/store';

import Gamut from '$lib/components/GamutSelect.svelte';
import { gamut, INITIAL_VALUES, reset } from '$lib/stores';

describe('Space', () => {
afterEach(() => {
reset();
});

it('renders editable gamut select', async () => {
const { getByLabelText } = render(Gamut);

expect(get(gamut)).toBe(INITIAL_VALUES.gamut);

const select = getByLabelText('Gamut');
await fireEvent.change(select, { target: { value: 'rec2020' } });

expect(get(gamut)).toBe('rec2020');
});
});
2 changes: 1 addition & 1 deletion test/lib/components/SpaceSelect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ describe('Space', () => {
expect(get(bg).space.id).toBe(INITIAL_VALUES.format);
expect(get(fg).space.id).toBe(INITIAL_VALUES.format);

const select = getByLabelText('Color Format');
const select = getByLabelText('Format');
await fireEvent.change(select, { target: { value: 'hsl' } });

expect(get(bg).space.id).toBe('hsl');
Expand Down
Loading