src/Scale.ts

   1/**

   2 * @module Axes

   3 */

   4

   5/** */ 

   6import { Domain, 

   7         Data,

   8         DataType,

   9         NumRange }     from 'hsdatab';

  10import { Axes }         from './Axes';

  11import { date, ms }     from 'hsutil';

  12import { Ticks,

  13         TickDefs,

  14         TickLabel,

  15         AxisType,

  16         ScaleCfg }     from './AxesTypes';

  17

  18

  19function addTickNumber(t:TickDefs, v:number) { 

  20    t.labels.push({ pos: v, text: ''+Math.round(v*1000000)/1000000 }); 

  21}

  22

  23function addTickDate(t:TickDefs, v:Date, fmt:string) { 

  24    t.labels.push({ pos: v.getTime(), text:date(fmt, v) }); 

  25}

  26

  27

  28/** calculate major and minor ticks on a lionear scale. The first and last tick will be smaller and larger than the provided domain. */

  29function linScaleTickMarks(dom:NumRange, ticks:Ticks, numTicks:number) {

  30    function addTicks(unit:number, ticks:TickDefs):number {

  31        let exp = Math.pow(10, Math.floor(Math.log10(unit)));

  32        unit = Math.floor(unit / exp)*exp;

  33        const min = Math.floor(dom[0]/unit)*unit;

  34        const max = Math.ceil(dom[1]/unit)*unit;

  35        for (let v=min; v<=max; v+=unit) { addTickNumber(ticks, v); }

  36        return unit;

  37    }

  38    const majorUnit = addTicks((dom[1] - dom[0]) / numTicks, ticks.major);

  39    addTicks(majorUnit / numTicks, ticks.minor);

  40}

  41

  42function percentScaleTickMarks(dom:NumRange, ticks:Ticks, numTicks:number) {

  43    const formatPercent = (m:TickLabel) => m.text = `${Math.round(m.pos)*100}%`;

  44    linScaleTickMarks(dom, ticks, numTicks);

  45    ticks.major.labels.forEach(formatPercent);

  46    ticks.minor.labels.forEach(formatPercent);

  47//    addMinMaxTicks(dom, ticks);

  48}

  49

  50function logScaleTickMarks(dom:NumRange, ticks:Ticks) {

  51    dom[0] = Math.max(dom[0], 1e-20);

  52    dom[1] = Math.max(dom[1], 1e-20);

  53    let dif = Math.pow(10, Math.floor(Math.log10(dom[1] - dom[0])));

  54    let min = Math.pow(10, Math.floor(Math.log10(dom[0])));

  55    let max = Math.pow(10, Math.ceil(Math.log10(dom[1])));

  56    if (dif > min) {

  57        for (let v = min; v<=max; v*=10) {

  58            for (let i=1; i<=20; i++) {

  59                if (i===1 && v*i

  60                else if (i%10===0) {}

  61                else if (i<10) {        addTickNumber(ticks.minor, v*i); }

  62                else if (i%2===0) {     addTickNumber(ticks.minor, v*i); }

  63            }

  64        }

  65    } else {

  66        min = Math.floor(dom[0]/dif)*dif;

  67        max = Math.ceil(dom[1]/dif)*dif;

  68        if ((max-min)/dif < 4) { 

  69            dif /= 2; 

  70        }

  71        for (let v = min; v<=max; v+=dif) {

  72            addTickNumber(ticks.major, v);

  73        }

  74        addTickNumber(ticks.major, min);

  75        addTickNumber(ticks.major, max);

  76    }

  77}

  78

  79function dateScaleTickMarks(dom:Domain, ticks:Ticks, fmt='%MM/%DD/%YY') {

  80    function majorTickIncr(d:number) {

  81        const units = [0,0,0,0,0,0]; // yr, mo, d, h, m, s

  82        const mins  = ms.toMinutes(d);

  83        const days = ms.toDays(d);

  84        if (days > 365)         { units[0] = 1; }   // >  1 years:    years

  85        else if (days > 270)    { units[1] = 3; }   // >  3 quarters: quarters

  86        else if (days > 20)     { units[1] = 1; }   // > 20 days:     months

  87        else if (days > 4)      { units[2] = 7; }   // >  4 days:     weeks

  88        else if (mins > 300)    { units[2] = 1; }   // >  5h:         days

  89        else if (mins > 75)     { units[3] = 1; }   // >  1h:         hours

  90        else if (mins > 45)     { units[4] = 15; }  // >  45 min:     quarter hours

  91        else if (mins > 10)     { units[4] = 5; }   // >  12min:      5 mins

  92        else if (mins > 2)      { units[4] = 1; }   // >  5min:       1 mnin

  93        else                    { units[5] = 1; }   // else:          1 sec

  94        return units;

  95    }

  96

  97    function round(d:Date, units:number[], rnd:(a:number)=>number):Date {

  98        let result:Date;

  99        if (units[0]) { 

 100            result = new Date(rnd(d.getFullYear()/units[0])*units[0], 0); 

 101        } else if (units[1]) { 

 102            result = new Date(d.getFullYear(), rnd(d.getMonth()/units[1])*units[1], 0); 

 103        } else if (units[2]) { 

 104            result = new Date(d.getFullYear(), d.getMonth(), rnd(d.getDate()/units[2])*units[2], 0); 

 105        } else if (units[3]) { 

 106            result = new Date(d.getFullYear(), d.getMonth(), d.getDate(), rnd(d.getHours()/units[3])*units[3], 0); 

 107        } else if (units[4]) { 

 108            result = new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours(), rnd(d.getMinutes()/units[4])*units[4], 0); 

 109        } else if (units[5]) { 

 110            result = new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours(), d.getMinutes(), rnd(d.getSeconds()/units[5])*units[5], 0); 

 111        }

 112        return result;

 113    }

 114

 115    const dateDom:Date[] = [

 116        (typeof dom[0] === 'number')? new Date(dom[0]) : dom[0], 

 117        (typeof dom[1] === 'number')? new Date(dom[1]) : dom[1]

 118    ];

 119    if (isNaN(dateDom[0].getTime())) { dateDom[0] = new Date('1/1/1980'); } 

 120    if (isNaN(dateDom[1].getTime())) { dateDom[0] = new Date(); } 

 121    const incr = majorTickIncr(dateDom[1].getTime() - dateDom[0].getTime());

 122    const date0 = round(dateDom[0], incr, Math.floor);

 123    const date1 = round(dateDom[1], incr, Math.ceil);

 124    let d = date0;

 125    while (d<=date1) {

 126        addTickDate(ticks.major, d, fmt); 

 127        d = new Date(d.getFullYear()+incr[0], d.getMonth()+incr[1], d.getDate()+incr[2], d.getHours()+incr[3], d.getMinutes()+incr[4], d.getSeconds()+incr[5]);

 128    }; 

 129}

 130

 131/** calculates major tick label domain values */

 132function createTickLabels(type:string, domain:Domain, numTicks:number, fmt:string):Ticks {

 133    const sort = (a:TickLabel,b:TickLabel) => a.pos-b.pos;

 134    function sortTicks() { 

 135        ticks.minor.labels.sort(sort); ticks.major.labels.sort(sort); 

 136    };

 137    const dom:NumRange = [domain[0], domain[1]];

 138    const ticks:Ticks = {

 139        major: {marks:[], labels: []},

 140        minor: {marks:[], labels: []}

 141    };

 142    switch(type) {

 143        case Axes.type.log:     logScaleTickMarks(dom, ticks); sortTicks(); break;

 144        case Axes.type.date:    dateScaleTickMarks(dom, ticks, fmt); sortTicks(); break;

 145        case Axes.type.percent: percentScaleTickMarks(dom, ticks, numTicks); sortTicks(); break;

 146        case Axes.type.ordinal: break; 

 147        case Axes.type.nominal: break;

 148        case Axes.type.index:   

 149        case Axes.type.linear:

 150        default:                linScaleTickMarks(dom, ticks, numTicks); sortTicks(); 

 151    }  

 152    return ticks;

 153}

 154

 155

 156/**

 157 * translates a domain into a range

 158 */

 159export class Scale {    

 160    /** Defines default values for all configurable parameters */

 161    private typeVal:AxisType    = Axes.type.linear;

 162    private rangeVal:NumRange   = [0,1];

 163    private domVal:Domain       = [0,1];

 164    private domMinAuto          = 0; // 0: explicit domain; 1: auto domain loose, 2: auto tight

 165    private domMaxAuto          = 0; // 0: explicit domain; 1: auto domain loose, 2: auto tight

 166    private labelFmt:string;

 167

 168    constructor(private cfg:ScaleCfg) { 

 169        this.typeVal = cfg.type;

 170        this.domain(cfg.domain);

 171    }

 172

 173    public setLabelFormat(labelFmt:string) {

 174        this.labelFmt = labelFmt;

 175    }

 176

 177    public range(r?:NumRange):NumRange   { 

 178        if (r) { 

 179            this.rangeVal = r; 

 180        }

 181        return this.rangeVal;

 182    }

 183    public domain(dom?:Domain):Domain { 

 184        if (dom) {

 185            if (this.scaleType() === Axes.type.date) {

 186                if (typeof dom[0] === 'string'|| typeof dom[1] === 'string') {

 187                    this.domVal[0] = (dom[0] === 'auto')? 0 : Date.parse(dom[0]); 

 188                    this.domVal[1] = (dom[1] === 'auto')? 1 : Date.parse(dom[1]); 

 189                }

 190            } else {

 191                    this.domVal[0] = (dom[0] === 'auto')? 0 : dom[0]; 

 192                    this.domVal[1] = (dom[1] === 'auto')? 1 : dom[1]; 

 193            }

 194            switch(dom[0]) {

 195                case 'tight' : this.domMinAuto = 2; break;

 196                case 'auto' :  this.domMinAuto = 1; break;

 197                default:       this.domMinAuto = 0;

 198            }

 199            switch(dom[1]) {

 200                case 'tight' : this.domMaxAuto = 2; break;

 201                case 'auto' :  this.domMaxAuto = 1; break;

 202                default:       this.domMaxAuto = 0;

 203            }

 204        }

 205        if (this.typeVal === Axes.type.log) {

 206            if (this.domVal[1] <= 0) { this.domVal[1] = 10; }

 207            if (this.domVal[0] <= 0) { this.domVal[0] = (this.domVal[1])/10; }

 208        }

 209        return this.domVal;

 210    }

 211    public scaleType(s?:DataType):AxisType {

 212        if (s) { 

 213            switch (s) {

 214                case Data.type.date:    this.typeVal = Axes.type.date; break;

 215                case Data.type.name:    this.typeVal = Axes.type.nominal; break;

 216                case Data.type.percent: this.typeVal = Axes.type.percent; break;

 217                case Data.type.number:  

 218                case Data.type.currency: 

 219                default:                this.typeVal = Axes.type.linear;

 220            }

 221        }

 222        return this.typeVal;

 223    }

 224

 225    /**

 226     * If a `domain` limit is set to `auto`, calling this function tells the `Scale`

 227     * what the values of the min or max of the data set in the `domain` range are. 

 228     * These will be rounded down (for min) and up (for max) to determine the auto-range.

 229     * @param dom the `[min,max]` range of the data

 230     */

 231    public setAutoDomain(dom:NumRange) {

 232        const ticks:Ticks = createTickLabels(this.scaleType(), dom, 4, this.labelFmt);

 233        switch (this.domMinAuto) {

 234                    // loose

 235            case 1: this.domVal[0] = ticks.major.labels[0]? ticks.major.labels[0].pos : dom[0]; break; 

 236                    // tight

 237            case 2: this.domVal[0] = dom[0]; break;             

 238        }

 239        switch (this.domMaxAuto) {

 240            case 1: this.domVal[1] = ticks.major.labels.length>1? ticks.major.labels[ticks.major.labels.length-1].pos : dom[1]; break;

 241            case 2: this.domVal[1] = dom[1]; break;

 242        }

 243    }

 244

 245    /**

 246     * Calculates major and minor tick marks in domain coordinates

 247     */

 248    public ticks(numTicks:number=4):Ticks   { 

 249        function marksFromLabels(ticks:Ticks, type:AxisType) {

 250            switch(type) {

 251                case Axes.type.nominal: 

 252                case Axes.type.index:   

 253                    const numLabels = ticks.major.labels.length;

 254                    ticks.major.marks = Array(numLabels+1).fill(1).map((e:any, i:number) => i-0.5);

 255                    ticks.minor.marks = ticks.minor.labels? ticks.minor.labels.map((l:TickLabel) => l.pos) : [];

 256                    break;

 257                case Axes.type.log: 

 258                case Axes.type.date:    

 259                case Axes.type.percent: 

 260                case Axes.type.ordinal:  

 261                case Axes.type.linear:

 262                default:

 263                    ticks.major.marks = ticks.major.labels? ticks.major.labels.map((l:TickLabel) => l.pos) : [];

 264                    ticks.minor.marks = ticks.minor.labels? ticks.minor.labels.map((l:TickLabel) => l.pos) : [];

 265            }

 266        }

 267        const dom:NumRange = [this.domain()[0], this.domain()[1]];

 268        const inRange = (t:TickLabel) => t.pos>=dom[0] && t.pos<=dom[1];

 269        const ticks:Ticks =  createTickLabels(this.scaleType(), this.domain(), numTicks, this.labelFmt);

 270        ticks.minor.labels = ticks.minor.labels.filter(inRange);

 271        ticks.major.labels = ticks.major.labels.filter(inRange);

 272        if (ticks.major.labels.length === 0) { ticks.major.labels = ticks.minor.labels; ticks.minor.labels = []; }

 273        marksFromLabels(ticks, this.scaleType());

 274        return ticks;

 275    }

 276    

 277    /** converts a domain value to a range value */

 278    convert(domVal:number):number { 

 279        const dom = this.domain();

 280        const range = this.range();

 281        const domMin = dom[0];

 282        const domMax = dom[1];

 283        let result;

 284        switch(this.scaleType()) {

 285            case Axes.type.log: 

 286                result = Math.log(domVal/domMin) / Math.log(domMax/domMin) * (range[1] - range[0]) + range[0];

 287                break;

 288            case Axes.type.nominal: break;

 289            case Axes.type.date:    

 290            case Axes.type.percent: 

 291            case Axes.type.index:   

 292            case Axes.type.ordinal:  

 293            case Axes.type.linear:

 294            default:

 295                result = (domVal- domMin) / (domMax - domMin) * (range[1] - range[0]) + range[0];

 296        }

 297        return result;

 298    }

 299}

 300