Gradient.ts

import { CC, Color, ColorSpec } from "./colors";

export abstract class GradientType {
  gradient?: string;
  abstract valueToHex(value: number, range?: number[]): number;
  abstract range(): number[] | null;
}

export function normalizeValue(
  lo: number,
  hi: number,
  val: number
): { lo: number; hi: number; val: number } {
  if (hi >= lo) {
    if (val < lo) val = lo;
    if (val > hi) val = hi;
    return { lo: lo, hi: hi, val: val };
  } else {
    if (val > lo) val = lo;
    if (val < hi) val = hi;
    //flip the meaning of val, lo, hi
    val = lo - val + hi;
    return { lo: hi, hi: lo, val: val };
  }
}

/**
*  Gradient specification.
* @see builtinGradients
*/
export type GradientSpec = {
  /** Kind of gradient. E.g. RWB, ROYGB, sinebow.  Can also specify linear[_color]* as a
   * shorthand for CustomLinear and passing a colors array.   */
  gradient?: string;
  /** Lower range of gradient */
  min?: number;
  /** Upper range of gradient */
  max?: number;
  /**  {AtomSpec} property to use for gradient calculation.  E.g., 'b' for temperature factors of a PDB. */
  prop?: string;
  /** mid point value for gradient (for rwb) */
  mid?: number;
  /** Custom colors for gradient (for {@link CustomLinear}) */
  colors?: Array<ColorSpec>;
  /** map of a certain {@link AtomSpec} property to a color of the form `{'prop': 'elem', map:elementColors.greenCarbon}` Allows the user to provide a mapping of elements to colors to the colorscheme.  This can be done with any properties, and not just 'elem'.
 */
  map?: Record<string, unknown>
};

//return a Gradient object, even if what is specified is descriptive
export function getGradient(grad: GradientSpec|GradientType): GradientType {
  if (grad instanceof GradientType) {
    return grad;
  } else if (grad.gradient !== undefined && builtinGradients[grad.gradient]
  ) {
    let min = grad.min === undefined ? -1 : grad.min;
    let max = grad.max === undefined ? 1 : grad.max;
    if (grad.mid === undefined) {
      if (grad.colors === undefined) {
        return new builtinGradients[grad.gradient](min, max);
      } else {
        return new builtinGradients[grad.gradient](min, max, grad.colors);
      }
    } else {
      return new builtinGradients[grad.gradient](min, max, grad.mid);
    }
  } else if(typeof(grad.gradient) == "string" && grad.gradient.startsWith('linear_')) {
    let colors = grad.gradient.split('_');
    colors.shift();
    let min = grad.min === undefined ? -1 : grad.min;
    let max = grad.max === undefined ? 1 : grad.max;    
    return new CustomLinear(min,max,colors);
  }
  return grad as GradientType;
}

/**
 * Color scheme red to white to blue, for charges
 * Reverse gradients are supported when min>max so that the colors are displayed in reverse order.
 * @subcategory Gradients
 */
export class RWB extends GradientType {
  gradient = "RWB";
  min: number;
  max: number;
  mid?: number;
  mult: number;
  constructor(min?: number | [number, number], max?: number, mid?: number) {
    super();
    this.mult = 1.0;
    this.mid = mid;
    this.min = min as number;
    this.max = max as number;
    if (typeof max == "undefined" && Array.isArray(min) && min.length >= 2) {
      //we were passed a single range
      this.max = min[1];
      this.min = min[0];
    } else if (!!min && !!max && !Array.isArray(min)) {
      this.min = min;
      this.max = max;
    }
  }

  //return range used for color mapping, null if none set
  range() {
    if (typeof this.min != "undefined" && typeof this.max != "undefined") {
      return [this.min, this.max] as [number, number];
    }
    return null;
  }

  //map value to hex color, range is provided
  valueToHex(val: number, range?: number[]) {
    var lo: number, hi: number;
    val = this.mult * val; //reverse if necessary
    if (range) {
      lo = range[0];
      hi = range[1];
    } else {
      lo = this.min;
      hi = this.max;
    }

    if (val === undefined) return 0xffffff;

    var norm = normalizeValue(lo, hi, val);
    lo = norm.lo;
    hi = norm.hi;
    val = norm.val;

    var middle = (hi + lo) / 2;
    if (range && typeof range[2] != "undefined") middle = range[2];
    else if (typeof this.mid != "undefined")
      middle = this.mid; //allow user to specify midpoint
    else middle = (lo + hi) / 2;
    var scale: number, color: number;

    //scale bottom from red to white
    if (val < middle) {
      scale = Math.floor(255 * Math.sqrt((val - lo) / (middle - lo)));
      color = 0xff0000 + 0x100 * scale + scale;
      return color;
    } else if (val > middle) {
      //form white to blue
      scale = Math.floor(255 * Math.sqrt(1 - (val - middle) / (hi - middle)));
      color = 0x10000 * scale + 0x100 * scale + 0xff;
      return color;
    } else {
      //val == middle
      return 0xffffff;
    }
  }
}

