From b9b98c7208271c4dd34ef818c488c3681a5a702e Mon Sep 17 00:00:00 2001 From: vtkalek Date: Thu, 12 Jan 2017 11:58:36 +0300 Subject: [PATCH] LineDotChart after convertion to new API 1.3.0 --- .gitignore | 7 + assets/icon.png | Bin 0 -> 457 bytes assets/icon.svg | 12 + assets/screenshot.png | Bin 0 -> 33938 bytes assets/thumb.png | Bin 0 -> 6796 bytes capabilities.json | 106 ++++++ karma.conf.js | 74 ++++ package.json | 47 +++ pbiviz.json | 37 ++ src/behavior.ts | 70 ++++ src/columns.ts | 110 ++++++ src/dataInterfaces.ts | 75 ++++ src/settings.ts | 57 ++++ src/visual.ts | 738 ++++++++++++++++++++++++++++++++++++++++ src/visualLayout.ts | 137 ++++++++ style/lineDotChart.less | 105 ++++++ test/_references.ts | 54 +++ test/helpers.ts | 47 +++ test/visualBuilder.ts | 79 +++++ test/visualData.ts | 88 +++++ test/visualTest.ts | 142 ++++++++ tsconfig.json | 28 ++ tslint.json | 58 ++++ typings.json | 9 + 24 files changed, 2080 insertions(+) create mode 100644 .gitignore create mode 100644 assets/icon.png create mode 100644 assets/icon.svg create mode 100644 assets/screenshot.png create mode 100644 assets/thumb.png create mode 100644 capabilities.json create mode 100644 karma.conf.js create mode 100644 package.json create mode 100644 pbiviz.json create mode 100644 src/behavior.ts create mode 100644 src/columns.ts create mode 100644 src/dataInterfaces.ts create mode 100644 src/settings.ts create mode 100644 src/visual.ts create mode 100644 src/visualLayout.ts create mode 100644 style/lineDotChart.less create mode 100644 test/_references.ts create mode 100644 test/helpers.ts create mode 100644 test/visualBuilder.ts create mode 100644 test/visualData.ts create mode 100644 test/visualTest.ts create mode 100644 tsconfig.json create mode 100644 tslint.json create mode 100644 typings.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5f3a711 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules +.DS_Store +.tmp +dist +typings +.api +*.log diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4eb96f2f2a28f1ef693314953c603a6bb41d2c5d GIT binary patch literal 457 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc3?z4jzqJQa>jHd2T!AzQgodtKwTi*Pp`oFH zAuWv|$qy*P5F5x47wX--+plSxZ^I7%mYsf0yS$rrcs6bGY25DHyvwh7hhO6khS)H_ z=Iy?XJA9jWGQ@^3#D+4&g!ndXXNV5-ZQc$Ni4Fsbh^LiEq!ly7gfhiNg2GKz_K7RS}`wwsC zNok-Fj7i?^E|%9BraAyQZJsWUArhC96Ap0ctkBlb>ekd)d7fD;CFoK}N=sL##3#os zr)SKb?d^C(g{NXc+A0RtsRwfso$FS(ezKB!mUU~A!?GQW`e({w^VrJR;%o)k6rCA& zOx|%&Wc|cx8x?k0A8lasUd(-pkyZ2pt7XX9tCog)P98eyrr{8Ak-<3N!WACg843M7 Z47pkYZ + + + + + + + + diff --git a/assets/screenshot.png b/assets/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..158c2ff0ee0fff77f52b44563f5f5111cbf69545 GIT binary patch literal 33938 zcmZ^K1CV6Rv+r<6JGO1_*tU0UTRXOG+qSi1?bx<$TetW7?z{K@;>GJY9dY`ctgK2@ zR@N`8!{lYfV4<;~fq;NuCB%h)0s#S+0>0^x-vCcEfR;>vfH26-g@oiKgoFs>?QKoW zt&D+y)Pj=SpxBkSmcDqO)<+J~kJL?mOp)AiPYF>ianG@c#KnG3G7={Y5)Kzt6bw!@ z8kG-65iC;*mUe?Lzq-MeEUME`LYJ&53 z0`5_a6F0I2Ds6ec6aDQ$>|H7p%lb+HzeOO-g4pY^;U$g-RNF$x*$xUC&~nY~FS7pG zAM^CJH+NAZhcxm=zlk~K+Kx^a7D# zrB4OM2G*LUNsx-yE9c46{Lpe8D)&2Y!}f#YNgmf|5{LehHt^2nCLVo zva5$Z;d&Dc_?rE=n#(gx^E3DGZ18g0ow#tdA*Qh#wv!A<+N+D*)(L_jG(KnjDcqpn zD_ssTyHK(8?}nQBzC+b!x%qCr#YNJ(Joifx>L)jItxucjK|A`=ZG-QKJAwB0z&|@r zEsPUxIcwYlL&f^BU4tQYllsu$G;8|DmcWL7%R$_S68u^@p+y?arojE`5G-GL`yqy;=r_sQyx*dv6Ih3nWen zRqsQ?h|l1a`jjd#&VGo>AfUk$$7sv={gvy72Se+{!P8Sf*Ui2U;}N)t9!}ld7Y`@f zANR)?I1NbsD&IxX3gUOfOEVK@u2}vtV&WM=O9Deh{=%;jhIjf1pTpa&59^z^DaOxa z#RvuJcaxpRlO{CH(rN`3#m@X!0TQI3&~#x_1B{#Rb(zbOIK-cYCb`Lltoe_*yrNlR+A3HcJ8qgYq8n_xr)-=|5*0>i87eu(AS>Ln3wFx#T+s+*2PE3yN z?gj7N@A2-#?g#Fx?vcMUfbs>v1#k(02tf&P^`j6cE2PeZ&crZA-H;iw?QtXXBycD2 z%;^_%7l%mijWaYLG$1+vIdIg`5@3ilCAlx7(AY$NDV4`4@U`gO!U{f&8 zkaRFmFdUFsDCLOhh$g74NYQB32;RhkC^HBgBtJ=h5QL*DqdlP0B3z&fg;9pSgdT)F zgckfk9q<}}`Sa#%=v;9Qx2oOfq2@0sC`BZgFSwgU(ok4t)L|60k4_0pXSmwj7i^;P?bd%7#6t7br(qH-xsPY zCe9Hz_gYJgrUGB-oZ??mA8tB3JhY#5Ho2;MO9W;qW?gGWYEJUh%Q>n@%MVJNgw7(G z8(G!gx*qGJ%Vi^F8|W%6b2G}EtcOI#FerF~MmTD*fkJN*265d%zwBJ;xZgLi5X zQ{vhcT4y@y;_V{sGN6*eqDDj#o{9~Mjpp2$64UC`i&wB61nvm#uweA`jpEE1Ehw$r zhHw2)7e{BQrcMKzA|Dja;IO}C1h6hyCtEdH&spoPg)IlIH7(CAw=bux(Q_ShW;$TF zB-%&Xc^>|`+&@z~i*#sqp>xt%FKMCo*h-A>mx>>o_TKpjdE(>g@g~^r=?`Frkbt;q zGtk1-s%fuIpb-}-6k#K7@oaH(!F{^B%;9F{spmG~S>Ty+ll3U`$hu3vQF)rY&wiM> z9=S=oTe#^wew&z@u9>!7+3NdB|1$9v;#Vf%oo1<}3*{Z=E2T~4dj9oGH`G=9=kPP( zg3wCH;RxCOtUQIIkC`ucQnVx_7ThX) zuLsE2(6_kc0#C6Rd@u9@R0mSDaHj}sjzJ%qn`Na;0j(9gKD)2IslB4TsR4Qtc9MB= zd(uHt*-Qp@g*f3XVOcERGUs{*%&hZ_^(>+kd%^?Naxn|jbVm)@)mO?V#jFxz*`w@D z?meesE^Wq-S@rXk+y)^`EKK90<4KX6jA7=n%E{dc$q9{N-qa6fG2W7E?j6tPmm96Q z^;H-U^hq7H?qdf#205-8svIUhQlHkIg50n8!=h2a>b}Olg|V&_T^+{XKeb7e5)~t9 zqbWY+4RmMilwT~|C_T(IP6nHDE%%HUjjazP4-S?%YD!ddsrsl*Y=5sGcb0iHH2=)M zl|*$=UZwp-{k}M~R^1YEnYwP?TH)Dpo4wun{`UPzu_EuZ^z;QQC;djFWVMTPuPxcV z>Gchs1YgT?+j7`r_@1FNZaQ`O_X^5J<1)@H_tWs(jPO`*@={fF6{xj}^~MF+g&f`) zpUuVeshNBJXW>rS>0F!a!DPB=_*8j2O4}u$s;hUmW?g%mSXoK4YEesl5x9C>^ zdK*0+JO(T`HZ%S?c0IN`$N3NL&)L0$@d{tnEm0!TMV+C}-VO=eBHRhgJsfIx>eprq zExY#}t_>IME9=ef>|Z(0%$S_5yh+b~&xEGwrfr_0&c*sFKFv>iT``I|g1l7|q+Z2u znO`FNW1ThWy6uO!MiX}1^YzU|+ z)DnC)Pe+*N)XNE2CAb_64>EARy@Af|k&BBYjZ~Pp=E$EBm0?(_TomKPN9vMtrHZaK zAtlw#v+}rnH_fvsI2~1mDebRHfvTNUMk69U3%x_rY#Y0&+oA$CHb|Emc#GK(o!p%gysTXw@2($&UpKzRJ!1k;y}X3UA&Da$ zdC$`|`a(NVh{kbgA%+DY`B~8zxXLjK;mX{_NF`3|B%6f2OpND$!U`)BNJm^0(-qri zgJnB;c)SiBUY{BwFr|*rn9v(k9Hx)b>|JyjejjJM(D1e7-u_hFX&3`l6%8MZFqJ}+ z+_984HaJ*a(xxJ^HQMMt#Yn+q2~7Ev$5pvreU#KYzn)r!rk6O=*{sp?1NrsAJ!F?>brh$ zaLAaBXvZv0Z?q%9P0_W9JF}s*wbky$iCxo;+!geerd$uQa)NULwW2z{vyWMNb9Qzn z2O~8L*Gt{G;&kTv`tDIV{iW_n*Je&j=GXPbu}kt*j87pw7;SuVM=X1~mi1Qb`ue(# zkM}X!D135|>2wRDotjuCr;fj1DuHxsgk*ZDq_xPNEVh)Y_|`DPnDAKTxS)ZIN&67* zpu`C2P-hHz^b@r1T=mj>gnyzScONv7XnwVU!fsl-t(2q0q;=DJi{9Nju! zufDfE9l7`0&j`1K==CNPa>p9Wde0M~h2ky!?h3OrAY#9x>jxMHX7T#*G{Zf7I+#A4 z1)UA{!#*Si$(<5p6vfMi=Bl&ZJ>Ad8Q|<>E#xNs}yp1MJy(Y8AUhF@Qz*i%v+yq{V zz&BFJpiSxJwB0oC%YWDGk1Pz%23uws=hWOOpLfUk+$p2((cZ0%44*XF>(;LpkkvGI zIYe4H_2NA`LK3KWOKB@aEOD(|PGwZI^9gtnd8X z&4vBpr<2dEPm>vqCibM4U-xraeOCY^|?em#Vk*)^!JVp zFI3{6%Kh4TG}s3YJ+GuSUnF zAV)JNzf1T|^AExRh+q!U>_Pv*J+jtfG!ym)l{+vPVkwoe7?r5SG*;eb3uobW#W!Cb|hH-Wwt#Evi_E9F$cpN|Nmm%IO2j7DCFOX|a`b;if3EM1 zd!;$3&#Y*0tR5xMZ_^ha%AnP1$I~jcR@#f?iI=Mt23}EVQf{yaVv2CUB1xRGY1w91 zcBnt>F23T%wNE}xZd_I9S2~rC)!^IoaHCi=o7r3PJ|6#+GCW&ro*44P<&^8l$v*A) zuqw4I&}=l-y?W`YL$3!df$09e)=`PQGNCJ#Tt&cZ@QWzdP)O?$s(Tp0tAEx0+cKaWTgyrYDCJ9sRT0o^=Io1R1isyJKo_3C>{ay zew_3i3OjMyoqnvpQ6d{Z@i7E`52rAEm|(iNHwJW;=xwjF83=gZv-~Or{JaFo*qK;W z-+L`44s1$qzZtg%DsCv9Xm{VVp>%9yB`S^8qPP3B#eW-muia3lhQPW4G%9AL>r3tG0%?%tQO7MM}! zXDs7BI4Qr#H1RPHbQF?kTK;fpM5)TWYAQ0TUre9!HAyfaOrRkf8P4Tv&87cr&#G!X%>DZLY?B`LSAVenLXh- znF_fz>6Vo0FrARDgx+9%lo4swwAr+Q)SNmT17njalLmvg9=rj8eTtp={mlK*ZPvrQ zL(GHDF&C2*`tD|+iXQUb5JA(RPT@uo@PXrjn|4MdR|yMBcXLk6j`8sG$n{#!l0M=A zG0Cja8s)AEg{wG~mesQg#A1oX+ZpG1#%bl5ge6s01_o|36id&6!hXli%znYCS^USOC7wG?5EDQPI9ls_tcLA5Gzn(f4XmM zA)X`+fu|uNfw~bG9MagvXj$I-#*y+?<8@(sn^28mSrYmq%ZUl*$BHAS?6aCoyG*zZ zPLmbd@?(nB@{}l@{&enIND8BN7y7N5;B!$kqmP#; zHGI%3ACA7*;=Y|d(5?XJ71YL$?iw-Q_YWac==TG`iD@wuCP{SEU@DQQ9>-e5Oh~qV zY%>-|<~F>}=$XDkgW$US<-KKvbsa54ycqaFc==L!6a;wy+Cfl6cK}L-S}rCmwF`0T zip865qJh3j+SYK7bU%gZj}aDWEWIjin_8guTfI$vsO3e<{s=m z(+26p6qPTk8sad7HAm zSeFISD(%&@wsH5QZkx69KdrmB!Yh=b0DnIIvkd=XdrmZUnukL_xn{-6`A- zJrtjDo`|2(A=1GEVzKOhVYs+j8o4G8#nIQ1+9?c$>@FcIkY7tQk{`qiDSn$hmz~I# z;H6HxpZ&BwKb>Vi!dX?_5tbhAiW5~A`@!5N?zn79h%(kf(IoSb= zzvYV02PHQkS73s~o6m=v@pdcnWjXKJ+cLM+?{oaArt`bYtn1l*#MIoJ!@%vaWNa!M zH7hAxdDmjt;w!yR<8rmdso)|1p$T>^Dx-T_H@e%#N-)DYW2t44Z(d5)`R&!Wv=O)` z(%R+|0R+ee&NnJ@VxlR3Vj{|bWW^Pf-=SyHhilCRmKNy6wne^VS#Q1j%kYIBWO&@R z6i7cDP(R2rS5k9Slab~$w6&(wH?lP_rgOFa1t=*10dc!>0&cC19rX!Zt*vYvI9+*& z{-NLm-2Z({Pekw!iK8VCk(!J=fsn1eF#!u5GaUmFFEjxG0k^%83Fl8?(f@7^_{BqH z=IHo~lb+ti#f8p=iO$yEl%A1;gM*&o2mOy9v;YcP2R9o>eOFo=2jYL3{2xBT#tw$| z=D!@xZEXnt^3^x6b#mk(BKq6W|9t)(r?IQ~|Mq0#@ZVwq1f>7_hMtj*f&PDZ1DbOG zeab0s?rLnMCTwnPY~ujvgO~XSGxtCA|IeHM?eRZas{cnzMuz`v`5$loyCpaMUjhC@ zpntXX&r^W9c%ixJ|3~$_&`^Tkw}F88fh2?llw5(&vmiXR7LvYV+^^yzNXC>ee*;(h z!o*^H51f{Yhdqg{CxjaLWLrV0N2t#LT9N59X*e(D4@KTXNbhxIu+dxpl@dXN?o{Do z{(1Ct&gP9ZAygp z7l|H1uKzB&ADFl=n0QYzSZS`+3T-6{R4fqSLPhBY{gKT^+ z7zuFIk^)mvXgb4Ci~P$TeBkPDOQ=|1S}1vem3mDz%D-d;04i^Ye;Zp#_(G2+Z}dsV z{X_W&2~e5%=PniO^z_tdu2|+!?C9w+0%GM)CUk zdJ;0S!IkR}Ja=*4x0R{U-T(;Wm)nEEhx2nQx37;UOfAVUZm_IVszEQk+3TOe~~QqeEL) z_bIdZ;*!#U#r>}lF>JsXnx5trM{46{#<^ zhktZLd(Ni)f>lCNGCqsTMN&s6JFc*-Y$UBQFRz!KwPpvj_=%w@!rCfKvnbKJMp&8Nninx^9zdFg&t(UuR;lqPp)Uxen@v@Q^V?^zH*c z6vmQi6a4-Cd4DlzKOYywV9{e}JyU4~+$Len{dK+`2D0hRO}d=tKje`L$mvj5hzzt- zBO{C&9~)QROR1OGeBK>(ap*cEi&dIOageO%HY@cayk9RqEPgRy+VJ^U z^1*ECr^ozFhgqpt`nB#bHPYqvw!d6wILQ4_s!*`6nDd&b<#{s@Fp>QP7%iBuFFiCB z&;U3|L}=!&$HRguEE}#L!E;@V7fmHzpFG*uOU{=$^T!_k*K;l^N+K>Af0YUknX1)x zf&UNl?>R2-kS}AppG0ddwrn`QeNYryo6_0bE3W!e-#u$yh@Oq%@qhVz-sQkH>?@YY zN?2Q0?Wj49CQ{z^!O&wQ8!d8cYG{DjxOgJI|LRMJCY6RfMkn4CK9a{>X>O-j;`mA9T~k!DWb% zCw(4~^WHV8gzu9_<*FP#IX?bHqto$QHOJ$XYnJ8vP#TkIhlP)fOq3A%%G-o0`q|rl z5w_4<=aBY>qmOKkC(!|zYhZ@(KBOeqsER)#uCun#`vyJ2>9>=9$*zwJTds(Y9e5uz z_Nc~7Dlx2fNbidZd)D&10ebHlx&`P8_wDvpyS+gjCp;#TNJJIj0^G>L?cHy;e~{%c zhAQiY^?otl z`%$UR`*9-YA!+f4=f>3zL$~Mhnpi9vgSO-ScI*Ix?^&Q`QG@N`p{)mAUygu}Z|hpe z>u!RLYY#L};acls!q$84dMkKpxikzm!?iV`Wz$`XVWN_lcJFq*)dALFKZ34pSZ(N! z#K!T^wNa@0TJqaAQUnfz1;17f*dD3Aue7|;wBgz zKJPiV-2kd8dyE@{CzPJi-PG6n)GhtjExpZ0g}&Djyw7t0BbGZL7Jf(g#eOO)(8~ko z{hT&>a;E#$_I_W&hg#8&W8@q3Aw}Qm$X{BzHd`bGAw2}PBjStg6fmv(T)0TA2NY z^Q(xPE0M#7r+>#1L6Lg*4?1D@j?%FoAzh>aagJmOI?D0ctlj>>1n?Xm%RnDizP%5R ztv8*V6FKQ#J;&Ex2z(5(6W3qScsv@#G8tsLpSLLo!PqvnCOh1NWPFKG)b!l*3`t-W z0^HvjlOVyvo|)A&{XKjtdJGVs_KcyONBf~CsA~D&KA*a6-(SCGV!VyC1iwDIzn;D- zN8E93H`_aoX0Y0TPU?~a zHlEbj0=}367ew28PTJ0@nJUTNV03{dyPpT>BQbbtSN}XAx4+lvf;Rw%T7}Cb_aaq{ zWfX|Tw9y_O@qM-0;Su9AIJQg;UPN(Ys)O}SloO*oD8qNio)LJNJ!l$O&{23K;klLmbST|DRXo(IdY&&_ui!LmlrdeY z{p|Cqy>(dPz03T@>+ri_(Lbpmt+@N?{@yW!_c`lem%k*XqYZu0Jl18)YbnipARKj& zL+c3cU=%8B{&!DYa`GZ|lx4=zM8?td zht=Y4q@kKG+`YE2&M`z*_?d%GuB=*X>Cf|oi#6Ns3#+XpRh^ZiwifbA70+kvf_ph2 zFMOLm#Kgz+ZvQg$NVV$-Jb&HRv3YV-Q5g$P>gS*+-OFFNw=rMOF``;Ye{DRuTP&Aj zGu~?$P{9Ek;DCXt22M<@DxfYNq1&gft}e=3Tl=F#<`ZC57*UWzvz`ZUAHD)Qbgf^0 zGbRqFPz40+@r~iF7*Wo&x?NiLy|!9|^e+HY&J2SVKuH7ZD+Ljl{W^( z4B0p8d_CX_p({i!WhNv( z4z0k$lm2Ff)8eW|_ET%E!8GQMHF8x8e9ohoMyErwV4O^Z#=ED|k*HxP$2=~%wVH2d=5 z6A(dG)6hrfAbe`h0&oS=(#pbG8*p3-gqX6Zj7&MbO4~ND1LE>*PFZI2Cfj;!Hf=h- z&Q19oj#1Ktl5A`44k029>bga`HwIw}SX32Kz9!GgJGAM!_#h0yj2B_9%Jp#DME6|x zhez1Mub)_8^YesbNtGEH&s$s;>Jd?usOYV1$r>%cvv zTK}5G$Ac2f?mNW+PeaE`*>ahyfdId{PaOdyX}~Pgku|*Bm8}3RqX}xv=->{sVF?z;~EoFH%|YvB&tMcXl#~+ z-a-X*?$m|aJXP&V{Cm}=hDmv&F6iRN@sMn;L7}Q#$HX&v+ey5PB1<9B4@Tt!o7c$> zM8y#CfM)+HO8IS*w>rd5KEM2cpOC@FSVawZ&-%p;FkaybO!lT&(jpl?VkRF|SMsa6 zZ>vkM`+mqO;KrRp?vZa!AB#7D47hEAI9}FY>Nix?bK}xGqVz)Aoc-4|{MWQ;_*gnQ z+SCLkJ;qNpHd2i_@KX#$u41(k&2fi3_pgN-Z)0i7JZ`J~i>X(bH+{Wh;9ke%8iN$p z_6oKRenTo`MdwSfxtyBL$8z6jLlX$yJBAHJ#64=ftSItwAUU#&`CqJ*avbo;yxY1cSgZl4?L~|P(n4;-t-7r2TXYC zj*K&&VSMk5OEUedYKZI!`w}u2@!?d_Fx*v4q_QuqtG0ur2GJ34s<%LA$Uy2TY$I zg*Nn*p)A!Zp@ak!`vQPa6LUU{HcHDKK0yx+WDGl9dayT0dUn}~l>0=GScMG`H=_+c z4T5fOj0A0}S~#G4grrOA!E79*d%Gx8fAEUB{>LX5?xy`ah8rU*DY0>7u%!p~E4&Nf@1ib%Uz?`P9^E^pUCYu7zk11JL0~ z)RaE~8VhK_*fFve!T7PEpXKF`qp(ZVvPQ5QS`9+g06U8$rd_#aPct7Qv(Z z?$Tj9G3dp^t^}#Yo2idI=~MF1q5{i$(7?p=5X6+W16u7pjydk`Y=ZQuZiJI4c+Cv> zAuFw|<_jtBomFBKvuT&!@{32jPpvwi7vHGx37cdT3_<$A@Cz7JORD9K@1rT%75#E^z5u~%>KI$@gb~~n)rwxe0RF5@XJ}ETJGlVdl^Ex$d7^S(?^N}nxLWj7bo1FT zUC4J7=%gk2E1PyiK-|6KR&0IVn^&c?@I0kI4FnO7(` za{^!G^0GghMUkOASn$pXcBG9ruibQW@-LC%Ok$liM3FC)GB(4RRBhht zn#Y&mqzj9+zJPs{Osh$jmX8* zOd%A+awpaJ<;(RJw+8ho0{%idgEbEG_}!CL-RV`9zZM_$Y5ypnXx8j4kE&%gbJA{T z%?VKU31j+yHonR-&+UqVv7)tqGiR=OdxfJG_N15gn*lyOmSDJbJFgo8D*(PDgw^%I z&`%%T*yzLv%0Tm8CN`#`b}`?`0yT}?)W40ASz5L|Fu$v7f17ZSPb|*wfp53<+`|^l zK6JXnJ&RhF1n$KnSEEP=0S`-qbV5JSDqyZjyTSnOx0uoD{}U54<^9-jXxtS7?6=HN zT3}rfN{M{ljoFay)-iDy|D+as+&AS01jz{1+CEe1k;ETG~BvkQ}UTY}qfGfiep`S}& zjPGvWFtj;BJnk8<$`~z$)~`U{1BWRda*Q-T(nnu?chrJtKQIsf>j->!;a`0LXnBKm zTi+D<)k+wi@Owf1Ul{&qUy*39|rn}`Wz_!*@#>~ zeXj2>*aJ^^&x8`t9>>}x^n3$C4Altld80SL1JilgwtYZtF)a*b&=c6iV5U3B79e{2V2+kkQOo~caC(~ZTU(HHCjlf zcw)`HvjR0yNK72>iml)Jm|iW$eEjxd=h8Pp=C0u~>O@(E!s{jt+|t!Gq>adaailV% z#N}buX44|@`wUBj_tipNvQR+>=iIS3!_mhg=(FRUQZc3t=!sL{HjtQ{xt$JvA>4BZ z(_AT06WW1%(RN31L5t23x0ml0XT=mh)79tZ14LL{78R4J+egt5%iY_48D93}nY8bc zvD#TK^r&?*XXg>WuKHXXUPV1)W>1@m?=(@hIggi$ArS-)oLHN$+;6B7KwkEZcv%qy zhVp#fh0WMsw<~qGzedjCbhwFI+IUB`lM7jU-#j=NaN>_fJ_B2=>V*Kd*zFo-ofD+4 z7_;0kGMQnu*D0QyCn)ab=DZ0&Ttb@$>D1LiXlmc|=JzOVg@qu ze-L~~SYWEs>0c(4e<}Gvc#(4bZxJyP{vwz_P=Ucfs7hw+4JiMj)c62Q{{=E~MB<++ zfS`~sG!=8EI`qGQe+-~3;0ZRk|1Y0BLVj>mtVx=nzo4pkj~HMCydZ}5|M2mf1{58j zVNH|&tx0SX1F8e~evBOd@ZmQDByT~%Oj3mZ4DUJ)kXOX?q-OrZhkzN-iC}+P(Z3ag zXK?`JE2j7V6BFUg(ET4N{c1_3S7!*{WV2%vOf8Bkn?*U>+xLGN2eIS8rLh|%{uvk? zUe44SnHo*bKi3+Ok%J#|hAWX2Ge2SvRt#81`#Wj|s7A-eu}8z9{f&S(V~-PsG7GEN z=4jcw`n9L$Wwj9*sT8-p!7*dS;*rd{JrWMWUhCCgoS3Mn#uR(C17b!d`U*X_-;G59wk@2f|3}S1J;g#0`=6$Z;)a$PV@W-p z(xDLvZ`ED0DwEmBso#ZCVNjJI^&2Su3RH`v*5H)ze)Q@zyMg>}{4hnQNys*(OHmdk zu}HQ2%8rAIGBIxbkz{ZHVBJAj``Af)dn`pCEXC z<0&K*6mQ4(QNm(wygiO4;VL>7&VT|PD=uE}g<#!N{eHxCN~MLIq{jrZ}`x%xS`%&w#E0rRmErQ& zTll9TtvEczpV*UL68TUhV_wMsr|!i~Ra>O#X%gY1apUGpPlX{Af`!k57crDoBFJQt zm2j0=%81V8WEU4l&pcelCScH>QX-e$3HarZy!rQN0iG@-1w@>DRp~AGn8SI{Or*WB zF}u1#NPB@hTYOb);J=4MLD8Yh?(J{IW3M)ssmfoTF&QTNdUCTXp{QiX5$U&N87GPsk@n-9>l>qVm`LNRS=3JJr0SP?DuxO(L?TIi2z0(3Dl>@!`Sl zJWJ)H<%+8h?jcmw)!BHIqZ0{2>4W8&Ju(lAttnJLSX8%i>EaC>WlqhMixO2-N74>W z{N6}Z%SvlfQk*Y-@k~qwkZiw@_YyE2Ui_W32s9vE^EG&2#6@k%Xj#FQ$m{_Z^HZDR z)zwV63SxD|e+K_xSjuB)J3TmJkB)RW7ne!Q#>W?Gh*`@!r+!M!!?vpxNG{!l+1-fF zrnp=ra-r*5Yz{sF{hjPU!X^TM9ymeBEi8^*cPyLfO$YfKyl^ z1ymG;Cs=XW6}EdOFBm2D(Sc5;6$P>eRk{54sYbeTT+?WD!BAiaOJ(3J7YZ05kjVVn$v^~b z#u>N~afxL2EYWj0&IL;A)@q@TEiPN};^<;Dbh%_4*cAaxoWDcA+1mgNU4YD&vQtdg zTc8<-E)FM4_U!mpm*c=3p%02!3~LbC9BQOIv)p3=kC;;1FCIhrr*^sHt|Ju#HKj;& zsX!3h&!t(aZjU1wH~T#<`z;py1ehV2>qFM!8-4(a&f~jd4v-;_9EcuP_p|?N_%L2* zkQrw&I(^=8UANag?l@7~K`cXhjQ$t))=1`LzXr0X;E+Z6_c=$~(er7F&TC;i!6#9# zy^*aktTw^hZy7_(O7eI!*C2pempqcI-4JW*RT=rblu$wg)^Y^c-Zvjo9asX3jEq9< ziAjB|u3tav^xzYem3+$J9ERg56P)0cOY}qaJ2$r&ZwERN{^Yyg7oqPHIt}bkGIw@+ zx?a!#%ii2%06bHV8pJh~@AJ^I-W`CvJop2HKK^*2w$8fvnV3LbGC6-LPClu&gjJDK za%xtt5QU@RXid$mZe_8?=f{ib1{L`{P+oTYJoCD4nT?qeJmSpYaH)pOpQC8pnv7o+ z`x~Hd2WG}aHh-++I!k0r?Sv!Gx$OEFNS}&m&cpg%lPdJWZ`*x75_(!`2RuL=o%VUC zi%EcyAc=roEuLQ@rpj$Q4DqB!6vuueJ2lz&1Vj+WuxZfkpO9%wUkEU=G&V_8w~<>Y z-#xr8n)o3Oq@HWJT^;KiLQmUgsD|t}f~ zEFhvPF?1u2X8_h+YLL{$x6ky2U`AmdHUgY)o@c!wn|g%=T$(1RQ3^1f%)P$kjJ&e( z276JId=%oYrPpE%%BeM*GZQkMm$yB|n_HfTF!4D_7nkg;uDPYNQK)hVxImKxq& z+>U4Djx|chnPb5P{-<2(tN6+~#DIm~4+zgz4q>BUV_COd~Sn69v3v(7XTiHn#e`PEe42}%pfdn0*@}XogAg_ zh#cqz6scM8O(a9);`%n#gC(3-@gqx|*1Vqbyz<=vfHGWZuKV3KIY65P$gVOne|W5o z9*WFd{ZrNDkH=^QBw$J!KBbj4sOJ`ED1CM)M#KNG7yF+E1d;<>-(MtGB>1M;% z2)^5m;UZg+nU3J)C1E6Srhw&Bu^zE?3zYx}2@=<@ zgtgl;P!wJ%Of3S-4@1t)&2Mz}%oBP6`Oouu9IqSK6}^Ki-`8Hdt(|QmI1*|i_&0b! z66r6NTbQr4c(IOn4KjLmW|T=4E^!J$uN0q?MmkWi?NGy-D{@Ikoj`l4mTaH=*LYBW z<2&K?`=fP$PQ{IqLSRE`sjP-63W}>FQth35pu;?jdz7r|H=*p+MghGQu^mzEf6ko< z^5+)UDKuLQKkAK|KM?T#;88@AP9=c~fE?K=i#~K;dhUqBVr+3Kb(MR1cIaws6Mf9) z-kGZEzr>5q4c1Pn7a-B&19_9p`F^~bP=cm%R{CvHwJlEvekz0B4;xxXzKjmphoOQ+TDQ?M7WLL3c{N?m$2 zXMqXw1HC0VCnW{v7H*0tnYX$U7QUf8+#?iyjapa~N-kA!wzU>;>3kt z&qE5F^Cgf)=9WV#FIb@qp(@SB6XJkEQPPSOe#+EN6cdk0Xlk0RS_+Z0SfC!*35TXC z%{v831{}jczyxFPFSj+vyd8WzmWR5@x%SVrvQ1iah;x8phBWx;I3Bw+qv7`ygPXjD zu4}-9YB$6jANrflG(du&5$qAJkJ8G_(xPTovZ4Y!o9#cw_5(O@VYxyS8f6QL;Bukt zm8olsJ@PWwA4@cAq&U`h12VI;D$Y5|SL!AUhzLS;9(<{A<4Ggv5e`ctFqut?*e{ew zercTuH|tSJ9{jYLLK2p|Z9$S~0Fktc)1WTF@#Ixa#LI}9B0Bf{KFUa2TrU-|=QHoI zT2`}(N+pVLTdYQf_5rTqs{J-x(p=?$nTohyl$ zvvS0q;clUXm{WFPTbX*Nk}HC9`FZ2h|wUlq?Uy>SWrAk09yi* zC*C#LzsUy+K9_{FQskc;n1LOeLq=js3C;z%9BmRZ&QUsL%y@Ts0az$!G)a&+0lmTr znmsHHO(PQlef;O^IS6A;+HO7Zk8f=%G}NRE4@$yb#s1-?mnMdPZD~I@E2y~*RFq}D z$Wr|5+UnW|CNb**34&-+TsXYoa42D}d<}vKC(qK7GISJ77umT6{ryHFJq_LtqSp2A za#CE(#e9*ab)|ly%RROR{jaOuZtX;^L#P#2gRX zbe^%L=e>3q9;Sb9&!56YEnMRO6XF{=0=MbEhP zH!ChEur$D*x1bPtp)tPi&IkVKUB`QT`Y90PgQQ@Q{9KA*LAHz|V>d&WHK|?La^#JyU=W4WTOO$LZGSKx7 zR=QJqDzVU1sg7xEfSnFa7H1a9J3(#j?`3o7tD%ejUBGPDpNX;pgIZ4ESFowxFDhCq z_pdq~S)T-fM?#OvFgv0OF0@gSC+r;;6eqBULNPKPYxD+`SdJJvndj4{TX3zXH!uY9HRWzaM<+Frh}5b<_VlE9M;FBcYT zQV-CQn4HQw({yW3c zOxIHz@5(iUhiRmcJ8A6qGr;c@H_xeK3*_6o%g&fZ2zjNyuw`vy1{#vCG%$8XC0n{J;J0x4GJ* zL;}$-n!b5Gxbt}3yH+?z)>tp`noYZEw;|)k{+HlPUjZ>12Yd69Qu;|;pKYt#Rw)mg z*(Qw^=W&v0dQ?vc-jKZat&(XgItf3G!_H{jql7U1(L$2oWpSDbBM_YJ+`K`R;>-i6 zR=suHnCFp$VkuKX>FMfcjKS`&IL1IyczU^DGbC`eNO867QNj83tf8evTeV!9wnVio zI*`^fO&h36) z`02oqls*5%WH|9>Ny>D&4m}V6m55qEKlC5guQ1(R9VfJUTwO}d%+AuN>3P--Cv!@Z zc;DN74G!LOT1vrCR#;Z}KmcCF2Ro6=%THTy!_Q}X*Tw`ULWFL#9u#H2)DrSg=GAe` z=dc}`rQUWkVlx=VwLDAzx2cKOcl|r^U@R<#>Q!J~UfwqC?!iH9iggo=jg8IdVs8q& zq^j&5>+jKN0d)DFs`4ddrD2bNwxlRtfLdOCRD7De#bVKAr0$!Y#pKVQ;m;7Eqruc`c1#UFtF(1cD$mN|zZOmsi1q{KxxzxUm)8iRs`T`iQ7b8%I}y&W57B z0=I*4Y6IEpwD5+zvf|U^EISoynb?Ci#SlSR=9F4Hw@51gcA; zkz~$l$6dun0Ivgi14)(c19-xdVhb>!C)ue zRwgur4uFLlG>j0SVhQf}g>siwX`-@9wLJsGpIbjANI7-AZznQ7mA{ct5Tw{Z1AF#6 zneC9DnBvMj-6J9tdSDerU5eLU)Jv}J{Y>rV$fe!FH69-bBD;U7!XL!=B_PY&UoE*j z6F12Cd%uH3wLd$%UnaUsl-0G+Edvr`a6zh*8OD^&go@Ibqx4mQWOF_~L}p26?UvuD z&dmVrqR`W{%Ole~Qb2wVh1@g*ijhudvuxopWci`K)Yt~e$tss`*i(#tgeEGLmGN~K zl7}Vrr8y?2Cbg&7s7M!a5!#e9qiI~Z6YjpM3J<^0COPQ}g`$iE!L8vbPu9LF-Zb7i z@+sT#X5PH6i8(AAcOT{FO+=+rxq|cDN!~}&9Sqbg%nWFHsxAo1LM(cS3|~h9>`ml;Q^oNlP@}KRjG*GB>?Z znR0a=8xgvC?zmK(iHaQqjXVn1PkUO@O}N7x`;JhoH_wmYm)7@v@QBb=YF~hKZY5bA z4>YAM_#;1zHVkJ(C+3N%1mEWIDg)WxALo%L8H)sXbLICkU6ffhARpeC!5taouco5 z=n6UN28U$j6SgKCyUc!aC+RBSzwtsW%H;PXbOjpbo2lAdQN+)bSp{(-Wc9-#8M+W4k!m6hEu!Txy4BqN0{&BKbLb_9xZ~ zGk-Bm?;jbz1Kx7&oxY1#Ejsdm*{2!)1 zgi|)mUflEgdop8Qac{rCd)a|CUX<-ko+2}Nrl>hIP(MxTqnvty$JfICsB$F{NKbFH zQWSH#y)2+O_k+wzhj@H>j?#m0uRH56_J<847xDbxg0BTc;$>5q0N%>MFZ$f2#zD(D zSvn8D@&^*~Td>0ZT)xS@;%rUKUMnyj+Uhr=w)tSt6H}k~?(b0XR7FkXM%& zUWs&0QgqB4esR^c28>|T;u*CVQt)v*8}VDGsq(3OHLmHUch9}pUt-w@>{C;K19N~2n-ZTnGW0(;!l zRF&2{PfyRB#-z0*3TAk^Lb?aOshYaaaMJ7`aXk%!zu2M|q~p?V=@?P*$XG`nTz=@9 z2cPLV1%#AHfDS|8uS-U3jKvvFwr7f{`SC%lUdz3oC9h)#C%qRs1b)Sx8W~qV{@>Q5 z_l-Wazxgv!lGnVDfzo`IN3PvmF}}1B>HTo0DV!-PWeaE)qy%OMK+qtwz<0a3pUl+( zXpd+$wRNn#Elx$a_{j>LPBw_Eu7i{JXgQ3qcx zbx48K@VTh|&Tqr<+37b5)scEO<~A|JeII|~JwH)a_?|h)|1WsyclkcRkFhJfYV$s@ z&8+?)_!40KFY);SK2}NXi&IwzaS`#23{U@lm}ajQ_&_j@@CT087jXHw>gmH_AjTpi zf|3vs{wV_a5_%weN<(1>Kg1$Ac~i*kFUe%um(vali0>|OMTjBTfMBnt2}!6GoU6YG zIp%3-r<=|R))ArBwbb5!@ z!U!>+oc+Ya7+zN09Hu2ix@sQOd+<5JiIrC}y%OZvk0K!$XGG~sB%m?8y+qGZlr2JNLMH~_@>1>{S zxW`FECyK;L3=8H^T`QcV3iH&#VO&R0x=*!8k*hJvte_tEae#C=>$fy}*kJJzD zXo|~fl#)45tvoG4&TCzQbINo+G&SWm*2!w}&gbbwJVeRsRx7hq=O6~cpX~bJm+A%GD4yaXRS)LkCOeMOxS`eiDa!VqIop<;j9@w)> zT@Az?T}1R$UP=tXdet{Xv&_imzeXyofTz?@2$lQ$U4wCv5jTQ}QBSK;kqb?pg*kA< z1NcnmWYgw|OSfrGxaFj3A75=d)HA6QmlQ`A-Q3v!m96*Bvp>yG zbCwZ7GVaqgNCU3?2~?rjLgB5}RccFvL!c2=UO#0_!LL~DL7%Ie;(j*Hu`e;fAan~) zx753J+$WLQB69cOamNpT1=~y%Q9teKbHU%&DN^XX(lXOOmCFglpVgOXCw{@2`Q zR>v%FcBgm6m!1%N*17Dp(L5v+<>UfhIPJAe{Bsq{uU}Edvt|gU7F%Fkm+sM)bw_t| zX&JEtIR_h4ok}_*XCP;2z!*Fbc#K-J=|W+0bq{W5&AL-KIaCgth?Iz5RB}=GbJMw6 z6Y-$XS1GQ1PV*IVaf%*>pViz9jV=a{bPVz`Tg($jutAnmDxyvdjA{%~cjYeYKyAWa zFHeN)a2<(us_1P2^o}&&GmXIX)43lH8?3cd$xp;b_0xZLAVdZ(r=Z}C8<#t(?N95P z5(~5V9p{$gO9J^Pk5T}AZZ?qq0C;)UbG zeh;-3)j^xHav!r?e%cqF82AZ;1za5t!PpsG-Iw~uE!M* z;E&HA^#BwEx{vJkGhdT1A3|iaDg7_L@y=n>gt)(Ck}`ySP=oal!P+d~(3J5Ip3z3) z%KlJS@QnoE9za{sL{*2oz6j~N_fsl)fpA3TD}?>=_}6nIXK~$h2=1*W=Ig8Up91e= zJ8*#4J z3%6e!dVqyCUa7la2Vp{JzyAJaW%f=x^_=??+UJi8+q1>Mv47h`R}BF44Y4oB1uw}C zcJqKvQx-QKDWOQkwlD;Q3~1#r=#&FnsCQ>;ASg6c*m!n&jy#QEpY~gUMD`R1e^ax> z{JCf4T}k{4d1B>O*BVpE^ZK#MBK)C#*8J`X4rPO44Uen*STsy>M z2Gaa-oYJQtM6@G|A-W@XwYIse-mGfNB3SZqJcrgF43_WK>-%-T`ws>7cc1gN?a zY8S=BnGKnlQRfHUw1nzg!gtA{MKyOW-R?J5sQ9|!@KEd{JYm(mK!RI~xDRWV2Bs;4C(hrvfy&3e95rz;{`N#N)rW%t~b;#*=ysOB+)v z(aDKq^^Dp?gV_|-40MG-2hMMj5j$gqV#-;G%ZejW+$bn(i+A}etm~@qQ&On&rhHxW zJ=$K7_&zcsPr-&T5Ls>4^-%Hpd#MFEJDL*{6Hz>-Hyw_BD3BX)`E&1Y$>(k)xra&n z1(bAB@-=Bt%x%PJ%O!>}%LVh=>~Da_$Hq3kO#DGtgge2^UB5j=QASLBx?wuq|1~+8 zh1z0e53p?YHtOtPLpUG`{)id`VvHiZh$l;DMwY9L1T*`eSOoQ%CkLOINatvxcG~}V zf&cOTI;lX#j5D#|nnVbMczFMTQt%ld@~aANjrT(nB}+|EzYmgX^6NwSa$NZj5WOb+ z6sL+5k1XXk9EC`FD-FsXa`6KddRnBR*%X5EU|s&AVgln$=?LMgH|w5%84<@nu~@20 z#%mOp>?!|>n7ihjM;RhfYjcgvjh7mxORipQ0`z@Q<i=|L&95w?&wuc%PwYv(IeYl!na983E!M%g_PGv7}aVZN% zpCA0K5Rp(V;cB{|fun8F<zQr0;no6 z^j(vIX6GYHOOt8B+A|xLI-mESry4Cw$|=$&N7^Exb;I;Ogo1ouTpuiWpXLr5B8U6v zZxJX9v9RI%^Nc=D%a`LhzQb%#w>k^B?lj}RcZ#ae7w?d8QCf%JL<=Qi`4EzqQhv#4 zX>*b1R$Yj9%J-`_uCG*tV$LBt6TPK6AtMP_EDOHq!s+X(+WS_*jvIT}tLZj9vAXmV+bM^d-cC`AMj9HMbe|$Yl6}7 zSNRh$&W3VG#`Poyo|0spC>hCIgjP*{xB!5M zgAZ(5Z;1CkfrF#7dz=N(r7avb*l%R|#0LafI5M_pkN3u+A>o-v_Qh%-63I}~mT{GT zACR7Shh3Dp=&+^%{(u}B8I%0RKM$kr*8o98FqEjQ(!yF8nB~GmeoG3Q31Sk}qcqLanrScPwo$MA(N*>#76(Y~~Lwy0;1^_Om z0%Ry(HlZ{&)@OUJoP6aoEuLB7RTA%Q4&whxBj9inbGNtEM$++CwZ}~H7~k)_MO>v} zq=i}Q)N;e|us(|c+Q0w8{J&AyE_8~qy%wOLxsVD%KiD`f)^OPL>#PzkvdA?dm?a5T z4z>ggijV@b(Y{yVLZ`m6Ur~R_&M+G4XUWl6RJv%73@d717BbtB#b?Ii!dYb&w?)!S zI5IFrh%~@dD*2NDN(Df$M;N{R(>>Lz46fH5JWr{bEmVA$TYN0!eY58H5@-UYj+O33 z|A8+QCx3o+osN}cM8}6ax6`XF%#phaS93z2=zfk8i=i5dux=3pw8E1ZS;-@@+z&P+ zSCTb$8{%_~4h4_H3{rU62P*n9QA8$!x9^Wy&EvX7GrSehjXih52$}UB`c(|FQa`Po zw;p@SXF8_4@6o#bx!Kt&E-8-&{fbLTJ&$t_jusnFe~4fV#K;TM)|igPKQ9G~qpAAi&(|V$H`%F?VAcQ*1qQIH1G!aDTWwL-FVgN3u$$8uXN9M>IbAp@ z#-{MaP9aOpsp-1q#m2=A@Ex~M*tA~6-vcB%ZmL90+RUuz{qkoFK^h!txmcZM=K=62 zjE~*4iyp_V*87UOx=i$H<*_OyssmjZZx7zcdQ%r=-sW^!fZ+CxqSE#B#ZCRf5fNLC zzs2@w%$-e>u{s^K<7Kt+kWqKg*}mu3Jz|84;o%zA^8d?gWUx>4{WAai=b_r=cv*KP zuD4gb@piAoc)C>M^m-%WD*5$6TxAAeH6A+FC_zH#ft^@%7j`sLmx0S}a(l6QR-lqJ zKzquWHhx!N@s^2Y5&y{k{@U*5;n5{a$Y!A7gux^^F1gDT zD_+KkhoMTnavnZ+$hb>wIUS_8zu8I{N^@B@Q?6=ins==)r%dWoQBfH+;Ro{=AR?%X z5kC{!-yv-=^DF~koMjV{B^q>zLo`_Is)627;ZK9XXQFbt1Rxz1o?+w>)MZL$d2ikD z(f7CDujf{gzV-2+3h7GEM^kwy6yBWejB(|ktgo+!s-B;x?kp^MPPZGhoU$2j4=4F? zxGXs(ST1}kn~BXz>P-Qj&W45+4PdNgC>_Iu!RFJ6cJ{01v~$h}K>+tsC?+7xt=lNo zXyiikkqD}4bc4w6O`twAGWqVNWM*?z??GTQJX~hgKR6go`sXC@^ep4Awywt^XX8=R ze&~>_tn8=}8W0HDfw>Q7MEO_NEx*N+xNOoF?S~jo_ev^mIY%UrwnPUJfFrb}j?DRK ziB}+tg|GO@dTy(?b|rk=a6H>1V^2%d=|r~y-iFLCpu*mWP-^O-2D^>t&ji)Cg`JO=tx6F-9LK2Wf2;x{BQ%;E zixxpgF&G09q0a2#B!jetOGO3Z6*3nDwv+#hMb>oCCY&@L=di4)ol1&nXf$rfK|DF( z+AADxxEJ&!Y8oCZdU!N{+M?HsA_~KNE{5>kee#hA22IRxrE!R-K7uWURmtyx!n6%*rGk5G15*~n~Sh*@iuVP6yH)<0e) zv%`<_9tDe`dN|<`ukVb7lj$muNgB5~beI|@tYfeF@avH`hvy(tAmnO-md8lt?JS(D{Q!12!HQ8p+60RoqS4VzYS zLi7U{)#F^vle+W;b%OtZZC1iHnDCl=s%Gwz%Shj*`9-225hajEr}d!s*}Ps5g<#3RO}MwaJxmD> znvp@%c&l$O=$WOz1_Wi!J~mVe-1^o%LOhth?bdV96lwI^I4vFNn)@rlisy9Z6?#Z# zRP_MFUHq^Hd$eY#LcWr_#mWL(VX+Mz4-~-5yoLO(4*JMHhWS~DlAeJ$6@;u@f-q}1 zr7~Wa0!z7Ysx5x^VT!)nh|{r9xi4?ZT>QxPW5Y=yO}jZ_U@>Lfmi<8Na@*GZU?>+_ zFvxB)Pkx(#J1Jm(d_Qq=*cUOlaCbT2?DPkfdCT_N9G527u93p%_$yApcaeZ6_%iS1 zk&C>#DR?!sEJl+1L_~d1&4W5>)WP>WMsBIP61fQ}r$1!kTRuh$LQ~Q87H@JwoGcqG zDa+E;=?u=LrbKB;EihmGUQR-+=|wa;Vx3n1KRo}`tJT0HB2Y8?QVXIlzK zV5sbrc6h}oOgvTpO)5qX$;8|hEik-L2F6|gbnxENQ$AFHFmcR1ye|tRa=7l>438!Y z2O-@<-rivrqz?SVa$|kG7uy+Y*;Q3!p)VL(Oy{J@(xzibke6RvlEaPGxTdikPI!Ft zy22lHEh_gb)R2L9u|aNe$rb%`aV#$_yKz%kw!9d~u|#0P&q({8KvVN>5kiTSgGy5Y zx{xVup}a01#%Z1~o_ABl?%dsBVGX)hGJtDfN8)gDo_4Iv+~%eFRJ7Jevth^FMLGtF z3~Ri@*Pbk@(~F>^bHT32DXHSw=}Ksp?sE8FjS}D`6o}s5F6iYpRoVH(rZdm{YJJq9 z8-?Xt;FhrY)sf0f%BR`c6kQEaCiKgoA;`aShor6(-U{lYfqfU-;jmCY(56)I$pnj( zkiN0V4CG59$y0`IX-}Xnj)NR-7Aqu%0;q#U(t517oi8%0U{caN6N?N$8*Q#0waIYi z$0}yO??*X0#*hEllPEHjkq6)?8s8Y+(|-ns{&6*q|>rdJZ**`L%a zp{r4?+q8(gFSUe?I~5}geyV;Sx$DJom)M_2OTi%5`yiRDce7o0jS^=t9d=`p48tBCI< zs}vli&1j z{&XcY4>JJnVPqs8&kVp~p&-pj*r|PKIb~tYIi;AGX1F}+l7b0lR8bLsH;)v`#TGi( z(nF$vS~gfImb5~3LOKfv=*%&)l6OnF&hS0^-j?zSuZpcN%4Gh2b#tKbwD!dvR;FLI zUPh%j6cZeO@qMR6-<(-a#8SD4T(rqOTv92pGGB<9xU4ifq_3p;wSb(woZ{K3wFr!S zB>QWRxzKwXDz0S%bz$&Ia=+&)X?rDrOG3yrBHb<8$$s+f{ex;amEVEt3tihtK)Se6trqTBY5&+@z^6Vst z66(gJQR-PEm>EdvrO?HBPCRYxF_yTuV0)6Ot)NbWS0twEB$C5wzW4J%o?@Im>>}^A zj-H<)U$HOx63WKg#vHcu9YD~xVW=Zu2dRVBBzveZ*ZeKr2|D~xbHs2uMaA*W50>g# zm%ZHBkH~2Li!?Tc>@Q~j#89L4a47t#3F$?T^`8j@1=}R1kZBZfWIHI(LYJrBh}I$K zMbVo4R!y~g0Sn*aUw-}R)=I=FJSHO*KjGx0OG_OacEJ$*fc!(U^S|Ci6bt`tr}x+z z3)YH;mz3PzyppPWa;VHuBs!jRjSsd!|u~AR_qN065+d0Aq5?XVOs5oX~mwk@*;LI$4bo z{(<(A0*rKP7c>ZyB+Et~W?&#tWJW)64}brn#|!-MKXyn2?}~jTKJPT9oKnvE{j+E* zf!KK=*BN)+uMm|OdsqJxmCCPI$vkJDTElihT*#*^U9?7EGCQYsxRdb;(_VFL{bha>m{m%h@aCDk+}=gQ1rl`g)mw)%*wT z^kGoUaL=h`3;P*S5j;Q)0*zdu7W8_%t8gf`!fTUGL(nS!ChGVN0050A!VT&q$;SW1 zn6|SW>b$%OiCBncB>rWwC&cSlcKb`=|x8I4Drc?=|rMUz_(fF_62Q-+z zI*t&>LI(d87h2rU154@`xSe%+ITUGjjg6yc{8KBUre0 zGGa(5NhX8R;=Nhf(&}x~UdLQf5u=Wl!>V-!qqJ~!NsJ~{QL^ujGFKo4lb#|}^J22{ z_kIHqJsGnir<|W6R3b$VkEFctSKQlB=Jdt$@ZqVTW)_=*S>X9&J?{cIDSQp&^+!BX z!ba*5IA2V7B4@zERO>}2#CHg#iWG5`Q^QkP&|-;+>5;FN=cdY}N;R&;+`^-q_pj~d z6zwJBAfg8>U#&qq2gP8A)(o|{zJxg>%)&+N&aJ6DlkkA05e zoYJ6rsKlIgKd;=1u?bX<>ExZ5t>yTtB=QyIm4;&O+D6WKM*aAYak@MC^wT#y6KL?j zr=r0-zJx)d##l_EpiKd6DKevC?19Wz>9p8{NwO zg-sc}51`fM3qE%U=(xZ%Q|_xcOw;koq8T2Ip8qNyBO^AB|mgYKr$85^p)!PgQ@g|f~aDq z&~a>>G@^|uG;Cc=eECF=+*FiX9!VhPS&s@unZn2>Zw35w?zILt3K*<*-faW}h~A%m zzyfM;I3?#=d~_)ZRoHT@3J8B&X297Jcdbsq<}qYWGhXEq;Kw@z^+l#;0f`j$#zu=h zz-&-`pT3=pJi?PnB!jWqjhBn#eA!cYNl~z|motVpFgAQj$ukj<2-Ymfw}u<6Srlti zk+b#@GS7tLBs0;HfIxLcwPNKZ2P|&MK;Tk>DTQq*W5HEE2pJSVqL~`cL3(ckNkD(S zDAr6zgDkE@Ox$`Ua>3MdR!|!IBB|6ao96P-p{%qSj6AMcqOL;W8v7H{qu0~&?EIF$ z(m3oVD@}-w!gBmjuSkp@U|^F0_Z|)0->c|(3ei5icpX-#agD+;aD;AS!L*N(44Ks? zH$0fQy}geEF?aKqo*3ihYZL@(qhNAytN$$B8{e-W0QH1wkbi;O{|=h_u*can61_pN zxZmh)22LL$x(XCKkpTO8N>lG!bJII$fiw~F;to({)!w940Z^7wln#Y;QZ-#h_Q{W( zguAC*ff-51#Ju)R?xWgg6f0J8Ah>T6cke|BK{0_@Mjd+{h$qFrt&HBQb!CkRIsJom z`IRzZpn$270oxWm<&8Lxa)7MGEG9_uDybAyl^FkCU8Vqqom|A>$HU$Dm^}eVbndh( z5=yHMN#indNNa_5qoQ?c`;QAnQO^LwMO%+WC1J5iwb(F9_&Xe*q3oS{o0xXXIhrje zPXtu=lFU`(52`=aZ^pOO>_=eDX{dvLRy?O4Wg#-ilN_9b4x}y2V@1`)X6Di5N7gyp z2(*Q@xuUeggK>N>nd6bnKSdF}y&mZ>@GD(0R}{Vo3B&`7E!p7$QBb~pVoK|e0GY<+ zYk>SxNmI#3F)aN^NsncCT0#dEZ%q=R4U?2lc(vzvnZs^6)X;);SV%82>ew3)3X7Q(ooD!p(^Q7;kQtCUz;S@A zt4^UT{QsJtzs_swxa>v0wRF{5UT#+tu}I~lt(c+}Y$4kvIJfmT4%;PV(xJ=HlOy#w zYQqJLHh0fXIkg*@QcAW7G+WdCARDMYoW-IoE1!n*Kg2G{TGX4ZT5^>@)V16@{Q5PX zri}Qqufd(o+kWFoQkL%Pmn9VfQrC}E>t8{mXOe(4)IMf2D?`&Gt)z{?{lEkj7v#)7@Yu9R(8E4 zw2*~#`E}S4(?0A?B^Wsj2=5;89J7;&7o1^kqYMtIG&$y2TZw$mg;Gf-Sa-%XTO*xrKzCcL$mH>QJy436@2=z@7Le0*H63?>(ppt*E6nZZ$C7m~9R z3Ki><)clamZU!_+lLonBC?1eyzG~FFe9%rH1aT&}EiG2`Dx;uw8ANgF-kuK*Xw!w- zq8Tj9em`H?Wv}Z^r#T~9#z+;kdg$L^0lN`5pSaIFMu;27hdu{r+b(AP(d(9_<)wBV zTD6FKG)*8#+t^mcxuBiu{8I$Gr>fMi&t$yMc7!`XxIubO$qvHJNkGSR7vhalmI<#9h<5cVAJpZ?Vd_OrB`3At9;Etia zqEDHe+0HsZqvAyA`dVJWMch@aXrPg&`DyD53zRxvv$qYRe$c+YXAE$(Ymbt}`@2X+ zut&)i@}^y$4#~TztenR}X;m}aXECO7r$6cN#wFz6WoD)>_W14{?7c5(cWyK}`mz^d z6RT#-64m;cvNe0F^I_wwB06VqmrvMv%J%6H@-T79Pan@yva(i1=f3m=(QF)awwX>= zGYgRaj%+{CQDZT?FB+=I(iWSMCqMO(*0 zUrpJ-x8T14jscHCl0MAXU9uzhm8ZG9ZEkk1md<6}5}qFKD%U}8@W>!pHizR< z`<_t1wYz&SRcrjIwfihu!exJ8cDp)TRfA?`{f6NZd#amXPNb=?y9aeT8~gT^Fm40^ zql?ZrnciXx=->$j=9a=MuaOTSAebJAuQq6IKOB59RUs#KQe~7VJFlES|MQb#e9?JY zf;5zjm*baGesXAEF$mSM%BJpTaD;Ana&+;{71ziD2`LIsT>GeY{{=X-A4M%-g7A;G z@YlTyZY|2ilinonboFBqjbrksT@t?yjgQoP;h8&~8A&nf`i|hq783F`z9m$K$8|6E zcBLc+U^<4I{u!ehd&`ACJ}CIo3b{r#Sgn0%G{4mLT@eg+0hZjd(X9;JS(i_TuM+iE z8N!{>MAqkkFT^deTo&rj)xOiv>cmaaH!a6zDY+=9R}t(CsG!z3t;C4`6j1T*oec{4 z@#=)z4yd|b#iUWhNJLC~%6(yTIpF5?clxr^vtz5Q00t{(8z%YxD+Xz@fONG#xh!Q< z)avl&8A&+HpqtZJAQ;))BAWWMF8?6*#lUz`bE3n(^sN6v_8N!o;!QdCq?4%PC%>vJ zE|HPGwe4RS70ut(D^3*<($?^DYhsi<^V&u3fyt<;XU*RT=bW6bK6Wp?-1t&?WIQ!>+HTWIwbJ~D$|l$_ z+29c=#do4gc?bCpt=u{xzllhgftfkQ&GbNv2CkFh^96UCr;xKXXG%1uDBOz6+4G!g zVIfZ_9dQ#AT}lRS-h>!@u^{jwX+wOn4A1pxVKg2nj$L7^U_7VS@6!-LT56-T(x0{^ z904)ZJQMTTGyA!9OEoSIiOGhOUv`D1jIdUb>8fv2$|&J8P?R}2`oVw=SZ3}gT$ve4 zx60c6_dPiXv}R@&2~hu;A9YlSzT+AKaWosmztc{tgs!A1OT@@1tC4LHaWx)BrlWnv z5Yrd_v>6oboQ1g~$c#En;&-NzeJH1updU0|PGpTPoc8?v(^ZU5#L}o(xQ94|nKkBA zzz@V7F$L3_BIqi;@8<&fBI6}z;@#`|jCQ3iBdFPP8nqgGnFWJ$qrn08I&qq7jW1%+ z#pH|R$ePHYZ+m+K&t`iuwzfw-hc;Ec$fhs`*n@Hxv+-3fnF#4~)czdcq!IbWvPf&^G$B z1BmnQiYOB<24bjRe}J-+aCS^d<(8bH+6t@ zXh8z$RfNqtg!G_d68r$8+8mIzfq6=&Z0=6#TDun$@q22XvR4~@4VatF@<=9_QX`e= zGsp|9qbgMqZXyPKBJ*f2;u$*M-o?wL?@9lb;Tr~@1FZ(#*g=v^0ddbM0(;u;6V~<6 z-2^@@mDU!Ud{8C(;*30(mb#1DfQ3+$BAn1MKDhQgpk*;9fem zNxiq0tI#q}N#^huueL)sSo+;*qFpK=j2V#A)$??PgR?R-5)LfNZ;!{enfS*h%Z0Ot zw1aW=S7rk+aqM4Yi6J{5jkNLQ$WY6>)&(FIa?-)nLnuqR7P!~PIpDXpXu&IhhJ>$2 zMCFvstT+vq8`F8~?b}jmm4c;GuOwnmRLIRJ9a12jYTY8BsbriOXKL}IGdoqAM|Em} zo%d2oOyajoO~qoMg*%Yx&(~Pup*)Wk%BRB$wa28LA>198ZKyMkZ_7GQ)OK3yiO!+O zQbl_g6iGu~0pcbo?os*3;{dDe%5>d>g+9(ID1qbfMGds22&?Et8%L<2k%tl1+`0&Lx z(;-+U;1;<$Ia2G#-K}dF$Em-E`IE9We=Kbh&0Z0B`vYxA3!rrIOM~K3Li)&yY~E1^ zY7Qs9;(|0~4~xjCeK7N)osvyc(ky1uircF@Ca=d z>6r}FCk!Dde$uOVD}UP1cyH*QQN0|hb6`Hdc$1F-@ujb5QIt#NCO>XD3%6Wu z(RiF0|14H@s4;BezmJM=KAfktX@B?zCiDwD9u?7426J<3ECqF{YMCY_#@qYK1&)Wb z^M{m4J?j>Ld$1fJhYk^vXXrUMHj&zszQRMZRw4DszvgXKOH3=%9ZDGJ50znm)`me2 zFVnTPwZ&t>0*1ftjvqo4-bB_}%nX**FVp?p@d{e}pJOH}&Pa$j-nXDGlKSUMg>i3iqlxiinUkL3rI~zj>m%*+;yfK z%(KnMZS+O+$4E#>iUs9`nXW8;Y{t=Y2qIF_(j!XaMd^DsKontkXb$>42|kVA9s9H6 z2%H=T&o^L)EIVKH0J=$R4*2v`$7$U_u( z+rcVsN$cH4LqCR=S68#;WKY*Y&l(1+cW-^4l3?O1KFTJdT}Sb9-ZJL;odyydllvl5 z`b14i*8RF2+73IN_VpwKD4Wz3wjT-TI00Ds3&5XyR zQl^J=iIs%izI^>ENmh+)$?quSb=O;B|0{JX-eMmsn>fYAyUHuA`&aBr&lz*{_Bn^! z$9W>yXMuwHK`leG48JtHH5_~|N3F@2GAtg0n>^HNr}iRzmyq0+v~Hyz1%<2oZP ztJ5^hZ_Vs{&1XEDP9|-~t+e|jri2v^1T@UNr4+t`Pz8op_J8M;j5@6v!ga-nBGyYT zv!^2?E1TNL^VXljpx7L8<=yDhVmdR7FgLcO1J~cmbd+qUJ#{XL{#W}x zvHyI^!v1*MYUa-E_X_sOg3w{#$3MBqOA#)s?4We{g9Bkdb11jTCNdwYOJ+#U$5%My z)Jln@524g3=OSQ(b8~~fh~25F$yWAefUZ7;C!l#tQ;mWB3m!5RM_P?Yv8nn2*@2^7 zIl!J7@lj3C{!S(>NCvtUB$K7|Fjt$Tb41K9Iw|MONt9c`9}(3=t6O(5>q`b@a?c~)v^^_`#z(XcisBby4?UN~7 z!DSgps|Xpo>AV3l$z&*OBtO(#Of_rI;&t=|HTQaYNQA}{=_&c{!)v#N0I584j3<8f zNKqx!tC2(RAYPcO&j&Gr4?ZKh`v^UsQ_4~J6^A+A-#C^_QE~+RP^~jPD)Rm6{iu+2 zc^bDF;|_6WDJ=oY5Vv0&iAbuzOYD=!7uSQ%VW6DD`C{P{7T8q>a@JImCG?IBhCPcJ zf9HOc(?!9C7`U`_7s`w1Bk3^Pa#=29Gn@;J`nan4D<&JAJ?Z E10TOnsQ>@~ literal 0 HcmV?d00001 diff --git a/assets/thumb.png b/assets/thumb.png new file mode 100644 index 0000000000000000000000000000000000000000..202b324dbd53de36a6b30a1ae6079734d4905ab9 GIT binary patch literal 6796 zcmch6bx;)0*S3NvAfVDMk_#-|OE)aNw3KxB(j|x>9ZN4EoeR

