E?Ay#P;utY9Ugo?2vMRP352_37m6cv%7rm3_gmoVY+dK9E$;ZZVn@Z#d
z*8Q(6-@mo_=hMUWX3XeGKX&JxLud6a#$v{lSa#D
zjjHn~-$)xqx)0%UsiLd5Wqzg8r_YAz+jBr`L$>zN)Q4nnw6A(}ppZu8`y9~%$mW*x
zZ;eEQ8{HkT22=xfl5q)LH)x|cU4QTdY+QUxWZ@x*H-SSB6XggOylU=f>>II33b
z!Kv0**R$(^3I_ms%gLz$O_R8?5NgK(b|1
z8-@QfBlK24&bwt`P;&g-jKgTB{-%0ep&1F$%b}l2*_Q%;fG{jK9$p}Lz76CSZrh
zcd~oC+K*r~DzhakKEo+sJkB7IZCOLe{mc`g27Zg3*Bp#!KOcs2j?z{tVU1#f9Tu$b
zg@-uXdl{iq^hm&)o1pv5pt?A1%{qU?ekOa09njeWREoj=wY#Orwad^Ex4$Oei}1Ma+{4mQ>uNU+PrMn{Ev$yR;>7zPm30MWwF
zu6`E9B-4t$TadNhspYCm(nAT5DVEAsgoQ3`l;#tFccZUKehE&-=9t~e{AQ#GT-OPb
z$AS^_O+z3GADt2={)!+L7E<<=3R1K+PICh_x2T~^`19ptyf;x2v;pGUxodN8St)&Zv
z=x@m|*~Rq*PiGh8W+uzQX7cfzpe-BN;Z%9WoO!cKc&IIYCH#$_;xj=*S?HUL#Xd`V
zRE5sbeP@{Xs=g1I#-X#5k-I)BKL?|At9)X=&VB>KXIS$Ou%oscX^Xd>-yUFdnd~Z9
zvd(C4$XHdR@LH6`r4Jot69RW+#adGe8epeSM+m=MKiRG3u<<)0A9E-7pJA*_mwLb>
zy`f)#sQJ0Y1|$<5`zbQq+i5VJNi)J-#>)-zUiQim|8tH2pg`ST=AYEqHXnr_L)lL4
zQV3^uP@weWfkg<9S+=0&^b4AvP@Ep6SURlcM<
zvS;Y7LZ^#)SiL$HS4pXnxVUM&6S&T8AN}aL08T?jGVH^olTT%lBfwFJ9&Y~rF7EXB
zRIZ<~Y>Gu9f{~odngANj8eu{pB$xk1co5YkZiE>;&B|!KOKiNd3L!{-UNMXa;V9
zVjO66Dm@z71J_U1G|@D!`!@t_kpwVJRZJG8l&>GS!Bbg2=l(g|j|{&{#kQvVqw$fH
zgxoma$)?d_Sw?=?eh*o|NIVKHXMija&$s7yl!
zKm%WbEI6e{O2wUivQS63&uW||Cslc&F@kt_FJ`|^0xP4(%SuD*Qk?lCbl~EMHm;R%pc@V4bj9Q0AqQaF$LLHenb&EHVK)Y6U-{1&hSPmqoCS5
zYcA(64vPn|6%H+HLZQeeE6Xp*-D2eoBxdjmEE@(b%ij?l^gPG>cD+Thi_>Y7y_SsI
zo=sC3h|UBECr?GHj(zTd$v5!Kh`QY>>JL=6n)IJ
z+t{A|^q!Ez$qd!t0z$bTof;QPGU&;6auQO?Y)zHzZ;Y@X>H-Ij`Pq5
z9WjKIFxss@zN-WABew|#DSJP?2bGGDwlHA#StM{0{H6#+OokN6ivFL=z&@UXp6sih
zLTYjYi>7{<7R>9>wVSEG88+tLdt>;b+%|6qsB5G#sOOCdZ(l!J=g@cVA|;mFM+57i
z$;M2p_d78_Zu9X7k{iMrQ>n&>+WJPjXvhuM=k3jtCn6lqYhQ^o
zk*jiZZunjVX9QU*X)pImuG|xN-N@_-p@%arT^9-MXE>nLKqiT3Zds`w&}8QCNpWWc
zTz~^&&j*b5K9Gflp`a77SdT6<)Zi3D+e!?3pEAdpvY6bIxqm&4eU2&R;_)u~R=(Uk
zol)ucfEmCl)p2kxVv=oy3QmYv+;LFa@Qt-X3kZ=aX{4*#ux+ncnY7e>6p+i`3}6d<
zrR_fM;EZXcj&sK$ki)-LI_)rrG!g)bYfL5M`f_BuG-zia)r&ta;I
z5H4k+`MYIw@|yA_`Xlog~UEdhJUN!caU10)A<
z#gF%KY#aD^W2d6VyC13AVP^{p3kx&q=5$JU;dl{v;ZkT)FTQ!VH6*`DVVtSYm6~h&
z)EcB8eT};MF2{o#I=1oj&_%Z)XVLrPFFx@7xQ2$tMwYTm{{yC9+YP{v8R&gpF*?{7YTC5S7XFlf@ml%?mvMFrC8#+w
zvzRsJ_tDW&b^wcg`-N@~MCEAVC4;)Iw~2|c6O5$}w*Vj)NelYnsva9dll
zgkJ6&ZoZxSFhh`(>E`1@pSo~C(NWC$ON|IaH!EB4`c2YUF*$FhT|@P0rpSK4<8<`6
zv(ZDJ_oNR7PA#cRrvaujFn$*hrYB_Xs~Z@vjUoXzO4s#v8C
zv+rN*95<<}gsEn#*!;kmy-mx^9JLV1EwM9P_(|^Cyl4_KHZhu^=9O~MBP9AuFqZ3GXo#&KPXF)pWT*5r?Qm%fG=ykuV-3e;me2!f$Lvb
zrJA0egoBBa8Z6pNN^_&qA<0;jClLwKLnW4p0bzYlsNNW24;K-oBLfKuf4uPIF0LFh
zGPGVT%zS1tQWx+6q~=WI=)k@Do37$|EVTIdNi*8B}yVe2!-Wj77TT&U2jDVpb*BQn>X5q?a)c&BrLi&(9!O8(B9tmTaFQ0
zz&PERBD^20c%W!dqbxLS!_H!%rRi<}D=I)-9Ko}Fw?r*wdGPN_-?q0qOAd$t&jB7TJ_gxVm>&4hNenARoc~;
zr;DOZ2b6XZD9h(|Q7;`(jY^ljNMF_bH#`FFhpF|lYLdnYS9=jj9m6882YY{Vwva?L
z^mpg#F(4S0W
zam5kk#~4u>teER_)^FtCub56Y|B2d?rnmsL-Q_mwZ?tc}t;p+>kQ-i!4nFmNP5+;e9iZ)K4rwj`F
zokcR@7$NOSqTIgNdY{BDMNdD+H>z(#Rq}tw5@@omWUBV0NNiM@%w{A>Ku0aRB^Wl+shg5$4E(sb$o!ZtjzTi|RO$wH1D5W83zwT66gz<_SywGJtk!*L%Id^p&@u)U`Z6Wsa$C3&|^GQ49+1R8Q@kFZfz-}PW
zJ?~sB2j`~b+HlOD|7933uI2#J7GFH~z-8lPt9ep6A{FC!ARqPde=qSq@
z%Ov|tX9DIXmPQ#^JX2X23++j%n@E2PD{cjYdKlyEx5J}B%)F3xs=L(F+=*1Wsf2*X
zMaPcl?Ad~OV0OQ=AYftDeAv?bH5ZI~ahg{OWQf>LLLYJks=@wRVk-Jm0<47O_SLe$
z6IIMd0`_ZeeJHbP7fH;+L2Sw908iD^7$FnrIEwRL(PfdP5^@Wibhu07Lf+v-nEz|s
z>9MfZbPxm^8Cmz0B*b;6k0A^i#e(gtyION}oU}Vy9b7g-@5ALe?Z(fIBlOY8OV$cq
z*NXx}@43PlcBL{R_G_}@f)c>Rv-BwN5QHN$C?|q-gUXa3AqRCB^jCrQbEF?Z6fIGY
z7a{J?ZBL_fEeWl=zzLEJr5B2c4x#&&UM;9umIbw39gE%>VHRknQh&VvTQwXJ0>t~Z
z-UOKFod-VkDiN-;2m^BICsvDsq&UZ`bmi^@>Bm(c|0fHI;ZjS*o%P|`wRzntbL%T3
z8oWWC%=I2gKS7pRHXQh*zNtv5^`0)jAi#h3qPv$XsJIkJ*fvN@OiB!BeBBFN)xUWJ
zD8=S)FR`!>H&Wf4)brwE_ATA(ulPx48-J+OIOw3`8hqm;yve6+Wv_RtA;9Y?0xu&?I80Bb4M<+#2K=W?
zH1vlNun3Hw2zkM}ef7o+smf>hA&`!Z4{|U?B-K<{)9fkmw^DXI@YOu-g}%YXV$%a_
zSX)w%CR#MY1wXXnuRIV7Gulfq)5el6DXEV*&o9vA`x+_A$b!$5j&FPC&b`VvQ{f|1
z5GM6oct_m&T1d
z9~s(C)gMDhB>20$H}yAe8S<6=2ZZkYhK@RDzNvuw8Z@vWyLYZ
z0&mLq-iq>&nHQaTs@-|!Vm>Q$LT=Q2TRR<+L(bRfkF~pj@K}oNG)ENS40^7_7*%P>
z{|78hP2epwgVBtHRp0Y45gAMcNe%M@@kTEUR-<>kJK%x8ll{Sv8r%$g=)NwYs?$#|
z!|FnZq%z>&q0s)ngP4i^c@Ki$-Wk?GnmBh8LDlF&^@z+_R_YRwUaVSAi8n?>vG8=k
z%;y#rsVlK@S@FSn)%}Zjkj$DU$-70;uOks3E>D8RBMYlRELT$ZXctFeN3xT`Qu{}I
zDrdbYO?M3Wi{eOwWrl#P8B00s?vSL3t&8
zQB}0mSo2VXnrA`85JSzX=n(U)jn~jnL}Du18eUTs4Pr{g5J3`}nuD9%FZciHe!gqn
zv(8#)?{l90?0ufI_wziz-#Y7@f92`q-(k}sVaK~IQJF#}|P3*Y)`+=BfEBknclZxg#oz-@)u
ze8z#T<>PZr*$AqWAL$(0>
z&EwP_@ekm$jj!ZM4SsEJti%Hg
zK!}AMBflhP`yVPi&$vp$PNWx|oQ-?x5tC1NNCt{Ps29I{zsMNf>galaP^al{e!K5&
z@5b!jGeM54U8}$Y0Y~6D7c?M6>Lp1{)zfEYPS{!CbdAlJNTHOXFvj*C6)<!wJ(fzW`NI<
zPoNFD2snf*n&jzVahe=WYYj!)E@d$vo28?~k4FW;h;`@fl^$%2%!`YipwzMb7g}8@
zy3x?DYLU!jUO1H9Zu@@ds^-1-S8c^}m2Ol+^5}C%ColxHANLQJAFM`vF;3sCMPaqP
z_mZ>&b}!FN*v3>|$?=TUQ7OI4g>wFZl$Q@h8o6CNm7rawo>i;hQ)DD=77Qb?bl?nj^Fs^^3&@-SSVJIyHCJz$$bv
zh+af^T%d~xHpR=8z<9n^=T#|(Mz;Q11m437flpw4Z+s;d_Vnh7Dsfn~zqwsI_~%jN
z-%}UPGuDhXNTU3jx8fz)@qjju4}$fEo1bcm;EZrYOy6T(+A*mM>wh%6fdz*N`-1;Q
zM}AOCq%6Lz2I#QAC2QWy{k{Z*a@_?>-icE%0Fvm{M}}U2H@bIjCkuQF7|RwbHV_x2
z@44JM2ZFkTt|s=mToca^tOk>SF)sXxPFg}AWtz2b;Z3>L`V20KjX~?-2`So&*Ax2&
z#pU&xK=x*)9`Nc}7M2~WG%ve(?%mcW!^&)ReJ1EOm59uOUlMEh!SnHsl-){lPl`pn
z`9GK$pzhWzTpUuspWgcm{pukM40q6do;SXE?uI(o_vjm=W&z(9G}!hosX@i#
zpWMTr2UAu{NkdUla*I~}&4_0U+|;j;I|}EtydBapv#mY`h3CbnxYI1E8mos
zj@WY5+X@K@jkxe@zthqhP)YK*?M+_w5)cv=MxsUi9da>%XJzv$*6kSI=_@Ss$Ap;@
z-a-Rj%CKPcE8N3EHYLh5mb6-WI92a%>;;FzWBVhtK<`SB4Go@X-qrZoR(h_TT;%G{
zN<(m#%4)-%XRWPcTc5szKp<>za4n%*x5Vvxq%8F==tWS=^4
zV?(BbSolK+^QDwt;(7M*bnYr826cPm&-D%es~TPcJ=o4J%gM+ar?5SsQ)+cwfq;#T
zlb`5<+|@bW+ERPKeal1P_=u
z8XZ`HnY2cQY|!D?V#us{9-+YRCWI*4w8G9zlZ3<7)oA?jf3FbOryo1XavVKce?1R5Wug0JrFq5yRnH0#7C(m9ksk*McaeZ8f??OE3~keN!!B9OU}M`+!XmQiCTzG0DDBh
zbl}m)tWABEwc448Qq2Dtz-t<2z=C2QR7@JH9hsV+ygif5s#o1*XTb<_WpPo~$*LYs
zZRXJwmlMOXM3ulrewMI>^9feetMoF^U<%!_Nc(@-*zhSzesyJKlINAh+QGQKdgj!5
zBcpUT#aug-vrU?vTy|L<8;+?K7zU!+oxU_{+$-?!Ezcb|7gR7j59bwUPTchrMBzj*(
zsZ+%LKQ_!a&s*qw4DFyG`YvOF>862^55k(Opqz_Z92^`ImgLrPT!24qLPtkuXF??%
zCvg2bUUhV02W8t^cZDg=Yw%~MqnbIUgg5UbjyHc+nyIpgzqGWp!>)~#i`a3M^hSky
z_jXe@+uJApyD~+HK4$(VQVX?UjoRGYlu1k%IfgSU-2X5O#L3CYVH~R;0Rb;T3hmg5
zb!8zk(qG`l4Td$V^6m3yFw><)ny?hi&(ACA?C&47vqms7G6HjNz!R#VHyX35h_I#A
zI}O|~`cxzbIQhpP{>|nVldB@7&uQ@Up-p*=rVxTdl*?e^i49v1*QrzWjl9n@>owYq
zRzI*A;2zpscnXRL_@{2Te#C&ygemGMe(qa|QGfmOB98xMy0ccm|Loy@O>w((C(j3j
zL2TXe=rz(1!N$qfb53tt>q^)E0pjJ*@zQe@HJ;BqPy#I)3lajDX
z)xiJZsNQ8PsdUZ|K8^-j){N4A*f3rtH~IC?nmA2)QQWEiAz=xjw36S7%7iWML8~KG
zgxwIaW5~9-pt%EYI-nh9M_-V^O)!RwANZooUn0@3WU)reGTqNS_sVvg*CNM8_b6mj
z%0yJrp&(+l<_g8CS#NEFLA$T9Ek>Y}+z>yfI`&bF&J!xO1tE;^}AAI}60UX?0
zU;49qGkt1-1-PR4DCGcE{!7R0W(7wkzsiW{_k(hx*&Grw@Sgma^f{v~f!jH#6{~DR
znT*3SB3&=GzT9ln^^3=4QundTx?ZI-R8JIn1xN+n$01w+gWYH
zOP74wQ~Q^tYFkxb=npJEtDO|#oC9w&x;wd2Gx+8GlVXl8M@8+?Ht1bx_7WSXvW|Q$
z*E-vV9jJ<%;>$HMOT`dU+fG~J)-x+VnW*Ti0a`X{GAQok0n_~gkOJ&|Kuj~GKfK~+
zk=#Fr((=cQmgQL4gOlQC3S8%Y!0orcCu?p5`45AJsw_xHnj0OXq*Hh1(+t@
zTZ3H<7`Q-duK9{inEE*JX``h<*m5cgfHq{G1o_Vmj7v{SSD*`{UpEi@DpZTGf2N-~
zRr?zVEj{}67+>DGFUvBeBRPs(UO&aTh#`WXRF>`Bot}(N#q&-Y5fgphH^t&FqZS3+
zoN20Q!>a)Gxkoc4S$4JsmcC07XLso9p9{DtVO8cZz6-~4VmcEllZx3K{$kXoY*};x
z0C|aYlNgu@479oc@>`pa8w6g-J`c;$OB>~W(VBXkP5UAxMS1gV<@7kR6PtP*joocN
z95lQ)@xG42Nfo1NAU+1?N@mdHh%J(lz2S0J`g@W=YYTzP)XRlniG$qMS93;o9mpL;
z+Sb<7%KP@72Nrp-_h|4ERdntE#+8{7V{R+Y7Q&7-oqfNSiII}=?a-S>cwxX|k%JVw
zA%FK}k;{@O8G1BH4@%c0LO=gGYPFtCk3oKPJ+M$G*bmM&x6DE7U++OpRH{Dxki2Bw
zlwlbd6RO%)B;5#ph(G=;YYf0j=>s_ESh}|f2Wr1t3glL5o07QN@ZjtVEhr*Q41k0RIxdFOL6GmG5DqV?#_oqMFb5g#{-g~yorzr
z%J_g7ButyuAgpV8AAc(D-iZfb3=k?Y$PT{j&)Wbu)l{oX#S{IYl;{J#`Zg!7S#ARw
zXH;DCz+AYE=fPIg@_66uU``pvm9APaYam2MFg-6k4ZE546xAy-}MmF)C;Dp6)>@1mcK2Q!
zD2Oh7&uzue*O8K(^g&Hyt9MNf7d0djs~mkBJg{927m@>)Us26#_z@1d9JF(XY6}gP
z!4_1^<9_JL9uw-+4oev(=kHkSXDg|yLy^1}V4ASHqloiYOy}N6r;(`(_`-}m
z!EelP!Arc)gHzMAsQ(cdqM}D~x9i(a_ohhrdB}-5@H%4XPX+n|lbZ!G)yZq+@B$d}
zwkR_FX4e4OUhSZ3N4N?rjOtc|fFOmk*aa|Psly5RxnW6AI5`P0JM44IS|u;=JkFP6
z6?v#4lsV3w)B48Ydx*zppYrYMMXxLBm5gv=sS@%tHMPNa=WDltuBEQO0vjI
zciFB?OOOKy42YkIdkCLDUn(ePiLT!HRja6K36v9oa1_&V(Zl-zR4oW@A;Y?r&Ih8K
z{mqk^PSUv9rLG95i1lLRnrG?KdT`|HCWAFf$a4CEEL2#Jr%!qC5Kv3PL(Ryi|Ml$>
zTpOLM8MX{1m{c`vT35Rgr@Msq;`E2CUHLBfC*LN1hGa6-8eBUH)9RcmvbWcQnchj%
z=t!^Qo9T4v78*Y#pF=@d39JT!AexuYOSB?$sEc?~jCoal6w%3}(GE6Vb+|7MIj~3r
z?t~EU87r#3@-J;+-e@FxG{MxX^xZC2R^)l(Tc>xpxpG^
nkNA&_;y<&C|7+#yKgaAOLQ0%z!yW9)wcXu=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
+ "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "peer": true,
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "peer": true,
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.0.0.tgz",
+ "integrity": "sha512-zmV3lRnlaLI08y9IMRXSDshQb5Nj77smnfpnd2LrBa/2K281Jijactokeak14QacHs/kKq0AQ121nidNYlarbQ==",
+ "peer": true,
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "peer": true,
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ }
+ }
+ },
+ "dependencies": {
+ "@d3fc/d3fc-rebind": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/@d3fc/d3fc-rebind/-/d3fc-rebind-6.0.1.tgz",
+ "integrity": "sha512-+ryBZ53ALMffbADwnFAtTYQJcT7PE5BwpducGYS0X6Jux6ESnp+fP+cDQvBGbDBOVqaziGnfeLeJXjtMnZujmQ=="
+ },
+ "d3-array": {
+ "version": "3.1.6",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.1.6.tgz",
+ "integrity": "sha512-DCbBBNuKOeiR9h04ySRBMW52TFVc91O9wJziuyXw6Ztmy8D3oZbmCkOO3UHKC7ceNJsN2Mavo9+vwV8EAEUXzA==",
+ "peer": true,
+ "requires": {
+ "internmap": "1 - 2"
+ }
+ },
+ "d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "peer": true
+ },
+ "d3-format": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
+ "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
+ "peer": true
+ },
+ "d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "peer": true,
+ "requires": {
+ "d3-color": "1 - 3"
+ }
+ },
+ "d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "peer": true,
+ "requires": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ }
+ },
+ "d3-time": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.0.0.tgz",
+ "integrity": "sha512-zmV3lRnlaLI08y9IMRXSDshQb5Nj77smnfpnd2LrBa/2K281Jijactokeak14QacHs/kKq0AQ121nidNYlarbQ==",
+ "peer": true,
+ "requires": {
+ "d3-array": "2 - 3"
+ }
+ },
+ "d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "peer": true,
+ "requires": {
+ "d3-time": "1 - 3"
+ }
+ },
+ "internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "peer": true
+ }
+ }
}
diff --git a/packages/d3fc-discontinuous-scale/src/discontinuity/skipUtcWeeklyPattern.js b/packages/d3fc-discontinuous-scale/src/discontinuity/skipUtcWeeklyPattern.js
new file mode 100644
index 000000000..5f5efd97c
--- /dev/null
+++ b/packages/d3fc-discontinuous-scale/src/discontinuity/skipUtcWeeklyPattern.js
@@ -0,0 +1,13 @@
+import { utcDay, utcMillisecond } from 'd3-time';
+import { base } from './skipWeeklyPattern';
+import { dateTimeUtility } from './skipWeeklyPattern/dateTimeUtility';
+
+export const utcDateTimeUtility = dateTimeUtility(
+ (date, hh, mm, ss, ms) => new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), hh, mm, ss, ms)),
+ date => date.getUTCDay(),
+ date => [date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds(), date.getUTCMilliseconds()],
+ utcDay,
+ utcMillisecond
+);
+
+export default (nonTradingUtcHoursPattern) => base(nonTradingUtcHoursPattern, utcDateTimeUtility);
\ No newline at end of file
diff --git a/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern.js b/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern.js
new file mode 100644
index 000000000..e63f26d79
--- /dev/null
+++ b/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern.js
@@ -0,0 +1,202 @@
+import { timeDay, timeMillisecond } from 'd3-time';
+import { tradingDay } from './skipWeeklyPattern/tradingDay';
+import { dayBoundary, millisPerDay } from './skipWeeklyPattern/constants';
+import { dateTimeUtility } from './skipWeeklyPattern/dateTimeUtility';
+
+export const localDateTimeUtility = dateTimeUtility(
+ (date, hh, mm, ss, ms) => new Date(date.getFullYear(), date.getMonth(), date.getDate(), hh, mm, ss, ms),
+ date => date.getDay(),
+ date => [date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()],
+ timeDay,
+ timeMillisecond
+);
+
+/**
+ * Discontinuity provider implemenation that works with 'non-trading' periods during a trading day
+ * @typedef { Object } WeeklyPatternDiscontinuityProvider
+ * @property { function(Date): Date } clampUp - When given a value, if it falls within a discontinuity (i.e. an excluded domain range) it should be shifted forwards to the discontinuity boundary. Otherwise, it should be returned unchanged.
+ * @property { function(Date): Date } clampDown - When given a value, if it falls within a discontinuity it should be shifted backwards to the discontinuity boundary. Otherwise, it should be returned unchanged.
+ * @property { function(Date, Date): number } distance - When given a pair of values, this function returns the distance between the, in domain units, minus any discontinuities. discontinuities.
+ * @property { function(Date, number): Date } offset - When given a value and an offset, the value should be advanced by the offset value, skipping any discontinuities, to return the final value.
+ * @property { function(): WeeklyPatternDiscontinuityProvider } copy - Creates a copy of the discontinuity provider.
+ */
+
+/**
+ * Creates WeeklyPatternDiscontinuityProvider
+ * @param {Object} nonTradingPattern - contains raw 'non-trading' time ranges for each day of the week
+ * @param {DateTimeUtility} dateTimeUtility - uses local or utc dates
+ * @returns { WeeklyPatternDiscontinuityProvider } WeeklyPatternDiscontinuityProvider
+ */
+export const base = (nonTradingPattern, dateTimeUtility) => {
+
+ const getDayPatternOrDefault = (day) => nonTradingPattern[day] === undefined ? [] : nonTradingPattern[day];
+
+ const tradingDays = [
+ tradingDay(getDayPatternOrDefault('Sunday'), dateTimeUtility),
+ tradingDay(getDayPatternOrDefault('Monday'), dateTimeUtility),
+ tradingDay(getDayPatternOrDefault('Tuesday'), dateTimeUtility),
+ tradingDay(getDayPatternOrDefault('Wednesday'), dateTimeUtility),
+ tradingDay(getDayPatternOrDefault('Thursday'), dateTimeUtility),
+ tradingDay(getDayPatternOrDefault('Friday'), dateTimeUtility),
+ tradingDay(getDayPatternOrDefault('Saturday'), dateTimeUtility)];
+
+ const totalTradingWeekMilliseconds = tradingDays.reduce((total, tradingDay) => total + tradingDay.totalTradingTimeInMiliseconds, 0);
+
+ if (totalTradingWeekMilliseconds === 0) {
+ throw 'Trading pattern must yield at least 1 ms of trading time';
+ }
+
+ const instance = { tradingDays, totalTradingWeekMilliseconds };
+
+ /**
+ * When given a value falls within a discontinuity (i.e. an excluded domain range) it should be shifted forwards to the discontinuity boundary.
+ * Otherwise, it should be returns unchanged.
+ * @param {Date} date - date to clamp up
+ * @returns {Date}
+ */
+ instance.clampUp = (date) => {
+ const tradingDay = tradingDays[dateTimeUtility.getDay(date)];
+
+ for (const range of tradingDay.nonTradingTimeRanges) {
+ if (range.isInRange(date)) {
+
+ return range.endTime === dayBoundary
+ ? instance.clampUp(dateTimeUtility.getStartOfNextDay(date))
+ : dateTimeUtility.setTime(date, range.endTime);
+ }
+ }
+
+ return date;
+ };
+
+ /**
+ * When given a value, if it falls within a discontinuity it should be shifted backwards to the discontinuity boundary. Otherwise, it should be returned unchanged.
+ * @param {Date} date - date to clamp down
+ * @returns {Date}
+ */
+ instance.clampDown = (date) => {
+ const tradingDay = tradingDays[dateTimeUtility.getDay(date)];
+
+ for (const range of tradingDay.nonTradingTimeRanges) {
+ if (range.isInRange(date)) {
+
+ return range.startTime === dayBoundary
+ ? instance.clampDown(dateTimeUtility.getEndOfPreviousDay(date))
+ : dateTimeUtility.setTime(date, range.startTime, -1);
+ }
+ }
+ return date;
+ };
+
+ /**
+ * When given a pair of values, this function returns the distance between the, in domain units, minus any discontinuities. discontinuities.
+ * @param {Date} startDate
+ * @param {Date} endDate
+ * @returns {number} - the number of milliseconds between the dates
+ */
+ instance.distance = (startDate, endDate) => {
+
+ if (startDate.getTime() === endDate.getTime()) {
+ return 0;
+ }
+
+ let [start, end, factor] = startDate <= endDate
+ ? [startDate, endDate, 1]
+ : [endDate, startDate, -1];
+
+ // same day distance
+ if (dateTimeUtility.dayInterval(start).getTime() === dateTimeUtility.dayInterval(end).getTime()) {
+ return instance.tradingDays[dateTimeUtility.getDay(start)].totalTradingMillisecondsBetween(start, end);
+ }
+
+ // combine any trading time left in the day after startDate
+ // and any trading time from midnight up until the endDate
+ let total = instance.tradingDays[dateTimeUtility.getDay(start)].totalTradingMillisecondsBetween(start, dateTimeUtility.dayInterval.offset(dateTimeUtility.dayInterval(start), 1)) +
+ instance.tradingDays[dateTimeUtility.getDay(end)].totalTradingMillisecondsBetween(dateTimeUtility.dayInterval(end), end);
+
+ // startDate and endDate are consecutive days
+ if (dateTimeUtility.dayInterval.count(start, end) === 1) {
+ return total;
+ }
+
+ // move the start date to following day
+ start = dateTimeUtility.dayInterval.offset(dateTimeUtility.dayInterval(start), 1);
+ // floor endDate to remove 'time component'
+ end = dateTimeUtility.dayInterval(end);
+
+ return factor * dateTimeUtility.dayInterval.range(start, end)
+ .reduce((runningTotal, currentDay, currentIndex, arr) => {
+
+ const nextDay = currentIndex < arr.length - 1
+ ? arr[currentIndex + 1]
+ : dateTimeUtility.dayInterval.offset(currentDay, 1);
+ const isDstBoundary = (nextDay - currentDay) !== millisPerDay;
+ const tradingDay = instance.tradingDays[dateTimeUtility.getDay(currentDay)];
+ return runningTotal += isDstBoundary
+ ? tradingDay.totalTradingMillisecondsBetween(currentDay, nextDay)
+ : tradingDay.totalTradingTimeInMiliseconds;
+
+ }, total);
+ };
+
+ /**
+ * When given a value and an offset in milliseconds, the value should be advanced by the offset value, skipping any discontinuities, to return the final value.
+ * @param {Date} date
+ * @param {number} ms
+ */
+ instance.offset = (date, ms) => {
+ date = ms >= 0
+ ? instance.clampUp(date)
+ : instance.clampDown(date);
+
+ const isDstBoundary = (d) => (dateTimeUtility.dayInterval.offset(d) - dateTimeUtility.dayInterval(d)) !== millisPerDay;
+
+ const moveToDayBoundary = (tradingDay, date, ms) => {
+
+ if (ms < 0) {
+ const dateFloor = dateTimeUtility.dayInterval(date);
+ const distanceToStartOfDay = tradingDay.totalTradingMillisecondsBetween(dateFloor, date);
+
+ return Math.abs(ms) <= distanceToStartOfDay
+ ? tradingDay.offset(date, ms)
+ : [instance.clampDown(dateTimeUtility.msInterval.offset(dateFloor, -1)), ms + distanceToStartOfDay + 1];
+
+ } else {
+ const nextDate = dateTimeUtility.getStartOfNextDay(date);
+ const distanceToDayBoundary = tradingDay.totalTradingMillisecondsBetween(date, nextDate);
+
+ return ms < distanceToDayBoundary
+ ? tradingDay.offset(date, ms)
+ : [instance.clampUp(nextDate), ms - distanceToDayBoundary];
+ }
+ };
+
+ if (ms === 0)
+ return date;
+
+ const moveDateDelegate = ms < 0
+ ? (date, remainingMs, tradingDayMs) => [instance.clampDown(dateTimeUtility.dayInterval.offset(date, -1)), remainingMs + tradingDayMs]
+ : (date, remainingMs, tradingDayMs) => [instance.clampUp(dateTimeUtility.dayInterval.offset(date)), remainingMs - tradingDayMs];
+
+ let tradingDay = instance.tradingDays[dateTimeUtility.getDay(date)];
+ [date, ms] = moveToDayBoundary(tradingDay, date, ms);
+ while (ms !== 0) {
+ tradingDay = instance.tradingDays[dateTimeUtility.getDay(date)];
+ if (isDstBoundary(date)) {
+ [date, ms] = moveToDayBoundary(tradingDay, date, ms);
+ } else {
+ [date, ms] = Math.abs(ms) >= tradingDay.totalTradingTimeInMiliseconds
+ ? moveDateDelegate(date, ms, tradingDay.totalTradingTimeInMiliseconds)
+ : moveToDayBoundary(tradingDay, date, ms);
+ }
+ }
+
+ return date;
+ };
+
+ instance.copy = () => instance;
+
+ return instance;
+};
+
+export default (nonTradingHoursPattern) => base(nonTradingHoursPattern, localDateTimeUtility);
\ No newline at end of file
diff --git a/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/constants.js b/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/constants.js
new file mode 100644
index 000000000..de0223abd
--- /dev/null
+++ b/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/constants.js
@@ -0,0 +1,4 @@
+export const millisPerDay = 24 * 3600 * 1000;
+export const dayBoundary = "00:00:00.000";
+export const SOD = 'SOD';
+export const EOD = 'EOD';
\ No newline at end of file
diff --git a/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/dateTimeUtility.js b/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/dateTimeUtility.js
new file mode 100644
index 000000000..b22561f17
--- /dev/null
+++ b/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/dateTimeUtility.js
@@ -0,0 +1,63 @@
+/**
+ * Object that helps with working with time strings and dates
+ * @typedef { Object } DateTimeUtility
+ * @property { function(Date): string } getTimeString - get's the time string for date as 'hh:mm:ss.fff'
+ * @property { function(Date , string, number): Date } setTime - set the time for date as
+ * @property { function(Date): Date } getStartOfNextDay - returns the start of the next day i.e. 00:00:00.000
+ * @property { function(Date): Date } getEndOfPreviousDay - returns the 'End' of the previous day i.e. one ms before midnight
+ */
+
+/**
+ *
+ * @param {function(Date, number, number, number, number): Date } setTimeForDate - sets a time on a Date object given hh, mm, ss & ms time compononets
+ * @param {function(Date): number } getDay
+ * @param {function(Date): number[] } getTimeComponentArray
+ * @param {function} dayInterval - d3-time timeDay or utcDay
+ * @param {function} msInterval - d3-time timeMillisecond or utcMillisecond
+ * @returns {DateTimeUtility}
+ */
+export const dateTimeUtility = (setTimeForDate, getDay, getTimeComponentArray, dayInterval, msInterval) => {
+ const utility = {};
+ utility.getTimeComponentArrayFromString = (timeString) => [timeString.slice(0, 2), timeString.slice(3, 5), timeString.slice(6, 8), timeString.slice(9, 12)];
+ /**
+ * Returns the local time part of a given Date instance as 'hh:mm:ss.fff'
+ * @param {Date} date - Data instance
+ * @returns {string} time string.
+ */
+ utility.getTimeString = date => {
+ const [hh, mm, ss, ms] = getTimeComponentArray(date).map(x => x.toString(10).padStart(2, '0'));
+ return `${hh}:${mm}:${ss}.${ms.padStart(3, '0')}`;
+ };
+
+ /**
+ * Returns the combined local date and time string
+ * @param {Date} date - Data instance
+ * @param {string} timeString - string as 'hh:mm:ss.fff'
+ * @param {number} offsetInmilliSeconds - additional offset in millisends. Default = 0; e.g. -1 is one millisecond before time specified by timeString;
+ * @returns {Date} - combined date and time.
+ */
+ utility.setTime = (date, timeString, offsetInmilliSeconds = 0) => {
+ const [hh, mm, ss, ms] = utility.getTimeComponentArrayFromString(timeString);
+ return msInterval.offset(setTimeForDate(date, hh, mm, ss, ms), offsetInmilliSeconds);
+ };
+
+ /**
+ * Returns the start of the next day i.e. 00:00:00.000
+ * @param {Date} date - Data instance
+ * @returns {Date}.
+ */
+ utility.getStartOfNextDay = (date) => dayInterval.offset(dayInterval.floor(date), 1);
+
+ /**
+ * Returns the end of the previous day (1ms before midnight) i.e. 23:59:59.999
+ * @param {Date} date - Data instance
+ * @returns {Date}.
+ */
+ utility.getEndOfPreviousDay = (date) => msInterval.offset(dayInterval.floor(date), -1);
+
+ utility.dayInterval = dayInterval;
+ utility.msInterval = msInterval;
+ utility.getDay = getDay;
+
+ return utility;
+};
\ No newline at end of file
diff --git a/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/nonTradingTimeRange.js b/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/nonTradingTimeRange.js
new file mode 100644
index 000000000..d6de36824
--- /dev/null
+++ b/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/nonTradingTimeRange.js
@@ -0,0 +1,114 @@
+import { EOD, SOD, dayBoundary, millisPerDay } from './constants';
+
+/**
+ * Attempts to parse and format a time string into a fixed lenght string 'hh:mm:ss.fff'
+ * @param {string} timeString - string representation of time 'hh:mm:ss.fff' e.g. '09:30' or '00:00:00.000'
+ * @returns {int[]} array of parsed time components [hh, mm, ss, ms] or throws.
+ */
+export function standardiseTimeString(timeString) {
+
+ if (arguments.length !== 1 || typeof timeString !== 'string') {
+ throw 'Expected single argument of type string';
+ }
+
+ const isPositiveIntegerUpTo = (toCheck, upperBound) => {
+ if (!Number.isInteger(toCheck))
+ return false;
+
+ return toCheck >= 0 && toCheck <= upperBound;
+ };
+
+ const result = [0, 0, 0, 0];
+ const time_components = timeString.split(":");
+
+ if (time_components.length < 2 || time_components.length > 3) {
+ throw 'Expected an argument wiht 2 or 3 colon delimited parts.';
+ }
+
+ result[0] = isPositiveIntegerUpTo(parseInt(time_components[0], 10), 23)
+ ? parseInt(time_components[0], 10)
+ : function () { throw `'Hours' component must be an int between 0 and 23, but was '${time_components[0]}'`; }();
+
+ result[1] = isPositiveIntegerUpTo(parseInt(time_components[1], 10), 59)
+ ? parseInt(time_components[1], 10)
+ : function () { throw `'Minutes' component must be an int between 0 and 59, but was '${time_components[1]}'`; }();
+
+ if (time_components.length === 3) {
+ const ms_components = time_components[2].split(".").map(x => parseInt(x, 10));
+
+ result[2] = isPositiveIntegerUpTo(ms_components[0], 59)
+ ? ms_components[0]
+ : function () { throw `'Seconds' component must be an int between 0 and 59, but was '${ms_components[0]}'`; }();
+
+ if (ms_components.length === 2) {
+ result[3] = isPositiveIntegerUpTo(ms_components[1], 999)
+ ? ms_components[1]
+ : function () { throw `'Miliseconds' component must be an int between 0 and 999, but was '${ms_components[1]}'`; }();
+ }
+ }
+
+ return `${result[0].toString(10).padStart(2, '0')}:${result[1].toString(10).padStart(2, '0')}:${result[2].toString(10).padStart(2, '0')}.${result[3].toString(10).padStart(3, '0')}`;
+}
+
+/**
+ * @typedef { Object } nonTradingTimeRange
+ * @property { string } startTime - Start time string with fixed format 'hh:mm:ss.fff'
+ * @property { string } endTime - End time string with fixed format 'hh:mm:ss.fff'
+ * @property { int } lenghtInMs - Absolute length in MS i.e. only valid on non-Daylight saving boundaries
+ */
+
+/**
+ * Represents a single continous Non-Trading time interval within a single day. You must denote day boundries as:
+ * SOD - start of day
+ * EOD - end of day
+ * @constructor
+ * @param { string[] } timeRangeTuple - Time range as a tuple of time strings e.g. ["07:45", "08:30"), ["SOD", "08:30:20") or ["19:00:45.500", "EOD").
+ * @param { import('./dateTimeUtility').DateTimeUtility } dateTimeUtility
+ * @returns { nonTradingTimeRange }
+ */
+export function nonTradingTimeRange(timeRangeTuple, dateTimeUtility) {
+
+ if (arguments.length != 2 ||
+ !Array.isArray(timeRangeTuple)
+ || timeRangeTuple.length !== 2
+ || typeof timeRangeTuple[0] !== 'string'
+ || typeof timeRangeTuple[1] !== 'string') {
+ throw `Expected argument is a single string[] of length 2.`;
+ }
+
+ if (timeRangeTuple[0] === SOD) {
+ timeRangeTuple[0] = dayBoundary;
+ }
+
+ if (timeRangeTuple[1] === EOD) {
+ timeRangeTuple[1] = dayBoundary;
+ }
+
+ const startTime = standardiseTimeString(timeRangeTuple[0]);
+ const endTime = standardiseTimeString(timeRangeTuple[1]);
+
+ if (endTime !== dayBoundary && startTime > endTime) {
+ throw `Time range start time '${startTime}' must be before end time '${endTime}' or both must equal ${dayBoundary}`;
+ }
+
+ const lenghtInMs = dateTimeUtility.setTime(new Date(endTime === dayBoundary ? millisPerDay : 0), endTime) - dateTimeUtility.setTime(new Date(0), startTime);
+ const instance = { startTime, endTime, lenghtInMs };
+
+ /**
+ * Returns if given date's time portion is within this discontinuity time range instance
+ * @param { Date } date - date
+ * @returns { boolean }
+ */
+ instance.isInRange = (date) => {
+ const time = dateTimeUtility.getTimeString(date);
+
+ if (instance.startTime <= time
+ && (instance.endTime === dayBoundary || instance.endTime > time)) {
+ return true;
+ }
+
+ return false;
+ };
+
+ return instance;
+}
\ No newline at end of file
diff --git a/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/tradingDay.js b/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/tradingDay.js
new file mode 100644
index 000000000..3075a857c
--- /dev/null
+++ b/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/tradingDay.js
@@ -0,0 +1,121 @@
+import { nonTradingTimeRange } from './nonTradingTimeRange';
+import { dayBoundary, millisPerDay } from './constants';
+
+/**
+ * Represents a Trading day
+ * @param { string[][] } rawDiscontinuityTimeRanges - Array of time range tuples e.g. [["07:45", "08:30"), ["19:00:45.500", "EOD")]
+ * @param { import('./dateTimeUtility').DateTimeUtility } dateTimeUtility
+ */
+export const tradingDay = (rawDiscontinuityTimeRanges, dateTimeUtility) => {
+ const nonTradingTimeRanges = rawDiscontinuityTimeRanges
+ .map(rawRange => nonTradingTimeRange(rawRange, dateTimeUtility))
+ .sort((a, b) => a.startTime < b.startTime ? -1 : a.startTime > b.startTime ? 1 : 0);
+ const totalTradingTimeInMiliseconds = millisPerDay - nonTradingTimeRanges.reduce((total, range) => total + range.lenghtInMs, 0);
+
+ const totalTradingMillisecondsBetween = (intervalStart, intervalEnd) => {
+
+ if (intervalStart.getTime() === intervalEnd.getTime()) {
+ return 0;
+ }
+
+ // ensure arguments are on the same day or intervalEnd is the next day boundary
+ if (dateTimeUtility.dayInterval(intervalStart).getTime() !== dateTimeUtility.dayInterval(intervalEnd).getTime()
+ && dateTimeUtility.getStartOfNextDay(intervalStart).getTime() !== intervalEnd.getTime()) {
+ throw `tradingDay.totalTradingMillisecondsBetween arguments must be on the same day or intervalEnd must be the start of the next day instead: intervalStart: '${intervalStart}'; intervalEnd: '${intervalEnd}'`;
+ }
+
+ let total = 0;
+
+ const relevantDiscontinuityRanges = nonTradingTimeRanges.filter(range => {
+ return range.endTime === dayBoundary ||
+ dateTimeUtility.setTime(intervalStart, range.endTime) >= intervalStart;
+ });
+
+ for (const nonTradingRange of relevantDiscontinuityRanges) {
+ const nonTradingStart = dateTimeUtility.setTime(intervalStart, nonTradingRange.startTime);
+ const nonTradingEnd = nonTradingRange.endTime === dayBoundary
+ ? dateTimeUtility.getStartOfNextDay(intervalStart)
+ : dateTimeUtility.setTime(intervalStart, nonTradingRange.endTime);
+
+ // both intervalStart and intervalEnd are before the start of this non-trading range
+ if (intervalStart < nonTradingStart && intervalEnd < nonTradingStart) {
+ return total + dateTimeUtility.msInterval.count(intervalStart, intervalEnd);
+ }
+
+ // intervalStart is before the start of this non-trading time range
+ if (intervalStart < nonTradingStart) {
+ total += dateTimeUtility.msInterval.count(intervalStart, nonTradingStart);
+ }
+
+ // interval ends within non-trading range
+ if (intervalEnd < nonTradingEnd) {
+ return total;
+ }
+
+ // set interval start to the end of non-trading range
+ intervalStart = nonTradingEnd;
+ }
+
+ // add any interval time still left after iterating through all non-trading ranges
+ return total + dateTimeUtility.msInterval.count(intervalStart, intervalEnd);
+ };
+
+ const offset = (date, ms) => {
+ if (ms === 0) {
+ return [date, ms];
+ }
+
+ let offsetDate = dateTimeUtility.msInterval.offset(date, ms);
+
+ const nonTradingRanges = (ms > 0)
+ ? nonTradingTimeRanges.filter(range => dateTimeUtility.setTime(date, range.startTime) >= date)
+ : nonTradingTimeRanges.filter(range => dateTimeUtility.setTime(date, range.startTime) < date).reverse();
+
+ if (nonTradingRanges.length === 0) {
+ return [dateTimeUtility.msInterval.offset(date, ms), 0];
+ }
+
+ if (ms > 0) {
+ for (const nonTradingRange of nonTradingRanges) {
+
+ const rangeStart = dateTimeUtility.setTime(date, nonTradingRange.startTime);
+
+ if (rangeStart <= offsetDate) {
+ // offsetDate is within non-trading range
+ ms -= dateTimeUtility.msInterval.count(date, rangeStart);
+ date = nonTradingRange.endTime === dayBoundary
+ ? dateTimeUtility.getStartOfNextDay(date)
+ : dateTimeUtility.setTime(date, nonTradingRange.endTime);
+ offsetDate = dateTimeUtility.msInterval.offset(date, ms);
+ }
+ }
+
+ ms -= dateTimeUtility.msInterval.count(date, offsetDate);
+
+ } else {
+
+ for (const nonTradingRange of nonTradingRanges) {
+ const endTime = nonTradingRange.endTime === dayBoundary
+ ? dateTimeUtility.getStartOfNextDay(date)
+ : dateTimeUtility.setTime(date, nonTradingRange.endTime);
+
+ if (offsetDate < endTime) {
+ // offsetDate is within non-trading range
+ ms += dateTimeUtility.msInterval.count(endTime, date) + 1;
+ date = dateTimeUtility.msInterval.offset(dateTimeUtility.setTime(date, nonTradingRange.startTime), - 1);
+ offsetDate = dateTimeUtility.msInterval.offset(date, ms);
+ }
+ }
+
+ ms += dateTimeUtility.msInterval.count(offsetDate, date);
+ }
+
+ if (ms !== 0) {
+ throw 'tradingDay.offset was called with an offset that spans more than a day';
+ }
+
+ return [offsetDate, ms];
+ };
+
+ return { totalTradingTimeInMiliseconds, nonTradingTimeRanges, totalTradingMillisecondsBetween, offset };
+};
diff --git a/packages/d3fc-discontinuous-scale/test/discontinuity/skipUtcWeeklyPatternSpec.js b/packages/d3fc-discontinuous-scale/test/discontinuity/skipUtcWeeklyPatternSpec.js
new file mode 100644
index 000000000..6ac4106d9
--- /dev/null
+++ b/packages/d3fc-discontinuous-scale/test/discontinuity/skipUtcWeeklyPatternSpec.js
@@ -0,0 +1,192 @@
+import { default as skipUtcWeeklyPattern } from '../../src/discontinuity/skipUtcWeeklyPattern';
+import { utcMillisecond } from 'd3-time';
+
+const nonTradingHoursPattern =
+{
+ Monday: [["13:20", "19:00"], ["07:45", "08:30"]],
+ Tuesday: [["07:45", "08:30"], ["13:20", "19:00"]],
+ Wednesday: [["07:45", "08:30"], ["13:20", "19:00"]],
+ Thursday: [["07:45", "08:30"], ["13:20", "19:00"]],
+ Friday: [["07:45", "08:30"], ["13:20", "EOD"]],
+ Saturday: [["SOD", "EOD"]],
+ Sunday: [["SOD", "19:00"]]
+};
+
+const tradingWeekWithoutDiscontinuities = {};
+
+const mondayFirstStartBoundary = new Date(Date.UTC(2018, 0, 1, 7, 45));
+const mondayFirstEndBoundary = new Date(Date.UTC(2018, 0, 1, 8, 30));
+const fridaySecondStartBoundary = new Date(Date.UTC(2018, 0, 5, 13, 20));
+const sundayEndBoundary = new Date(Date.UTC(2018, 0, 7, 19));
+
+describe('skipUtcWeeklyPattern', () => {
+ const sut = skipUtcWeeklyPattern(nonTradingHoursPattern);
+
+ it('has 7 trading days', () => {
+ expect(sut.tradingDays.length).toBe(7);
+ });
+
+ describe('clampUp', () => {
+
+ it('should do nothing 1ms before non trading period', () => {
+ const expected = utcMillisecond.offset(mondayFirstStartBoundary, - 1);
+ const actual = sut.clampUp(expected);
+ expect(actual).toEqual(expected);
+ });
+
+ it('should do nothing if provided with a valid trading date', () => {
+ const expected = mondayFirstEndBoundary;
+ const actual = sut.clampUp(expected);
+ expect(actual).toEqual(expected);
+ });
+
+ it('should advance to end of non trading period', () => {
+ const expected = mondayFirstEndBoundary;
+ const actual = sut.clampUp(mondayFirstStartBoundary);
+ expect(actual).toEqual(expected);
+ });
+
+ it('should advance from Friday 13:20 to Sunday 7:00pm', () => {
+ const expected = sundayEndBoundary;
+ const actual = sut.clampUp(fridaySecondStartBoundary);
+ expect(actual).toEqual(expected);
+ });
+ });
+
+ describe('clampDown', () => {
+
+ it('should do nothing one ms before non trading period', () => {
+ const expected = utcMillisecond.offset(mondayFirstStartBoundary, - 1);
+ const actual = sut.clampDown(expected);
+ expect(actual).toEqual(expected);
+ });
+
+ it('should do nothing if provided with a valid trading hour', () => {
+ const expected = mondayFirstEndBoundary;
+ const actual = sut.clampDown(expected);
+ expect(actual).toEqual(expected);
+ });
+
+ it('should clamp down to one millisecond before start of non-trading period', () => {
+ const expected = utcMillisecond.offset(mondayFirstStartBoundary, -1);
+ const actual = sut.clampDown(mondayFirstStartBoundary);
+ expect(actual).toEqual(expected);
+ });
+
+ it('should clamp down from Sunday 6:59:59.999pm to 1ms before Friday 13:20', () => {
+ const expected = utcMillisecond.offset(fridaySecondStartBoundary, -1);
+ const actual = sut.clampDown(utcMillisecond.offset(sundayEndBoundary, -1));
+ expect(actual).toEqual(expected);
+ });
+ });
+
+ describe('distance', () => {
+ it('should return totalTradingWeekMilliseconds', () => {
+ expect(sut.distance(new Date(Date.UTC(2018, 0, 1)), new Date(Date.UTC(2018, 0, 8)))).toBe(sut.totalTradingWeekMilliseconds);
+ });
+
+ it('should return 52 * totalTradingWeekMilliseconds', () => {
+ expect(sut.distance(new Date(Date.UTC(2018, 0, 1)), new Date(Date.UTC(2018, 11, 31)))).toBe(52 * sut.totalTradingWeekMilliseconds);
+ });
+
+ it('should return negative 52 * totalTradingWeekMilliseconds', () => {
+ expect(sut.distance(new Date(Date.UTC(2018, 11, 31)), new Date(Date.UTC(2018, 0, 1)))).toBe(-52 * sut.totalTradingWeekMilliseconds);
+ });
+
+ it('on DST boundaries should return 24 * 3600 * 1000', () => {
+ const sut = skipUtcWeeklyPattern(tradingWeekWithoutDiscontinuities);
+ expect(sut.distance(new Date(Date.UTC(2022, 2, 27)), new Date(Date.UTC(2022, 2, 28)))).toBe(24 * 3600 * 1000);
+ expect(sut.distance(new Date(Date.UTC(2022, 9, 30)), new Date(Date.UTC(2022, 9, 31)))).toBe(24 * 3600 * 1000);
+ });
+
+ it('should return 23 * 3600 * 1000 for a DST sunday as it skips missing hour', () => {
+ const sut = skipUtcWeeklyPattern({ Sunday: [["1:0", "2:0"]] });
+ expect(sut.distance(new Date(Date.UTC(2022, 2, 27)), new Date(Date.UTC(2022, 2, 28)))).toBe(23 * 3600 * 1000);
+ });
+
+ it('should return 7 * 24 * 3600 * 1000 for trading week without non-trading periods', () => {
+ const sut = skipUtcWeeklyPattern(tradingWeekWithoutDiscontinuities);
+ expect(sut.distance(new Date(Date.UTC(2018, 0, 1)), new Date(Date.UTC(2018, 0, 8)))).toBe(7 * 24 * 3600 * 1000);
+ });
+
+ it('should return 52 * 7 * 24 * 3600 * 1000 for trading week without non-trading periods', () => {
+ const sut = skipUtcWeeklyPattern(tradingWeekWithoutDiscontinuities);
+ expect(sut.distance(new Date(Date.UTC(2018, 0, 1)), new Date(Date.UTC(2018, 11, 31)))).toBe(52 * 7 * 24 * 3600 * 1000);
+ });
+ });
+
+ describe('offset', () => {
+ it('0 offset should return same date', () => {
+ expect(sut.offset(new Date(Date.UTC(2018, 0, 1)), 0)).toEqual(new Date(Date.UTC(2018, 0, 1)));
+ });
+
+ it('-1ms offset should return end of previous day', () => {
+ expect(sut.offset(new Date(Date.UTC(2018, 0, 1)), -1)).toEqual(new Date(Date.UTC(2017, 11, 31, 23, 59, 59, 999)));
+ });
+
+ it('-1ms offset at the of non-trading period should return start of non-trading period', () => {
+ expect(sut.offset(new Date(Date.UTC(2018, 0, 1, 8, 30)), -1)).toEqual(new Date(Date.UTC(2018, 0, 1, 7, 44, 59, 999)));
+ });
+
+ it('-2ms offset should return end of previous day - 1ms', () => {
+ expect(sut.offset(new Date(Date.UTC(2018, 0, 1)), -2)).toEqual(new Date(Date.UTC(2017, 11, 31, 23, 59, 59, 998)));
+ });
+
+ it('-1ms offset after 1ms into trading day should return start of day', () => {
+ expect(sut.offset(new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 1)), -1)).toEqual(new Date(Date.UTC(2018, 0, 1)));
+ });
+
+ it('should return start of next day when offset is 24hr', () => {
+ const sut = skipUtcWeeklyPattern(tradingWeekWithoutDiscontinuities);
+ expect(sut.offset(new Date(Date.UTC(2018, 0, 1)), 24 * 3600 * 1000)).toEqual(new Date(Date.UTC(2018, 0, 2)));
+ });
+
+ it('should return start of previous day when offset is -24hr', () => {
+ const sut = skipUtcWeeklyPattern(tradingWeekWithoutDiscontinuities);
+ expect(sut.offset(new Date(Date.UTC(2018, 0, 2)), - 24 * 3600 * 1000)).toEqual(new Date(Date.UTC(2018, 0, 1)));
+ });
+
+ it('1ms offset at start of non-trading period should return end of non-trading period', () => {
+ expect(sut.offset(new Date(Date.UTC(2018, 0, 1, 7, 45)), 1)).toEqual(new Date(Date.UTC(2018, 0, 1, 8, 30, 0, 1)));
+ });
+
+ it('should return start of next week when offset is totalTradingWeekMilliseconds', () => {
+ expect(sut.offset(new Date(Date.UTC(2018, 0, 1)), sut.totalTradingWeekMilliseconds)).toEqual(new Date(Date.UTC(2018, 0, 8)));
+ });
+
+ it('should return start of 53rd week when offset is 52 * totalTradingWeekMilliseconds', () => {
+ expect(sut.offset(new Date(Date.UTC(2018, 0, 1)), 52 * sut.totalTradingWeekMilliseconds)).toEqual(new Date(Date.UTC(2018, 11, 31)));
+ });
+
+ it('on a DST Sunday boundary should return 1h into next trading period when offset is 1h', () => {
+ expect(sut.offset(new Date(Date.UTC(2022, 2, 27, 1)), 3600 * 1000)).toEqual(new Date(Date.UTC(2022, 2, 27, 20)));
+ });
+
+ it('on a DST boundry day should return next day when offset is 24 hours', () => {
+ const sut = skipUtcWeeklyPattern(tradingWeekWithoutDiscontinuities);
+ expect(sut.offset(new Date(Date.UTC(2022, 2, 27)), 24 * 3600 * 1000)).toEqual(new Date(Date.UTC(2022, 2, 28)));
+ });
+
+ it('should return next day on a DST boundry when offset is 23 hours', () => {
+ const sut = skipUtcWeeklyPattern({ Sunday: [["1:0", "2:0"]] });
+ expect(sut.offset(new Date(Date.UTC(2022, 2, 27)), 23 * 3600 * 1000)).toEqual(new Date(Date.UTC(2022, 2, 28)));
+ });
+
+ it('should return end of second non-trading range', () => {
+ const offset = utcMillisecond.count(new Date(Date.UTC(2018, 0, 1, 8, 30)), new Date(Date.UTC(2018, 0, 1, 13, 20)));
+ expect(sut.offset(new Date(Date.UTC(2018, 0, 1, 7, 45)), offset)).toEqual(new Date(Date.UTC(2018, 0, 1, 19, 0, 0, 0)));
+ });
+
+ it('should return 1ms before Friday 13:20 when offset = -1ms on Sunday 7:00pm', () => {
+ const expected = utcMillisecond.offset(fridaySecondStartBoundary, -1);
+ const actual = sut.offset(sundayEndBoundary, -1);
+ expect(actual).toEqual(expected);
+ });
+ });
+
+ describe('copy', () => {
+ it('should return same object', () => {
+ expect(sut.copy() === sut.copy()).toBeTruthy();
+ });
+ });
+});
diff --git a/packages/d3fc-discontinuous-scale/test/discontinuity/skipWeeklyPattern/nonTradingTimeRangeSpec.js b/packages/d3fc-discontinuous-scale/test/discontinuity/skipWeeklyPattern/nonTradingTimeRangeSpec.js
new file mode 100644
index 000000000..7f4aa3517
--- /dev/null
+++ b/packages/d3fc-discontinuous-scale/test/discontinuity/skipWeeklyPattern/nonTradingTimeRangeSpec.js
@@ -0,0 +1,80 @@
+import { nonTradingTimeRange, standardiseTimeString } from "../../../src/discontinuity/skipWeeklyPattern/nonTradingTimeRange";
+import { utcDateTimeUtility } from "../../../src/discontinuity/skipUtcWeeklyPattern";
+import { localDateTimeUtility } from "../../../src/discontinuity/skipWeeklyPattern";
+
+describe('nonTradingTimeRange', () => {
+ it('throws for no arguments', () => {
+ expect(() => nonTradingTimeRange()).toThrow();
+ });
+
+ it('throws for more than 1 argument', () => {
+ expect(() => nonTradingTimeRange("a", "b")).toThrow();
+ });
+
+ it('throws for argument that is not string[]', () => {
+ expect(() => nonTradingTimeRange("a")).toThrow();
+ });
+
+ it('throws for argument that is not string[]', () => {
+ expect(() => nonTradingTimeRange([1, 2], utcDateTimeUtility)).toThrow();
+ });
+
+ it('throws for argument that is not string[]', () => {
+ expect(() => nonTradingTimeRange([new Date(), new Date()], utcDateTimeUtility)).toThrow();
+ });
+
+ it('throws for string[] argument when lenght != 2', () => {
+ expect(() => nonTradingTimeRange(["a"], utcDateTimeUtility)).toThrow();
+ });
+
+ it('throws for string[] argument with invalid time string', () => {
+ expect(() => nonTradingTimeRange(["", ""], utcDateTimeUtility)).toThrow();
+ });
+
+ it('should return lenght = 1 for SOD and "00:00:00.001" ', () => {
+ expect(nonTradingTimeRange(["SOD", "00:00:00.001"], utcDateTimeUtility).lenghtInMs).toEqual(1);
+ expect(nonTradingTimeRange(["SOD", "00:00:00.001"], localDateTimeUtility).lenghtInMs).toEqual(1);
+ });
+
+ it('should return total milliseconds per day for timeRange [SOD, EOD)', () => {
+ expect(nonTradingTimeRange(["SOD", "EOD"], utcDateTimeUtility).lenghtInMs).toEqual(1000 * 60 * 60 * 24);
+ expect(nonTradingTimeRange(["SOD", "EOD"], localDateTimeUtility).lenghtInMs).toEqual(1000 * 60 * 60 * 24);
+ });
+});
+
+describe('standardiseTimeString', () => {
+ it('throws for no arguments', () => {
+ expect(() => standardiseTimeString()).toThrow();
+ });
+
+ it('throws for more than 1 argument', () => {
+ expect(() => standardiseTimeString("a", "b")).toThrow();
+ });
+
+ it('throws for argument that is not a valid time', () => {
+ expect(() => standardiseTimeString("25:00:00.000")).toThrow();
+ });
+
+ it('throws for argument that is not a valid time', () => {
+ expect(() => standardiseTimeString("24:00:00.000")).toThrow();
+ });
+
+ it('throws for argument that is not a valid time', () => {
+ expect(() => standardiseTimeString("20")).toThrow();
+ });
+
+ it('parses argument formatted as h:m as expected', () => {
+ const actual = standardiseTimeString("8:3");
+ expect(actual).toEqual("08:03:00.000");
+ });
+
+ it('parses argument formatted as h:mm:s as expected', () => {
+ const actual = standardiseTimeString("1:59:7");
+ expect(actual).toEqual("01:59:07.000");
+ });
+
+ it('parses argument formatted as h:mm:s:ff as expected', () => {
+ const actual = standardiseTimeString("1:59:9.56");
+ expect(actual).toEqual("01:59:09.056");
+ });
+});
\ No newline at end of file
diff --git a/packages/d3fc-discontinuous-scale/test/discontinuity/skipWeeklyPattern/tradingDaySpec.js b/packages/d3fc-discontinuous-scale/test/discontinuity/skipWeeklyPattern/tradingDaySpec.js
new file mode 100644
index 000000000..ee955419e
--- /dev/null
+++ b/packages/d3fc-discontinuous-scale/test/discontinuity/skipWeeklyPattern/tradingDaySpec.js
@@ -0,0 +1,41 @@
+import { tradingDay } from '../../../src/discontinuity/skipWeeklyPattern/tradingDay';
+import { localDateTimeUtility } from '../../../src/discontinuity/skipWeeklyPattern';
+import { utcDateTimeUtility } from '../../../src/discontinuity/skipUtcWeeklyPattern';
+
+describe('tradingDay', () => {
+ describe('totalTradingMillisecondsBetween', () => {
+ it(' should return 0 ms for non trading day', () => {
+ expect(tradingDay([["SOD", "EOD"]], localDateTimeUtility).totalTradingMillisecondsBetween(new Date(2018, 0, 1), new Date(2018, 0, 2))).toBe(0);
+ expect(tradingDay([["SOD", "EOD"]], utcDateTimeUtility).totalTradingMillisecondsBetween(new Date(Date.UTC(2018, 0, 1)), new Date(Date.UTC(2018, 0, 2)))).toBe(0);
+ });
+
+ it('should return 1 ms', () => {
+ expect(tradingDay([["0:0:0.1", "EOD"]], localDateTimeUtility).totalTradingMillisecondsBetween(new Date(2018, 0, 1), new Date(2018, 0, 2))).toBe(1);
+ expect(tradingDay([["0:0:0.1", "EOD"]], utcDateTimeUtility).totalTradingMillisecondsBetween(new Date(Date.UTC(2018, 0, 1)), new Date(Date.UTC(2018, 0, 2)))).toBe(1);
+ });
+
+ it('should return 2 ms', () => {
+ expect(tradingDay([["0:0:0.1", "23:59:59.999"]], localDateTimeUtility).totalTradingMillisecondsBetween(new Date(2018, 0, 1), new Date(2018, 0, 2))).toBe(2);
+ expect(tradingDay([["0:0:0.1", "23:59:59.999"]], utcDateTimeUtility).totalTradingMillisecondsBetween(new Date(Date.UTC(2018, 0, 1)), new Date(Date.UTC(2018, 0, 2)))).toBe(2);
+ });
+
+ it('should return 2 ms - one at start and one at the end of the day', () => {
+ expect(tradingDay([["0:0:0.1", "23:59:59.998"]], localDateTimeUtility).totalTradingMillisecondsBetween(new Date(2018, 0, 1), new Date(2018, 0, 1, 23, 59, 59, 999))).toBe(2);
+ expect(tradingDay([["0:0:0.1", "23:59:59.998"]], utcDateTimeUtility).totalTradingMillisecondsBetween(new Date(Date.UTC(2018, 0, 1)), new Date(Date.UTC(2018, 0, 1, 23, 59, 59, 999)))).toBe(2);
+ });
+
+ it('should skip multiple non-trading time ranges and return 3 ms', () => {
+ expect(tradingDay([["0:0:0.1", "0:0:0.2"], ["0:0:0.3", "0:0:0.4"], ["0:0:0.5", "EOD"]], localDateTimeUtility).totalTradingMillisecondsBetween(new Date(2018, 0, 1), new Date(2018, 0, 2))).toBe(3);
+ expect(tradingDay([["0:0:0.1", "0:0:0.2"], ["0:0:0.3", "0:0:0.4"], ["0:0:0.5", "EOD"]], utcDateTimeUtility).totalTradingMillisecondsBetween(new Date(Date.UTC(2018, 0, 1)), new Date(Date.UTC(2018, 0, 2)))).toBe(3);
+ });
+
+ it('should skip multiple non-trading time ranges and return 4 ms', () => {
+ expect(tradingDay([["0:0:0.1", "0:0:0.2"], ["0:0:0.3", "0:0:0.4"], ["0:0:0.5", "23:59:59.998"]], localDateTimeUtility).totalTradingMillisecondsBetween(new Date(2018, 0, 1), new Date(2018, 0, 1, 23, 59, 59, 999))).toBe(4);
+ expect(tradingDay([["0:0:0.1", "0:0:0.2"], ["0:0:0.3", "0:0:0.4"], ["0:0:0.5", "23:59:59.998"]], utcDateTimeUtility).totalTradingMillisecondsBetween(new Date(Date.UTC(2018, 0, 1)), new Date(Date.UTC(2018, 0, 1, 23, 59, 59, 999)))).toBe(4);
+ });
+
+ it('should return 1', () => {
+ expect(tradingDay([["7:00", "7:30"], ["8:00", "EOD"]], localDateTimeUtility).totalTradingMillisecondsBetween(new Date(2018, 0, 1, 7, 59, 59, 999), new Date(2018, 0, 2)));
+ });
+ });
+});
\ No newline at end of file
diff --git a/packages/d3fc-discontinuous-scale/test/discontinuity/skipWeeklyPatternSpec.js b/packages/d3fc-discontinuous-scale/test/discontinuity/skipWeeklyPatternSpec.js
new file mode 100644
index 000000000..7e4c61c32
--- /dev/null
+++ b/packages/d3fc-discontinuous-scale/test/discontinuity/skipWeeklyPatternSpec.js
@@ -0,0 +1,260 @@
+import { default as skipWeeklyPattern } from '../../src/discontinuity/skipWeeklyPattern';
+import { timeMillisecond } from 'd3-time';
+
+const nonTradingHoursPattern =
+{
+ Monday: [["13:20", "19:00"], ["07:45", "08:30"]],
+ Tuesday: [["07:45", "08:30"], ["13:20", "19:00"]],
+ Wednesday: [["07:45", "08:30"], ["13:20", "19:00"]],
+ Thursday: [["07:45", "08:30"], ["13:20", "19:00"]],
+ Friday: [["07:45", "08:30"], ["13:20", "EOD"]],
+ Saturday: [["SOD", "EOD"]],
+ Sunday: [["SOD", "19:00"]]
+};
+
+const mondayFirstStartBoundary = new Date(2018, 0, 1, 7, 45);
+const mondayFirstEndBoundary = new Date(2018, 0, 1, 8, 30);
+const fridaySecondStartBoundary = new Date(2018, 0, 5, 13, 20);
+const sundayEndBoundary = new Date(2018, 0, 7, 19);
+
+describe('skipWeeklyPattern', () => {
+ const sut = skipWeeklyPattern(nonTradingHoursPattern);
+
+ it('has 7 trading days', () => {
+ expect(sut.tradingDays.length).toBe(7);
+ });
+
+ it('should throw due to no trading periods', () => {
+ expect(() => skipWeeklyPattern({
+ Monday: [["SOD", "EOD"]], Tuesday: [["SOD", "EOD"]],
+ Wednesday: [["SOD", "EOD"]], Thursday: [["SOD", "EOD"]],
+ Friday: [["SOD", "EOD"]], Saturday: [["SOD", "EOD"]], Sunday: [["SOD", "EOD"]]
+ })).toThrow();
+ });
+
+ describe('clampUp', () => {
+
+ it('should do nothing 1ms before non trading period', () => {
+ const expected = timeMillisecond.offset(mondayFirstStartBoundary, - 1);
+ const actual = sut.clampUp(expected);
+ expect(actual).toEqual(expected);
+ });
+
+ it('should do nothing if provided with a valid trading date', () => {
+ const expected = mondayFirstEndBoundary;
+ const actual = sut.clampUp(expected);
+ expect(actual).toEqual(expected);
+ });
+
+ it('should advance to end of non trading period', () => {
+ const expected = mondayFirstEndBoundary;
+ const actual = sut.clampUp(mondayFirstStartBoundary);
+ expect(actual).toEqual(expected);
+ });
+
+ it('should advance from Friday 13:20 to Sunday 7:00pm', () => {
+ const expected = sundayEndBoundary;
+ const actual = sut.clampUp(fridaySecondStartBoundary);
+ expect(actual).toEqual(expected);
+ });
+
+ it('should advance to single trading ms', () => {
+ const sut = skipWeeklyPattern({ Sunday: [["SOD", "23:59:59.999"]], Monday: [["SOD", "EOD"]], Tuesday: [["SOD", "EOD"]], Wednesday: [["SOD", "EOD"]], Thursday: [["SOD", "EOD"]], Friday: [["SOD", "EOD"]], Saturday: [["SOD", "EOD"]] });
+ expect(sut.clampUp(new Date(2018, 0, 1))).toEqual(new Date(2018, 0, 7, 23, 59, 59, 999));
+ expect(sut.clampUp(new Date(2018, 0, 2))).toEqual(new Date(2018, 0, 7, 23, 59, 59, 999));
+ expect(sut.clampUp(new Date(2018, 0, 3))).toEqual(new Date(2018, 0, 7, 23, 59, 59, 999));
+ expect(sut.clampUp(new Date(2018, 0, 4))).toEqual(new Date(2018, 0, 7, 23, 59, 59, 999));
+ expect(sut.clampUp(new Date(2018, 0, 5))).toEqual(new Date(2018, 0, 7, 23, 59, 59, 999));
+ expect(sut.clampUp(new Date(2018, 0, 6))).toEqual(new Date(2018, 0, 7, 23, 59, 59, 999));
+ expect(sut.clampUp(new Date(2018, 0, 7))).toEqual(new Date(2018, 0, 7, 23, 59, 59, 999));
+ });
+ });
+
+ describe('clampDown', () => {
+
+ it('should do nothing one ms before non trading period', () => {
+ const expected = timeMillisecond.offset(mondayFirstStartBoundary, - 1);
+ const actual = sut.clampDown(expected);
+ expect(actual).toEqual(expected);
+ });
+
+ it('should do nothing if provided with a valid trading hour', () => {
+ const expected = mondayFirstEndBoundary;
+ const actual = sut.clampDown(expected);
+ expect(actual).toEqual(expected);
+ });
+
+ it('should clamp down to one millisecond before start of non-trading period', () => {
+ const expected = timeMillisecond.offset(mondayFirstStartBoundary, -1);
+ const actual = sut.clampDown(mondayFirstStartBoundary);
+ expect(actual).toEqual(expected);
+ });
+
+ it('should clamp down from Sunday 6:59:59.999pm to 1ms before Friday 13:20', () => {
+ const expected = timeMillisecond.offset(fridaySecondStartBoundary, -1);
+ const actual = sut.clampDown(timeMillisecond.offset(sundayEndBoundary, -1));
+ expect(actual).toEqual(expected);
+ });
+
+ it('should clamp down to single trading ms', () => {
+ const expected = new Date(2018, 0, 1);
+ const sut = skipWeeklyPattern({ Monday: [["00:00:00.001", "EOD"]], Tuesday: [["SOD", "EOD"]], Wednesday: [["SOD", "EOD"]], Thursday: [["SOD", "EOD"]], Friday: [["SOD", "EOD"]], Saturday: [["SOD", "EOD"]], Sunday: [["SOD", "EOD"]] });
+ expect(sut.clampDown(new Date(2018, 0, 1, 0, 0, 0, 1))).toEqual(expected);
+ expect(sut.clampDown(new Date(2018, 0, 2))).toEqual(expected);
+ expect(sut.clampDown(new Date(2018, 0, 3))).toEqual(expected);
+ expect(sut.clampDown(new Date(2018, 0, 4))).toEqual(expected);
+ expect(sut.clampDown(new Date(2018, 0, 5))).toEqual(expected);
+ expect(sut.clampDown(new Date(2018, 0, 6))).toEqual(expected);
+ expect(sut.clampDown(new Date(2018, 0, 7))).toEqual(expected);
+ });
+ });
+
+ describe('distance', () => {
+ it('should return totalTradingWeekMilliseconds', () => {
+ expect(sut.distance(new Date(2018, 0, 1), new Date(2018, 0, 8))).toBe(sut.totalTradingWeekMilliseconds);
+ });
+
+ it('should return 52 * totalTradingWeekMilliseconds', () => {
+ expect(sut.distance(new Date(2018, 0, 1), new Date(2018, 11, 31))).toBe(52 * sut.totalTradingWeekMilliseconds);
+ });
+
+ it('should return negative 52 * totalTradingWeekMilliseconds', () => {
+ expect(sut.distance(new Date(2018, 11, 31), new Date(2018, 0, 1))).toBe(-52 * sut.totalTradingWeekMilliseconds);
+ });
+
+ it('should return 7 * 24 * 3600 * 1000 for trading week without non-trading periods', () => {
+ const sut = skipWeeklyPattern({});
+ expect(sut.distance(new Date(2018, 0, 1), new Date(2018, 0, 8))).toBe(7 * 24 * 3600 * 1000);
+ });
+
+ it('should return 52 * 7 * 24 * 3600 * 1000 for trading week without non-trading periods', () => {
+ const sut = skipWeeklyPattern({});
+ expect(sut.distance(new Date(2018, 0, 1), new Date(2018, 11, 31))).toBe(52 * 7 * 24 * 3600 * 1000);
+ });
+
+ it('should return 0 between consecutive non-trading ranges spanning multiple days', () => {
+ expect(sut.distance(fridaySecondStartBoundary, sundayEndBoundary)).toEqual(0);
+ expect(sut.distance(sundayEndBoundary, fridaySecondStartBoundary)).toEqual(-0);
+ });
+
+ it('should return -1', () => {
+ const expected = -1;
+ const sut = skipWeeklyPattern({ Monday: [["00:00:00.001", "EOD"]], Tuesday: [["SOD", "EOD"]], Wednesday: [["SOD", "EOD"]], Thursday: [["SOD", "EOD"]], Friday: [["SOD", "EOD"]], Saturday: [["SOD", "EOD"]], Sunday: [["SOD", "EOD"]] });
+ expect(sut.distance(new Date(2018, 0, 8), new Date(2018, 0, 1))).toEqual(expected);
+ expect(sut.distance(new Date(2018, 0, 9), new Date(2018, 0, 2))).toEqual(expected);
+ expect(sut.distance(new Date(2018, 0, 10), new Date(2018, 0, 3))).toEqual(expected);
+ expect(sut.distance(new Date(2018, 0, 11), new Date(2018, 0, 4))).toEqual(expected);
+ expect(sut.distance(new Date(2018, 0, 12), new Date(2018, 0, 5))).toEqual(expected);
+ expect(sut.distance(new Date(2018, 0, 13), new Date(2018, 0, 6))).toEqual(expected);
+ expect(sut.distance(new Date(2018, 0, 14), new Date(2018, 0, 7))).toEqual(expected);
+ });
+
+ it('on DST boundaries should return 0hr or 1hr', () => {
+ const sut = skipWeeklyPattern({});
+ expect(sut.distance(new Date(2022, 2, 27, 1), new Date(2022, 2, 27, 2))).toBe(0);
+ expect(sut.distance(new Date(2022, 9, 30, 1), new Date(2022, 9, 30, 2))).toBe(2 * 3600 * 1000);
+ });
+
+ it('on DST boundary when clocks go back should return 0', () => {
+ const sut = skipWeeklyPattern({ Sunday: [["1:0", "2:0"]] });
+ expect(sut.distance(new Date(2022, 9, 30, 1), new Date(2022, 9, 30, 2))).toBe(0);
+ });
+
+ it('on DST boundary when clocks go back should return 90min', () => {
+ const sut = skipWeeklyPattern({ Sunday: [["1:15", "1:45"]] });
+ expect(sut.distance(new Date(2022, 9, 30, 1), new Date(2022, 9, 30, 2))).toBe(90 * 60000);
+ });
+
+ it(`KNOWN BUG:
+ on DST boundary when clocks go forward should return 50min (1h:10min - 15min) when non-trading period falls within 'wall clock' change
+ instead it returns 55min i.e. the non-trading period 1:55-2:00 is only counted once rather than twice`, () => {
+ const sut = skipWeeklyPattern({ Sunday: [["1:55", "2:05"]] });
+ expect(sut.distance(new Date(2022, 9, 30, 1), new Date(2022, 9, 30, 2))).toBe(/*50*/ 55 * 60000);
+ });
+
+ it('on DST boundaries should return 22hr or 24hr in single non-trading hour in day', () => {
+ const sut = skipWeeklyPattern({ Sunday: [["7:45", "8:45"]] });
+ expect(sut.distance(new Date(2022, 2, 27), new Date(2022, 2, 28))).toBe(22 * 3600 * 1000);
+ expect(sut.distance(new Date(2022, 9, 30), new Date(2022, 9, 31))).toBe(24 * 3600 * 1000);
+ });
+ });
+
+ describe('offset', () => {
+ it('0 offset should return same date', () => {
+ expect(sut.offset(new Date(2018, 0, 1), 0)).toEqual(new Date(2018, 0, 1));
+ });
+
+ it('0 offset in non-trading range clamps up', () => {
+ expect(sut.offset(new Date(2018, 0, 1, 7, 45), 0)).toEqual(new Date(2018, 0, 1, 8, 30));
+ });
+
+ it('should return end of previous day when offset = -1ms on day boundary ', () => {
+ expect(sut.offset(new Date(2018, 0, 1), -1)).toEqual(new Date(2017, 11, 31, 23, 59, 59, 999));
+ });
+
+ it('-2ms offset should return end of previous day - 1ms', () => {
+ expect(sut.offset(new Date(2018, 0, 1), -2)).toEqual(new Date(2017, 11, 31, 23, 59, 59, 998));
+ });
+
+ it('-1ms offset after 1ms into trading day should return start of day', () => {
+ expect(sut.offset(new Date(2018, 0, 1, 0, 0, 0, 1), -1)).toEqual(new Date(2018, 0, 1));
+ });
+
+ it('should return 1ms before the start of this period when offset = -1ms at the end of non-trading period', () => {
+ expect(sut.offset(new Date(2018, 0, 1, 8, 30), -1)).toEqual(new Date(2018, 0, 1, 7, 44, 59, 999));
+ });
+
+ it('should return start of next day when offset is 24hr', () => {
+ const sut = skipWeeklyPattern({});
+ expect(sut.offset(new Date(2018, 0, 1), 24 * 3600 * 1000)).toEqual(new Date(2018, 0, 2));
+ });
+
+ it('should return start of previous day when offset is -24hr', () => {
+ const sut = skipWeeklyPattern({});
+ expect(sut.offset(new Date(2018, 0, 2), - 24 * 3600 * 1000)).toEqual(new Date(2018, 0, 1));
+ });
+
+ it('should return end of non-trading period when offset is 1ms at start of non-trading period', () => {
+ expect(sut.offset(new Date(2018, 0, 1, 7, 45), 1)).toEqual(new Date(2018, 0, 1, 8, 30, 0, 1));
+ });
+
+ it('should return start of next week when offset is totalTradingWeekMilliseconds', () => {
+ expect(sut.offset(new Date(2018, 0, 1), sut.totalTradingWeekMilliseconds)).toEqual(new Date(2018, 0, 8));
+ });
+
+ it('should return start of 53rd week when offset is 52 * totalTradingWeekMilliseconds', () => {
+ expect(sut.offset(new Date(2018, 0, 1), 52 * sut.totalTradingWeekMilliseconds)).toEqual(new Date(2018, 11, 31));
+ });
+
+ it('should return end of second non-trading range when offset covers entire trading period sandwiched between 2 non-trading ones', () => {
+ const offset = timeMillisecond.count(new Date(2018, 0, 1, 8, 30), new Date(2018, 0, 1, 13, 20));
+ expect(sut.offset(new Date(2018, 0, 1, 7, 45), offset)).toEqual(new Date(2018, 0, 1, 19, 0, 0, 0));
+ });
+
+ it('should return 1ms before Friday 13:20 when offset = -1ms on Sunday 7:00pm', () => {
+ expect(sut.offset(sundayEndBoundary, -1)).toEqual(timeMillisecond.offset(fridaySecondStartBoundary, -1));
+ });
+
+ it('should return Sunday 7pm when offset = 1ms on Friday 13:20', () => {
+ expect(sut.offset(timeMillisecond.offset(fridaySecondStartBoundary, -1), 1)).toEqual(sundayEndBoundary);
+ });
+
+ it('should return Monday 22nd when offset = 2ms on any when day since it skips the 8th and the 15th', () => {
+ const expected = new Date(2018, 0, 22);
+ const sut = skipWeeklyPattern({ Monday: [["00:00:00.001", "EOD"]], Tuesday: [["SOD", "EOD"]], Wednesday: [["SOD", "EOD"]], Thursday: [["SOD", "EOD"]], Friday: [["SOD", "EOD"]], Saturday: [["SOD", "EOD"]], Sunday: [["SOD", "EOD"]] });
+ expect(sut.offset(new Date(2018, 0, 1, 1), 2)).toEqual(expected);
+ expect(sut.offset(new Date(2018, 0, 2), 2)).toEqual(expected);
+ expect(sut.offset(new Date(2018, 0, 3), 2)).toEqual(expected);
+ expect(sut.offset(new Date(2018, 0, 4), 2)).toEqual(expected);
+ expect(sut.offset(new Date(2018, 0, 5), 2)).toEqual(expected);
+ expect(sut.offset(new Date(2018, 0, 6), 2)).toEqual(expected);
+ expect(sut.offset(new Date(2018, 0, 7), 2)).toEqual(expected);
+ });
+ });
+
+ describe('copy', () => {
+ it('should return same object', () => {
+ expect(sut.copy() === sut.copy()).toBeTruthy();
+ });
+ });
+});
\ No newline at end of file
diff --git a/scripts/jest/jest.config.js b/scripts/jest/jest.config.js
index 1741d40d4..5ad8ceab6 100644
--- a/scripts/jest/jest.config.js
+++ b/scripts/jest/jest.config.js
@@ -1,6 +1,8 @@
+process.env.TZ = 'Europe/London';
module.exports = {
rootDir: '../../',
roots: ['/packages'],
setupFilesAfterEnv: [require.resolve('./setup.js')],
+ moduleNameMapper: { '^d3-(.*)$': `d3-$1/dist/d3-$1` },
testMatch: ['**/test/**/*[sS]pec.js']
};