/**
 * rainbow gradient, but without purple to match jmol
 * Reverse gradients are supported when min>max so that the colors are displayed in reverse order.
 * @subcategory Gradients
 */
export class ROYGB extends GradientType {
  gradient = "ROYGB";
  mult: number;
  max?: number;
  min?: number;
  constructor(min?: number, max?: number) {
    super();
    this.mult = 1.0;
    this.min = min;
    this.max = max;
    if (typeof max == "undefined" && Array.isArray(min) && min.length >= 2) {
      //we were passed a single range
      this.max = min[1];
      this.min = min[0];
    } else if (!!min && !!max && !Array.isArray(min)) {
      this.min = min;
      this.max = max;
    }
  };
  //map value to hex color, range is provided
  valueToHex(val: number, range?: any[]) {
    var lo: number, hi: number;
    val = this.mult * val;
    if (range) {
      lo = range[0];
      hi = range[1];
    } else {
      lo = this.min!;
      hi = this.max!;
    }

    if (typeof val == "undefined") return 0xffffff;

    var norm = normalizeValue(lo, hi, val);
    lo = norm.lo;
    hi = norm.hi;
    val = norm.val;

    var mid = (lo + hi) / 2;
    var q1 = (lo + mid) / 2;
    var q3 = (mid + hi) / 2;

    var scale: number, color: number;
    if (val < q1) {
      //scale green up, red up, blue down
      scale = Math.floor(255 * Math.sqrt((val - lo) / (q1 - lo)));
      color = 0xff0000 + 0x100 * scale + 0;
      return color;
    } else if (val < mid) {
      //scale red down, green up, blue down
      scale = Math.floor(255 * Math.sqrt(1 - (val - q1) / (mid - q1)));
      color = 0x010000 * scale + 0xff00 + 0x0;
      return color;
    } else if (val < q3) {
      //scale blue up, red down, green up
      scale = Math.floor(255 * Math.sqrt((val - mid) / (q3 - mid)));
      color = 0x000000 + 0xff00 + 0x1 * scale;
      return color;
    } else {
      //scale green down, blue up, red down
      scale = Math.floor(255 * Math.sqrt(1 - (val - q3) / (hi - q3)));
      color = 0x000000 + 0x0100 * scale + 0xff;
      return color;
    }
  };

  //return range used for color mapping, null if none set
  range() {
    if (typeof this.min != "undefined" && typeof this.max != "undefined") {
      return [this.min, this.max] as [number, number];
    }
    return null;
  };
}
/**
 * rainbow gradient with constant saturation, all the way to purple!
 * Reverse gradients are supported when min>max so that the colors are displayed in reverse order.
  * @subcategory Gradients 
 * 
 * @example $.get('data/1fas.pqr', function(data){
      viewer.addModel(data, "pqr");
      $.get("data/1fas.cube",function(volumedata){
          viewer.addSurface($3Dmol.SurfaceType.VDW, {
              opacity:0.85,
              voldata: new $3Dmol.VolumeData(volumedata, "cube"),
              volscheme: new $3Dmol.Gradient.Sinebow(2,0,1)
          },{});
          
      viewer.render();
      });
      viewer.zoomTo();
  });
 */
export class Sinebow extends GradientType {
  gradient = "Sinebow";
  mult: number;
  max: number;
  min: number;
  constructor(min: number, max: number) {
    super();
    this.mult = 1.0;
    this.min = min;
    this.max = max;
    if (typeof max == "undefined" && Array.isArray(min) && min.length >= 2) {
      //we were passed a single range
      this.max = min[1];
      this.min = min[0];
    }
    if (max < min) {
      //reverse the order
      this.mult = -1.0;
      this.min *= -1.0;
      this.max *= -1.0;
    }
  };

