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