From 633869d7968eed8c8129a902897ba3e39d2ea7ce Mon Sep 17 00:00:00 2001 From: Alexander Rose <alexander.rose@weirdbyte.de> Date: Thu, 30 May 2019 21:27:35 -0700 Subject: [PATCH] added generator for visually distinct colors --- src/mol-theme/color/chain-id.ts | 23 ++-- src/mol-theme/color/entity-source.ts | 19 ++-- src/mol-theme/color/polymer-id.ts | 23 ++-- src/mol-theme/color/polymer-index.ts | 21 ++-- src/mol-theme/color/unit-index.ts | 19 ++-- src/mol-theme/color/util.ts | 59 ++++++++++ src/mol-util/color/distinct.ts | 164 +++++++++++++++++++++++++++ 7 files changed, 284 insertions(+), 44 deletions(-) create mode 100644 src/mol-theme/color/util.ts create mode 100644 src/mol-util/color/distinct.ts diff --git a/src/mol-theme/color/chain-id.ts b/src/mol-theme/color/chain-id.ts index 46629e03e..58034403c 100644 --- a/src/mol-theme/color/chain-id.ts +++ b/src/mol-theme/color/chain-id.ts @@ -1,24 +1,26 @@ /** - * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2018-2019 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author Alexander Rose <alexander.rose@weirdbyte.de> */ import { Unit, StructureProperties, StructureElement, Link } from 'mol-model/structure'; -import { ColorScale, Color } from 'mol-util/color'; +import { Color } from 'mol-util/color'; import { Location } from 'mol-model/location'; import { ColorTheme, LocationColor } from '../color'; import { ParamDefinition as PD } from 'mol-util/param-definition' import { ThemeDataContext } from 'mol-theme/theme'; -import { ColorListOptions, ColorListName } from 'mol-util/color/scale'; +import { ScaleLegend } from 'mol-util/color/scale'; import { Column } from 'mol-data/db'; +import { getPaletteParams, getPalette } from './util'; +import { TableLegend } from 'mol-util/color/tables'; const DefaultColor = Color(0xCCCCCC) const Description = 'Gives every chain a color based on its `asym_id` value.' export const ChainIdColorThemeParams = { - list: PD.ColorScale<ColorListName>('RedYellowBlue', ColorListOptions), + ...getPaletteParams({ scaleList: 'RedYellowBlue' }), } export type ChainIdColorThemeParams = typeof ChainIdColorThemeParams export function getChainIdColorThemeParams(ctx: ThemeDataContext) { @@ -48,7 +50,7 @@ function addAsymIds(map: Map<string, number>, data: Column<string>) { export function ChainIdColorTheme(ctx: ThemeDataContext, props: PD.Values<ChainIdColorThemeParams>): ColorTheme<ChainIdColorThemeParams> { let color: LocationColor - const scale = ColorScale.create({ listOrName: props.list, minLabel: 'Start', maxLabel: 'End' }) + let legend: ScaleLegend | TableLegend | undefined if (ctx.structure) { // TODO same asym ids in different models should get different color @@ -63,18 +65,19 @@ export function ChainIdColorTheme(ctx: ThemeDataContext, props: PD.Values<ChainI addAsymIds(asymIdSerialMap, m.coarseHierarchy.gaussians.asym_id) } } - scale.setDomain(0, asymIdSerialMap.size - 1) - const scaleColor = scale.color + + const palette = getPalette(asymIdSerialMap.size, props) + legend = palette.legend color = (location: Location): Color => { if (StructureElement.isLocation(location)) { const asym_id = getAsymId(location.unit) - return scaleColor(asymIdSerialMap.get(asym_id(location)) || 0) + return palette.color(asymIdSerialMap.get(asym_id(location)) || 0) } else if (Link.isLocation(location)) { const asym_id = getAsymId(location.aUnit) l.unit = location.aUnit l.element = location.aUnit.elements[location.aIndex] - return scaleColor(asymIdSerialMap.get(asym_id(l)) || 0) + return palette.color(asymIdSerialMap.get(asym_id(l)) || 0) } return DefaultColor } @@ -88,7 +91,7 @@ export function ChainIdColorTheme(ctx: ThemeDataContext, props: PD.Values<ChainI color, props, description: Description, - legend: scale ? scale.legend : undefined + legend } } diff --git a/src/mol-theme/color/entity-source.ts b/src/mol-theme/color/entity-source.ts index 828f0b824..6311735d5 100644 --- a/src/mol-theme/color/entity-source.ts +++ b/src/mol-theme/color/entity-source.ts @@ -5,20 +5,22 @@ */ import { StructureProperties, StructureElement, Link, Model } from 'mol-model/structure'; -import { ColorScale, Color } from 'mol-util/color'; +import { Color } from 'mol-util/color'; import { Location } from 'mol-model/location'; import { ColorTheme, LocationColor } from '../color'; import { ParamDefinition as PD } from 'mol-util/param-definition' import { ThemeDataContext } from 'mol-theme/theme'; -import { ColorListOptions, ColorListName } from 'mol-util/color/scale'; +import { ScaleLegend } from 'mol-util/color/scale'; import { Table, Column } from 'mol-data/db'; import { mmCIF_Schema } from 'mol-io/reader/cif/schema/mmcif'; +import { getPaletteParams, getPalette } from './util'; +import { TableLegend } from 'mol-util/color/tables'; const DefaultColor = Color(0xCCCCCC) const Description = 'Gives ranges of a polymer chain a color based on the entity source it originates from. Genes get the same color per entity' export const EntitySourceColorThemeParams = { - list: PD.ColorScale<ColorListName>('RedYellowBlue', ColorListOptions), + ...getPaletteParams({ scaleList: 'RedYellowBlue' }), } export type EntitySourceColorThemeParams = typeof EntitySourceColorThemeParams export function getEntitySourceColorThemeParams(ctx: ThemeDataContext) { @@ -77,7 +79,7 @@ function addSrc(seqToSrcByModelEntity: Map<string, Int16Array>, srcKeySerialMap: export function EntitySourceColorTheme(ctx: ThemeDataContext, props: PD.Values<EntitySourceColorThemeParams>): ColorTheme<EntitySourceColorThemeParams> { let color: LocationColor - const scale = ColorScale.create({ listOrName: props.list, minLabel: 'Start', maxLabel: 'End' }) + let legend: ScaleLegend | TableLegend | undefined const { structure } = ctx if (structure) { @@ -94,8 +96,9 @@ export function EntitySourceColorTheme(ctx: ThemeDataContext, props: PD.Values<E addSrc(seqToSrcByModelEntity, srcKeySerialMap, i, m, entity_src_nat) addSrc(seqToSrcByModelEntity, srcKeySerialMap, i, m, pdbx_entity_src_syn) } - scale.setDomain(1, srcKeySerialMap.size) - const scaleColor = scale.color + + const palette = getPalette(srcKeySerialMap.size + 1, props) + legend = palette.legend const getSrcColor = (location: StructureElement) => { const modelIndex = structure.models.indexOf(location.unit.model) @@ -104,7 +107,7 @@ export function EntitySourceColorTheme(ctx: ThemeDataContext, props: PD.Values<E const seqToSrc = seqToSrcByModelEntity.get(mK) if (seqToSrc) { // minus 1 to convert seqId to array index - return scaleColor(seqToSrc[StructureProperties.residue.label_seq_id(location) - 1]) + return palette.color(seqToSrc[StructureProperties.residue.label_seq_id(location) - 1]) } else { return DefaultColor } @@ -130,7 +133,7 @@ export function EntitySourceColorTheme(ctx: ThemeDataContext, props: PD.Values<E color, props, description: Description, - legend: scale ? scale.legend : undefined + legend } } diff --git a/src/mol-theme/color/polymer-id.ts b/src/mol-theme/color/polymer-id.ts index f97def2d4..8b9764041 100644 --- a/src/mol-theme/color/polymer-id.ts +++ b/src/mol-theme/color/polymer-id.ts @@ -1,25 +1,27 @@ /** - * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2018-2019 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author Alexander Rose <alexander.rose@weirdbyte.de> */ import { Unit, StructureProperties, StructureElement, Link } from 'mol-model/structure'; -import { ColorScale, Color } from 'mol-util/color'; +import { Color } from 'mol-util/color'; import { Location } from 'mol-model/location'; import { ColorTheme, LocationColor } from '../color'; import { ParamDefinition as PD } from 'mol-util/param-definition' import { ThemeDataContext } from 'mol-theme/theme'; -import { ColorListOptions, ColorListName } from 'mol-util/color/scale'; import { Column } from 'mol-data/db'; import { Entities } from 'mol-model/structure/model/properties/common'; +import { getPalette, getPaletteParams } from './util'; +import { ScaleLegend } from 'mol-util/color/scale'; +import { TableLegend } from 'mol-util/color/tables'; const DefaultColor = Color(0xCCCCCC) const Description = 'Gives every polymer chain a color based on its `asym_id` value.' export const PolymerIdColorThemeParams = { - list: PD.ColorScale<ColorListName>('RedYellowBlue', ColorListOptions), + ...getPaletteParams({ scaleList: 'RedYellowBlue' }), } export type PolymerIdColorThemeParams = typeof PolymerIdColorThemeParams export function getPolymerIdColorThemeParams(ctx: ThemeDataContext) { @@ -53,7 +55,7 @@ function addPolymerAsymIds(map: Map<string, number>, asymId: Column<string>, ent export function PolymerIdColorTheme(ctx: ThemeDataContext, props: PD.Values<PolymerIdColorThemeParams>): ColorTheme<PolymerIdColorThemeParams> { let color: LocationColor - const scale = ColorScale.create({ listOrName: props.list, minLabel: 'Start', maxLabel: 'End' }) + let legend: ScaleLegend | TableLegend | undefined if (ctx.structure) { // TODO same asym ids in different models should get different color @@ -68,18 +70,19 @@ export function PolymerIdColorTheme(ctx: ThemeDataContext, props: PD.Values<Poly addPolymerAsymIds(polymerAsymIdSerialMap, m.coarseHierarchy.gaussians.asym_id, m.coarseHierarchy.spheres.entity_id, m.entities) } } - scale.setDomain(0, polymerAsymIdSerialMap.size - 1) - const scaleColor = scale.color + + const palette = getPalette(polymerAsymIdSerialMap.size, props) + legend = palette.legend color = (location: Location): Color => { if (StructureElement.isLocation(location)) { const asym_id = getAsymId(location.unit) - return scaleColor(polymerAsymIdSerialMap.get(asym_id(location)) || 0) + return palette.color(polymerAsymIdSerialMap.get(asym_id(location)) || 0) } else if (Link.isLocation(location)) { const asym_id = getAsymId(location.aUnit) l.unit = location.aUnit l.element = location.aUnit.elements[location.aIndex] - return scaleColor(polymerAsymIdSerialMap.get(asym_id(l)) || 0) + return palette.color(polymerAsymIdSerialMap.get(asym_id(l)) || 0) } return DefaultColor } @@ -93,7 +96,7 @@ export function PolymerIdColorTheme(ctx: ThemeDataContext, props: PD.Values<Poly color, props, description: Description, - legend: scale ? scale.legend : undefined + legend } } diff --git a/src/mol-theme/color/polymer-index.ts b/src/mol-theme/color/polymer-index.ts index 3e0da2186..d90b37d6b 100644 --- a/src/mol-theme/color/polymer-index.ts +++ b/src/mol-theme/color/polymer-index.ts @@ -1,22 +1,24 @@ /** - * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2018-2019 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author Alexander Rose <alexander.rose@weirdbyte.de> */ -import { ColorScale, Color } from 'mol-util/color'; +import { Color } from 'mol-util/color'; import { Location } from 'mol-model/location'; import { StructureElement, Link } from 'mol-model/structure'; import { ColorTheme, LocationColor } from '../color'; import { ParamDefinition as PD } from 'mol-util/param-definition' import { ThemeDataContext } from 'mol-theme/theme'; -import { ColorListName, ColorListOptions } from 'mol-util/color/scale'; +import { ScaleLegend } from 'mol-util/color/scale'; +import { TableLegend } from 'mol-util/color/tables'; +import { getPaletteParams, getPalette } from './util'; const DefaultColor = Color(0xCCCCCC) const Description = 'Gives every polymer a unique color based on the position (index) of the polymer in the list of polymers in the structure.' export const PolymerIndexColorThemeParams = { - list: PD.ColorScale<ColorListName>('RedYellowBlue', ColorListOptions), + ...getPaletteParams({ scaleList: 'RedYellowBlue' }), } export type PolymerIndexColorThemeParams = typeof PolymerIndexColorThemeParams export function getPolymerIndexColorThemeParams(ctx: ThemeDataContext) { @@ -26,7 +28,7 @@ export type PolymerIndexColorThemeProps = PD.Values<typeof PolymerIndexColorThem export function PolymerIndexColorTheme(ctx: ThemeDataContext, props: PD.Values<PolymerIndexColorThemeParams>): ColorTheme<PolymerIndexColorThemeParams> { let color: LocationColor - const scale = ColorScale.create({ listOrName: props.list, minLabel: 'Start', maxLabel: 'End' }) + let legend: ScaleLegend | TableLegend | undefined if (ctx.structure) { const { units } = ctx.structure @@ -34,11 +36,14 @@ export function PolymerIndexColorTheme(ctx: ThemeDataContext, props: PD.Values<P for (let i = 0, il = units.length; i <il; ++i) { if (units[i].polymerElements.length > 0) ++polymerCount } - scale.setDomain(0, polymerCount - 1) + + const palette = getPalette(polymerCount, props) + legend = palette.legend + const unitIdColor = new Map<number, Color>() for (let i = 0, j = 0, il = units.length; i <il; ++i) { if (units[i].polymerElements.length > 0) { - unitIdColor.set(units[i].id, scale.color(j)) + unitIdColor.set(units[i].id, palette.color(j)) ++j } } @@ -62,7 +67,7 @@ export function PolymerIndexColorTheme(ctx: ThemeDataContext, props: PD.Values<P color, props, description: Description, - legend: scale ? scale.legend : undefined + legend } } diff --git a/src/mol-theme/color/unit-index.ts b/src/mol-theme/color/unit-index.ts index caacec5aa..c6e7764c3 100644 --- a/src/mol-theme/color/unit-index.ts +++ b/src/mol-theme/color/unit-index.ts @@ -1,22 +1,24 @@ /** - * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2018-2019 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author Alexander Rose <alexander.rose@weirdbyte.de> */ -import { ColorScale, Color } from 'mol-util/color'; +import { Color } from 'mol-util/color'; import { Location } from 'mol-model/location'; import { StructureElement, Link } from 'mol-model/structure'; import { ColorTheme, LocationColor } from '../color'; import { ParamDefinition as PD } from 'mol-util/param-definition' import { ThemeDataContext } from 'mol-theme/theme'; -import { ColorListOptions, ColorListName } from 'mol-util/color/scale'; +import { ScaleLegend } from 'mol-util/color/scale'; +import { getPaletteParams, getPalette } from './util'; +import { TableLegend } from 'mol-util/color/tables'; const DefaultColor = Color(0xCCCCCC) const Description = 'Gives every unit (single chain or collection of single elements) a unique color based on the position (index) of the unit in the list of units in the structure.' export const UnitIndexColorThemeParams = { - list: PD.ColorScale<ColorListName>('RedYellowBlue', ColorListOptions), + ...getPaletteParams({ scaleList: 'RedYellowBlue' }), } export type UnitIndexColorThemeParams = typeof UnitIndexColorThemeParams export function getUnitIndexColorThemeParams(ctx: ThemeDataContext) { @@ -25,14 +27,15 @@ export function getUnitIndexColorThemeParams(ctx: ThemeDataContext) { export function UnitIndexColorTheme(ctx: ThemeDataContext, props: PD.Values<UnitIndexColorThemeParams>): ColorTheme<UnitIndexColorThemeParams> { let color: LocationColor - const scale = ColorScale.create({ listOrName: props.list, minLabel: 'Start', maxLabel: 'End' }) + let legend: ScaleLegend | TableLegend | undefined if (ctx.structure) { const { units } = ctx.structure - scale.setDomain(0, units.length - 1) + const palette = getPalette(units.length, props) + legend = palette.legend const unitIdColor = new Map<number, Color>() for (let i = 0, il = units.length; i <il; ++i) { - unitIdColor.set(units[i].id, scale.color(i)) + unitIdColor.set(units[i].id, palette.color(i)) } color = (location: Location): Color => { @@ -53,7 +56,7 @@ export function UnitIndexColorTheme(ctx: ThemeDataContext, props: PD.Values<Unit color, props, description: Description, - legend: scale ? scale.legend : undefined + legend } } diff --git a/src/mol-theme/color/util.ts b/src/mol-theme/color/util.ts new file mode 100644 index 000000000..63efaedf3 --- /dev/null +++ b/src/mol-theme/color/util.ts @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author Alexander Rose <alexander.rose@weirdbyte.de> + */ + +import { ParamDefinition as PD } from 'mol-util/param-definition' +import { DistinctColorsParams, distinctColors } from 'mol-util/color/distinct'; +import { ColorListName, ColorListOptions, ScaleLegend, ColorScale } from 'mol-util/color/scale'; +import { Color } from 'mol-util/color'; +import { TableLegend } from 'mol-util/color/tables'; + +const DefaultGetPaletteProps = { + scaleList: 'RedYellowBlue' as ColorListName +} +type GetPaletteProps = typeof DefaultGetPaletteProps + +export function getPaletteParams(props: Partial<GetPaletteProps> = {}) { + const p = { ...DefaultGetPaletteProps, props } + return { + palette: PD.MappedStatic('generate', { + scale: PD.Group({ + list: PD.ColorScale<ColorListName>(p.scaleList, ColorListOptions), + }, { isFlat: true }), + generate: PD.Group(DistinctColorsParams, { isFlat: true }) + }, { + options: [ + ['scale', 'From Scale'], + ['generate', 'Generate Distinct'] + ] + }) + } +} + +const DefaultPaletteProps = PD.getDefaultValues(getPaletteParams()) +type PaletteProps = typeof DefaultPaletteProps + +export interface Palette { + color: (i: number) => Color + legend?: TableLegend | ScaleLegend +} + +export function getPalette(count: number, props: PaletteProps) { + let color: (i: number) => Color + let legend: ScaleLegend | TableLegend | undefined + + if (props.palette.name === 'scale') { + const listOrName = props.palette.params.list + const domain: [number, number] = [0, count - 1] + const scale = ColorScale.create({ listOrName, domain, minLabel: 'Start', maxLabel: 'End' }) + legend = scale.legend + color = scale.color + } else { + const colors = distinctColors(count, props.palette.params) + color = (i: number) => colors[i] + } + + return { color, legend } +} \ No newline at end of file diff --git a/src/mol-util/color/distinct.ts b/src/mol-util/color/distinct.ts new file mode 100644 index 000000000..f818f2b6c --- /dev/null +++ b/src/mol-util/color/distinct.ts @@ -0,0 +1,164 @@ +/** + * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author Alexander Rose <alexander.rose@weirdbyte.de> + * + * adapted from https://github.com/internalfx/distinct-colors (ISC License Copyright (c) 2015, InternalFX Inc.) + * which is heavily inspired by http://tools.medialab.sciences-po.fr/iwanthue/ + */ + +import { Lab } from './spaces/lab'; +import { Hcl } from './spaces/hcl'; +import { deepClone } from 'mol-util/object'; +import { deepEqual } from 'mol-util'; +import { arraySum } from 'mol-util/array'; +import { ParamDefinition as PD } from 'mol-util/param-definition' + +export const DistinctColorsParams = { + hue: PD.Interval([1, 360], { min: 0, max: 360, step: 1 }), + chroma: PD.Interval([40, 70], { min: 0, max: 100, step: 1 }), + luminance: PD.Interval([15, 85], { min: 0, max: 100, step: 1 }), + + clusteringStepCount: PD.Numeric(50, { min: 10, max: 200, step: 1 }, { isHidden: true }), + minSampleCount: PD.Numeric(800, { min: 100, max: 5000, step: 100 }, { isHidden: true }) +} +export type DistinctColorsParams = typeof DistinctColorsParams +export type DistinctColorsProps = PD.Values<typeof DistinctColorsParams> + +function distance(colorA: Lab, colorB: Lab) { + return Math.sqrt( + Math.pow(Math.abs(colorA[0] - colorB[0]), 2) + + Math.pow(Math.abs(colorA[1] - colorB[1]), 2) + + Math.pow(Math.abs(colorA[2] - colorB[2]), 2) + ) +} + +const LabTolerance = 2 +const tmpCheckColorHcl = [0, 0, 0] as Hcl +const tmpCheckColorLab = [0, 0, 0] as Lab +function checkColor(lab: Lab, props: DistinctColorsProps) { + Lab.toHcl(tmpCheckColorHcl, lab) + // roundtrip to RGB for conversion tolerance testing + Lab.fromColor(tmpCheckColorLab, Lab.toColor(lab)) + + return ( + tmpCheckColorHcl[0] >= props.hue[0] && + tmpCheckColorHcl[0] <= props.hue[1] && + tmpCheckColorHcl[1] >= props.chroma[0] && + tmpCheckColorHcl[1] <= props.chroma[1] && + tmpCheckColorHcl[2] >= props.luminance[0] && + tmpCheckColorHcl[2] <= props.luminance[1] && + tmpCheckColorLab[0] >= (lab[0] - LabTolerance) && + tmpCheckColorLab[0] <= (lab[0] + LabTolerance) && + tmpCheckColorLab[1] >= (lab[1] - LabTolerance) && + tmpCheckColorLab[1] <= (lab[1] + LabTolerance) && + tmpCheckColorLab[2] >= (lab[2] - LabTolerance) && + tmpCheckColorLab[2] <= (lab[2] + LabTolerance) + ) +} + +function sortByContrast(colors: Lab[]) { + const unsortedColors = colors.slice(0) + const sortedColors = [unsortedColors.shift()!] + while (unsortedColors.length > 0) { + const lastColor = sortedColors[sortedColors.length - 1] + let nearest = 0 + let maxDist = Number.MIN_SAFE_INTEGER + for (let i = 0; i < unsortedColors.length; ++i) { + const dist = distance(lastColor, unsortedColors[i]) + if (dist > maxDist) { + maxDist = dist + nearest = i + } + } + sortedColors.push(unsortedColors.splice(nearest, 1)[0]) + } + return sortedColors +} + +function getSamples(count: number, p: DistinctColorsProps) { + const samples = new Map<string, Lab>() + const rangeDivider = Math.cbrt(count) * 1.001 + + const hStep = (p.hue[1] - p.hue[0]) / rangeDivider + const cStep = (p.chroma[1] - p.chroma[0]) / rangeDivider + const lStep = (p.luminance[1] - p.luminance[0]) / rangeDivider + for (let h = p.hue[0]; h <= p.hue[1]; h += hStep) { + for (let c = p.chroma[0]; c <= p.chroma[1]; c += cStep) { + for (let l = p.luminance[0]; l <= p.luminance[1]; l += lStep) { + const lab = Lab.fromHcl(Lab(), Hcl.create(h, c, l)) + if (checkColor(lab, p)) samples.set(lab.toString(), lab) + } + } + } + + return Array.from(samples.values()) +} + +/** + * Create a list of visually distinct colors + */ +export function distinctColors(count: number, props: Partial<DistinctColorsProps> = {}) { + const p = { ...PD.getDefaultValues(DistinctColorsParams), ...props } + + if (count <= 0) return [] + + const samples = getSamples(Math.max(p.minSampleCount, count * 5), p) + if (samples.length < count) { + throw new Error('Not enough samples to generate distinct colors, increase sample count.') + } + + const colors: Lab[] = [] + const zonesProto: (Lab[])[] = [] + const sliceSize = Math.floor(samples.length / count) + + for (let i = 0; i < samples.length; i += sliceSize) { + colors.push(samples[i]) + zonesProto.push([]) + if (colors.length >= count) break + } + + for (let step = 1; step <= p.clusteringStepCount; ++step) { + const zones = deepClone(zonesProto) + + // Find closest color for each sample + for (let i = 0; i < samples.length; ++i) { + let minDist = Number.MAX_SAFE_INTEGER + let nearest = 0 + for (let j = 0; j < colors.length; j++) { + const dist = distance(samples[i], colors[j]) + if (dist < minDist) { + minDist = dist + nearest = j + } + } + zones[nearest].push(samples[i]) + } + + const lastColors = deepClone(colors) + + for (let i = 0; i < zones.length; ++i) { + const zone = zones[i] + const size = zone.length + const Ls: number[] = [] + const As: number[] = [] + const Bs: number[] = [] + + for (let sample of zone) { + Ls.push(sample[0]) + As.push(sample[1]) + Bs.push(sample[2]) + } + + const lAvg = arraySum(Ls) / size + const aAvg = arraySum(As) / size + const bAvg = arraySum(Bs) / size + + colors[i] = [lAvg, aAvg, bAvg] as Lab + } + + if (deepEqual(lastColors, colors)) break + } + + return sortByContrast(colors).map(c => Lab.toColor(c)) +} \ No newline at end of file -- GitLab