Iw);NSBnjAl=Qv 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 literal 0 HcmV?d00001 diff --git a/capabilities.json b/capabilities.json new file mode 100644 index 0000000..d3e2808 --- /dev/null +++ b/capabilities.json @@ -0,0 +1,106 @@ +{ + "dataRoles": [ + { + "name": "Date", + "kind": "Grouping", + "displayName": "Date" + }, + { + "name": "Values", + "kind": "Measure", + "displayName": "Values" + } + ], + "dataViewMappings": [ + { + "conditions": [ + { + "Date": { + "min": 0, + "max": 1 + }, + "Values": { + "min": 0, + "max": 1 + }, + "Labels": { + "min": 0, + "max": 1 + } + } + ], + "categorical": { + "categories": { + "select": [ + { "for": { "in": "Date" } } + ], + "dataReductionAlgorithm": { "top": { "count": 10000 } } + }, + "values": { + "select": [ + { "bind": { "to": "Values" } } + ] + } + } + } + ], + "supportsHighlight": true, + "objects": { + "lineoptions": { + "displayName": "Line", + "properties": { + "fill": { + "displayName": "Fill", + "type": { "fill": { "solid": { "color": true } } } + }, + "lineThickness": { + "displayName": "Thickness", + "type": { "numeric": true } + } + } + }, + "dotoptions": { + "displayName": "Dot", + "properties": { + "color": { + "displayName": "Fill", + "type": { "fill": { "solid": { "color": true } } } + }, + "dotSizeMin": { + "displayName": "Min size", + "type": { "numeric": true } + }, + "dotSizeMax": { + "displayName": "Max size", + "type": { "numeric": true } + } + } + }, + "counteroptions": { + "displayName": "Counter", + "properties": { + "counterTitle": { + "displayName": "Title", + "type": { "text": true } + } + } + }, + "misc": { + "displayName": "Animation", + "properties": { + "isAnimated": { + "displayName": "Animated", + "type": { "bool": true } + }, + "isStopped": { + "displayName": "Stop on load", + "type": { "bool": true } + }, + "duration": { + "displayName": "Time", + "type": { "numeric": true } + } + } + } + } +} diff --git a/karma.conf.js b/karma.conf.js new file mode 100644 index 0000000..c183db8 --- /dev/null +++ b/karma.conf.js @@ -0,0 +1,74 @@ +/* + * Power BI Visualizations + * + * Copyright (c) Microsoft Corporation + * All rights reserved. + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the ""Software""), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +'use strict'; + +const recursivePathToTests = 'test/**/*.ts'; + +module.exports = (config) => { + const browsers = []; + + if (process.env.TRAVIS) { + browsers.push('ChromeTravisCI'); + } else { + browsers.push('Chrome'); + } + + config.set({ + browsers, + customLaunchers: { + ChromeTravisCI: { + base: 'Chrome', + flags: ['--no-sandbox'] + } + }, + colors: true, + frameworks: ['jasmine'], + reporters: ['progress'], + singleRun: true, + files: [ + '.tmp/drop/visual.css', + '.tmp/drop/visual.js', + 'node_modules/powerbi-visuals-utils-testutils/lib/index.js', + 'node_modules/jasmine-jquery/lib/jasmine-jquery.js', + recursivePathToTests + ], + preprocessors: { + [recursivePathToTests]: ['typescript'] + }, + typescriptPreprocessor: { + options: { + sourceMap: false, + target: 'ES5', + removeComments: false, + concatenateOutput: false + }, + transformPath: (path) => { + return path.replace(/\.ts$/, '.js'); + } + } + }); +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..0a17b3c --- /dev/null +++ b/package.json @@ -0,0 +1,47 @@ +{ + "name": "powerbi-visuals-linedotchart", + "description": "LineDot Chart", + "version": "0.3.3", + "author": { + "name": "Microsoft", + "email": "pbicvsupport@microsoft.com" + }, + "scripts": { + "postinstall": "typings install && pbiviz update 1.3.0", + "typings": "typings", + "pbiviz": "pbiviz", + "start": "pbiviz start", + "package": "pbiviz package", + "lint": "node node_modules/tslint/bin/tslint \"+(src|test)/**/*.ts\"", + "pretest": "pbiviz package --resources --no-minify --no-pbiviz", + "test": "karma start" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/Microsoft/PowerBI-visuals-linedotchart.git" + }, + "devDependencies": { + "@types/jasmine": "^2.5.37", + "@types/jasmine-jquery": "^1.5.28", + "@types/lodash": "^4.14.43", + "d3": "3.5.5", + "globalize": "0.1.0-a2", + "jasmine": "^2.5.2", + "jasmine-jquery": "2.1.1", + "jquery": "3.1.1", + "karma": "1.3.0", + "karma-chrome-launcher": "2.0.0", + "karma-jasmine": "1.0.2", + "karma-typescript-preprocessor": "0.3.0", + "lodash": "4.16.2", + "moment": "2.15.1", + "powerbi-visuals-tools": "1.3.0", + "powerbi-visuals-utils-chartutils": "^0.2.1", + "powerbi-visuals-utils-dataviewutils": "^1.0.1", + "powerbi-visuals-utils-testutils": "^0.2.2", + "powerbi-visuals-utils-tooltiputils": "^0.3.0", + "tslint": "3.15.1", + "typings": "1.4.0" + } +} diff --git a/pbiviz.json b/pbiviz.json new file mode 100644 index 0000000..2bfb12f --- /dev/null +++ b/pbiviz.json @@ -0,0 +1,37 @@ +{ + "visual": { + "name": "LineDotChart", + "displayName": "LineDot Chart", + "guid": "LineDotChart1460463831201", + "visualClassName": "LineDotChart", + "version": "0.3.3", + "description": "The LineDot chart is an animated line chart with fun animated dots. Use the LineDot chart to engage your audience especially in a presentation context. The bubbles size can be dynamic based on data you provide. A counter is provided that you can use to show a running value as the chart animates. Format options are provided for Lines, Dots, and Animation.", + "supportUrl": "http://community.powerbi.com", + "gitHubUrl": "https://github.com/Microsoft/powerbi-visuals-linedotchart" + }, + "apiVersion": "1.3.0", + "author": { + "name": "Microsoft", + "email": "pbicvsupport@microsoft.com" + }, + "assets": { + "icon": "assets/icon.png" + }, + "externalJS": [ + "node_modules/jquery/dist/jquery.min.js", + "node_modules/lodash/lodash.min.js", + "node_modules/d3/d3.js", + "node_modules/powerbi-visuals-utils-typeutils/lib/index.js", + "node_modules/globalize/lib/globalize.js", + "node_modules/globalize/lib/cultures/globalize.culture.en-US.js", + "node_modules/powerbi-visuals-utils-svgutils/lib/index.js", + "node_modules/powerbi-visuals-utils-dataviewutils/lib/index.js", + "node_modules/powerbi-visuals-utils-formattingutils/lib/index.js", + "node_modules/powerbi-visuals-utils-interactivityutils/lib/index.js", + "node_modules/powerbi-visuals-utils-chartutils/lib/index.js", + "node_modules/powerbi-visuals-utils-dataviewutils/lib/index.js", + "node_modules/powerbi-visuals-utils-tooltiputils/lib/index.js" + ], + "style": "style/lineDotChart.less", + "capabilities": "capabilities.json" +} \ No newline at end of file diff --git a/src/behavior.ts b/src/behavior.ts new file mode 100644 index 0000000..206a8c7 --- /dev/null +++ b/src/behavior.ts @@ -0,0 +1,70 @@ +/* + * Power BI Visualizations + * + * Copyright (c) Microsoft Corporation + * All rights reserved. + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the ""Software""), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +module powerbi.extensibility.visual { + import converterHelper = powerbi.extensibility.utils.dataview.converterHelper; + import IInteractiveBehavior = powerbi.extensibility.utils.interactivity.IInteractiveBehavior; + import IInteractivityService = powerbi.extensibility.utils.interactivity.IInteractivityService; + import ISelectionHandler = powerbi.extensibility.utils.interactivity.ISelectionHandler; + import SelectableDataPoint = powerbi.extensibility.utils.interactivity.SelectableDataPoint; + + export class LineDotChartWebBehavior implements IInteractiveBehavior { + private selection: d3.Selection; + private interactivityService: IInteractivityService; + private hasHighlights: boolean; + + public bindEvents(options: LineDotChartBehaviorOptions, selectionHandler: ISelectionHandler): void { + let selection: d3.Selection = this.selection = options.selection; + let clearCatcher: d3.Selection = options.clearCatcher; + this.interactivityService = options.interactivityService; + this.hasHighlights = options.hasHighlights; + + selection.on('click', function (d: SelectableDataPoint) { + selectionHandler.handleSelection(d, (d3.event as MouseEvent).ctrlKey); + (d3.event as MouseEvent).stopPropagation(); + }); + + clearCatcher.on('click', function () { + selectionHandler.handleClearSelection(); + }); + } + + public renderSelection(hasSelection: boolean): void { + let hasHighlights: boolean = this.hasHighlights; + + this.selection.style("opacity", (d: LineDotPoint) => { + return lineDotChartUtils.getFillOpacity(d.selected, d.highlight, !d.highlight && hasSelection, !d.selected && hasHighlights); + }); + } + } + + export interface LineDotChartBehaviorOptions { + selection: d3.Selection; + clearCatcher: d3.Selection; + interactivityService: IInteractivityService; + hasHighlights: boolean; + } +} diff --git a/src/columns.ts b/src/columns.ts new file mode 100644 index 0000000..a391660 --- /dev/null +++ b/src/columns.ts @@ -0,0 +1,110 @@ +/* + * Power BI Visualizations + * + * Copyright (c) Microsoft Corporation + * All rights reserved. + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the ""Software""), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +module powerbi.extensibility.visual { + import DataViewMetadataColumn = powerbi.DataViewMetadataColumn; + import DataViewValueColumns = powerbi.DataViewValueColumns; + import DataViewCategoricalColumn = powerbi.DataViewCategoricalColumn; + import DataViewValueColumn = powerbi.DataViewValueColumn; + import converterHelper = powerbi.extensibility.utils.dataview.converterHelper; + + export class LineDotChartColumns { + + public static getColumnSources(dataView: DataView) { + return this.getColumnSourcesT(dataView); + } + + public static getTableValues(dataView: DataView) { + let table: DataViewTable = dataView && dataView.table; + let columns: LineDotChartColumns = this.getColumnSourcesT(dataView); + return columns && table && _.mapValues( + columns, (n: DataViewMetadataColumn, i) => n && table.rows.map(row => row[n.index])); + } + + public static getTableRows(dataView: DataView) { + let table: DataViewTable = dataView && dataView.table; + let columns: LineDotChartColumns = this.getColumnSourcesT(dataView); + return columns && table && table.rows.map(row => + _.mapValues(columns, (n: DataViewMetadataColumn, i) => n && row[n.index])); + } + + public static getCategoricalValues(dataView: DataView) { + let categorical: DataViewCategorical = dataView && dataView.categorical; + let categories: DataViewCategoryColumn[] = categorical && categorical.categories || []; + let values: DataViewValueColumns = categorical && categorical.values || []; + let series: any = categorical && values.source && this.getSeriesValues(dataView); + return categorical && _.mapValues(new this(), (n, i) => + (_.toArray(categories)).concat(_.toArray(values)) + .filter(x => x.source.roles && x.source.roles[i]).map(x => x.values)[0] + || values.source && values.source.roles && values.source.roles[i] && series); + } + + public static getSeriesValues(dataView: DataView) { + return dataView && dataView.categorical && dataView.categorical.values + && dataView.categorical.values.map(x => converterHelper.getSeriesName(x.source)); + } + + public static getCategoricalColumns(dataView: DataView) { + let categorical: DataViewCategorical = dataView && dataView.categorical; + let categories: DataViewCategoryColumn[] = categorical && categorical.categories || []; + let values: DataViewValueColumns = categorical && categorical.values || []; + return categorical && _.mapValues( + new this(), + (n, i) => { + let result: any = categories.filter(x => x.source.roles && x.source.roles[i])[0]; + if (!result) { + result = values.source && values.source.roles && values.source.roles[i] && values; + } + if (!result) { + result = values.filter(x => x.source.roles && x.source.roles[i]); + if (_.isEmpty(result)) { + result = undefined; + } + } + + return result; + }); + } + + public static getGroupedValueColumns(dataView: DataView) { + let categorical: DataViewCategorical = dataView && dataView.categorical; + let values: DataViewValueColumns = categorical && categorical.values; + let grouped: DataViewValueColumnGroup[] = values && values.grouped(); + return grouped && grouped.map(g => _.mapValues( + new this(), + (n, i) => g.values.filter(v => v.source.roles[i])[0])); + } + + private static getColumnSourcesT(dataView: DataView) { + let columns: DataViewMetadataColumn[] = dataView && dataView.metadata && dataView.metadata.columns; + return columns && _.mapValues( + new this(), (n, i) => columns.filter(x => x.roles && x.roles[i])[0]); + } + + public Date: T = null; + public Values: T = null; + } +} diff --git a/src/dataInterfaces.ts b/src/dataInterfaces.ts new file mode 100644 index 0000000..cab7a8e --- /dev/null +++ b/src/dataInterfaces.ts @@ -0,0 +1,75 @@ +/* + * Power BI Visualizations + * + * Copyright (c) Microsoft Corporation + * All rights reserved. + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the ""Software""), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +module powerbi.extensibility.visual { + import SelectableDataPoint = powerbi.extensibility.utils.interactivity.SelectableDataPoint; + import IInteractivityService = powerbi.extensibility.utils.interactivity.IInteractivityService; + import IAxisProperties = powerbi.extensibility.utils.chart.axis.IAxisProperties; + import IValueFormatter = powerbi.extensibility.utils.formatting.IValueFormatter; + + export interface LineDotPoint extends SelectableDataPoint { + time: number | Date; + value: number; + dot: number; + sum: number; + highlight?: boolean; + } + + export interface Legend { + text: string; + transform?: string; + dx?: string; + dy?: string; + } + + export interface LineDotChartViewModel { + dotPoints: LineDotPoint[]; + settings: LineDotChartSettings; + dateMetadataColumn: DataViewMetadataColumn; + valuesMetadataColumn: DataViewMetadataColumn; + dateColumnFormatter: IValueFormatter; + isDateTime: boolean; + minDate: number; + maxDate: number; + minValue: number; + maxValue: number; + sumOfValues: number; + hasHighlights: boolean; + } + + export interface MinMaxValue { + min: number; + max: number; + } + + export interface LineDotChartDefaultSettingsRange { + dotSize: MinMaxValue; + lineThickness: MinMaxValue; + animationDuration: MinMaxValue; + } +} + + diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..425d66c --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,57 @@ +/* + * Power BI Visualizations + * + * Copyright (c) Microsoft Corporation + * All rights reserved. + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the ""Software""), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +module powerbi.extensibility.visual { + import DataViewObjectsParser = powerbi.extensibility.utils.dataview.DataViewObjectsParser; + + export class LineDotChartSettings extends DataViewObjectsParser { + public lineoptions: LineSettings = new LineSettings(); + public dotoptions: DotSettings = new DotSettings(); + public counteroptions: CounterSettings = new CounterSettings(); + public misc: MiscSettings = new MiscSettings(); + } + + export class LineSettings { + public fill: string = "rgb(102, 212, 204)"; + public lineThickness: number = 3; + } + + export class DotSettings { + public color: string = "#005c55"; + public dotSizeMin: number = 4; + public dotSizeMax: number = 38; + } + + export class CounterSettings { + public counterTitle: string = "Total features"; + } + + export class MiscSettings { + public isAnimated: boolean = true; + public isStopped: boolean = true; + public duration: number = 20; + } +} diff --git a/src/visual.ts b/src/visual.ts new file mode 100644 index 0000000..b0c4396 --- /dev/null +++ b/src/visual.ts @@ -0,0 +1,738 @@ +/* + * Power BI Visualizations + * + * Copyright (c) Microsoft Corporation + * All rights reserved. + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the ""Software""), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +module powerbi.extensibility.visual { + import ClassAndSelector = powerbi.extensibility.utils.svg.CssConstants.ClassAndSelector; + import createClassAndSelector = powerbi.extensibility.utils.svg.CssConstants.createClassAndSelector; + import DataViewObjectPropertyTypeDescriptor = powerbi.DataViewPropertyValue; + import SelectableDataPoint = powerbi.extensibility.utils.interactivity.SelectableDataPoint; + import IValueFormatter = powerbi.extensibility.utils.formatting.IValueFormatter; + import IInteractivityService = powerbi.extensibility.utils.interactivity.IInteractivityService; + import IMargin = powerbi.extensibility.utils.chart.axis.IMargin; + import IInteractiveBehavior = powerbi.extensibility.utils.interactivity.IInteractiveBehavior; + import ISelectionHandler = powerbi.extensibility.utils.interactivity.ISelectionHandler; + import appendClearCatcher = powerbi.extensibility.utils.interactivity.appendClearCatcher; + import createInteractivityService = powerbi.extensibility.utils.interactivity.createInteractivityService; + import valueFormatter = powerbi.extensibility.utils.formatting.valueFormatter; + import IAxisProperties = powerbi.extensibility.utils.chart.axis.IAxisProperties; + import IVisualHost = powerbi.extensibility.visual.IVisualHost; + import SVGUtil = powerbi.extensibility.utils.svg; + import AxisHelper = powerbi.extensibility.utils.chart.axis; + import TextMeasurementService = powerbi.extensibility.utils.formatting.textMeasurementService; + import IColorPalette = powerbi.extensibility.IColorPalette; + import tooltip = powerbi.extensibility.utils.tooltip; + import TooltipEventArgs = powerbi.extensibility.utils.tooltip.TooltipEventArgs; + import ITooltipServiceWrapper = powerbi.extensibility.utils.tooltip.ITooltipServiceWrapper; + import valueType = utils.type.ValueType; + import DataViewObjectsParser = utils.dataview.DataViewObjectsParser; + + export interface LineDotChartDataRoles { + Date?: T; + Values?: T; + } + + export class LineDotChart implements IVisual { + + private static Identity: ClassAndSelector = createClassAndSelector("lineDotChart"); + private static Axes: ClassAndSelector = createClassAndSelector("axes"); + private static Axis: ClassAndSelector = createClassAndSelector("axis"); + private static Legends: ClassAndSelector = createClassAndSelector("legends"); + private static Legend: ClassAndSelector = createClassAndSelector("legend"); + private static Line: ClassAndSelector = createClassAndSelector("line"); + + private static LegendSize: number = 50; + private static AxisSize: number = 30; + + private static defaultSettingsRange: LineDotChartDefaultSettingsRange = { + dotSize: { + min: 0, + max: 100 + }, + lineThickness: { + min: 0, + max: 50, + }, + animationDuration: { + min: 0, + max: 1000, + } + }; + + private data: LineDotChartViewModel; + private root: d3.Selection; + private main: d3.Selection; + private axes: d3.Selection; + private axisX: d3.Selection; + private axisY: d3.Selection; + private axisY2: d3.Selection; + private legends: d3.Selection; + private line: d3.Selection; + private colors: IColorPalette; + private xAxisProperties: IAxisProperties; + private yAxisProperties: IAxisProperties; + private yAxis2Properties: IAxisProperties; + private layout: VisualLayout; + private interactivityService: IInteractivityService; + private behavior: IInteractiveBehavior; + private hostService: IVisualHost; + + private get settings(): LineDotChartSettings { + return this.data && this.data.settings; + } + + private static viewportMargins = { + top: 10, + right: 30, + bottom: 10, + left: 10 + }; + + private static viewportDimentions = { + width: 150, + height: 150 + }; + + private tooltipServiceWrapper: ITooltipServiceWrapper; + constructor(options: VisualConstructorOptions) { + this.tooltipServiceWrapper = tooltip.createTooltipServiceWrapper( + options.host.tooltipService, + options.element); + this.hostService = options.host; + this.layout = new VisualLayout(null, LineDotChart.viewportMargins); + this.layout.minViewport = LineDotChart.viewportDimentions; + this.interactivityService = createInteractivityService(options.host); + this.behavior = new LineDotChartWebBehavior(); + this.root = d3.select(options.element) + .append('svg') + .classed(LineDotChart.Identity.class, true); + + this.main = this.root.append('g'); + this.axes = this.main.append('g').classed(LineDotChart.Axes.class, true); + this.axisX = this.axes.append('g').classed(LineDotChart.Axis.class, true); + this.axisY = this.axes.append('g').classed(LineDotChart.Axis.class, true); + this.axisY2 = this.axes.append('g').classed(LineDotChart.Axis.class, true); + this.legends = this.main.append('g').classed(LineDotChart.Legends.class, true); + this.line = this.main.append('g').classed(LineDotChart.Line.class, true); + + this.colors = options.host.colorPalette; + } + + public update(options: VisualUpdateOptions) { + + if (!options || !options.dataViews || !options.dataViews[0]) { + return; + } + + this.layout.viewport = options.viewport; + let data: LineDotChartViewModel = LineDotChart.converter(options.dataViews[0], this.hostService); + if (!data || _.isEmpty(data.dotPoints)) { + this.clear(); + return; + } + + this.data = data; + + if (this.interactivityService) { + this.interactivityService.applySelectionStateToData(this.data.dotPoints); + } + + this.resize(); + this.calculateAxes(); + this.draw(); + } + + public destroy() { + this.root = null; + } + + public onClearSelection(): void { + if (this.interactivityService) { + this.interactivityService.clearSelection(); + } + } + + public clear() { + this.settings.misc.isAnimated = false; + this.axes.selectAll(LineDotChart.Axis.selector).selectAll("*").remove(); + this.main.selectAll(LineDotChart.Legends.selector).selectAll("*").remove(); + this.main.selectAll(LineDotChart.Line.selector).selectAll("*").remove(); + this.main.selectAll(LineDotChart.Legend.selector).selectAll("*").remove(); + this.line.selectAll(LineDotChart.textSelector).remove(); + } + + public setIsStopped(isStopped: Boolean): void { + let objects: VisualObjectInstancesToPersist = { + merge: [ + { + objectName: "misc", + selector: undefined, + properties: { + "isStopped": isStopped, + } + } + ] + }; + this.hostService.persistProperties(objects); + } + + public enumerateObjectInstances(options: EnumerateVisualObjectInstancesOptions): VisualObjectInstanceEnumeration { + return LineDotChartSettings.enumerateObjectInstances( + this.settings || LineDotChartSettings.getDefault(), + options); + } + + private static validateDataValue(value: number, defaultValues: MinMaxValue): number { + if (value < defaultValues.min) { + return defaultValues.min; + } else if (value > defaultValues.max) { + return defaultValues.max; + } + return value; + } + private static dateMaxCutter: number = .05; + private static makeSomeSpaceForCounter: number = .10; + private static converter(dataView: DataView, visualHost: IVisualHost): LineDotChartViewModel { + let categorical: LineDotChartColumns = LineDotChartColumns.getCategoricalColumns(dataView); + if (!categorical + || !categorical.Date + || _.isEmpty(categorical.Date.values) + || !categorical.Values + || !categorical.Values[0] + || _.isEmpty(categorical.Values[0].values)) { + return null; + } + + let categoryType: valueType = AxisHelper.getCategoryValueType(categorical.Date.source, true); + if (AxisHelper.isOrdinal(categoryType)) { + return null; + } + + let isDateTime: boolean = AxisHelper.isDateTime(categoryType); + let categoricalValues: LineDotChartColumns = LineDotChartColumns.getCategoricalValues(dataView); + let settings: LineDotChartSettings = this.parseSettings(dataView); + let dateValues: number[] = [], + valueValues: number[] = []; + for (let i = 0, length = categoricalValues.Date.length; i < length; i++) { + if (_.isDate(categoricalValues.Date[i]) || _.isNumber(categoricalValues.Date[i])) { + if (isDateTime) { + dateValues.push((categoricalValues.Date[i]).getTime()); + } else { + dateValues.push(categoricalValues.Date[i]); + } + + valueValues.push(categoricalValues.Values[i] || 0); + } + } + + let hasHighlights: boolean = !!(categorical.Values.length > 0 && categorical.Values[0].highlights); + + let extentDate: [number, number] = d3.extent(dateValues); + let minDate: number = extentDate[0]; + let maxDate: number = extentDate[1] + (extentDate[1] - extentDate[0]) * LineDotChart.dateMaxCutter; + let dateColumnFormatter = valueFormatter.create({ + format: valueFormatter.getFormatStringByColumn(categorical.Date.source, true) || categorical.Date.source.format + }); + + let extentValues: [number, number] = d3.extent(valueValues); + let minValue: number = extentValues[0]; + let maxValue: number = extentValues[1]; + let dotPoints: LineDotPoint[] = []; + let sumOfValues: number = 0; + for (let i: number = 0, length: number = dateValues.length; i < length; i++) { + let value: number = valueValues[i]; + let time: number = dateValues[i]; + sumOfValues += value; + + let selector: ISelectionId = visualHost.createSelectionIdBuilder().withCategory(categorical.Date, i).createSelectionId(); + dotPoints.push({ + dot: (maxValue - minValue) ? (value - minValue) / (maxValue - minValue) : 0, + value: value, + sum: sumOfValues, + time: time, + selected: false, + identity: selector, + highlight: hasHighlights && !!(categorical.Values[0].highlights[i]) + }); + } + + // make some space for counter + 25% + sumOfValues = sumOfValues + (sumOfValues - minValue) * LineDotChart.makeSomeSpaceForCounter; + + return { + dotPoints: dotPoints, + settings: settings, + dateMetadataColumn: categorical.Date.source, + valuesMetadataColumn: categorical.Values[0].source, + dateColumnFormatter: dateColumnFormatter, + isDateTime: isDateTime, + minDate: minDate, + maxDate: maxDate, + minValue: minValue, + maxValue: maxValue, + sumOfValues: sumOfValues, + hasHighlights: hasHighlights, + }; + } + + private static parseSettings(dataView: DataView): LineDotChartSettings { + let settings: LineDotChartSettings = LineDotChartSettings.parse(dataView); + let defaultRange: LineDotChartDefaultSettingsRange = this.defaultSettingsRange; + settings.dotoptions.dotSizeMin = this.validateDataValue(settings.dotoptions.dotSizeMin, defaultRange.dotSize); + settings.dotoptions.dotSizeMax = this.validateDataValue(settings.dotoptions.dotSizeMax, { + min: settings.dotoptions.dotSizeMin, + max: defaultRange.dotSize.max + }); + settings.lineoptions.lineThickness = this.validateDataValue(settings.lineoptions.lineThickness, defaultRange.lineThickness); + settings.misc.duration = this.validateDataValue(settings.misc.duration, defaultRange.animationDuration); + + return settings; + } + private static outerPadding: number = 0; + private static forcedTickSize: number = 150; + private static xLabelMaxWidth: number = 160; + private static xLabelTickSize: number = 3.2; + private calculateAxes() { + let effectiveWidth: number = Math.max(0, this.layout.viewportIn.width - LineDotChart.LegendSize - LineDotChart.AxisSize); + let effectiveHeight: number = Math.max(0, this.layout.viewportIn.height - LineDotChart.LegendSize); + + this.xAxisProperties = AxisHelper.createAxis({ + pixelSpan: effectiveWidth, + dataDomain: [this.data.minDate, this.data.maxDate], + metaDataColumn: this.data.dateMetadataColumn, + formatString: null, + outerPadding: LineDotChart.outerPadding, + isCategoryAxis: true, + isScalar: true, + isVertical: false, + forcedTickCount: Math.max(this.layout.viewport.width / LineDotChart.forcedTickSize, 0), + useTickIntervalForDisplayUnits: true, + getValueFn: (index: number, type: valueType) => { + if (this.data.isDateTime) { + return this.data.dateColumnFormatter.format(new Date(index)); + } else { + return index; + } + } + }); + this.xAxisProperties.xLabelMaxWidth = Math.min(LineDotChart.xLabelMaxWidth, this.layout.viewportIn.width / LineDotChart.xLabelTickSize); + this.xAxisProperties.formatter = this.data.dateColumnFormatter; + + this.yAxisProperties = AxisHelper.createAxis({ + pixelSpan: effectiveHeight, + dataDomain: [this.data.minValue, this.data.sumOfValues], + metaDataColumn: this.data.valuesMetadataColumn, + formatString: null, + outerPadding: LineDotChart.outerPadding, + isCategoryAxis: false, + isScalar: true, + isVertical: true, + useTickIntervalForDisplayUnits: true + }); + + this.yAxis2Properties = AxisHelper.createAxis({ + pixelSpan: effectiveHeight, + dataDomain: [this.data.minValue, this.data.sumOfValues], + metaDataColumn: this.data.valuesMetadataColumn, + formatString: null, + outerPadding: LineDotChart.outerPadding, + isCategoryAxis: false, + isScalar: true, + isVertical: true, + useTickIntervalForDisplayUnits: true + }); + this.yAxis2Properties.axis.orient('right'); + } + + private static rotateAngle: number = 270; + private generateAxisLabels(): Legend[] { + return [ + { + transform: SVGUtil.translate((this.layout.viewportIn.width) / 2, (this.layout.viewportIn.height)), + text: "", // xAxisTitle + dx: "1em", + dy: "-1em" + }, { + transform: SVGUtil.translateAndRotate(0, this.layout.viewportIn.height / 2, 0, 0, LineDotChart.rotateAngle), + text: "", // yAxisTitle + dx: "3em" + } + ]; + } + + private resize(): void { + this.root.attr({ + width: this.layout.viewport.width, + height: this.layout.viewport.height + }); + this.main.attr('transform', SVGUtil.translate(this.layout.margin.left, this.layout.margin.top)); + this.legends.attr('transform', SVGUtil.translate(this.layout.margin.left, this.layout.margin.top)); + this.line.attr('transform', SVGUtil.translate(this.layout.margin.left + LineDotChart.LegendSize, 0)); + this.axes.attr('transform', SVGUtil.translate(this.layout.margin.left + LineDotChart.LegendSize, 0)); + this.axisX.attr('transform', SVGUtil.translate(0, this.layout.viewportIn.height - LineDotChart.LegendSize)); + this.axisY2.attr('transform', SVGUtil.translate(this.layout.viewportIn.width - LineDotChart.LegendSize - LineDotChart.AxisSize, 0)); + } + private static tickText: string = '.tick text'; + private static dotPointsText: string = "g.path, g.dot-points"; + private static dotPathText: string = "g.path"; + private draw(): void { + this.stopAnimation(); + this.renderLegends(); + this.drawPlaybackButtons(); + + this.axisX.call(this.xAxisProperties.axis); + this.axisY.call(this.yAxisProperties.axis); + this.axisY2.call(this.yAxis2Properties.axis); + + this.axisX.selectAll(LineDotChart.tickText).call( + AxisHelper.LabelLayoutStrategy.clip, + this.xAxisProperties.xLabelMaxWidth, + TextMeasurementService.svgEllipsis); + + if (this.settings.misc.isAnimated && this.settings.misc.isStopped) { + this.main.selectAll(LineDotChart.Line.selector).selectAll(LineDotChart.dotPointsText).remove(); + this.line.selectAll(LineDotChart.textSelector).remove(); + // this.updateLineText(""); + return; + } + + let linePathSelection: d3.selection.Update = this.line + .selectAll(LineDotChart.dotPathText) + .data([this.data.dotPoints]); + + this.drawLine(linePathSelection); + this.drawClipPath(linePathSelection); + + linePathSelection + .exit().remove(); + + this.drawDots(); + } + + private static lineDotChartPlayBtn: string = "lineDotChart__playBtn"; + private static lineDotChartPlayBtnTranslate: string = "lineDotChartPlayBtnTranslate"; + private static gLineDotChartPayBtn: string = "g.lineDotChart__playBtn"; + private static playBtnGroupDiameter: number = 34; + private static playBtnGroupLineValues: string = "M0 2l10 6-10 6z"; + private static playBtnGroupPlayTranslate: string = "playBtnGroupPlayTranslate"; + private static playBtnGroupPathTranslate: string = "playBtnGroupPathTranslate"; + private static playBtnGroupRectTranslate: string = "playBtnGroupRectTranslate"; + private static playBtnGroupRectWidth: string = "2"; + private static playBtnGroupRectHeight: string = "12"; + private static StopButton: ClassAndSelector = createClassAndSelector("stop"); + private drawPlaybackButtons() { + let playBtn: d3.selection.Update = this.line.selectAll(LineDotChart.gLineDotChartPayBtn).data([""]); + let playBtnGroup: d3.Selection = playBtn.enter() + .append("g") + .classed(LineDotChart.lineDotChartPlayBtn, true); + + playBtnGroup + .classed(LineDotChart.lineDotChartPlayBtnTranslate, true) + .append("circle") + .attr("r", LineDotChart.playBtnGroupDiameter / 2) + .on('click', () => this.setIsStopped(!this.settings.misc.isStopped)); + + playBtnGroup.append("path") + .classed("play", true) + .classed(LineDotChart.playBtnGroupPlayTranslate, true) + .attr("d", LineDotChart.playBtnGroupLineValues) + .attr('pointer-events', "none"); + + playBtnGroup + .append("path") + .classed(LineDotChart.StopButton.class, true) + .classed(LineDotChart.playBtnGroupPathTranslate, true) + .attr("d", LineDotChart.playBtnGroupLineValues) + .attr("transform-origin", "center") + .attr('pointer-events', "none"); + + playBtnGroup + .append("rect") + .classed(LineDotChart.StopButton.class, true) + .classed(LineDotChart.playBtnGroupRectTranslate, true) + .attr("width", LineDotChart.playBtnGroupRectWidth) + .attr("height", LineDotChart.playBtnGroupRectHeight) + .attr('pointer-events', "none"); + + playBtn.selectAll("circle").attr("opacity", () => this.settings.misc.isAnimated ? 1 : 0); + playBtn.selectAll(".play").attr("opacity", () => this.settings.misc.isAnimated && this.settings.misc.isStopped ? 1 : 0); + playBtn.selectAll(LineDotChart.StopButton.selector).attr("opacity", () => this.settings.misc.isAnimated && !this.settings.misc.isStopped ? 1 : 0); + + playBtn.exit().remove(); + } + + private static pathClassName: string = "path"; + private static pathPlotClassName: string = "path.plot"; + private static plotClassName: string = "plot"; + private static lineClip: string = "lineClip"; + private drawLine(linePathSelection: d3.selection.Update) { + linePathSelection.enter().append("g").classed(LineDotChart.pathClassName, true); + + let pathPlot: d3.selection.Update = linePathSelection.selectAll(LineDotChart.pathPlotClassName).data(d => [d]); + pathPlot.enter() + .append('path') + .classed(LineDotChart.plotClassName, true); + + // Draw the line + let drawLine: d3.svg.Line = d3.svg.line() + .x((d: any) => this.xAxisProperties.scale(d.time)) + .y((d: any) => this.yAxisProperties.scale(d.sum)); + + pathPlot + .attr('stroke', () => this.settings.lineoptions.fill) + .attr('stroke-width', this.settings.lineoptions.lineThickness) + .attr('d', drawLine) + .attr("clip-path", "url(" + location.href + '#' + LineDotChart.lineClip + ")"); + } + + private static zeroX: number = 0; + private static zeroY: number = 0; + private static millisecondsInOneSecond: number = 1000; + private drawClipPath(linePathSelection: d3.selection.Update) { + let clipPath: d3.selection.Update = linePathSelection.selectAll("clipPath").data(d => [d]); + clipPath.enter().append("clipPath") + .attr("id", LineDotChart.lineClip) + .append("rect") + .attr("x", LineDotChart.zeroX) + .attr("y", LineDotChart.zeroY); + + let line_left: any = this.xAxisProperties.scale(_.first(this.data.dotPoints).time); + let line_right: any = this.xAxisProperties.scale(_.last(this.data.dotPoints).time); + + if (this.settings.misc.isAnimated) { + clipPath + .selectAll("rect") + .attr('x', line_left) + .attr('width', 0) + .interrupt() + .transition() + .ease("linear") + .duration(this.animationDuration * LineDotChart.millisecondsInOneSecond) + .attr('width', line_right - line_left) + .attr("height", this.layout.viewportIn.height); + } else { + clipPath + .selectAll("rect") + .interrupt() + .attr('x', line_left) + .attr('width', line_right - line_left); + } + } + + private static pointTime: number = 300; + private static dotPointsClass: string = "dot-points"; + private static pointClassName: string = 'point'; + private static pointScaleValue: number = 0.005; + private static pointTransformScaleValue: number = 3.4; + private drawDots() { + let point_time: number = this.settings.misc.isAnimated && !this.settings.misc.isStopped ? LineDotChart.pointTime : 0; + + let hasHighlights: boolean = this.data.hasHighlights; + let hasSelection: boolean = this.interactivityService && this.interactivityService.hasSelection(); + + // Draw the individual data points that will be shown on hover with a tooltip + let lineTipSelection: d3.selection.Update = this.line.selectAll('g.' + LineDotChart.dotPointsClass) + .data([this.data.dotPoints]); + + lineTipSelection.enter() + .append("g") + .classed(LineDotChart.dotPointsClass, true); + + let dotsSelection: d3.selection.Update = lineTipSelection + .selectAll("circle." + LineDotChart.pointClassName) + .data(d => d); + + dotsSelection.enter() + .append('circle') + .classed(LineDotChart.pointClassName, true) + .on('mouseover.point', this.showDataPoint) + .on('mouseout.point', this.hideDataPoint); + + dotsSelection + .attr('fill', this.settings.dotoptions.color) + .style("opacity", (d: LineDotPoint) => { + return lineDotChartUtils.getFillOpacity(d.selected, d.highlight, !d.highlight && hasSelection, !d.selected && hasHighlights); + }) + .attr('r', (d: LineDotPoint) => + this.settings.dotoptions.dotSizeMin + d.dot * (this.settings.dotoptions.dotSizeMax - this.settings.dotoptions.dotSizeMin)); + + if (this.settings.misc.isAnimated) { + let maxTextLength: number = Math.min(350, this.xAxisProperties.scale.range()[1] - this.xAxisProperties.scale.range()[0] - 60); + let lineTextSelection: d3.Selection = this.line.selectAll(LineDotChart.textSelector); + let lineText: d3.selection.Update = lineTextSelection.data([""]); + lineText + .enter() + .append("text") + .attr('text-anchor', "end") + .classed("text", true); + lineText + .attr('x', this.layout.viewportIn.width - LineDotChart.widthMargin) + .attr('y', LineDotChart.yPosition) + .call(selection => TextMeasurementService.svgEllipsis(selection.node(), maxTextLength)); + lineText.exit().remove(); + + dotsSelection + .interrupt() + .attr('transform', (d: LineDotPoint) => + SVGUtil.translateAndScale(this.xAxisProperties.scale(d.time), this.yAxisProperties.scale(d.sum), LineDotChart.pointScaleValue)) + .transition() + .each("start", (d: LineDotPoint, i: number) => { + let text = this.settings.counteroptions.counterTitle + ' ' + (i + 1); + this.updateLineText(lineText, text); + }) + .duration(point_time) + .delay((d: LineDotPoint, i: number) => this.pointDelay(this.data.dotPoints, i, this.animationDuration)) + .ease("linear") + .attr('transform', (d: LineDotPoint) => + SVGUtil.translateAndScale(this.xAxisProperties.scale(d.time), this.yAxisProperties.scale(d.sum), LineDotChart.pointTransformScaleValue)) + .transition() + .duration(point_time) + .delay((d: LineDotPoint, i: number) => this.pointDelay(this.data.dotPoints, i, this.animationDuration) + point_time) + .ease("elastic") + .attr('transform', (d: LineDotPoint) => + SVGUtil.translateAndScale(this.xAxisProperties.scale(d.time), this.yAxisProperties.scale(d.sum), 1)); + } else { + dotsSelection + .interrupt() + .attr('transform', (d: LineDotPoint) => + SVGUtil.translateAndScale(this.xAxisProperties.scale(d.time), this.yAxisProperties.scale(d.sum), 1)); + this.line.selectAll(LineDotChart.textSelector).remove(); + } + + for (let i: number = 0; i < dotsSelection[0].length; i++) { + this.addTooltip(dotsSelection[0][i]); + } + + dotsSelection.exit().remove(); + lineTipSelection.exit().remove(); + + if (this.interactivityService) { + // Register interactivity; + let behaviorOptions: LineDotChartBehaviorOptions = { + selection: dotsSelection, + clearCatcher: this.root, + interactivityService: this.interactivityService, + hasHighlights: hasHighlights + }; + this.interactivityService.bind(this.data.dotPoints, this.behavior, behaviorOptions); + } + } + + private get animationDuration(): number { + if (this.settings && this.settings.misc) { + return this.settings.misc.duration; + } + return 0; + } + + private stopAnimation(): void { + this.line.selectAll("*") + .transition() + .duration(0) + .delay(0); + + d3.timer.flush(); + } + + private static textSelector: string = "text.text"; + private static widthMargin: number = 85; + private static yPosition: number = 30; + private updateLineText(textSelector: d3.Selection, text?: string): void { + textSelector.text(d => text); + } + + private pointDelay(points: LineDotPoint[], num: number, animation_duration: number): number { + if (!points.length || !points[num] || num === 0 || !this.settings.misc.isAnimated || this.settings.misc.isStopped) { + return 0; + } + + let time: number = points[num].time; + let min: number = points[0].time; + let max: number = points[points.length - 1].time; + return animation_duration * 1000 * (time - min) / (max - min); + } + private static showClassName: string = 'show'; + private showDataPoint(data: LineDotPoint, index: number): void { + d3.select(this).classed(LineDotChart.showClassName, true); + } + + private hideDataPoint(data: LineDotPoint, index: number): void { + d3.select(this).classed(LineDotChart.showClassName, false); + } + + private addTooltip(element: any): void { + let selection: d3.Selection = d3.select(element); + let data: LineDotPoint = selection.datum(); + this.tooltipServiceWrapper.addTooltip(selection, (event) => { + return [ + { + displayName: "", + value: this.data.dateColumnFormatter.format(data.time) + }, + { + displayName: "", + value: data.value.toString() + } + ]; + }); + } + + private renderLegends(): void { + let legends: Legend[] = this.generateAxisLabels(); + let legendSelection: d3.selection.Update = this.legends + .selectAll(LineDotChart.Legend.selector) + .data(legends); + + legendSelection + .enter() + .append("svg:text"); + + legendSelection + .attr("x", 0) + .attr("y", 0) + .attr("dx", (item: Legend) => item.dx) + .attr("dy", (item: Legend) => item.dy) + .attr("transform", (item: Legend) => item.transform) + .text((item: Legend) => item.text) + .classed(LineDotChart.Legend.class, true); + + legendSelection + .exit() + .remove(); + } + } + + export module lineDotChartUtils { + export let DimmedOpacity: number = 0.4; + export let DefaultOpacity: number = 1.0; + + export function getFillOpacity(selected: boolean, highlight: boolean, hasSelection: boolean, hasPartialHighlights: boolean): number { + if ((hasPartialHighlights && !highlight) || (hasSelection && !selected)) { + return DimmedOpacity; + } + return DefaultOpacity; + } + } +} diff --git a/src/visualLayout.ts b/src/visualLayout.ts new file mode 100644 index 0000000..732f21b --- /dev/null +++ b/src/visualLayout.ts @@ -0,0 +1,137 @@ +/* + * Power BI Visualizations + * + * Copyright (c) Microsoft Corporation + * All rights reserved. + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the ""Software""), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +module powerbi.extensibility.visual { + // powerbi + import IViewport = powerbi.IViewport; + + // powerbi.visuals + import IMargin = powerbi.extensibility.utils.chart.axis.IMargin; + + export class VisualLayout { + private marginValue: IMargin; + private viewportValue: IViewport; + private viewportInValue: IViewport; + private minViewportValue: IViewport; + private originalViewportValue: IViewport; + private previousOriginalViewportValue: IViewport; + + public defaultMargin: IMargin; + public defaultViewport: IViewport; + + constructor(defaultViewport?: IViewport, defaultMargin?: IMargin) { + this.defaultViewport = defaultViewport || { width: 0, height: 0 }; + this.defaultMargin = defaultMargin || { top: 0, bottom: 0, right: 0, left: 0 }; + } + + public get viewport(): IViewport { + return this.viewportValue || (this.viewportValue = this.defaultViewport); + } + + public get viewportCopy(): IViewport { + return _.clone(this.viewport); + } + + // Returns viewport minus margin + public get viewportIn(): IViewport { + return this.viewportInValue || this.viewport; + } + + public get minViewport(): IViewport { + return this.minViewportValue || { width: 0, height: 0 }; + } + + public get margin(): IMargin { + return this.marginValue || (this.marginValue = this.defaultMargin); + } + + public set minViewport(value: IViewport) { + this.setUpdateObject(value, v => this.minViewportValue = v, VisualLayout.restrictToMinMax); + } + + public set viewport(value: IViewport) { + this.previousOriginalViewportValue = _.clone(this.originalViewportValue); + this.originalViewportValue = _.clone(value); + this.setUpdateObject(value, + v => this.viewportValue = v, + o => VisualLayout.restrictToMinMax(o, this.minViewport)); + } + + public set margin(value: IMargin) { + this.setUpdateObject(value, v => this.marginValue = v, VisualLayout.restrictToMinMax); + } + + // Returns true if viewport has updated after last change. + public get viewportChanged(): boolean { + return !!this.originalViewportValue && (!this.previousOriginalViewportValue + || this.previousOriginalViewportValue.height !== this.originalViewportValue.height + || this.previousOriginalViewportValue.width !== this.originalViewportValue.width); + } + + public get viewportInIsZero(): boolean { + return this.viewportIn.width === 0 || this.viewportIn.height === 0; + } + + public resetMargin(): void { + this.margin = this.defaultMargin; + } + + private update(): void { + this.viewportInValue = VisualLayout.restrictToMinMax({ + width: this.viewport.width - (this.margin.left + this.margin.right), + height: this.viewport.height - (this.margin.top + this.margin.bottom) + }, this.minViewportValue); + } + + private setUpdateObject(object: T, setObjectFn: (T) => void, beforeUpdateFn?: (T) => void): void { + object = _.clone(object); + setObjectFn(VisualLayout.createNotifyChangedObject(object, o => { + if (beforeUpdateFn) beforeUpdateFn(object); + this.update(); + })); + + if (beforeUpdateFn) { beforeUpdateFn(object); } + this.update(); + } + + private static createNotifyChangedObject(object: T, objectChanged: (o?: T, key?: string) => void): T { + let result: T = {}; + _.keys(object).forEach(key => Object.defineProperty(result, key, { + get: () => object[key], + set: (value) => { object[key] = value; objectChanged(object, key); }, + enumerable: true, + configurable: true + })); + return result; + } + + private static restrictToMinMax(value: T, minValue?: T): T { + _.keys(value).forEach(x => value[x] = Math.max(minValue && minValue[x] || 0, value[x])); + return value; + } + } +} + diff --git a/style/lineDotChart.less b/style/lineDotChart.less new file mode 100644 index 0000000..1d982b7 --- /dev/null +++ b/style/lineDotChart.less @@ -0,0 +1,105 @@ +/* + * Power BI Visualizations + * + * Copyright (c) Microsoft Corporation + * All rights reserved. + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the ""Software""), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +/** + * Imports external styles. + * We compile it as a less file in order to wrap the external CSS rules. + */ +@import (less) "node_modules/powerbi-visuals-utils-interactivityutils/lib/index.css"; +@import (less) "node_modules/powerbi-visuals-utils-formattingutils/lib/index.css"; + +.lineDotChart { + font-family: helvetica, arial, sans-serif; +} +.lineDotChart .axis path, +.lineDotChart .legends path, +.lineDotChart .axis line, +.lineDotChart .legends line { + fill: none; + stroke: black; + shape-rendering: crispEdges; +} +.lineDotChart .axis text, +.lineDotChart .legends text { + fill: black; + font-size: 14px; +} +.lineDotChart .legends text { + font-size: 16px; + fill: #000; + text-anchor: middle; +} +.lineDotChart .line { + fill: none; + stroke-width: 2px; +} +.lineDotChart .point { + stroke: white; + stroke-opacity: .7; + stroke-width: .5; + pointer-events: all; + transition: opacity 0.2s ease-out; +} +.lineDotChart .text { + fill: black; + font-size: 32px; +} +.lineDotChart__playBtn { + opacity: .23; + cursor: pointer; + transition: opacity .3s; +} +.lineDotChart__playBtn:hover { + opacity: 1; +} +.lineDotChart__playBtn circle { + stroke-width: .5; + stroke: gray; + fill: white; +} +.lineDotChart__playBtn rect { + fill: black; +} + +.lineDotChart__playBtn path { + fill: black; +} + +.playBtnGroupPathTranslate { + transform:translate(-6px, -8px) rotate(180deg); +} + +.lineDotChartPlayBtnTranslate{ + transform:translate(40px, 20px); +} + +.playBtnGroupPlayTranslate{ + transform:translate(-4px, -8px); +} + +.playBtnGroupRectTranslate{ + transform:translate(-7px, -6px); +} \ No newline at end of file diff --git a/test/_references.ts b/test/_references.ts new file mode 100644 index 0000000..18d632a --- /dev/null +++ b/test/_references.ts @@ -0,0 +1,54 @@ + +/* + * Power BI Visualizations + * + * Copyright (c) Microsoft Corporation + * All rights reserved. + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the ""Software""), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +// External +/// +/// +/// + +// Power BI API +/// + +// Power BI Extensibility +/// +/// +/// +/// +/// +/// +/// +/// +/// + +// The visual +/// + +// Test +/// +/// +/// + diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..86138d9 --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,47 @@ +/* + * Power BI Visualizations + * + * Copyright (c) Microsoft Corporation + * All rights reserved. + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the ""Software""), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +/// + +namespace powerbitests.customVisuals { + import helpers = powerbi.extensibility.utils.test.helpers; + + export function getHexColorFromNumber(value: number) { + let hex: string = value.toString(16).toUpperCase(); + return "#" + (hex.length === 6 ? hex : _.range(0, 6 - hex.length, 0).join("") + hex); + } + export function getRandomInteger(min: number, max: number, exceptionList?: number[]): number { + return helpers.getRandomNumber(max, min, exceptionList, Math.floor); + } + export function getRandomHexColor(): string { + return getHexColorFromNumber(getRandomInteger(0, 16777215 + 1)); + } + + export function getRandomHexColors(count: number): string[] { + return _.range(count).map(x => getRandomHexColor()); + } + +} \ No newline at end of file diff --git a/test/visualBuilder.ts b/test/visualBuilder.ts new file mode 100644 index 0000000..1794735 --- /dev/null +++ b/test/visualBuilder.ts @@ -0,0 +1,79 @@ +/* + * Power BI Visualizations + * + * Copyright (c) Microsoft Corporation + * All rights reserved. + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the ""Software""), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +/// + +module powerbi.extensibility.visual.test { + import VisualBuilderBase = powerbi.extensibility.utils.test.VisualBuilderBase; + import getRandomNumber = powerbi.extensibility.utils.test.helpers.getRandomNumber; + // LineDotChart1460463831201 + import VisualPlugin = powerbi.visuals.plugins.LineDotChart1460463831201; + import VisualClass = powerbi.extensibility.visual.LineDotChart1460463831201.LineDotChart; + import VisualSettings = powerbi.extensibility.visual.LineDotChart1460463831201.LineDotChartSettings; + + export class LineDotChartBuilder extends VisualBuilderBase { + constructor(width: number, height: number) { + super(width, height); + } + + protected build(options: VisualConstructorOptions) { + return new VisualClass(options); + } + + public get mainElement(): JQuery { + return this.element.children(".lineDotChart"); + } + + public get line() { + return this.mainElement + .children("g") + .children("g.line"); + } + + public get linePath() { + return this.line + .children("g.path") + .children("path.plot"); + } + + public get dots() { + return this.line + .children("g.dot-points") + .children("circle.point"); + } + + public get animationPlay(): JQuery { + return this.mainElement + .find("g.lineDotChart__playBtn"); + } + + public get counterTitle(): JQuery { + return this.line + .children("text.text"); + } + + } +} diff --git a/test/visualData.ts b/test/visualData.ts new file mode 100644 index 0000000..c59942e --- /dev/null +++ b/test/visualData.ts @@ -0,0 +1,88 @@ +/* + * Power BI Visualizations + * + * Copyright (c) Microsoft Corporation + * All rights reserved. + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the ""Software""), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +/// + +module powerbi.extensibility.visual.test { + // powerbi.extensibility.utils.type + import ValueType = powerbi.extensibility.utils.type.ValueType; + + // powerbi.extensibility.utils.test + import getRandomNumber = powerbi.extensibility.utils.test.helpers.getRandomNumber; + import CustomizeColumnFn = powerbi.extensibility.utils.test.dataViewBuilder.CustomizeColumnFn; + import TestDataViewBuilder = powerbi.extensibility.utils.test.dataViewBuilder.TestDataViewBuilder; + import helpers = powerbi.extensibility.utils.test.helpers; + + export function getRandomUniqueNumbers(count: number, min: number = 0, max: number = 1): number[] { + let result: number[] = []; + for (let i = 0; i < count; i++) { + result.push(getRandomNumber(min, max, result)); + } + + return result; + } + + export function getRandomUniqueDates(count: number, start: Date, end: Date): Date[] { + return getRandomUniqueNumbers(count, start.getTime(), end.getTime()).map(x => new Date(x)); + } + + export function getRandomUniqueSortedDates(count: number, start: Date, end: Date): Date[] { + return getRandomUniqueDates(count, start, end).sort((a, b) => a.getTime() - b.getTime()); + } + + export class LineDotChartData extends TestDataViewBuilder { + public static ColumnDate: string = "Date"; + public static ColumnValue: string = "Value"; + + public valuesDate: Date[] = getRandomUniqueSortedDates( + 50, + new Date(2014, 9, 12, 3, 9, 50), + new Date(2016, 3, 1, 2, 43, 3)); + public valuesValue = helpers.getRandomNumbers(this.valuesDate.length, 0, 5361); + + public getDataView(columnNames?: string[]): powerbi.DataView { + return this.createCategoricalDataViewBuilder([ + { + source: { + displayName: LineDotChartData.ColumnDate, + type: ValueType.fromDescriptor({ dateTime: true }), + roles: { Date: true } + }, + values: this.valuesDate + } + ], [ + { + source: { + displayName: "Values", + type: ValueType.fromDescriptor({ integer: true }), + roles: { Values: true } + }, + values: this.valuesValue + } + ], columnNames).build(); + } + } +} diff --git a/test/visualTest.ts b/test/visualTest.ts new file mode 100644 index 0000000..bd13cf4 --- /dev/null +++ b/test/visualTest.ts @@ -0,0 +1,142 @@ +/* + * Power BI Visualizations + * + * Copyright (c) Microsoft Corporation + * All rights reserved. + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the ""Software""), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +/// + +namespace powerbitests.customVisuals { + import VisualClass = powerbi.extensibility.visual.test.LineDotChartBuilder; + import LineDotChartData = powerbi.extensibility.visual.test.LineDotChartData; + import LineDotChartBuilder = powerbi.extensibility.visual.test.LineDotChartBuilder; + import helpers = powerbi.extensibility.utils.test.helpers; + import colorHelper = powerbi.extensibility.utils.test.helpers.color; + import RgbColor = powerbi.extensibility.utils.test.helpers.color.RgbColor; + import MockISelectionId = powerbi.extensibility.utils.test.mocks.MockISelectionId; + import createSelectionId = powerbi.extensibility.utils.test.mocks.createSelectionId; + import fromPointToPixel = powerbi.extensibility.utils.type.PixelConverter.fromPointToPixel; + import getRandomHexColor = powerbitests.customVisuals.getRandomHexColor; + + describe("LineDotChartTests", () => { + let visualBuilder: powerbi.extensibility.visual.test.LineDotChartBuilder; + let defaultDataViewBuilder: LineDotChartData; + let dataView: powerbi.DataView; + beforeEach(() => { + visualBuilder = new LineDotChartBuilder(1000, 500); + defaultDataViewBuilder = new LineDotChartData(); + dataView = defaultDataViewBuilder.getDataView(); + }); + + describe("DOM tests", () => { + it("main element was created", () => { + expect(visualBuilder.mainElement.get(0)).toBeDefined(); + }); + + it("update", (done) => { + visualBuilder.updateRenderTimeout(dataView, () => { + expect(visualBuilder.mainElement.find(".axis").length).not.toBe(0); + expect(visualBuilder.mainElement.find(".tick").length).not.toBe(0); + expect(visualBuilder.mainElement.find(".lineDotChart__playBtn").get(0)).toBeDefined(); + expect(visualBuilder.mainElement.find(".legends").get(0)).toBeDefined(); + + done(); + }); + }); + }); + + describe("Resize test", () => { + it("Counter", (done) => { + visualBuilder.viewport.width = 300; + dataView.metadata.objects = { + misc: { + isAnimated: true, + duration: 20 + }, + counteroptions: { + counterTitle: "Counter: " + } + }; + visualBuilder.updateFlushAllD3Transitions(dataView); + + helpers.clickElement(visualBuilder.animationPlay); + helpers.renderTimeout(() => { + expect(visualBuilder.counterTitle).toBeInDOM(); + done(); + }); + }); + }); + + describe("Format settings test", () => { + beforeEach(() => { + dataView.metadata.objects = { + misc: { + isAnimated: false + } + }; + }); + + describe("Line", () => { + it("color", () => { + let color: string = getRandomHexColor(); + (dataView.metadata.objects as any).lineoptions = { fill: colorHelper.getSolidColorStructuralObject(color) }; + visualBuilder.updateFlushAllD3Transitions(dataView); + colorHelper.assertColorsMatch(visualBuilder.linePath.css('stroke'), color); + }); + }); + + describe("Dot", () => { + it("color", () => { + let color: string = getRandomHexColor(); + + dataView.metadata.objects = { + dotoptions: { + color: colorHelper.getSolidColorStructuralObject(color) + } + }; + visualBuilder.updateFlushAllD3Transitions(dataView); + visualBuilder.dots.toArray().map($).forEach(e => + colorHelper.assertColorsMatch(e.attr('fill'), color)); + }); + }); + + describe("Validate params", () => { + it("Dots", () => { + + dataView.metadata.objects = { + dotoptions: { + dotSizeMin: -6, + dotSizeMax: 678 + } + }; + visualBuilder.updateFlushAllD3Transitions(dataView); + visualBuilder.dots.toArray().map($).forEach(e => { + expect(e.attr("r")).toBeGreaterThan(-1); + expect(e.attr("r")).toBeLessThan(101); + }); + }); + }); + + }); + }); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8e2f6cf --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "allowJs": false, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "target": "ES5", + "sourceMap": true, + "out": "./.tmp/build/visual.js", + "declaration": true + }, + "files": [ + "typings/index.d.ts", + ".api/v1.3.0/PowerBI-visuals.d.ts", + "node_modules/powerbi-visuals-utils-formattingutils/lib/index.d.ts", + "node_modules/powerbi-visuals-utils-interactivityutils/lib/index.d.ts", + "node_modules/powerbi-visuals-utils-typeutils/lib/index.d.ts", + "node_modules/powerbi-visuals-utils-svgutils/lib/index.d.ts", + "node_modules/powerbi-visuals-utils-chartutils/lib/index.d.ts", + "node_modules/powerbi-visuals-utils-dataviewutils/lib/index.d.ts", + "node_modules/powerbi-visuals-utils-tooltiputils/lib/index.d.ts", + "src/dataInterfaces.ts", + "src/settings.ts", + "src/columns.ts", + "src/visualLayout.ts", + "src/behavior.ts", + "src/visual.ts" + ] +} \ No newline at end of file diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..aaaf8a6 --- /dev/null +++ b/tslint.json @@ -0,0 +1,58 @@ +{ + "rules": { + "class-name": true, + "comment-format": [ + true, + "check-space" + ], + "indent": [ + true, + "spaces" + ], + "no-duplicate-variable": true, + "no-eval": true, + "no-internal-module": false, + "no-trailing-whitespace": true, + "no-unsafe-finally": true, + "no-var-keyword": true, + "one-line": [ + true, + "check-open-brace", + "check-whitespace" + ], + "quotemark": [ + false, + "double" + ], + "semicolon": [ + true, + "always" + ], + "triple-equals": [ + true, + "allow-null-check" + ], + "typedef-whitespace": [ + true, + { + "call-signature": "nospace", + "index-signature": "nospace", + "parameter": "nospace", + "property-declaration": "nospace", + "variable-declaration": "nospace" + } + ], + "variable-name": [ + true, + "ban-keywords" + ], + "whitespace": [ + true, + "check-branch", + "check-decl", + "check-operator", + "check-separator", + "check-type" + ] + } +} \ No newline at end of file diff --git a/typings.json b/typings.json new file mode 100644 index 0000000..8d0d468 --- /dev/null +++ b/typings.json @@ -0,0 +1,9 @@ +{ + "globalDependencies": { + "d3": "registry:dt/d3#0.0.0+20160907005744", + "jasmine": "registry:dt/jasmine#2.5.0+20161119044246", + "jasmine-jquery": "registry:dt/jasmine-jquery#1.5.8+20161128184045", + "jquery": "registry:dt/jquery#1.10.0+20161119044246", + "lodash": "registry:dt/lodash#4.14.0+20161110215204" + } +}