distinct.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. /**
  2. * Copyright (c) 2019-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  3. *
  4. * @author Alexander Rose <alexander.rose@weirdbyte.de>
  5. * @author David Sehnal <david.sehnal@gmail.com>
  6. *
  7. * adapted from https://github.com/internalfx/distinct-colors (ISC License Copyright (c) 2015, InternalFX Inc.)
  8. * which is heavily inspired by http://tools.medialab.sciences-po.fr/iwanthue/
  9. */
  10. import { Lab } from './spaces/lab';
  11. import { Hcl } from './spaces/hcl';
  12. import { deepClone } from '../../mol-util/object';
  13. import { deepEqual } from '../../mol-util';
  14. import { arraySum } from '../../mol-util/array';
  15. import { ParamDefinition as PD } from '../../mol-util/param-definition';
  16. import { ColorNames } from './names';
  17. export const DistinctColorsParams = {
  18. hue: PD.Interval([1, 360], { min: 0, max: 360, step: 1 }),
  19. chroma: PD.Interval([40, 70], { min: 0, max: 100, step: 1 }),
  20. luminance: PD.Interval([15, 85], { min: 0, max: 100, step: 1 }),
  21. clusteringStepCount: PD.Numeric(50, { min: 10, max: 200, step: 1 }, { isHidden: true }),
  22. minSampleCount: PD.Numeric(800, { min: 100, max: 5000, step: 100 }, { isHidden: true })
  23. };
  24. export type DistinctColorsParams = typeof DistinctColorsParams
  25. export type DistinctColorsProps = PD.Values<typeof DistinctColorsParams>
  26. function distance(colorA: Lab, colorB: Lab) {
  27. return Math.sqrt(
  28. Math.pow(Math.abs(colorA[0] - colorB[0]), 2) +
  29. Math.pow(Math.abs(colorA[1] - colorB[1]), 2) +
  30. Math.pow(Math.abs(colorA[2] - colorB[2]), 2)
  31. );
  32. }
  33. const LabTolerance = 2;
  34. const tmpCheckColorHcl = [0, 0, 0] as unknown as Hcl;
  35. const tmpCheckColorLab = [0, 0, 0] as unknown as Lab;
  36. function checkColor(lab: Lab, props: DistinctColorsProps) {
  37. Lab.toHcl(tmpCheckColorHcl, lab);
  38. // roundtrip to RGB for conversion tolerance testing
  39. Lab.fromColor(tmpCheckColorLab, Lab.toColor(lab));
  40. return (
  41. tmpCheckColorHcl[0] >= props.hue[0] &&
  42. tmpCheckColorHcl[0] <= props.hue[1] &&
  43. tmpCheckColorHcl[1] >= props.chroma[0] &&
  44. tmpCheckColorHcl[1] <= props.chroma[1] &&
  45. tmpCheckColorHcl[2] >= props.luminance[0] &&
  46. tmpCheckColorHcl[2] <= props.luminance[1] &&
  47. tmpCheckColorLab[0] >= (lab[0] - LabTolerance) &&
  48. tmpCheckColorLab[0] <= (lab[0] + LabTolerance) &&
  49. tmpCheckColorLab[1] >= (lab[1] - LabTolerance) &&
  50. tmpCheckColorLab[1] <= (lab[1] + LabTolerance) &&
  51. tmpCheckColorLab[2] >= (lab[2] - LabTolerance) &&
  52. tmpCheckColorLab[2] <= (lab[2] + LabTolerance)
  53. );
  54. }
  55. function sortByContrast(colors: Lab[]) {
  56. const unsortedColors = colors.slice(0);
  57. const sortedColors = [unsortedColors.shift()!];
  58. while (unsortedColors.length > 0) {
  59. const lastColor = sortedColors[sortedColors.length - 1];
  60. let nearest = 0;
  61. let maxDist = Number.MIN_SAFE_INTEGER;
  62. for (let i = 0; i < unsortedColors.length; ++i) {
  63. const dist = distance(lastColor, unsortedColors[i]);
  64. if (dist > maxDist) {
  65. maxDist = dist;
  66. nearest = i;
  67. }
  68. }
  69. sortedColors.push(unsortedColors.splice(nearest, 1)[0]);
  70. }
  71. return sortedColors;
  72. }
  73. function getSamples(count: number, p: DistinctColorsProps) {
  74. const samples = new Map<string, Lab>();
  75. const rangeDivider = Math.cbrt(count) * 1.001;
  76. const hStep = Math.max((p.hue[1] - p.hue[0]) / rangeDivider, 1);
  77. const cStep = Math.max((p.chroma[1] - p.chroma[0]) / rangeDivider, 1);
  78. const lStep = Math.max((p.luminance[1] - p.luminance[0]) / rangeDivider, 1);
  79. for (let h = p.hue[0]; h <= p.hue[1]; h += hStep) {
  80. for (let c = p.chroma[0]; c <= p.chroma[1]; c += cStep) {
  81. for (let l = p.luminance[0]; l <= p.luminance[1]; l += lStep) {
  82. const lab = Lab.fromHcl(Lab(), Hcl.create(h, c, l));
  83. if (checkColor(lab, p)) samples.set(lab.toString(), lab);
  84. }
  85. }
  86. }
  87. return Array.from(samples.values());
  88. }
  89. /**
  90. * Create a list of visually distinct colors
  91. */
  92. export function distinctColors(count: number, props: Partial<DistinctColorsProps> = {}) {
  93. const p = { ...PD.getDefaultValues(DistinctColorsParams), ...props };
  94. if (count <= 0) return [];
  95. const samples = getSamples(Math.max(p.minSampleCount, count * 5), p);
  96. if (samples.length < count) {
  97. console.warn('Not enough samples to generate distinct colors, increase sample count.');
  98. return (new Array(count)).fill(ColorNames.lightgrey);
  99. }
  100. const colors: Lab[] = [];
  101. const zonesProto: (Lab[])[] = [];
  102. const sliceSize = Math.floor(samples.length / count);
  103. for (let i = 0; i < samples.length; i += sliceSize) {
  104. colors.push(samples[i]);
  105. zonesProto.push([]);
  106. if (colors.length >= count) break;
  107. }
  108. for (let step = 1; step <= p.clusteringStepCount; ++step) {
  109. const zones = deepClone(zonesProto);
  110. // Find closest color for each sample
  111. for (let i = 0; i < samples.length; ++i) {
  112. let minDist = Number.MAX_SAFE_INTEGER;
  113. let nearest = 0;
  114. for (let j = 0; j < colors.length; j++) {
  115. const dist = distance(samples[i], colors[j]);
  116. if (dist < minDist) {
  117. minDist = dist;
  118. nearest = j;
  119. }
  120. }
  121. zones[nearest].push(samples[i]);
  122. }
  123. const lastColors = deepClone(colors);
  124. for (let i = 0; i < zones.length; ++i) {
  125. const zone = zones[i];
  126. const size = zone.length;
  127. const Ls: number[] = [];
  128. const As: number[] = [];
  129. const Bs: number[] = [];
  130. for (const sample of zone) {
  131. Ls.push(sample[0]);
  132. As.push(sample[1]);
  133. Bs.push(sample[2]);
  134. }
  135. const lAvg = arraySum(Ls) / size;
  136. const aAvg = arraySum(As) / size;
  137. const bAvg = arraySum(Bs) / size;
  138. colors[i] = [lAvg, aAvg, bAvg] as unknown as Lab;
  139. }
  140. if (deepEqual(lastColors, colors)) break;
  141. }
  142. return sortByContrast(colors).map(c => Lab.toColor(c));
  143. }