  //map value to hex color, range is provided
  valueToHex(val: number, range?: any[]) {
    var lo: number, hi: number;
    val = this.mult * val;
    if (range) {
      lo = range[0];
      hi = range[1];
    } else {
      lo = this.min;
      hi = this.max;
    }

    if (typeof val == "undefined") return 0xffffff;
    var norm = Gradient.normalizeValue(lo, hi, val);
    lo = norm.lo;
    hi = norm.hi;
    val = norm.val;

    var scale = (val - lo) / (hi - lo);
    var h = (5 * scale) / 6.0 + 0.5;
    var r = Math.sin(Math.PI * h);
    r *= r * 255;
    var g = Math.sin(Math.PI * (h + 1 / 3.0));
    g *= g * 255;
    var b = Math.sin(Math.PI * (h + 2 / 3.0));
    b *= b * 255;

    return (
      0x10000 * Math.floor(r) + 0x100 * Math.floor(b) + 0x1 * Math.floor(g)
    );
  };

  //return range used for color mapping, null if none set
  range() {
    if (typeof this.min != "undefined" && typeof this.max != "undefined") {
      return [this.min, this.max] as [number, number];
    }
    return null;
  };
}


/**
 * Custom linear gradient using user supplied colors.
 * Reverse gradients are supported when min>max so that the colors are displayed in reverse order.
 * Midpoints are not supported - color map should be specified to get desired middle color.
 * 
 * @param {number} min 
 * @param {number} max
 * @param {Array} colors  Array of colors that will be linearly interpolated between from min to max values.
 * @subcategory Gradients
 * 
 * @example
       $3Dmol.get('../test_structs/af.pdb', function(data){
              viewer.addModel(data);
              viewer.setStyle({cartoon:{colorscheme:{prop: 'b', gradient:'linear', min: 70, max: 100, colors: ["blue","yellow","green"]}}});
              viewer.zoomTo();
              viewer.render();
            });
 */
export class CustomLinear extends GradientType {
  gradient = "linear";
  min: number;
  max: number;
  colors = new Array<Color>();

  constructor(min: any, max: any, colors?: any) {
    super();

    var carr: Array<any>;
    if (Array.isArray(min) && min.length >= 2) {
      //we were passed a single range
      this.max = min[1] as number;
      this.min = min[0] as number;
      carr = max;
    } else {
      this.min = min as number;
      this.max = max as number;
      carr = colors;
    }

    //convert colors
    if (carr) {
      for (let c of carr) {
        this.colors.push(CC.color(c));
      }
    } else {
      this.colors.push(CC.color(0));
    }

  }

  //return range used for color mapping, null if none set
  range() {
    if (typeof this.min != "undefined" && typeof this.max != "undefined") {
      return [this.min, this.max] as [number, number];
    }
    return null;
  }

  //map value to hex color, range is provided
  valueToHex(val: number, range?: any[]) {
    var lo: number, hi: number;
    if (range) {
      lo = range[0];
      hi = range[1];
    } else {
      lo = this.min;
      hi = this.max;
    }

    if (val === undefined) return 0xffffff;

    var norm = normalizeValue(lo, hi, val);
    lo = norm.lo;
    hi = norm.hi;
    val = norm.val;

    let nsteps = this.colors.length;
    let stepsize = (hi - lo) / nsteps;
    let startpos = Math.min(Math.floor((val - lo) / stepsize), nsteps - 1);
    let endpos = Math.min(startpos + 1, nsteps - 1);

    let frac = (val - lo - (startpos * stepsize)) / stepsize;

    let startcol = this.colors[startpos];
    let endcol = this.colors[endpos];

    let col = new Color(startcol.r + frac * (endcol.r - startcol.r),
      startcol.g + frac * (endcol.g - startcol.g),
      startcol.b + frac * (endcol.b - startcol.b));
    return col.getHex();
  }
}

/**
 * built in gradient schemes
 * The user can pass these strings directly as the gradient
 * @prop rwb - red/white/blue, supports setting a mid point for white
 * @prop roygb - rainbow
 * @prop sinebow - rainbow with better saturation properties
 * @prop linear  - linearly maps between provided colors
 *
  */
 export const builtinGradients  = {
  "rwb": RWB,
  "RWB": RWB,
  "roygb": ROYGB,
  "ROYGB": ROYGB,
  "sinebow": Sinebow,
  "linear": CustomLinear
};

export class Gradient extends GradientType {
  static RWB = RWB;
  static ROYGB = ROYGB;
  static Sinebow = Sinebow;
  static CustomLinear = CustomLinear;
  static builtinGradients = builtinGradients;
  static normalizeValue = normalizeValue;
  static getGradient = getGradient;
  valueToHex(_value: number, _range?: number[]): number { return 0; }
  range(): [number, number] | null { return null; }
}