From eebcb0e253cc4bfa9ff13b456315b5fed3676317 Mon Sep 17 00:00:00 2001 From: Andrew Michael McNutt Date: Thu, 18 Jan 2024 12:48:24 -0800 Subject: [PATCH] polar everything --- package.json | 3 +- src/components/ColorScatterPlot.svelte | 299 +++++++++++------- .../ColorScatterPlotPolarGuide.svelte | 116 +++++++ .../ColorScatterPlotXYGuides.svelte | 14 +- src/lib/Color.ts | 21 +- src/lib/utils.ts | 69 ++-- 6 files changed, 352 insertions(+), 170 deletions(-) create mode 100644 src/components/ColorScatterPlotPolarGuide.svelte diff --git a/package.json b/package.json index 527c9e59..6d500dce 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "color-namer": "^1.4.0", "colorjs.io": "^0.4.5", "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", "idb-keyval": "^6.2.1", "openai": "^4.21.0", "svelte-codemirror-editor": "^1.2.0", @@ -50,4 +51,4 @@ "vega-embed": "^6.23.0", "vega-lite": "^5.16.3" } -} \ No newline at end of file +} diff --git a/src/components/ColorScatterPlot.svelte b/src/components/ColorScatterPlot.svelte index d3aa8080..3e854908 100644 --- a/src/components/ColorScatterPlot.svelte +++ b/src/components/ColorScatterPlot.svelte @@ -11,6 +11,7 @@ import { scaleLinear } from "d3-scale"; import simulate_cvd from "../lib/blindness"; import ColorScatterPlotXyGuides from "./ColorScatterPlotXYGuides.svelte"; + import ColorScatterPlotPolarGuide from "./ColorScatterPlotPolarGuide.svelte"; import ColorScatterPlotZGuide from "./ColorScatterPlotZGuide.svelte"; import GamutMarker from "./GamutMarker.svelte"; @@ -43,36 +44,40 @@ y: $configStore.yZoom, z: $configStore.zZoom, }; - $: pickedColors = focusedColors.map((x) => colors[x].toChannels()); + $: pickedColors = focusedColors.map((el) => [ + x(colors[el]), + y(colors[el]), + z(colors[el]), + ]); $: config = colorPickerConfig[colorSpace]; $: bg = Pal.background; $: colors = Pal.colors.map((x) => toColorSpace(x, colorSpace)); - $: xRange = config.xDomain; - $: domainXScale = scaleLinear().domain([0, 1]).range(xRange); - + $: domainXScale = scaleLinear().domain([0, 1]).range(config.xDomain); $: xScale = scaleLinear() .domain([domainXScale(extents.x[0]), domainXScale(extents.x[1])]) .range([0, plotWidth]); - $: yRange = config.yDomain; - $: domainYScale = scaleLinear().domain([0, 1]).range(yRange); + $: rScale = scaleLinear() + .domain([domainXScale(extents.x[0]), domainXScale(extents.x[1])]) + .range([0, plotWidth / 2]); + + $: domainYScale = scaleLinear().domain([0, 1]).range(config.yDomain); $: yScale = scaleLinear() .domain([domainYScale(extents.y[0]), domainYScale(extents.y[1])]) .range([0, plotHeight]); - $: zRange = config.zDomain; - $: domainLScale = scaleLinear().domain([0, 1]).range(zRange); + $: domainZScale = scaleLinear().domain([0, 1]).range(config.zDomain); $: zScale = scaleLinear() - .domain([domainLScale(extents.z[0]), domainLScale(extents.z[1])]) + .domain([domainZScale(extents.z[0]), domainZScale(extents.z[1])]) .range([0, plotHeight]); - $: miniConfig = { - xIdx: config.xChannelIndex, - yIdx: config.yChannelIndex, - zIdx: config.zChannelIndex, - }; - $: pos = makePosAndSizes(pickedColors, xScale, yScale, zScale, miniConfig); + $: angleScale = scaleLinear() + .domain([0, 360]) + .range([0, 2 * Math.PI]); + + // bound box for selected colors + $: pos = makePosAndSizes(pickedColors); let dragging: false | { x: number; y: number } = false; let dragBox: false | { x: number; y: number } = false; @@ -86,23 +91,27 @@ if (!dragging || dragging.x === undefined || dragging.y === undefined) return color; - const { x, y } = toXY(e); - + const values = toXY(e); const screenPosDelta = { - x: x - dragging.x, - y: y - dragging.y, + x: values.x - dragging.x, + y: values.y - dragging.y, }; - const { xIdx, yIdx } = miniConfig; - const coords = originalColor.toChannels(); - coords[xIdx] = clampToRange( - xScale.invert(xScale(coords[xIdx]) + screenPosDelta.x), - xRange - ); - coords[yIdx] = clampToRange( - yScale.invert(yScale(coords[yIdx]) + screenPosDelta.y), - yRange - ); + const xClamp = (v: number) => clampToRange(v, config.xDomain); + const yClamp = (v: number) => clampToRange(v, config.yDomain); + // screen coordinates + const newPos = [ + x(originalColor) + screenPosDelta.x, + y(originalColor) + screenPosDelta.y, + ]; + // color space coordinates + const newVal = [ + xClamp(xInv(newPos[0], newPos[1])), + yClamp(yInv(newPos[0], newPos[1])), + ]; + const coords = originalColor.toChannels(); + coords[config.xChannelIndex] = newVal[0]; + coords[config.yChannelIndex] = newVal[1]; return colorFromChannels(coords, colorSpace); }; @@ -111,11 +120,10 @@ return color; const screenPosDelta = toXY(e).y - dragging.y; - const { zIdx } = miniConfig; const coords = originalColor.toChannels(); - coords[zIdx] = clampToRange( - zScale.invert(zScale(coords[zIdx]) + screenPosDelta), - zRange + const zClamp = (v: number) => clampToRange(v, config.zDomain); + coords[config.zChannelIndex] = zClamp( + zInv(z(originalColor) + screenPosDelta) ); return colorFromChannels(coords, colorSpace); @@ -216,11 +224,17 @@ ) { const { xMin, xMax, yMin, yMax } = dragExtent(dragBox, dragging); + // check if selected in screen space const newFocusedColors = colors .map((color, idx) => { - const [_l, a, b] = color.toChannels(); - const [x, y] = [xScale(a), yScale(b)]; - return xMin <= x && x <= xMax && yMin <= y && y <= yMax ? idx : -1; + let [xVal, yVal] = [x(color), y(color)]; + if (config.isPolar) { + xVal += plotWidth / 2; + yVal += plotHeight / 2; + } + const inXBound = xMin <= xVal && xVal <= xMax; + const inYBound = yMin <= yVal && yVal <= yMax; + return inXBound && inYBound ? idx : -1; }) .filter((x) => x !== -1); onFocusedColorsChange([...newFocusedColors]); @@ -232,7 +246,7 @@ const { yMin, yMax } = dragExtent(dragBox, dragging); const newFocusedColors = colors .map((color, idx) => { - const y = zScale(color.toChannels()[0]); + const y = z(color); return yMin <= y && y <= yMax ? idx : -1; }) .filter((x) => x !== -1); @@ -246,9 +260,39 @@ let hoveredPoint: Color | false = false; let hoverPoint = (x: typeof hoveredPoint) => (hoveredPoint = x); - $: x = (point: Color) => xScale(point.toChannels()[config.xChannelIndex]); - $: y = (point: Color) => yScale(point.toChannels()[config.yChannelIndex]); - $: z = (point: Color) => zScale(point.toChannels()[config.zChannelIndex]); + // coordinate transforms + // color space -> screen + type Channels = [number, number, number]; + // slight indirection to make these mappings more re-usable + $: xPre = config.isPolar + ? (coords: Channels) => { + const r = rScale(coords[config.xChannelIndex]); + const theta = angleScale(coords[config.yChannelIndex]); + return r * Math.cos(theta); + } + : (coords: Channels) => xScale(coords[config.xChannelIndex]); + $: yPre = config.isPolar + ? (coords: Channels) => { + const r = rScale(coords[config.xChannelIndex]); + const theta = angleScale(coords[config.yChannelIndex]); + return r * Math.sin(theta); + } + : (coords: Channels) => yScale(coords[config.yChannelIndex]); + $: zPre = (coords: Channels) => zScale(coords[config.zChannelIndex]); + $: x = (color: Color) => xPre(color.toChannels()); + $: y = (color: Color) => yPre(color.toChannels()); + $: z = (color: Color) => zPre(color.toChannels()); + // screen -> color space + $: xInv = config.isPolar + ? (x: number, y: number) => rScale.invert(Math.sqrt(x * x + y * y)) + : (x: number) => xScale.invert(x); + $: yInv = config.isPolar + ? (x: number, y: number) => { + const angle = angleScale.invert(Math.atan2(y, x)) % 360; + return angle < 0 ? angle + 360 : angle; + } + : (x: number, y: number) => yScale.invert(y); + $: zInv = (z: number) => zScale.invert(z); function clickResponse(e: any, i: number) { if (e.metaKey || e.shiftKey) { @@ -282,6 +326,17 @@ cursor: "grab", stroke: selectionColor, }; + $: guideProps = { + xScale, + yScale, + rScale, + plotHeight, + plotWidth, + axisColor, + textColor, + colorSpace, + dragging: !!dragging, + }; @@ -302,16 +357,11 @@ on:touchend={stopDrag} > - + {#if config.isPolar} + + {:else} + + {/if} @@ -331,78 +381,93 @@ class:cursor-pointer={dragging} /> - - - + - BG - - - - {#each colors as color, i} - - - - {#if scatterPlotMode === "moving"} + + + BG + + + + {#each colors as color, i} + + + + {#if scatterPlotMode === "moving"} + startDrag(true, i)(e)} + on:touchstart|preventDefault={startDrag(true, i)} + on:touchend|preventDefault={() => onFocusedColorsChange([i])} + on:mouseenter={() => hoverPoint(color)} + pointer-events={!focusSet.has(i) ? "all" : "none"} + r={10 + (focusSet.has(i) ? 5 : 0)} + stroke={focusedColors.length === 1 && + focusedColors[0] === i && + dragging + ? axisColor + : color.toDisplay()} + on:click={(e) => clickResponse(e, i)} + /> + {/if} + {#if scatterPlotMode === "looking"} + hoverPoint(color)} + /> + {/if} + {#if !color.inGamut()} + + {/if} + {/each} + {#each blindColors as blindColor, i} + startDrag(true, i)(e)} + {...CircleProps(blindColor)} + class="cursor-pointer" + stroke={blindColor.toDisplay()} + fill={"none"} + stroke-width="4" + on:mousedown|preventDefault={startDrag(true, i)} on:touchstart|preventDefault={startDrag(true, i)} on:touchend|preventDefault={() => onFocusedColorsChange([i])} - on:mouseenter={() => hoverPoint(color)} pointer-events={!focusSet.has(i) ? "all" : "none"} - r={10 + (focusSet.has(i) ? 5 : 0)} - stroke={focusedColors.length === 1 && - focusedColors[0] === i && - dragging - ? axisColor - : color.toDisplay()} on:click={(e) => clickResponse(e, i)} /> + {/each} + + {#if hoveredPoint} + + {hoveredPoint.toDisplay()} + {/if} - {#if scatterPlotMode === "looking"} - hoverPoint(color)} + {#if pickedColors.length > 1} + {/if} - {#if !color.inGamut()} - - {/if} - {/each} - {#each blindColors as blindColor, i} - - onFocusedColorsChange([i])} - pointer-events={!focusSet.has(i) ? "all" : "none"} - on:click={(e) => clickResponse(e, i)} - /> - {/each} - - {#if hoveredPoint} - - {hoveredPoint.toDisplay()} - - {/if} + {#if dragging && dragBox && isMainBoxDrag} @@ -416,20 +481,12 @@ class="pointer-events-none" /> {/if} - {#if pickedColors.length > 1} - - {/if}
+ X
@@ -526,7 +583,7 @@ -moz-transition: r 0.2s ease-in-out; } - /* svg { + svg { overflow: visible; - } */ + } diff --git a/src/components/ColorScatterPlotPolarGuide.svelte b/src/components/ColorScatterPlotPolarGuide.svelte new file mode 100644 index 00000000..5dc76890 --- /dev/null +++ b/src/components/ColorScatterPlotPolarGuide.svelte @@ -0,0 +1,116 @@ + + + + {#each [...new Array(rBgResolution)] as _, i} + {#each [...new Array(angleBgResolution)] as _, j} + + + {/each} + {/each} + + diff --git a/src/components/ColorScatterPlotXYGuides.svelte b/src/components/ColorScatterPlotXYGuides.svelte index 2c24ddaf..2afa4e51 100644 --- a/src/components/ColorScatterPlotXYGuides.svelte +++ b/src/components/ColorScatterPlotXYGuides.svelte @@ -56,12 +56,14 @@ nums.reduce((acc, x) => acc + x, 0) / nums.length; $: fillColor = (i: number, j: number) => { if (dragging && focusedColors.length === 1) { - const avgColor = [ - avgNums(focusedColors.map((x) => colors[x].toChannels()[0])), - xNonDimScale(i / bgResolution), - yNonDimScale(j / bgResolution), - ] as [number, number, number]; - return colorFromChannels(avgColor, colorSpace as any).toDisplay(); + const coords = [0, 0, 0] as [number, number, number]; + coords[config.xChannelIndex] = xNonDimScale(i / bgResolution); + coords[config.yChannelIndex] = yNonDimScale(j / bgResolution); + const avgZChannel = avgNums( + focusedColors.map((x) => colors[x].toChannels()[config.zChannelIndex]) + ); + coords[config.zChannelIndex] = avgZChannel; + return colorFromChannels(coords, colorSpace as any).toDisplay(); } return "#ffffff00"; }; diff --git a/src/lib/Color.ts b/src/lib/Color.ts index 3f7dcb68..6295eb2f 100644 --- a/src/lib/Color.ts +++ b/src/lib/Color.ts @@ -16,6 +16,7 @@ export class Color { channelNames: string[] = []; dimensionToChannel: Record<"x" | "y" | "z", string> = { x: "", y: "", z: "" }; axisLabel: (num: number) => string = (x) => x.toFixed(1).toString(); + isPolar = false; constructor() { this.domains = {}; @@ -154,7 +155,7 @@ export class CIELAB extends Color { chromaBind = chroma.lab; spaceName = "lab" as const; stepSize: Channels = [1, 1, 1]; - dimensionToChannel = { x: "L", y: "b", z: "a" }; + dimensionToChannel = { x: "a", y: "b", z: "L" }; axisLabel = (num: number) => `${Math.round(num)}`; toString(): string { @@ -198,9 +199,10 @@ export class HSL extends Color { channels = { h: 0, s: 0, l: 0 }; chromaBind = chroma.hsl; spaceName = "hsl" as const; - domains = { h: [0, 360], s: [0, 100], l: [0, 100] } as Domain; + domains = { h: [0, 360], s: [0, 100], l: [100, 0] } as Domain; stepSize: Channels = [1, 1, 1]; - dimensionToChannel = { x: "s", y: "l", z: "h" }; + dimensionToChannel = { x: "l", y: "h", z: "s" }; + isPolar = true; toString(): string { const [h, s, l] = this.stringChannels(); @@ -325,20 +327,21 @@ export const colorPickerConfig = Object.fromEntries( return [ name, { + axisLabel: exampleColor.axisLabel, + isPolar: exampleColor.isPolar, title: exampleColor.name, - xDomain: exampleColor.domains[x], - yDomain: exampleColor.domains[y], - zDomain: exampleColor.domains[z], xChannel: x, xChannelIndex: exampleColor.channelNames.indexOf(x), + xDomain: exampleColor.domains[x], + xStep: exampleColor.stepSize[1], yChannel: y, yChannelIndex: exampleColor.channelNames.indexOf(y), + yDomain: exampleColor.domains[y], + yStep: exampleColor.stepSize[2], zChannel: z, zChannelIndex: exampleColor.channelNames.indexOf(z), - xStep: exampleColor.stepSize[1], - yStep: exampleColor.stepSize[2], + zDomain: exampleColor.domains[z], zStep: exampleColor.stepSize[0], - axisLabel: exampleColor.axisLabel, }, ]; }) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 87442456..97f0e25c 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,5 +1,9 @@ -import chroma from "chroma-js"; -import { Color, colorFromString, colorFromChannels } from "./Color"; +import { + Color, + colorFromString, + colorFromChannels, + colorPickerConfig, +} from "./Color"; import type { PalType } from "../stores/color-store"; export const insert = (arr: Color[], newItem: Color, index?: number) => { if (index === undefined) { @@ -128,19 +132,6 @@ export function draggable(node: any) { }; } -const extent = (arr: number[]) => [Math.min(...arr), Math.max(...arr)]; -function makeExtents( - arr: number[][], - config: { xIdx: number; yIdx: number; zIdx: number } -) { - const { xIdx, yIdx, zIdx } = config; - return { - x: extent(arr.map((x) => x[xIdx])), - y: extent(arr.map((x) => x[yIdx])), - z: extent(arr.map((x) => x[zIdx])), - }; -} - export const clamp = (n: number, min: number, max: number) => Math.min(Math.max(n, min), max); @@ -213,30 +204,42 @@ export const colorBrewerMapToType = Object.entries(colorBrewerTypeMap).reduce( {} as any ) as Record; +const extent = (arr: number[]) => [Math.min(...arr), Math.max(...arr)]; +function makeExtents(arr: number[][]) { + return Object.fromEntries( + ["x", "y", "z"].map((key, idx) => [key, extent(arr.map((el) => el[idx]))]) + ) as { x: number[]; y: number[]; z: number[] }; +} + export function makePosAndSizes( - pickedColors: [number, number, number][], - xScale: any, - yScale: any, - zScale: any, - config: { xIdx: number; yIdx: number; zIdx: number } + pickedColors: number[][], + config: (typeof colorPickerConfig)[string] ) { - const selectionExtents = makeExtents(pickedColors, config); - const makePos = (key: keyof typeof selectionExtents, scale: any) => { - const [a, b] = scale.domain(); - return scale(selectionExtents[key][a > b ? 1 : 0]); + const selectionExtents = makeExtents(pickedColors); + console.log(selectionExtents, pickedColors); + const makePos = (key: keyof typeof selectionExtents) => { + // const [a, b] = config[`${key}Domain`]; + // return selectionExtents[key][a > b ? 1 : 0]; + return selectionExtents[key][0]; }; - const diff = (key: keyof typeof selectionExtents, scale: any) => { + const diff = (key: keyof typeof selectionExtents) => { const [a, b] = selectionExtents[key]; - return Math.abs(scale(a) - scale(b)); + return Math.abs(a - b); }; - let xPos = makePos("x", xScale) - 15; - let yPos = makePos("y", yScale) - 15; - let zPos = makePos("z", zScale); - - let selectionWidth = diff("x", xScale) + 30; - let selectionHeight = diff("y", yScale) + 30; - let selectionDepth = diff("z", zScale); + let xPos = makePos("x") - 15; + let yPos = makePos("y") - 15; + let zPos = makePos("z"); + let selectionWidth = diff("x") + 30; + let selectionHeight = diff("y") + 30; + let selectionDepth = diff("z"); + // let xPos = makePos("x"); + // let yPos = makePos("y"); + // let zPos = makePos("z"); + + // let selectionWidth = diff("x"); + // let selectionHeight = diff("y"); + // let selectionDepth = diff("z"); return { xPos, yPos, zPos, selectionWidth, selectionHeight, selectionDepth }; }