forked from OHDSI/Atlas
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathohdsi.util.js
1712 lines (1657 loc) · 60.7 KB
/
ohdsi.util.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/* ohdsi.util version 1.1.0
*
* Author: Chris Knoll (I think)
* AMD setup
* _pruneJSON
* dirtyFlag
*
* Author: Sigfried Gold
* elementConvert
* d3AddIfNeeded
* D3Element
* shapePath
* ResizableSvgContainer extends D3Element
* SvgLayout
* SvgElement
* ChartLabel extends SvgElement
* ChartLabelLeft extends ChartLabel
* ChartLabelBottom extends ChartLabel
* ChartAxis extends SvgElement
* ChartAxisY extends ChartAxis
* ChartAxisX extends ChartAxis
* ChartChart extends SvgElement
* ChartProps
* getState, setState, deleteState, hasState, onStateChange
* Field
* cachedAjax
* storagePut
* storageExists
* storageGet
* SharedCrossfilter
*/
define(['jquery','knockout','lz-string', 'lodash', 'crossfilter/crossfilter'], function($,ko, LZString, _, crossfilter) {
var DEBUG = true;
var ALLOW_CACHING = [
//'.*',
//'/WebAPI/[^/]+/person/',
];
var utilModule = { version: '1.0.0' };
// private functions
function _pruneJSON(key, value) {
if (value === 0 || value) {
return value;
} else {
return
}
}
// END private functions
// module functions
function dirtyFlag(root, isInitiallyDirty) {
var result = function () {},
_initialState = ko.observable(ko.toJSON(root, _pruneJSON)),
_isInitiallyDirty = ko.observable(isInitiallyDirty);
result.isDirty = ko.pureComputed(function () {
return _isInitiallyDirty() || _initialState() !== ko.toJSON(root, _pruneJSON);
}).extend({
rateLimit: 200
});;
result.reset = function () {
_initialState(ko.toJSON(root, _pruneJSON));
_isInitiallyDirty(false);
};
return result;
}
/* elementConvert
* call with css id (with or without #)
* or dom node or d3 selection or jquery selection
*
* returns type requested ("dom", "d3", "jquery", "id")
*/
function elementConvert(target, type = "dom") {
if (target.selectAll) { // it's a d3 selection
if (type === "d3") return target;
//console.warn("this should't return target.node(), it should return target[0]");
// but i haven't been able to get that to work yet
return elementConvert(target.node(), type); // call again with dom node
// (first in selection, that is)
}
if (target.jquery) { // it's a jquery selection
if (type === "jquery") return target;
return elementConvert(target[0], type); // call again with dom node
}
if (typeof target === "string") { // only works with ids for now, not other css selectors
var id = target[0] === '#' ? target.slice(1) : target;
var dom = document.getElementById(id);
if (dom) return elementConvert(dom, type); // call again with dom node
throw new Error(`not a valid element id: ${target}`);
}
// target ought to be a dom node
if (!target.tagName) {
throw new Error(`invalid target`);
}
switch (type) {
case "dom":
return target;
case "id":
return target.getAttribute('id');
case "d3":
return d3.select(target);
case "jquery":
return $(target);
}
throw new Error(`invalid type: ${type}`);
}
/* d3AddIfNeeded
* call with parent element (can be selector, dom, jquery or d3 item),
* data (scalar uses selection.datum(), array uses selection.data())
* tag of element(s) to be appended to parent
* array of class names
* callback to use on enter selection
* callback to use for update
* (could also add remove callback but don't have it yet)
* and array of params to send to callbacks
* returns d3 selection with data appropriately attached
* warning: if your addCb appends further elements (or if you add more
* using the returned selection, i think),
* they will also have data appropriately attached, but that
* data may end up stale for the updateCb if you call this again
* with new data unless you explicitly d3.select it
* for example:
* (is this really true? check before finishing example)
*
* d3AddIfNeeded({parentElement: pdiv,
* data: [1,2,3], // three items appended (if they
* // don't already exist) as children of pdiv
* tag: 'div', // they are divs
* classes: [],
* addCb: function(el, params) {
* // each div will have an svg appended
* el.append('svg');
* },
* updateCb: function(el, params) {
* // this will force new data to attach
* // to previously appended svg
* // if this d3AddIfNeeded is called
* // a second time with new data
* el.select('svg');
* // more selects may be necessary to propogate
* // data to svg's children
* },
* cbParams: []});
*/
function d3AddIfNeeded({parentElement, data, tag, classes=[], addCb=()=>{}, updateCb=()=>{},
cbParams} = {}) {
var el = elementConvert(parentElement, "d3");
var selection = el.selectAll([tag].concat(classes).join('.'));
if (Array.isArray(data)) {
selection = selection.data(data);
} else {
selection = selection.data([data]);
// might want this? : selection = selection.datum(data);
}
selection.exit().remove();
selection.enter().append(tag)
.each(function(d) {
var newNode = d3.select(this);
classes.forEach(cls => {
newNode.classed(cls, true);
});
})
.call(addCb, cbParams);
selection = el.selectAll([tag].concat(classes).join('.'));
selection.call(updateCb, cbParams);
return selection;
}
/* D3Element
* this is an OO class to replace the d3AddIfNeeded function above
*
* making a new D3Element instance (d3El) will add <tag> elements to the
* parentElement using a D3 join to the data param. but if elements
* already exist, they elements will be appropriately joined to the
* data: extras will be removed (after running an exit callback if
* you specify one), entering items will be appended, and the update
* callback will be run on everything remaining after the join.
*
* you could also just say: d3El.data(newData); d3El.run(). that will
* also perform the appropriate join and run the callbacks.
*
* so you do not need to keep track of whether you've already created
* the elements. if you have and you still have a reference to the
* D3Element instance, d3El.data(d); d3El.run(); works. but calling the
* same code that created it originally and sending new data will
* work as well.
*
* if you also create child elements like:
* var d3El = new D3Element(params);
* d3El.addChild(params);
* then calling d3El.data(newData); d3El.run(); will not only update d3El,
* it will also rejoin and update its children with newData.
*
* var d3El = new D3Element({parentElement:p,
* data:arrOrObj, // data to be joined to selection
* // if it's scalar, will be turned
* // into single-item array
* tag: 'div', // tag of element to be appended
* // if necessary to parent
* classes: ['big','bright'],
* // to add to element
* // should also insure that
* // parent.selectAll('tag.classes')
* // only returns elements elements
* // created here
* enterCb: null, // only needed if you want to
* // run extra code when d3El is
* // first created
* exitCb: null, // only needed if you want
* // to run extra code (transition?)
* // when d3El is removed
* cbParams: null,// will be passed to all callbacks
* // along with d3 selection
* updateCb: // code to run on creation and
* // after possible data changes
* function(selection, cbParams, updateOpts) {
* // updateOpts are set by calling
* // d3El.run(opts) or d3El.update(opts)
* // and they are sent to the updateCb not
* // just for the d3El in question, but to
* // all its children
* selection
* .attr('x', function(d) {
* return cbParams.scale(cbParams.xVal(d))
* })
* },
* children: null,// k/v obj with child descriptors (need to document)
* // should only allow children with explicit d3El.addChild
* dataPropogationSelectors: null, // document when implemented
* });
*
* d3El.run() returns the d3 selection after performing joins and running callbacks.
* you can also get the d3 selection with d3El.selectAll();
*
* there are many ways to add child elements (using the addChild method, using
* the d3 selection returned from run and selectAll methods, or in the add or
* update callbacks). I recommend:
*
* add using d3El.addChild()
* set attributes in the update callback
* don't use the d3 selections at all
* you probably don't need to do anything in the enterCb
* (because that would probably mean creating some nested dom nodes
* below the one you're adding, and then how would you access those?)
*/
function combineFuncs(funcs) {
return (...args) => { return funcs.map(function(f) { return f.apply(this, args) }) }
}
class D3Element {
constructor(props, passParams = {}, parentSelection, parentD3El) {
// really need to change (simplify) the way data and opts are
// handled... this whole thing is a bit of a monstrosity
this.parentD3El = parentD3El;
/* not using anymore:
this.parentElement = props.parentElement // any form ok: d3, jq, dom, id
|| this.parentD3El.selectAll();
this.el = elementConvert(this.parentElement, "d3");
*/
this.parentSelection = parentSelection;
this.tag = props.tag;
this.classes = props.classes || [];
this.enterCb = props.enterCb || (()=>{});
this.updateCb = props.updateCb || (()=>{});
this.updateCb = props.updateCbs ? combineFuncs(props.updateCbs) // in case you want to run more than one callback on update
: this.updateCb;
this.exitCb = props.exitCb || (()=>{});
this.cbParams = props.cbParams;
this._children = {};
//this.dataPropogationSelectors = props.dataPropogationSelectors; // not implemented yet
if (typeof props.data === "function")
/*
console.warn(`d3 is supposed to handle selectAll().data(fn) nicely,
but it doesn't. so you can pass a func that accepts its
d3El and returns a data array`);
*/
this.dataKey = props.dataKey;
if (!props.stub) {
// props.data can be array or function that accepts this.parentD3El
// or it will default to parent's data
// but it can be overridden later:
// permanently by calling this.data(parentD3El, newData)
// or temporarily by calling this.selectAll(newData)
this._data = props.data || this.parentD3El._data;
if (! (Array.isArray(this._data) || typeof this._data === 'function'))
throw new Error("data must be array or function");
this.run(passParams);
}
}
selectAll() {
var cssSelector = [this.tag].concat(this.classes).join('.');
return this.parentSelection.selectAll(cssSelector);
}
selectAllJoin(data) {
data = data || this._data;
if (typeof data === "function") {
// the function should accept 'this' and return the join selection
return data(this);
/*
return this.dataKey ?
this.selectAll().data(data(this.parentD3El._data), this.dataKey) :
this.selectAll().data(data(this.parentD3El._data));
*/
} else {
return this.dataKey ?
this.selectAll().data(data, this.dataKey) :
this.selectAll().data(data);
}
}
as(type) {
return elementConvert(this.selectAll(), type);
}
data(data) {
//bad idea:
//if (typeof data === "undefined") return this.selectAll().data();
//hope i'm not breaking anything by doing the more expectable:
if (typeof data === "undefined") return this._data;
if (! (Array.isArray(data) || typeof data === 'function'))
throw new Error("data must be array or function");
this._data = data;
//return this.selectAll(data); same here... don't think this was being used
return this;
}
run(passParams={}, enter=true, exit=true, update=true) {
// fix opts: split up data and transition
let self = this;
var data = passParams.data || self._data;
var selection = self.selectAllJoin(data);
var passParamsForChildren = _.omit(passParams, ['data']); // data gets passed automatically
//var {delay=0, duration=0} = passParams;
//var mainTrans = passParams.transition || d3.transition();
//passParams.transition = mainTrans;
// should allow callbacks to pass transitions back so they
// can be passed on to next callback?
if (exit && selection.exit().size()) {
//if (selection.exit().size()) console.log(`exiting ${self.name}`);
var exitSelection = selection.exit();
_.each(self.children(), (c, name) => {
self.child(name).exit(passParamsForChildren, exitSelection);
});
exitSelection
//.call(self.exitCb, self.cbParams, passParams, self, mainTrans)
.call(self.exitCb, self.cbParams, passParams, self)
.remove() // allow exitCb to remove? -> doesn't seem to work
}
if (enter && selection.enter().size()) {
var enterSelection = selection.enter()
.append(self.tag)
.each(function(d) { // add classes
var newNode = d3.select(this);
self.classes.forEach(cls => {
newNode.classed(cls, true);
});
})
//.call(self.enterCb, self.cbParams, passParams, self, mainTrans)
.call(self.enterCb, self.cbParams, passParams, self)
_.each(self.children(), (c, name) => {
var child = self.makeChild(name, passParamsForChildren, enterSelection);
});
}
selection = self.selectAllJoin(data);
if (update && selection.size()) {
selection
//.call(self.updateCb, self.cbParams, passParams, self, mainTrans)
.call(self.updateCb, self.cbParams, passParams, self)
_.each(self.children(), (c, name) => {
self.child(name).run(passParamsForChildren, enter, exit, update, selection);
});
}
return selection;
}
childDesc(name, desc) {
if (desc)
this._children[name] = {desc};
else if (!this._children[name])
throw new Error(`${name} child not created yet`);
return this._children[name].desc;
}
child(name, d3El) {
if (!this._children[name])
throw new Error(`${name} child not created yet`);
if (d3El)
this._children[name].d3El = d3El;
return this._children[name].d3El;
}
addChild(name, desc, passParams) {
this.childDesc(name, desc);
if (desc.stub)
return this.childDesc(name);
return this.makeChild(name, passParams, this.selectAll()); // this.selectAll()?
}
// should we attempt to send selectAll options (for transition durations)
// through addChild/makeChild? not doing this yet. but update calls will
// send these options down the D3Element tree
makeChild(name, passParams, selection) {
var desc = this.childDesc(name);
//var d3ElProps = $.extend( { parentD3El: this }, desc);
var d3ElProps = _.merge( { parentD3El: this }, _.cloneDeep(desc));
return this.child(name, new D3Element(d3ElProps, passParams, selection, this));
// it sort of doesn't matter because if you repeatedly create D3Elements
// with the same parameters, d3 enter and exit selections will be empty
// and update won't have a visible effect since data is the same,
// but maybe if makeChild (or addChild) is called repeatedly with the
// same exact parameters, we should avoid actually creating a new
// D3Element instance
}
children() {
return this._children;
}
implicitChild(selectorFunc) {
}
exit(passParams) {
return this.run(passParams, false, true, false);
}
enter(passParams) {
return this.run(passParams, true, false, false);
}
update(passParams) {
return this.run(passParams, false, false, true);
}
}
function shapePath(type, cx, cy, r) {
// shape fits inside the radius
var shapes = {
circle: function(cx, cy, r) {
// http://stackoverflow.com/questions/5737975/circle-drawing-with-svgs-arc-path
return `
M ${cx} ${cy}
m -${r}, 0
a ${r},${r} 0 1,0 ${r * 2},0
a ${r},${r} 0 1,0 ${-r * 2},0
`;
},
square: function(cx, cy, r) {
var side = Math.sqrt(1/2) * r * 2;
return `
M ${cx} ${cy}
m ${-side / 2} ${-side / 2}
l ${side} 0
l 0 ${side}
l ${-side} 0
z
`;
},
triangle: function(cx, cy, r) {
var side = r * Math.sqrt(3);
var alt = r * 1.5;
return `
M ${cx} ${cy}
m 0 ${-r}
l ${side/2} ${alt}
l ${-side} 0
z
`;
},
}
if (type === "types")
return _.keys(shapes);
if (! (type in shapes)) throw new Error("unrecognized shape type");
return shapes[type](cx, cy, r);
}
// svgSetup could probably be used for all jnj.charts; it works
// (i believe) the way line chart and scatterplot were already working
// (without the offscreen stuff, which I believe was not necessary).
class ResizableSvgContainer extends D3Element {
// call from chart obj like:
// var divEl = svgSetup.call(this, data, target, w, h, ['zoom-scatter']);
// target gets a new div, new div gets a new svg. div/svg will resize
// with consistent aspect ratio.
// svgSetup can be called multiple times but will only create div/svg
// once. data will be attached to div and svg (for subsequent calls
// it may need to be propogated explicitly to svg children)
// returns a D3Element
// ( maybe shouldn't send data to this func, attach it later)
constructor(target, data, w, h, divClasses=[], svgClasses=[], makeMany=false) {
if (Array.isArray(data) && data.length > 1 && !makeMany) {
data = [data];
}
function aspect() {
return w / h;
}
super({
//parentElement: target,
data,
tag:'div',
classes: divClasses,
}, undefined, elementConvert(target,'d3'));
var divEl = this;
var svgEl = divEl.addChild('svg', {
tag: 'svg',
classes: svgClasses,
updateCb: function(selection, params, updateOpts, thisEl) {
var targetWidth = divEl.divWidth();
selection
.attr('width', targetWidth)
.attr('height', Math.round(targetWidth / aspect()))
.attr('viewBox', '0 0 ' + w + ' ' + h);
},
});
this.w = w;
this.h = h;
this.svgClasses = svgClasses;
var resizeHandler = $(window).on("resize",
() => svgEl.as('d3')
.attr("width", this.divWidth())
.attr("height", Math.round(this.divWidth() / aspect())));
setTimeout(function () {
$(window).trigger('resize');
}, 0);
}
divWidth() {
try {
return this.as("jquery").width();
} catch(e) {
return this.w;
}
}
}
/*
// svgSetup could probably be used for all jnj.charts; it works
// (i believe) the way line chart and scatterplot were already working
// (without the offscreen stuff, which I believe was not necessary).
function svgSetup(target, data, w, h, divClasses=[], svgClasses=[]) {
// call from chart obj like:
// var divEl = svgSetup.call(this, data, target, w, h, ['zoom-scatter']);
// target gets a new div, new div gets a new svg. div/svg will resize
// with consistent aspect ratio.
// svgSetup can be called multiple times but will only create div/svg
// once. data will be attached to div and svg (for subsequent calls
// it may need to be propogated explicitly to svg children)
// returns a D3Element
// ( maybe shouldn't send data to this func, attach it later)
this.container = this.container || elementConvert(target, "dom");
if (Array.isArray(data) && data.length > 1) {
data = [data];
}
this.svgDivEl = new D3Element( {
parentElement:this.container,
data, tag:'div', classes: divClasses,
});
var self = this;
this.svgDivEl.addChild('svg',
{
tag: 'svg',
classes: svgClasses,
updateCb: function(selection, params, updateOpts) {
try {
var targetWidth = self.svgDivEl.as("jquery").width();
} catch(e) {
var targetWidth = w;
}
var aspect = w/h;
console.log(targetWidth, aspect);
selection
//.attr('width', w)
//.attr('height', h)
.attr('width', targetWidth)
.attr('height', Math.round(targetWidth / aspect))
.attr('viewBox', '0 0 ' + w + ' ' + h);
},
});
var resizeHandler = $(window).on("resize", {
svgDivEl: this.svgDivEl,
aspect: w / h
},
function (event) {
// set svg to size of container div
var targetWidth = event.data.svgDivEl.as("jquery").width();
event.data.svgDivEl.child('svg').as("d3")
.attr("width", targetWidth)
.attr("height", Math.round(targetWidth / event.data.aspect));
});
setTimeout(function () {
$(window).trigger('resize');
}, 0);
return this.svgDivEl;
}
*/
/* SvgLayout class
* manages layout of subcomponents in zones of an svg
* initialize with layout like:
var layout = new SvgLayout(w, h,
// zones:
{
top: { margin: { size: 5}, }, // top zone initialized with margin
// 5 pixels (or whatever units) high
bottom: { margin: { size: 5}, },
left: { margin: { size: 5}, },
right: { margin: { size: 5}, },
})
* add components to zones like one of these:
// size is constant:
layout.add('left','axisLabel', { size: 20 })
// size returned by function:
layout.add('left','axisLabel', { size: ()=>axisLabel.node().getBBox().width * 1.5 })
// provide svg element to get size from (must specify 'width' or 'height' as dim)
layout.add('left','axis', { obj: cp.y.axisG.node(), dim:'width' })
* retrieve dimensions of svg chart area (inside all zones):
layout.svgWidth()
layout.svgHeight()
* retrieve svg dimensions:
layout.w()
layout.h()
* retrieve total size of zone
layout.zone('bottom')
* retrieve total size of one zone element
layout.zone('left.margin')
* retrieve total size of more than one zone element
layout.zone(['left.margin','left.axisLabel'])
* y position of bottom zone:
layout.h() - layout.zone('bottom')
*
* when adding zones, you can also include a position func that will
* do something based on the latest layout parameters
*
var position = function(layout) {
// positions element to x:left margin, y: middle of svg area
axisLabel.attr("transform",
`translate(${layout.zone(["left.margin"])},
${layout.zone(["top"]) + (h - layout.zone(["top","bottom"])) / 2})`);
}
layout.add('left','axisLabel', { size: 20 }, position: position)
*
* whenever you call layout.positionZones(), all registered position functions
* will be called. the position funcs should position their subcomponent, but
* shouldn't resize them (except they will, won't they? because, e.g.,
* the y axis needs to fit after the x axis grabs some of the vertical space.
* but as long as left and right regions don't change size horizontally and top
* and bottom don't change size vertically, only two rounds of positioning
* will be needed)
*/
class SvgLayout {
constructor(w, h, zones) {
this._w = w;
this._h = h;
['left','right','top','bottom'].forEach(
zone => this[zone] = _.cloneDeep(zones[zone]));
this.chart = {};
}
svgWidth() {
return this._w - this.zone(['left','right']);
}
svgHeight() {
return this._h - this.zone(['top','bottom']);
}
w() {
return this._w;
}
h() {
return this._h;
}
zone(zones) {
zones = typeof zones === "string" ? [zones] : zones;
var size = _.chain(zones)
.map(zone=>{
var zoneParts = zone.split(/\./);
if (zoneParts.length === 1 && this[zoneParts]) {
return _.values(this[zoneParts]);
}
if (zoneParts.length === 2 && this[zoneParts[0]][zoneParts[1]]) {
return this[zoneParts[0]][zoneParts[1]];
}
throw new Error(`invalid zone: ${zone}`);
})
.flatten()
.map(d=>{
return d.obj ? d.obj.getBBox()[d.dim] : d3.functor(d.size)();
})
.sum()
.value();
//console.log(zones, size);
return size;
};
add(zone, componentName, config) {
return this[zone][componentName] = config;
}
positionZones() {
return _.chain(this)
.map(_.values)
.compact()
.flatten()
.map('position')
.compact()
.each(position=>position(this))
.value();
}
}
/* SvgElement combines D3Element, SvgLayout, and ChartProps
* ChartProps is where configuration options for your chart
* are assembled. SvgElement is the place for code that
* generates common chart elements (axes, labels, etc.)
* So your chart code shouldn't have to worry about placement
* of these items (and readjusting placement of other items
* when the size of these changes). Chart code should just
* say what elements should be included and should (probably
* through chartProps) provide methods for generating their
* content.
*
* SvgElement will make a g as a child of the parent D3Element
* and then another element inside that (determined by the subclass).
*
* SvgElement is an abstract class. Subclasses should define
* - zone: where they belong: top, bottom, left, right, center
* - subzone: their (unique) name within their zone
* - enterCb: to be passed to D3Element
* - gEnterCb: enterCb for the g container
* - updateContent: updateCb to be passed to D3Element
* - updatePosition: updateCb to be passed to the g container
* - sizedim: width or height. for determining this element's size
* - size: optional func. by default size is sizedim of element's
* g's getBBox()
*
* SvgElements are one per chart instance. Use them to make titles,
* axes, legends, etc. Not to make dots. The data they get is
* the chartProp
*
*/
class SvgElement {
// assume it always gets a g and then something inside the g
// the inside thing will be added in the subclass's _addContent
// method which will include a line like this.gEl.addChild(...).
// so making a new SvgElement means adding a child (g) and a
// grandchild (whatever) to the parent D3Eelement
constructor(d3El, layout, chartProp) {
if (new.target === SvgElement) throw TypeError("new of abstract class SvgElement");
this.parentEl = d3El;
this.layout = layout;
this.chartProp = chartProp;
this.gEl = d3El.addChild(chartProp.name,
{ tag:'g', data:[chartProp],
classes: this.cssClasses(), // move to gEnterCb
// no, don't, will break D3Element
enterCb: this.gEnterCb.bind(this),
updateCb: this.updatePosition.bind(this),
cbParams: {layout},
});
if (!this.emptyG()) {
// if g is empty, don't use enterCb ot updateContent methods
this.contentEl = this.gEl.addChild(chartProp.name,
{ tag: this.tagName(),
data:[chartProp],
classes: this.cssClasses(), // move to enterCb
enterCb: this.enterCb.bind(this),
updateCb: this.updateContent.bind(this),
cbParams: {layout},
});
}
layout.add(this.zone(), this.subzone(),
{ size:this.size.bind(this),
position:this.updatePosition.bind(this, this.gEl.as('d3'), {layout:this.layout}),
});
}
enterCb() {}
gEnterCb() {}
updateContent() {}
updatePosition() {}
emptyG() {}
size() {
return this.gEl.as('dom').getBBox()[this.sizedim()];
}
}
class ChartChart extends SvgElement {
zone () { return 'chart'; }
subzone () { return 'chart'; }
cssClasses() { // classes needed on g element
return [this.chartProp.cssClass];
}
gEnterCb(selection, params, opts) {
selection.attr('clip-path','url(#clip)');
}
tagName() { return 'defs'; }
enterCb(selection, params, opts) {
selection.append("defs")
.append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("width", this.layout.svgWidth())
.attr("height", this.layout.svgHeight())
.attr("x", 0)
.attr("y", 0);
}
updatePosition(selection, params, opts) {
selection
.attr("transform",
`translate(${params.layout.zone(['left'])},${params.layout.zone(['top'])})`)
}
}
class ChartInset extends SvgElement {
emptyG() { return true; }
cssClasses() { // classes needed on g element
return ['insetG'];
}
zone() { return 'top'; }
subzone() { return 'inset'; }
tagName() { return 'g'; }
sizedim() { return 0; }
updatePosition(selection, params, opts) {
selection.attr('transform',
`translate(${params.layout.w(params.layout) - this.w(params.layout)},0)`);
}
// could hold on to original layout instead of passing in as param...maybe
// not sure if it would get stale
w(layout) { return layout.w() * 0.15; }
h(layout) { return layout.h() * 0.15; }
}
class ChartLabel extends SvgElement {
tagName() { return 'text'; }
}
class ChartLabelLeft extends ChartLabel {
cssClasses() { // classes needed on g element
return ['y-axislabel','axislabel'];
}
zone () { return 'left'; }
subzone () { return 'axisLabel'; }
sizedim() { return 'width'; }
size() {
return this.gEl.as('dom').getBBox().width * 1.5;
// width is calculated as 1.5 * box height due to rotation anomolies
// that cause the y axis label to appear shifted.
}
updateContent(selection, params, opts) {
selection
.attr("transform", "rotate(-90)")
.attr("y", 0)
.attr("x", 0)
.attr("dy", "1em")
.style("text-anchor", "middle")
.text(field => fieldAccessor(field, ['label','title','name'], 'Y Axis')())
}
updatePosition(selection, params, opts) {
selection.attr('transform',
`translate(${params.layout.zone(["left.margin"])},
${params.layout.zone(["top"]) + (params.layout.h() - params.layout.zone(["top","bottom"])) / 2})`);
}
}
class ChartLabelBottom extends ChartLabel {
cssClasses() { // classes needed on g element
return ['x-axislabel','axislabel'];
}
zone () { return 'bottom'; }
subzone () { return 'axisLabel'; }
sizedim() { return 'height'; }
enterCb(selection, params, opts) {
selection
.style("text-anchor", "middle")
}
updateContent(selection, params, opts) {
selection
.text(field => fieldAccessor(field, ['label','title','name'], 'X Axis')())
}
updatePosition(selection, params, opts) {
selection.attr('transform',
`translate(${params.layout.w() / 2},${params.layout.h() - params.layout.zone(["bottom.margin"])})`);
}
}
class ChartAxis extends SvgElement {
//tagName() { return 'g'; } // pretty bad. axes have an unneeded extra g
emptyG() { return true; }
gEnterCb(selection, params, opts) {
this.axis = this.chartProp.axis || d3.svg.axis();
// somewhat weird that scale belongs to chartProp and axis belongs to svgElement
}
updatePosition(selection, params, opts) {
this.axis.scale(this.chartProp.scale)
.tickFormat(this.chartProp.format)
.ticks(this.chartProp.ticks)
.orient(this.zone());
}
}
class ChartAxisY extends ChartAxis {
zone () { return 'left'; }
subzone () { return 'axis'; }
sizedim() { return 'width'; }
cssClasses() { return ['y','axis']; } // classes needed on g element
updatePosition(selection, params, opts) {
this.chartProp.scale.range([params.layout.svgHeight(), 0]);
super.updatePosition(selection, params, opts);
// params.layout === this.layout (i think)
selection
.attr('transform',
`translate(${params.layout.zone(['left'])},${params.layout.zone(['top'])})`)
this.axis && selection.call(this.axis);
}
}
class ChartAxisX extends ChartAxis {
zone () { return 'bottom'; }
subzone () { return 'axis'; }
sizedim() { return 'height'; }
updatePosition(selection, params, opts) {
if (this.chartProp.tickFormat) { // check for custom tick formatter
this.axis.tickFormat(this.chartProp.tickFormat); // otherwise uses chartProp.format above
}
}
cssClasses() { // classes needed on g element
return ['x','axis'];
}
updatePosition(selection, params, opts) {
// if x scale is ordinal, then apply rangeRoundBands, else apply standard range
if (typeof this.chartProp.scale.rangePoints === 'function') {
this.chartProp.scale.rangePoints([0, params.layout.svgWidth()]);
} else {
this.chartProp.scale.range([0, params.layout.svgWidth()]);
}
super.updatePosition(selection, params, opts);
selection
.attr('transform', `translate(${params.layout.zone('left')},
${params.layout.h() - params.layout.zone('bottom')})`);
this.axis && selection.call(this.axis);
}
}
/* ChartProps OBSOLETE, using _.merge now
some of this documentation is worth keep and putting in new places
even though it describes a class that is no longer present
* The chart class should have default options
* which can be overridden when instantiating the chart.
* All options are grouped into named chartProps, like:
* (For example defaults, see this.defaultOptions in module.zoomScatter.
* For an example of explicit options, see function chartOptions() in sptest.js.)
*
defaults = {
x: {
showAxis: true,
showLabel: true,
rangeFunc: layout => [0, layout.svgWidth()],
format: module.util.formatSI(3),
ticks: 10,
needsLabel: true,
needsValueFunc: true,
needsScale: true,
},...
}
explicit = {
x: {
value: d=>d.beforeMatchingStdDiff,
label: "Before matching StdDiff",
tooltipOrder: 1,
},...
}
*
* If a chart is expecting a label for some prop (like an axis
* label for the x axis or tooltip label for the x value), and
* no prop.label is specified, the prop name will be used (e.g., 'x').
* prop.label can be a function. If it's a string, it will be turned
* into a function returning that string. (So the chart needs to
* call it, not just print it.) Label generation will be done
* automatically if prop.needsLabel is true.
*
* If needsValueFunc is true for a prop, prop.value will be used.
* If prop.value hasn't been specified in default or explicit
* prop options, it will be be generated from the label. (Which is
* probably not what you want as it will give every data point's
* x value (for instance) as x's label.)
*
* If prop.value is a string or number, it will be transformed into
* an accessor function to extract a named property or indexed array
* value from a datum object or array.
*
* If prop.value is a function, it will be called with these arguments:
* - datum (usually called 'd' in d3 callbacks)
* - index of datum in selection data (i)
* - index of data group (series) in parent selection (j)
* - the whole ChartProps instance
* - all of the data (not grouped into series)
* - data for the series
* - prop name (so you can get prop with chartProps[name])
*
* If prop.needsScale is true, prop.scale will be used (it will default
* to d3.scale.linear if not provided.) prop.domainFunc and prop.rangeFunc
* will be used to generate domain and range. If they are not provided
* they will be generated as functions returning prop.domain or prop.range
* if those are provided. If neither prop.domainFunc nor prop.domain is
* provided, a domainFunc will be generated that returns the d3.extent
* of the prop.value function applied to all data items.
* If neither prop.rangeFunc nor prop.range is provided, an Error will be
* thrown.
*
* The domainFunc will be called with these arguments:
* - the whole data array (not grouped into series)
* - the array of series
* - the whole ChartProps instance
* - prop name