src/Graph.ts

   1/**

   2 * # Graph

   3 * The main `Graph` object that contains all graph components and sets up the controlling logic.

   4 * `Graph` sets up a viewBox that is always 1000 units wide. the height automatically adjusts to fill available space while 

   5 * preserving a uniform scaling (i.e. preserveAspectRatio = default (xMidYMid)).

   6 * 

   7 * ### Attributes

   8 * The main entry point for applications using this library is the `Graph` class,

   9 * typically called as `m(Graph, {cfgFn: (cfg:any) => {...});` 

  10 * Accepted attributes are:

  11 * - cfgFn: a {@link Graph.CfgFn CfgFn} function that allows setting graph parameters.

  12 *

  13 * ### Example

  14 * 

  15 * 'script.js'>

  16 * const names = ['time''volume''price'];

  17 * const data = [

  18 *     [-1,   0.2, 0.8],

  19 *     [0.2,  0.7, 0.87],

  20 *     [0.4, -0.2, 0.7],

  21 *     [0.6,    0, 0.7],

  22 *     [0.8,  0.5, 0.6],

  23 *     [1,    0.7, 0.75]

  24 * ];

  25 * let noise = [];

  26 * 

  27 * function jiggleData() {

  28 *      noise = data.map(row => row.map((col, c) => ((c==0)? 0 : (0.1*(Math.random()-0.5)))));

  29 *      m.redraw();

  30 *      setTimeout(jiggleData, 500);

  31 * }

  32 * jiggleData();

  33 * 

  34 * function myConfig(cfg) { 

  35 *      cfg.series.series = [

  36 *          { x:'time', y:'volume'},

  37 *          { x:'time', y:'price'}

  38 *      ];

  39 *      cfg.series.data = [{

  40 *         colNames: names,

  41 *         rows: data.map((row, r) => row.map((col, c) => {

  42 * console.log(`${r}, ${c}`);

  43 *             return col + noise[r][c];

  44 *         }))

  45 *      }];

  46 *      cfg.series.series[0].style.marker.visible = true;

  47 *      cfg.series.series[1].style.marker.visible = true;

  48 *      cfg.series.series[1].style.marker.shape = hsgraph.Series.marker.diamond;

  49 *      const axes = cfg.axes.primary;

  50 *      cfg.chart.title.text    = 'Volume over Time';

  51 *      cfg.chart.title.xpos    = 'end';

  52 *      cfg.chart.title.ypos    = 'top';

  53 *      cfg.chart.title.vOffset = -1.5;

  54 *      axes.x.title.text       = 'time';

  55 *      axes.y.title.text       = 'volume';

  56 *      axes.x.crossesAt = 0;

  57 *      axes.y.crossesAt = 0;

  58 *      axes.y.scale.domain = [-0.5, 1.2];

  59 *      cfg.axes.secondary.x.visible = false;

  60 *      cfg.axes.secondary.y.visible = false;

  61 * }

  62 * 

  63 * m.mount(root, {

  64 *      view:() => m(hsgraph.Graph, {cfgFn: myConfig }),

  65 * });

  66 * 

  67 *

  68 * 

  69 * 'style.css'>

  70 * .hs-graph-chart { fill: #fff; }

  71 * .hs-graph-series { stroke-width: 5; }

  72 * 

  73 * 

  74 * 

  75 * ### Configurations and Defaults

  76 * See {@link Graph.Graph.defaultConfig Graph.defaultConfig}

  77 */

  78

  79/** */ 

  80export const m = require("mithril");

  81export type Vnode = typeof m.Vnode;

  82import { Data, 

  83         DataSet,

  84         DataType,

  85         NumDomain }    from 'hsdatab';

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

  87import { AxesConfig,

  88         Scales }       from './AxesTypes';

  89import { Scale }        from './Scale';

  90import { Canvas, 

  91         CanvasConfig } from './Canvas';

  92import { Series, 

  93         SeriesDef,

  94         SeriesConfig } from './Series';

  95import { Chart, 

  96         ChartConfig }  from './Chart';

  97import { Grid, 

  98         GridsConfig }  from './Grid';

  99import { Legend, 

 100         LegendConfig } from './Legend';

 101import { SVGElem, 

 102         TextElem,

 103         Rect, 

 104         round }        from './SVGElem';

 105import { delay }        from 'hsutil';

 106

 107const viewBoxWidth:number  = 1000;  // the viewBox size in px

 108let   viewBoxHeight:number = 700;   // the viewBox size in px

 109

 110

 111export interface VisibleCfg extends GraphBaseCfg{

 112    visible: boolean;

 113}

 114

 115/** 

 116 * Configurator for a title, such as Chart title, or Axis title. 

 117 * Titles have their own visible switch.

 118 */

 119export interface LabelCfg extends TextElem, VisibleCfg {

 120}

 121

 122export interface GraphBaseCfg {

 123}

 124

 125/**

 126 */

 127export interface Config {

 128    viewBox?: { w: number; h: number; };

 129    graph?:  GraphConfig;

 130    canvas?: CanvasConfig;

 131    axes?:   AxesConfig;

 132    chart?:  ChartConfig;

 133    grid?:   GridsConfig;

 134    series?: SeriesConfig;

 135    legend?: LegendConfig;

 136}

 137

 138/**

 139 * `Graph` related configuration options.

 140 * See {@link Graph.Graph.makeConfig Graph.makeConfig} for all configurations and

 141 * {@link Graph.Graph.config Graph.config} for default `Graph` configuration.

 142 */

 143export interface GraphConfig extends GraphBaseCfg {

 144    margin :{           // in viewBox units 

 145        top:    number;          

 146        left:   number;   

 147        bottom: number;   

 148        right:  number;   

 149    };

 150    timeCond: any;

 151}

 152

 153/** 

 154 * signature of a user configuration function, used in {@link Graph.Graph.makeConfig `Graph.makeConfig`} 

 155 * @param cfg the fully initialized configuration object. `CfgFn` should overwrite selected values as needed.

 156 */

 157export interface CfgFn { (cfg:Config):void; }

 158

 159

 160

 161/** The main `Graph` object, responsible for setting up the grpahing components and logic. */

 162export class Graph extends SVGElem {

 163    /**

 164     * Creates and returns a `cfg` configuration object containing configuration entries

 165     * for  `Graph` and all of its subcomponents:

 166     * -   {@link Graph.Graph.config `cfg.graph`}: some general Graph setting, such as margins

 167     * -   {@link Canvas.Canvas.config `cfg.canvas`}:  the background canvas on which all components are rendered

 168     * -   {@link Chart.Chart.config `cfg.chart`}: the chart area and title

 169     * -   {@link Axes.Axes.config `cfg.axes`}: the x- and y-axes, tick marks and labels, and axis title

 170     * -   {@link Grid.Grid.config `cfg.grid`}: the major and minor gridlines

 171     * -   {@link Series.Series.config `cfg.series`}: the one or more data series to render

 172     * -   {@link Legend.Legend.config `cfg.legend`}: the legend for the shown series

 173     * if a `userCfg` function is provided, it gets called after all configurations are

 174     * initialized with default values. The `cfg` object is passed as parameter into the 

 175     * function, which then can selectively overwrite certain settings as needed.

 176     * @param userCfg a user defined configuration function with the signature 

 177     * `(cfg:{@link Config Config}):void.`

 178     */

 179    private static makeConfig(userCfg?:CfgFn):Config { 

 180        const cfg:Config = {};

 181        Graph.defaultConfig(cfg);

 182        Canvas.defaultConfig(cfg);

 183        Axes.defaultConfig(cfg);

 184        Series.defaultConfig(cfg);

 185        Grid.defaultConfig(cfg);

 186        Chart.defaultConfig(cfg);

 187        Legend.defaultConfig(cfg);

 188        if (userCfg) { 

 189            try { 

 190                userCfg(cfg); 

 191            } catch(e) {

 192                console.log('error in usercfg');

 193                console.log(e);

 194                console.log(e.stack);

 195            }

 196        }

 197        return cfg;

 198    }

 199

 200    /** 

 201     * Defines default values for all configurable parameters in `Graph`

 202     * See {@link Graph.Graph.makeConfig Graph.makeConfig} for the sequence of initializations.

 203     * 

 204     * #### Configurations and Defaults

 205     * ```

 206     *  cfg.graph = {@link Graph.GraphConfig } {

 207     *     margin: {      // the margin between viewBox edges and nearest plot component

 208     *        top: 10,    // viewBox units      

 209     *        left: 10,   // viewBox units    

 210     *        bottom: 10, // viewBox units    

 211     *        right: 10   // viewBox units 

 212     *     }   

 213     *  } 

 214     * ``` 

 215     * @param cfg the configuration object, containing default settings for all 

 216     * previously configured components.

 217     */

 218    protected static defaultConfig(cfg:Config) {      

 219        cfg.graph = {

 220            margin :{

 221                top: 10,    // viewBox units      

 222                left: 10,   // viewBox units    

 223                bottom: 10, // viewBox units    

 224                right: 10   // viewBox units    

 225            },

 226            timeCond: {}

 227        };

 228    }

 229

 230    /**

 231     * Makes adjustments to cfg based on current settings.

 232     * Called just prior to drawing.

 233     * @param cfg the configuration object, containing default settings for all components

 234     */

 235    protected static adjustConfig(cfg:Config) {

 236        Canvas.adjustConfig(cfg);

 237        Axes.adjustConfig(cfg);

 238        Series.adjustConfig(cfg);

 239        Grid.adjustConfig(cfg);

 240        Chart.adjustConfig(cfg);

 241        Legend.adjustConfig(cfg);

 242    }

 243

 244    private marginOffset = {

 245        left:   0,     // in px

 246        right:  0,     // in px

 247        top:    0,     // in px

 248        bottom: 0      // in px

 249    };

 250

 251

 252    private scales: Scales;

 253

 254    private createPlotArea(cfgm:{top:number, left:number, bottom:number, right:number}):Rect {

 255        const tl = {

 256            x: cfgm.left + this.marginOffset.left,

 257            y: cfgm.top + this.marginOffset.top

 258        };

 259        const br = {

 260            x: viewBoxWidth  - cfgm.right  - this.marginOffset.right,

 261            y: viewBoxHeight - cfgm.bottom - this.marginOffset.bottom

 262        };

 263        return { tl: tl, br: br };

 264    }

 265

 266    private createData(cfg:any):Data[] {

 267        if (!cfg.series.data) {

 268            console.log('cfg.series.data not set');

 269        }

 270        if (!(cfg.series.data.length > 0)) {

 271            console.log('cfg.series.data not initialised with array of DataSets');

 272        }

 273        const timeCond = cfg.graph.timeCond;

 274        return cfg.series.data.map((d:DataSet|Data) => 

 275            ((d instanceof Data)? d : new Data(d)).filter(timeCond));

 276    }

 277

 278    private createScales(axes:any):Scales {

 279        if (!this.scales) { this.scales = {

 280            primary:   { x: new Scale(axes.primary.x.scale),   y: new Scale(axes.primary.y.scale) },

 281            secondary: { x: new Scale(axes.secondary.x.scale), y: new Scale(axes.secondary.y.scale) }

 282        };}

 283        return this.scales;

 284    }

 285

 286    /**

 287     * sets the scales for the given `plotArea`

 288     * @param plotArea a rect describing the input range for scaling

 289     * @param scales primary and secondary axis scales

 290     */

 291    setScaleRange(plotArea:Rect, scales:Scales) { 

 292        scales.primary.x.range([plotArea.tl.x, plotArea.br.x]);

 293        scales.primary.y.range([plotArea.br.y, plotArea.tl.y]);

 294        scales.secondary.x.range([plotArea.tl.x, plotArea.br.x]);

 295        scales.secondary.y.range([plotArea.br.y, plotArea.tl.y]);

 296    }

 297

 298    /**

 299    * determines the max ranges each coordinate of each series and auto-sets the domains on the respective scales. 

 300    * @param cfg used to gain access to the series config

 301    * @param scales 

 302    * @param data 

 303    */

 304    setScaleDomains(cfg:SeriesConfig, scales:Scales, data:Data[]) {

 305        // structure to accumulate axis domains

 306        const domains = [[1e20, -1e20], [1e20, -1e20]]; // [x-domain, y-domain]

 307

 308        function updateDomains(s:SeriesDef) {

 309            let defX: DataType;

 310            if (s.x) {  // domain is defined by min/max of data, or set of categorical values

 311                data[s.dataIndex].findDomain(s.x, domains[0]); 

 312                defX = data[s.dataIndex].colType(s.x);

 313            } else {    // domain is defined by index range of data

 314                domains[0][0] = 0; domains[0][1] = data[s.dataIndex].export().rows.length-1; 

 315            }

 316            if (s.y)     { data[s.dataIndex].findDomain(s.y, domains[1]); }

 317            if (s.yBase) { data[s.dataIndex].findDomain(s.yBase, domains[1]); }

 318            if (defX && scales.primary.x.scaleType() === Axes.type.auto) { scales.primary.x.scaleType(defX); }

 319        }

 320

 321        cfg.series.map(updateDomains);  // for each series, update the domains

 322        scales.primary.x.setAutoDomain(domains[0]);

 323        scales.primary.y.setAutoDomain(domains[1]);

 324    }

 325

 326

 327    /**

 328     * adjust the height of the viewBox to match available height in containing window,

 329     * e.g. after a resize

 330     * @param node the Graph node

 331     */

 332    adjustHeight(node: Vnode) {

 333        if (node.dom && node.dom.parentElement) {

 334            const p = node.dom.parentElement;

 335            const temp = viewBoxWidth * p.clientHeight / p.clientWidth;

 336            if (!isNaN(temp) && temp !== viewBoxHeight) {

 337                viewBoxHeight = temp; 

 338            }

 339        }

 340    }

 341

 342    /**

 343     * check on update of axes bounding box and notify Graph.boxNotify

 344     */

 345    adjustMargins(cfg:Config) {

 346        const cfgm = cfg.graph.margin;

 347        const margin = {t:-1e6,l:-1e6,b:-1e6,r:-1e6};

 348

 349        function getBBox(css: string) {

 350            const elems = document.getElementsByClassName(css);

 351            const box = Array.prototype.map.call(elems, (e:any)=>e.getBBox());

 352            if(box && box[0]) { 

 353                margin.t = Math.max(margin.t, cfgm.top-box[0].y);               

 354                margin.l = Math.max(margin.l, cfgm.left-box[0].x);               

 355                margin.b = Math.max(margin.b,  box[0].y+box[0].height+cfgm.bottom-viewBoxHeight);               

 356                margin.r = Math.max(margin.r, box[0].x+box[0].width +cfgm.right -viewBoxWidth);               

 357            }

 358            // margin.t = Math.min(margin.t, 40);  // limit to max 20px

 359            // margin.b = 30; //Math.min(margin.b, 40);  // limit to max 20px

 360            // margin.l = 40;

 361        }

 362

 363        getBBox('hs-graph-axis');

 364        getBBox('hs-graph-chart');

 365        this.marginOffset.top    = margin.t;

 366        this.marginOffset.left   = margin.l;

 367        this.marginOffset.bottom = margin.b;

 368        this.marginOffset.right  = margin.r;

 369    }

 370

 371    onupdate(node: Vnode) { 

 372        this.adjustHeight(node); 

 373        // this.adjustMargins(node.attrs.cfg);

 374    }

 375

 376    oncreate(node: Vnode) {

 377        window.addEventListener("resize", function() { m.redraw(); });

 378        this.adjustHeight(node); 

 379        Promise.resolve(node.attrs.cfg)

 380            .then(delay(10))

 381            .then(this.adjustMargins.bind(this))

 382            .then(m.redraw);

 383    }

 384

 385

 386    view(node: Vnode): Vnode {

 387        const cfgFn:CfgFn = node.attrs.cfgFn;

 388        const cfg:Config = Graph.makeConfig(cfgFn);

 389        const data = this.createData(cfg);

 390        const plotArea:Rect = this.createPlotArea(cfg.graph.margin);

 391        const scales:Scales = this.createScales(cfg.axes); 

 392        this.setScaleRange(plotArea, scales);

 393        this.setScaleDomains(cfg.series, scales, data);

 394

 395        Graph.adjustConfig(cfg);

 396        node.attrs.cfg = cfg;

 397        return m('svg', { class:'hs-graph', width:'100%', height:'100%'

 398                viewBox:`0 0 ${round(viewBoxWidth)} ${round(viewBoxHeight)}` }, [

 399            m(Canvas, { cfg:cfg.canvas}),

 400            m(Chart, { cfg:cfg.chart, plotArea:plotArea }),

 401            m(Grid, { cfg:cfg.grid, scales:scales }),

 402            m(Series, { cfg:cfg.series, scales:scales, data:data }),

 403            m(Axes, { cfg:cfg.axes, scales:scales }),

 404            m(Legend, { cfg:cfg.legend })

 405        ]);

 406    }

 407}

 408