From 5104f1f2fdd81880016380c15f4c8cb45165e39a Mon Sep 17 00:00:00 2001 From: Michael Spivak <118991512+mspivak-actionengine@users.noreply.github.com> Date: Wed, 3 Apr 2024 13:11:56 +0300 Subject: [PATCH] feat(basemap): Add and select basemap (#366) --- public/icons/basemaps/arcgis-dark-gray.png | Bin 0 -> 12510 bytes public/icons/basemaps/arcgis-light-gray.png | Bin 0 -> 10196 bytes public/icons/basemaps/arcgis-streets-dark.png | Bin 0 -> 15362 bytes public/icons/basemaps/arcgis-streets.png | Bin 0 -> 14406 bytes public/icons/basemaps/custom-map.png | Bin 0 -> 1077 bytes public/icons/basemaps/maplibre-dark.png | Bin 0 -> 9469 bytes public/icons/basemaps/maplibre-light.png | Bin 0 -> 8898 bytes public/icons/basemaps/terrain.png | Bin 0 -> 10586 bytes public/icons/custom-map.svg | 4 +- public/icons/terrain-map.png | Bin 3389 -> 0 bytes src/app.tsx | 4 + .../comparison-side/comparison-side.tsx | 9 +- src/components/debug-panel/debug-panel.tsx | 8 +- .../deck-gl-wrapper/deck-gl-wrapper.spec.tsx | 25 +- .../input-dropdown/input-dropdown.spec.tsx | 89 +++++++ .../input-dropdown/input-dropdown.tsx | 119 +++++++++ .../base-map-icon/base-map-icon.spec.tsx | 49 ---- .../base-map-icon/base-map-icon.tsx | 43 ---- .../base-map-list-item.spec.tsx | 27 -- .../base-map-list-item/base-map-list-item.tsx | 53 ---- .../basemap-list-panel.spec.tsx | 206 +++++++++++++++ .../basemap-list-panel/basemap-list-panel.tsx | 235 ++++++++++++++++++ .../basemap-options-menu.tsx | 44 ++-- .../insert-panel/insert-panel.spec.tsx | 24 +- .../insert-panel/insert-panel.tsx | 37 ++- .../layers-control-panel.spec.tsx | 6 +- .../layers-panel/layers-panel.spec.tsx | 16 +- src/components/layers-panel/layers-panel.tsx | 46 ++-- .../layers-panel/map-options-panel.spec.tsx | 152 ++--------- .../layers-panel/map-options-panel.tsx | 108 ++------ src/constants/map-styles.ts | 72 +++++- .../use-arcgis-hook/use-arcgis-hook.spec.ts | 24 -- .../use-arcgis-hook/use-arcgis-hook.spec.tsx | 45 ++++ src/hooks/use-arcgis-hook/use-arcgis-hook.ts | 25 +- src/hooks/use-deckgl-hook/use-deckgl-hook.ts | 9 +- src/pages/comparison/comparison.tsx | 58 ++--- src/pages/comparison/e2e.comparison.spec.ts | 16 +- src/pages/debug-app/debug-app.tsx | 81 ++++-- src/pages/debug-app/e2e.debug-app.spec.ts | 4 +- src/pages/viewer-app/e2e.viewer-app.spec.ts | 4 +- src/pages/viewer-app/viewer-app.tsx | 11 +- src/redux/slices/base-maps-slice.spec.ts | 215 +++++++++++----- src/redux/slices/base-maps-slice.ts | 58 +++-- src/types.ts | 13 +- src/utils/testing-utils/e2e-layers-panel.tsx | 77 +++--- 45 files changed, 1341 insertions(+), 675 deletions(-) create mode 100644 public/icons/basemaps/arcgis-dark-gray.png create mode 100644 public/icons/basemaps/arcgis-light-gray.png create mode 100644 public/icons/basemaps/arcgis-streets-dark.png create mode 100644 public/icons/basemaps/arcgis-streets.png create mode 100644 public/icons/basemaps/custom-map.png create mode 100644 public/icons/basemaps/maplibre-dark.png create mode 100644 public/icons/basemaps/maplibre-light.png create mode 100644 public/icons/basemaps/terrain.png delete mode 100644 public/icons/terrain-map.png create mode 100644 src/components/input-dropdown/input-dropdown.spec.tsx create mode 100644 src/components/input-dropdown/input-dropdown.tsx delete mode 100644 src/components/layers-panel/base-map-icon/base-map-icon.spec.tsx delete mode 100644 src/components/layers-panel/base-map-icon/base-map-icon.tsx delete mode 100644 src/components/layers-panel/base-map-list-item/base-map-list-item.spec.tsx delete mode 100644 src/components/layers-panel/base-map-list-item/base-map-list-item.tsx create mode 100644 src/components/layers-panel/basemap-list-panel/basemap-list-panel.spec.tsx create mode 100644 src/components/layers-panel/basemap-list-panel/basemap-list-panel.tsx delete mode 100644 src/hooks/use-arcgis-hook/use-arcgis-hook.spec.ts create mode 100644 src/hooks/use-arcgis-hook/use-arcgis-hook.spec.tsx diff --git a/public/icons/basemaps/arcgis-dark-gray.png b/public/icons/basemaps/arcgis-dark-gray.png new file mode 100644 index 0000000000000000000000000000000000000000..8b35f5a3d5fc0fbe3b3878871147246e8765081a GIT binary patch literal 12510 zcmV<4Fd@&0P)?VERx9Yqzb8p955qoEzy0=Ya$b;9v`Zxdf zCzXUhUo2-|uUA*kXS2EN_j`Ux&2EOzAuC%G2#{#ekGx`o2a&CDNUb`k*9>`Aq6M==-94{`u$9 zcY_@D8N+~QRC?W6?zdb1jOXunI~~JOnzrI|x^vX~UrE$wAN8Kc!&&-%kaIdod~$E& zJJqsWH2TmWX(3YST&iB5*$k4ZLHc7Qv$c--oTQhiDgE-ThXdf6(6rdG_Ryv`uB>)OiP8vq7)>@L~6Dis9RO2>)U2M`AEvF;E$jG zNM60vzv(z;^Mx+vT<@K95l-^{{X5y|rffD>`nA?sC4G-BV5;T2?|&=5{QFBSob0gA15Og_lUDC}NcDiBv zwQgd|=V5JdZ;a_qg++Aj<}HL)*CkZ^J+8qzVO?j7namd}75D>%cfOeaUoOO{P?K(!idCzN_wvnc4^%~*E;MEU3 z7ZV$dfeiXHG^?r`SuHnmd;3VWVa@?#Qn)7u{_gF2dGq$QO8r6atqY^?4=NNJ-M}9` zf3CmNNs7G(1H)!~`0zoxzUTYQ=|sa^lzd9)xSCqO_{riIUfFhey-zbRKw0H#oY;$Xt|;L<6bxAPB&ebqiT4Mc{|sI?sa23 zIqmM`6i#xj0???`v^qwZ*IE~{)eWE3bAHFhMBT)rer;BbCL5ekB6fi)Q>>QVHau3`!knpp*xR7$L}zd#eAhV+{k>kH1k!P{p?k8 zAuUIJUxevU37)IOHu~OyX0O*pZ1sLCYR`N&;{+fzm>@Rur1rVf_hD|=*SD(j3l)Nu zTwPsJ3uuZU>-wI%JJkXmD>Mr~4waueCT~unU_ra+e6dtP zeTVvt>a5QjbaUq_)oH~AtyHLbbqqsyvUT%KH#>Z}DK60*6MXPd(Ofmy{0;cbc*$9X zYPH_zd?V2$yiZNH0c0Y7-t^t+MUt8(1QLCHeJub0SNeUS=qK-R@(H({Ia-*GdtE5q zcp|Hyf5RdWov74Slo|krlRA7wUuu%z7a%o&;IslnYoSOn{)K_#xh*%eDVv4MFmP(E zg9WqS;tt)lWQgvubXHA8KtRD5k|QD;`%e8IH&Ryt{;#4ysX)R}12XG%NuB+C-f;X$ z-FNA*u-;1{aPCeW3s*y$1l!Y(C@jw`z%k`xiVCF@R+4eURB1L7LkeAv;sE+QaiEAH^{Ks5vK zRZwXx!v3(QU9Z*0P?~}30ke}y+||`amd~Ctu7O|>W$AVBefR3O5OQP^jD`R1-Fvt0@makh<7upfiR9&()92b-^%B6-Oe) zN@EymyY7&InsBKtoPXp$@jkArt_9Yo>rE&yj~V?EzH-sEsR5dGZU-BBBWE)c)KxxukP^84Xz3LOl2q$-&v02m;k#DdlaDzS9Q4Izv?1$tIRJAA>JcWPw?AbF)9W>-#1pz3G z!lPz_a9rH(i@{f-I7NJ`)rNwI=iP5Lgy=Cx)hxsudOfTH{Ac#L(9l{pWW5HI5AvxO zt|4S6VuS!C3Z-ejX%j3>ufU@l4qTT3!mIHQ1QLSTynXwYlfVK2rC_&?M{~006VT0( zySz|ffW>GWwGDuHK?)+Niasket*av4tvhGNHi(1bU=mebP6}qSqgn`&4{SfaTMXoE zK{0z#vWdqy0YQ0fXJ`)p-tBsBs=?F9)#7u51-rpbAg(h;PH5wDB$vdC+3L`)0_s-2p z{m=r4D;-3vICnb3ke%)L%74$k24V-qbgz*6Zu`ME8$Mz|?WQRU;!H#;@Acia1+IcG zwr$E3U|86&P-bC6>yeVjU{PecGdWns+~F@_nj!cQu)NOAOc7Xbp0Fdk5*|7#X$Cs5 z;D{z#1h4;xhaYx(P8Jq+vsuvyv>IH}xr1)epJ($WZR7p!zR(L-*A;E+S$~GM4Fn7d zk5v<6UP#Gbd7*t#lFg3NmBz&~C|py@FkzV0!(s1Khi_cv5?m+{n=G`o3iez855zL9 zeuodr!7W1qclh~z9mgG=ezI}is~}_ZmwJyn)(_Xy_bAY8K}Zc=!!Px`Km96@rv|NS z^{avIKtqu3Qt+BykrFwu+K4quxWdt1B^xLU18azeYEE#WgdY})af3<}AS~h%s5f9W zg29=Z#NK0@J>w$S*61h-jy2Lh1m6VkN+bN7IQd{22knLAL-HWW;z8y{7YAk+lP4g^ zBwflHXas22175du__`9hIKFYzmOz5FDiApN!U}z5z6brQuTN18f7U}&mNgK53+^CE zj%^}02eB4-g z3Kttt1)I}HVN5cuWjqLTsMqy_1&$n(L^p#*&p-efScbgdiEF6uofDU11Mu&r>8awz zHaNGV|C`U|gq8Rk%v0roU8kEk3|mK0TmCJ1+>se)#v|02z-OgogYETNz;F0AEHoq( z8-W*CYQ`6I{E-WZL3iD$AT1d{}aWD`bE576YlZay zEQZ^_+$qoqHlanJm7Zu&2i^#ddmwBxLJ|teK{_18&A|>Jn#JNR)am1OJin6+9+Ep! zqqogOVZk0fdc^N{8V|jE^On-%>sMYedi3uQm=w+hq)y}a#9^8<)yV=`BDl=HXG-N5 zV6!;F7=VHAIiGh7nondavL7)ap)jqDt+>6uu@s0y<2x0`%syn#kWJHctbESUbJMZ5 zpk2VW<7e}U7CYf*_#q%%#CJ2IVb!Krzx^F|@T^old;vEDHZ|0i`@o{9r0*1^A%H_r z7K@Lpi_~YSHkKn5_LRY*O8HKoaR8VIu=SBBGdX{hp)jPmQIv8f7bZk<6`6ujyNp_Q zdMpFj# zz0m}+2e@%c33G|5qtt^rdHM3C`Rp{J1PZa5VXW)5CilWs;P~)d2tLi7*>16bCxo&< z!6n#Kxgj-y8y?m6ViFd_?Z!!_RFYo#KG^+dpM6Fm6fk+F4<%>h{wecghFT1P8JE>N z?EQqj$Vn!9sF_UP6#NR3_r({VD=4|5W*eE<(}oY8Q2W$O_=v*P-*t}ES(BuZ!oSem-z91~{a|UDvYEQ2|sMlv> z{tW^q;PTD-iiisnWTsLlb8~ae#2IWMHV_*T^=l*nD`q3bxhhLN8}*J&xz&jQ2q4Rd zJUA8#{s|kjQ}grg-P>`K^uIk*2|KeVZ11^fU^40atCS>b>=!5k=+ zATH$`zacMsVB~^6d93-5jWGvMOGr?o1IASV2l##O9Gn!}p6U8n?ER--p(M)kB(a#> z6iAv15AAkuW8LcFE$65&*dT5v2mzRZAR_*`+;E!%h*>ZhNN0Tew|`5}0KbCYW5d=8 z;lKRyOY(TAvMWV$8`)0DZeGzQ|Yx7=X-2__c`0D!=p(zWhB zq-=vIRNu7fHbgQ&XpxB>qat#3if@B0l0#XVp~{$PNYQ)Mkg7H|7mLsl7F(2!XW{3Q zCa1FL3T^^3yd}`k=V1|}`zxqB1O-X3<-$-SsrYC-7oaG#0h+e)#MnA5siiPu+x?d0 zXD`r+<91@->@Jv9vDI%{yc-f5BzJg&xKjiv%> zXX8zSG>dxfOMq*DBNkpSGZT?D4Rd+&g_?&4=1SxOmErj?wTSjDWt^ya z_WYWT9qEY8+A42qPxirAfZW_4woc9l3d91{IdG)eFRJ#>)QrGHW08?Z0XG;5n_&JB z!5^NBwVCT!Dz5m#YZ{^_69<|nGm)_bkVd|mAcDoXx?0os0#)K~S3361XU}+T14kA; z$%RQGmLMgrYQ>y?GWZDl3<$YZdkWQsL_!kA;)1CS&FbqWB0KAkOo#m7D@~GNtl7{p zfNeOZ$RrxIkSXelCM1)J5QKAY8-^kgEaLIFgkk}NfBnU=)d|I62}rPF6% z({9zYbKh7$r`l2_qR~YN&eh3;rWv_B`aNjAWGfI#*pZxjVWLsj`-A#erq6XVA3b@h z8)bnq;~3OoG2fs!fJwl=Gt8W=mwXuPYuz|>P8KL(P&17U03P7vj@6N%!L&ijce${( z|G3weT4KuWxn+=vaRI?Vn3zSyGJ1eSbCn{s`@8Ox|4L3Bx~k|9QbmTVoS>GF^%f1h>DqQ-{vM-e09H$Ds3gVfdeAYetc z5ylM=fftc*VA|6HpX95-%v^?!mBkh3!?|SGu;^e1)|#_MDmsvtx+1p)Rysr1=>nI3 zi+maej~r&Xo@s;1isV@7=#od!kW1kk3RcCYSpkqcPGy_&!~k^`Sb3bYKP`eHcu3`YD{vtXNm09=RcC5 z?|=XOFJ*iBUhnC&OwOOVkYRjLtj27ioZ?rC+}3K1ut|t%qWU3#C^PKUM>b@2H3}6r zu!)&k7+LJ{GHFeYl!n6VSUKv3H_*MHT}N&{qpqr+@>~!7i7Ptovuyx28Sk?~ki|%y z483PF_ePhn8t0+*P5qIFt6@r+SUs}ZKDm4O8@YS+8y52?bLd_SFHgroE0|D3(QESamM)?7y@H`uhs)=wbQr+g)Rt5iUoL(!6jJn zpcPC(gEwRH=@XvVq}te3Z!!uW9w%qGmDt!pX-#$+ZVYYk!j z?z`_i0o7jYe7?anc|>8XM%b5QkZ^H5HmH{0_nKxNQ-+k27hvu^?Ybc>U*R8oLCWZN zng@KMIBKHI+sOl(L)0OMRyIo{(9ZJa`~NM?T%EPX7V)fX?{HKHykz`R=}jZ5eso$8 z7zChmvJI^T%)01g*H5Riz$I`NnV9yxMk0la%G=_aXZ#?`Wh;k`p}c{9C3x}k5K2W0 zH>8y2SS_=7H3{<;g^&yrm46WRN`{l-ql*Pr8jGDg?y)OO?d!5}MNq=%ba4$#2863k zGf7d37Uz_8l{_Jr;QaMHsP2I#b-no`B_497NSTR)986~e8r>?O*+PAy;2$=T1gFZ%a)f` zH)#-UbaY|`2067$Nz4TkRXrw?QW~iUkx@N;!^LTod{2X#w&^it33F3dWg@k5|1ps; zxsw}tp zGyf+ynG-gGv-bG^CS+P!8`gGd1T1BenqD^VUHROlF>2m92{28XTGMZwh}|4CwOf zAf&EL08w0Tk{21E!xdl?XDrmelI3cleor@Mvwot2fjs{^?zYh_#(9;(gCeCCzy*&T zj3;{_$)PF|m^Y;W2lrVyvGn-yBL=c19jw1ceKBk|;vtZpWR5|IqQrK;|GiscLJ_zy zxi|z48No9O^gyf{EHy1`6_G(Wm#8m5wpN5J2-4R;Q$U=cdUD7>%i#i-a*;-uScf=G zMFhz)U3W&#CNg-XNvlR7X0TgXdLP#T;3&KNJLX^77KM6CFR%+dzLtyGvSG>_fx70k z7fK)kPf1F-k@sb_N&P|k-c3g^CKQ9NnL5rB^;jlZi70aCAS#ni z_=pmvgOz#WZ)t>Ro-Z*YY;=7^#|+caX(VhZhpLpx@*ZH8QNdDGJY@y=jLpdSADKdE z5PU$ZCZOwUP7^``5OZ^*@kp@yk+IOtIr*8+ICxrtwO&TDl5+#weXulh2c8ecT)by{K1f>KV@uHI1$hQpvp)oz#l>;D-p;W4MrAfD#g{Vj|p>yguxl z(&>4SvDerpjE>$+5$tMXHuVg~6#SK@T~EFL4M!NneZ%7T>RM&D&Y^{$?$%Z`{Kd&^H{*mvZ0vcECUs_N8bgxI=U;& zw@l2_bS{3A{3|PyGFpGB!hEC2!R6f2+8{$=f-z5IejyAvnsQLKBy~d>n;n4C4O2ri zQ!Pf4uE`I=jjlEN#tjCVCwEdQOl(DErB3eu8L;syN;Pi2O*g~BNhC$;B%y})Oui~q zlTf;-y-(VMFe}6}zt?(}G)kt^t#sF_PwWs>j*r5$V&(^cgXMMHL|3B#S?zHws7ulm zreAJO4l$tN@LyMIdNUk`FkoHb>&~Sb=1kDh6Qh?K&t;8_v6}ZLO9`a%^~+Zl zn($F%LEwv<8kL))_+T^#h#!;6!F^#o1ZyxGS-wn62=^B$l;a zFoFHxT$u%LInaEk*~UW`S*8o=tU~w%a1gjIDHN|W|iqDRq7VTxOLY$&ttTY(Q&`(WRHkFgIR$R|O)8#jVj3}9b zu<@}pOZa7< zG-Z1~RpMko;0NxMg0mNi!o&mO00m-6Hv`AUnq=X!)P==HbqIbk4M*bbo=qmzLKst| z+2h4YK$z39jUTg%!$`sB!>umzl!W9bd@CMwH4vDugQH8-%&{h{48k`i@wBtPKUv9Y z$R<|8xIzxe4KM`KC(O_EZkkmfh6zPy{ie1q&vT z&IW&m=P2Yt?6*;t9OI5^Zr_1@P``ARu4py;D(F{}%~5dA7Sg2b#t*q#J*!t;db0uX zV2fuzd=v77?bLD2sNLXi=ahtL3rlDltEv(Cc;0z|hQ61a-(c#&{(7-6**}hfA95`< zwWXc6-ZkUla@t1EnY(ur6)rW#&i|zHkOG}2-|-qbLhAqm6Ans+Jq*N3y0E6G`(CW_ zSt5e4>O*(l!FnRLj|PJ zn3xWo{}`GUDaL-{8rhE^sx2C>80!Z>(Et@$n-o)teY0?=+OMVd_5>~60Ve5G$_ z0d1?6YM;;5RAD?-hM#ARnO6jgp*qPLi(6&HpZu6b@_;VN$kTaHDI;l%gGr!iz#3=9 zAf-#~F*7tcY*&=0pa(5kaB2>|bER=jRC)!z8$D`{?@qGa9m#^WeMSq^Dbhr2 zgN`D>BZ#JbuFmR>#uey36cj;i6!Jbrz45^kSifv!*{!6n&mQ?tN>W7%SeQyuGBU$z ze)~wXM*<)pp%yV54{d@|G&_3XZ0M(W%o|6$#SYZUXu>?CFkj@5m4~79zxmR-R*;_e zBOwzVEB8+q&$D~+oTV4egDHRpAYjcUu%Lyk+SJ;l(A`&-8plzU`}+@!1??KsoCfE) zF(19>YH8(GtQS{C0tFc-9>Fk+lWVyf`MbH)a8Nq17Mh_Fa12N!)$A-(r&>;RFHAPX zV2kKdT5P_A2b(h@J4g)a?~toTYM0*HQVTBa)zojK3_p)Vbcjs`MR?6(B@bV@tTmqq zV;$Vy8vV5M#V5>I2B?=XIwd#sL+8Gb>_4 znGcRtSAcFx;w5#YHEvW*rkC=%N^#AcYvl)i^1@Xm$g_4^5m{TnfiajA3uEGBp@`=} zAf{?k0s8SWONw4eBZVP=2BNBQcQ{%O>A_nr)8dZIYCB#CX-2?&eSPKST7$+%@3|>o ze)-Qh$K1mW?NdO+K33C&8n$$+>!?Bmeh#I^!t*ZHb!2F)k_JM_QxH6pW5tm-*Eb%3 zdu?;@257lEMDMh;oljS(9;}xPWDuK`Jg4P#SoeioxNAF2n` zMl1d}?72BSfW>kn^PMnNko3K!8NST;rbj_kktH z7TZ`;rGp{kn$eD+8jZs;D#PofazO>Lb`GeDC}%%!q@@#DQ7JGoIf|9WqU-j z!-JWV`aq{2Yt@kHw!WxnHV#d#jsT5_>-4zKc!#(`f$ zt*x4~g~l)qD_c7cLTh&U!?DBrEqWjfCP;!~iEZmtMlXd?9u>nYVPvvl6;FK`--Bc2 zJ?nzUc*Fa=NO11#?Db&QS_G^z^WhRn>>1TEW(O}`$?6G^SsY4E=c6^2G2F1jwVRc7 zO*(th@1s}BF<9^ItHe7q*;@QiFqsp92nvUirtmCo7)?8jW5dQhGi94sv?M@>IrkGi z?nB|nVSkjDJCrqnLo*?PurzQ=Rj>l=3aM4^Z)y39F%8_-)@^UOSQ8qulK+50zb&k! zmo_AHjp+fFG^IsT(UGNczEWLnNH2wBmIo=deaxHK>6;I6O(vuGr-xx|J}7;ZkL+g2zeKl=Dkozu=m-S}jLa5r^RF7=X$UltOS}5}WMc zOihhwT(@f;xqulN{HzQX{jRqYyRhMC8CccU90=SF8Y%$)FV~x*b#RV&?{;@@TIXid z+GgcCvaHrpj%r`lm06pQOvl*bgyNI3PzM>~tKjC;S`SM>Pg1V4#&@gb6^|prjAKz! z81p|P+8j5A0RMtI65n_?ji6YE8ZHZZ}^#=nzW;S z3D3@G9CLTKGZTVd$*rAfKT!1|J7$1Vx!No|^B9>2VFCGJU%K&O!E;$_s0r~gM2kU_U-LL-dj7G5tr-%-p8Fx);Jn_)^jFR(CI>S^(X3oBIEC7{HKTP6 z9A|LY!Ez!v2U@i2Q_<;zS0;G#M&;TFl*8gw9@fwfP@Twx&iv^Sm*Ay zv237aIP22tB)_lr{-4#uCItV0&7lY-Z$ARBTN`^lXc~Pui~B#A<}+!V!?9z%HLMIv zxO80icOR@#E5L) zlISjajo6Y!3D%0l@+@`cz&z5jGQ{*oQi%C61%LO#nqYSl9h)+7uGL7MP_zmnZoEi(`_In-) z%;y@>*0)3I7g03ZkeFn6agxn7S$;g%5#YQdTkb;Juwae(E;!0PgD&e}fjYB1;nEQn zHv^5QV@;lB=6QTbzaqaD?c32PFIX*twQID%&f+gKMLCnj4>jFw>$KbnU;oBjWvB>v)J0oy0T_BM(mFLS5C*ioF=oH zVG9^@zZRG$n7fqrc7{IK8N=2)g2S0b=eY<9LageuNv6=zOz0wD9)R5YpF=FACT&MnoXR;$2}G`j*#0JN=1r{#4CQsz*qS$%n@3~ z6Lbz9veuX;M5cQ!={Nd63%K%YtSBx3|}9ebt}sjAN!_pfR;4x3C8MFb3~$mZ=u& z^we;%+1#Pu8-QqS<6tsv!3^a*efES*0y>?CQYS66Ck#-nl?SUfvq>yRJ$#MrLlMkB z!5ZK`)*5kMSOYt?9p$p!qjv|HFPv_g##_Oe2HS8j&+MLyeghpW$BAQX9VF$A$i2&= zsgz{+&+IPzkwMt8{;QM=rF5AfqD}Ql4OZc}+OFiv6G|{K`2!g2G?ulDx3iKxt0P0S z4&96d1`viL`6(ja6vsB#8Wi+2akr`ghPV%W zxG|u2;4>TV?L3_`)`N>1Vl<`ge0SlQi9)q#zITx1+{|Q z23E|f4y^;L@k-?kiQDUZ-Uge2%0~DoR>nJA2BiZ@=jRMG7OSwEL_0HwF0L^_+zgvv z>Z6@1+YhMTu_G8nW>v0;v|YCi^LaAK7mDVws;3?+N6k;7DN^f#5OhQCd3OJ-X=3;r z0;OpFTAXw>Bsq47GV5xEFeH(G{3ri173leZBYJoyE6fC3wX5I0UAiLC>RD0Sh}vPtIUXwfV^ywJloix!-w<-`abBiRUOv3?Ig{L`uy? zKni*uJ=qQ(mtg!vh>j9X2CO@5o-cghS~VMujCgh?`Gi4?B6eFWIadIjnu69)bF4aZ z$0LL!dU8*+p62dbjPS4Z>p#dJ_#)4g#)52&3m^HhfAh9z5mEz;+Bq@n`H$MRD&*FV z7Vxb%Ei3#xwiwABSZi;|Zs26MLs6VHSj|~SaVL+yHTZ4~i^L?+3CR+0O7i21xt^?+ zTx#1|PPo)XpFCPmY=|A`J8&;V^#CT&Jdl+*vT`PC+%t{G z7pa(Tk8dn`H`7wHO-pU@*zsIoF1m?tRa0^f2L1?f6SQEZCj1f5Dn1thrNbanhchLTr6B`I6}<(r zzLx6;LtYn+FikNjg&EJSeCx`>1faUiG>A76vSWtUzCQMYZ?px7Ih9^}n@EHU=+?(T zb!*%qde#ilIyJG_9t}zl6$J?_2Ui3{Z!;@nMDbrp@!P|P!_Ui_;r0B*i`#i@^_Srf z_BTKD8jI?tT+?`TkY`Bz(=2Ll>1)yRn6WhH8$8o$yTuP3s{FvKx8mi2*U%QKpWP;^ z#y>$L^J+fzG>!$rKw2K`vY)L)Q=c>p?!0F-uXKsEHEqBKfyQCwkAHxXnQ!yGO`UT{ z79!ZQjB&48Gt=LZ+`KYCq7$Oa< zJ_5}}VFKBi5RNT=M00000 literal 0 HcmV?d00001 diff --git a/public/icons/basemaps/arcgis-light-gray.png b/public/icons/basemaps/arcgis-light-gray.png new file mode 100644 index 0000000000000000000000000000000000000000..9e11fed91e4c00cc2d728c300a893eeb927e777a GIT binary patch literal 10196 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

1PtoF2BN*Ki6u!`%;)1 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 literal 0 HcmV?d00001 diff --git a/public/icons/basemaps/arcgis-streets.png b/public/icons/basemaps/arcgis-streets.png new file mode 100644 index 0000000000000000000000000000000000000000..100f773c3bee4f84b1c6cc2e5bca8dc4bfc4e5fd GIT binary patch literal 14406 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

3ws!9Di`_ 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 m>vKEqA3rf$gWf+Hd{=@rxa&>`J97 zcBL}ojK||;cI^^HiKIk{J3$fzK`hMq&h67|G(l}6N=d~>g(3-{yYD^wdX;c77#SJ) zYx(iDL?ZEZ{&ar?x4pgXDwT@M<#PHvuX4GppEWdOTwh)aj8^F zuU}nV4d3tY?{}@Ot)HaRsZSn0oc-Qif#`J!`$Y)SQCC;@9eL=t-5()@VzC$!Vq;@N z-xEB!WHPDmrPFCa^4^4r1J^b-HoD>AA^kg_&%5dAdl2vYLPQ9(!uv`Xh$0g5w)}d* z{Sg#QN>)}@c6|mhy1KgD^769V+S+nY105ah?%=_LuBoZnEiEp&i8~YeekPL<>1zH& z9{U!=N%E#V_dvCepgqUAX3DTaMT`?nx5i}7+cjA@sdS1}d+M*lhh4Dn?Y!$X1xh{L5et6mX zx#@091T{4waDO5(-@`wiMDm_o(Db3Bz%WL=h5Q5kI{JeV-XlrY80|Nu@+_|&v zv(GNqBqZNq4|rq{9I06ruWvcx`JFm-!qp3rpNTLKgUv$_5L1gwYXOUevp_Ct0@3Fu z07Cg3!)y0qqi`9|I(+Dmav?72{h9mTZpo`dbbq0cS1-w=;$=Xe*auY9N_I_DQchKw z0c%CljVOskojL%;VoCPrZ5w#uJ@A>6_n(R|CYmNCQI+td*Rx3b+0fYF4)h*yW#M!m zylt!Gu?Yy))zzt@p3{wPs{UPAT$I=%D;ug)*R!>`QHjT<05)clQwvN5*4L-SmMptx z0VEY0#JYGMf48=^x&~3VE5bNy!eHzJU@xkEg$cO^W=x9es-RZOrBKyF81TW`wu)O% zMi`TbN4bbk3g(qyGkPVJapdTT8p%c>Or|j-s$;?Rh(SJm`jjBJ>?S9t-1zvo5Mx<& zu(`76^Olyh5)2hNI5>oraI4u>S?8L&ckkY=XM=ZWJWE{q}#mX8{m={``6O z>8GD6Lv?g?sMl7POt{9eaZjR#!czno5PM%{f3_4h{`!lwJ|f>Hhs0 zWe5<mA*Y9%ez(Aio;~D{C=!tPZkPb7X$p%q*&CN|B5go2qIGs(ctr6U<%Ni?6 zKyp<4-Kd04i6gM3>JPUR?ktsx5n@(^lk>VS^t`RZ5DYGi8F_YL(>pGeig#*2HcY^C zkqA{wf^=-f=4M{+&2@0TBqvo0z$NO5Yqw-A#o+$!?d_oew97rgev}podC<$TmQMiW zqlShKx-(}^s(NGNYvRq^9-nYC4<5P)@`}Qcs3@<>R>eCyB}B|=Fo~L*lOA7!U7h>2+SHk!XB{BSBhaa%#HfdMv(z01&vpzfYwD<@w;j1Ggqb zt8Zvj;$gfqlJ38M@4h0}R4hb-ScfF?Cs4g{^OoL&(m-2Vi`cyfx+d0n=FAzr_j+z! z)uqOH5(0D!GeYF&7mhzA;Eh25AA>kG^y&0&cNdjI^nLHaeRZda&QodeYD5LG5xCM^ z_oz+W72JVLK^y?GARL4>+l38aJrz-RJs{XLB7Abw)*l2gT%0DICW7}4Tx%MFDYUS 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 P00000NkvXXu0mjf@6uF2 literal 0 HcmV?d00001 diff --git a/public/icons/basemaps/maplibre-light.png b/public/icons/basemaps/maplibre-light.png new file mode 100644 index 0000000000000000000000000000000000000000..8725db399e5507cd5f6004b09e3dfbbd43b023fe GIT binary patch literal 8898 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 literal 0 HcmV?d00001 diff --git a/public/icons/basemaps/terrain.png b/public/icons/basemaps/terrain.png new file mode 100644 index 0000000000000000000000000000000000000000..94fbc5e45a726d3ee1622a07b254fdb57b6a4336 GIT binary patch literal 10586 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 literal 0 HcmV?d00001 diff --git a/public/icons/custom-map.svg b/public/icons/custom-map.svg index 0511df45..e4295877 100644 --- a/public/icons/custom-map.svg +++ b/public/icons/custom-map.svg @@ -1,3 +1,3 @@ - - + + diff --git a/public/icons/terrain-map.png b/public/icons/terrain-map.png deleted file mode 100644 index 46c8d8f9f5a69687de8267a5fd2d90d25650b795..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3389 zcmV-D4Z`w?P)+_3FLquKt`Jd&Yz92uCakNGKzSf+Hc3{DOo9D_FoTOa4Ke4azc+Snv~Imw!N( z7OUAtAYvQrvBxvh-`(|HoO56Ghb>F#QB8Nfu6N&k_ndp~eI3YauY13`|DlNaBnaeV z-q!nH2J(g%2Jui`2NIY-?}O&Ksw^>%FE<0h=3lMj!EW#j181hJK7aJ^;iES$dGEsy z52VaL3#9(j&dB}~dkguy#=y=&y$=GtI968WYmWKvHx}xu_Vf2V!S`QE7=Hffn{OuD zVLDeA`9E-TT5pXs4!*V(|NY)L2N=D*XE~p9>#Ay^+RT&JChNxPy?NVZ&T3~rqLYSq zWtx752%P$$8#Pv8QGf5>H<#Ymn+wx%sdKMg!3IJ#hWn}+=jYmATs(wVM*Qck9Ry0s z@-ub%x2ZC<^sQ z7H~YMr3p76p+OUd^8K%R`&Pf!7t6(FPs`8ml0R8ghb^Wo$ z>r@fJ168x|E^+Ivv-Nf%EJCQvt5FHIS-Nv}8{WXX%Y5O~5dT7Ts`6YlK?ARU&Z~f` z#y#sIt46D`1}}?d+^@cqlD&zI6C5e3N}&iE_qb576ArusGE;3gO0x`tI;yNI8^lVr zV}f8$8~rC+!70-~X~QN#Dz8zETk7z>vT%_AQHh+-KaFy|GaG2Mf@VSM;jBF=N^B}G z3aM~;qke2P3;LX4FfBzDt!!p*ltt=)@h&bFy44b#oD5%vT8<3?zfo=`s`tCyx#f zWxia>I$6VRDfI?f^QvIk9u{!;JZu>`J{lqXrL6N@vMd)-*MR2;-WU_FurbNIohmuRD}W#k<12; zL@lS9w_1Vt*g2yVA(09=Hk&Sx?U~%Vaa(@;_B+z;b>!*A6B!H!fUb+Vk)pCjQlaQW z6+%z~ykeGy)I6iA!RRMx0Evnfsj{SsB;fv9f*<|ttvZA}a3P8~jUV1PR9T?3q-my* z#$8OLvDFF&*OGz?Ex`Aaw|^?PZ@r~O{s8d49G^>8a=}FAkR&>&U6%1JL{<5gYMsCV zNDJ@DeGLZI44Y1hSsXEwJDr}2Wdhvk6ro{+2pJuZ<4_$NUru!sbdXT=dwsxpfT(rh zKqV)4e<0)OCA9J>AYUO;sYZd>Pa3t6M3dHB27rC7GZ5w{EGxp zWbs(Tv8oYcW30ZC5FR+CISmFgf_?MA~`gLCkWo*&&jM%g>o8u{|_C171k7Z0mO*-)dL zWt0MJW;NHQfq!wQtuE5pkqP*sYLO5o$K@N0H?Wkvy8A^)N zNX_SS>9jji)a+y?3NgdZjM~&vF6s|BM)|n{6&Ov6q@s2*F*t`w#$+QGmA}R}8B7XR z56;&L1_W8+D##UkOv(=(OxFn%0uWe~H;VKc;@Wafl{^7#oYaHfwPHjF{Gh?}7td6D z=`7)3>qQP}bHPQ$R|)E$hYh_X8rob6A+a5rv!(k^;W*ZE9%{s6iz+GL#(M3O+Z1XeNM|Hr4s6 zIVVH1xTzY=f;Cnh*CPP3S}K3K1a2&g6*LT-0yH$PeI2`Mq&cI;Y+S%W!bUQ)rC*^^ zP`HPKp>O@27h4s)Isd6Xi%X`edE%p}M9KrR9oNf)HPCRhdmXVg3Gu6E}P znq8u_JAv1lI8*+xpvqZOi>*j6H!k<0Z5FCw8fM}`N zniepQ6G?>)nxjw;T9IvO#98VbHp50FPx*Zyy?B5IcA}f=K+e|71j*`nl<3Iu@h#;5 zJ?;YcHfm$+V#%sp`@a4`U*XAcG>D^E^~+72Ix`v^sH~9OY@sSR&_z`WlG^{cgs4Su z#z7Mq9rD`pEaO5gtra58j7ElNFUg|@Yj%OI#~~nQIP5)Ud1^&Y^(}@V-#R{#8%H-Z z83?;dN60c*=%S&PJJe$=$P=Ff*CHEfCi4gyw_>G3jCpLIXonfe4v4BiaW0wdpj!vu z6Z+L6(K$qqyy;=%z5d|L;9UH5g3jR*wKPq=s$b!ZaEbD?`GJtCP6)XG4fD(!BO**1 z=URFaBV-5QBn9?*Jk|Q$>q4X*WhK&#&EVt`y7hB;R=s?Dk?I-8i2@&NGd( z;aPZkdYV91{y@^lz`ob-q7j&>P8}ZJgoZ_$h14ZN-$6wn@e2|jTFH4ve_JuyW|g=P zRcLE%`NxxMO&&cy0KO@D#C~@m*ORd}ChQ9-&L_7|l;3llr`teagp(eU?>FJYhyQ%^ z{s+GpAsOzox8tdf2Oj46nAn<7py@F}& z;n;BcvqmKAY=s3yAz}HW5p5g*A1egq!`WCXMFnzg4nFjZQPN*YkllyFp(YE@-;6$O zQ>@w1uxV{M z&6rR*h<^9Z4^fpaQLl4Zpc&ykPi`;pGm0;L5i|dBny$aj>+0*jeEHXJ_Wk)kY;` @@ -138,9 +139,11 @@ export const ComparisonSide = ({ ? selectLeftSublayers : selectRightSublayers ); - const selectedBaseMapId = useAppSelector(selectSelectedBaseMapId); + const selectedBaseMap = useAppSelector(selectSelectedBaseMap); const MapWrapper = - selectedBaseMapId === "ArcGis" ? ArcgisWrapper : DeckGlWrapper; + selectedBaseMap?.group === BaseMapGroup.ArcGIS + ? ArcgisWrapper + : DeckGlWrapper; const [isCompressedGeometry, setIsCompressedGeometry] = useState(true); const [isCompressedTextures, setIsCompressedTextures] = diff --git a/src/components/debug-panel/debug-panel.tsx b/src/components/debug-panel/debug-panel.tsx index 2141dde5..49c610b5 100644 --- a/src/components/debug-panel/debug-panel.tsx +++ b/src/components/debug-panel/debug-panel.tsx @@ -4,6 +4,7 @@ import { addIconItem } from "../../redux/slices/icon-list-slice"; import { type IIconItem, IconListSetName, + BaseMapGroup, ButtonSize, FileType, type FileUploaded, @@ -18,7 +19,6 @@ import { IconListPanel } from "../icon-list-panel/icon-list-panel"; import { ActionIconButton } from "../action-icon-button/action-icon-button"; import PlusIcon from "../../../public/icons/plus.svg"; import { UploadPanel } from "../upload-panel/upload-panel"; - import { useAppLayout } from "../../utils/hooks/layout"; import { CloseButton } from "../close-button/close-button"; import { @@ -35,7 +35,7 @@ import { setDebugOptions, selectDebugOptions, } from "../../redux/slices/debug-options-slice"; -import { selectSelectedBaseMapId } from "../../redux/slices/base-maps-slice"; +import { selectSelectedBaseMap } from "../../redux/slices/base-maps-slice"; export const TEXTURE_ICON_SIZE = 54; @@ -95,8 +95,8 @@ export const DebugPanel = ({ onClose }: DebugPanelProps) => { const dispatch = useAppDispatch(); const [showFileUploadPanel, setShowFileUploadPanel] = useState(false); const debugOptions = useAppSelector(selectDebugOptions); - const selectedBaseMapId = useAppSelector(selectSelectedBaseMapId); - const minimapDisabled = selectedBaseMapId === "ArcGis"; + const selectedBaseMap = useAppSelector(selectSelectedBaseMap); + const minimapDisabled = selectedBaseMap?.group === BaseMapGroup.ArcGIS; if (minimapDisabled && debugOptions.minimap) { dispatch(setDebugOptions({ minimap: false })); } diff --git a/src/components/deck-gl-wrapper/deck-gl-wrapper.spec.tsx b/src/components/deck-gl-wrapper/deck-gl-wrapper.spec.tsx index 983b5c3d..a5f10ad4 100644 --- a/src/components/deck-gl-wrapper/deck-gl-wrapper.spec.tsx +++ b/src/components/deck-gl-wrapper/deck-gl-wrapper.spec.tsx @@ -1,7 +1,12 @@ // Get tileset stub before Mocks. The order is important import { getTileset3d, getTile3d } from "../../test/tile-stub"; import { getTilesetJson } from "../../test/tileset-header-stub"; -import { DragMode, TilesetType, TileColoredBy } from "../../types"; +import { + DragMode, + TilesetType, + TileColoredBy, + BaseMapGroup, +} from "../../types"; import { act } from "@testing-library/react"; import { DeckGlWrapper } from "./deck-gl-wrapper"; @@ -357,7 +362,15 @@ describe("Deck.gl I3S map component", () => { describe("Render TerrainLayer", () => { const store = setupStore(); - store.dispatch(addBaseMap({ id: "Terrain", mapUrl: "", name: "Terrain" })); + store.dispatch( + addBaseMap({ + id: "Terrain", + mapUrl: "", + name: "Terrain", + group: BaseMapGroup.Terrain, + iconId: "Dark", + }) + ); it("Should render terrain", () => { callRender(renderWithProvider, undefined, store); expect(TerrainLayer).toHaveBeenCalled(); @@ -366,7 +379,13 @@ describe("Deck.gl I3S map component", () => { it("Should call onTerrainTileLoad", async () => { const store = setupStore(); store.dispatch( - addBaseMap({ id: "Terrain", mapUrl: "", name: "Terrain" }) + addBaseMap({ + id: "Terrain", + mapUrl: "", + name: "Terrain", + group: BaseMapGroup.Terrain, + iconId: "Terrain", + }) ); const { rerender } = callRender(renderWithProvider, undefined, store); const { onTileLoad } = TerrainLayer.mock.lastCall[0]; diff --git a/src/components/input-dropdown/input-dropdown.spec.tsx b/src/components/input-dropdown/input-dropdown.spec.tsx new file mode 100644 index 00000000..33a4e7c3 --- /dev/null +++ b/src/components/input-dropdown/input-dropdown.spec.tsx @@ -0,0 +1,89 @@ +import { fireEvent } from "@testing-library/react"; +import { renderWithTheme } from "../../utils/testing-utils/render-with-theme"; +import { InputDropdown } from "./input-dropdown"; + +describe("Input Text", () => { + it("Should render InputText without label", () => { + const onChange = jest.fn(); + + const dom = renderWithTheme( + + ); + expect(dom).toBeDefined(); + if (!dom) { + return; + } + const input: HTMLSelectElement | null = + dom.container.querySelector("select"); + const inputLabel: HTMLLabelElement | null = + dom.container.querySelector("label"); + + expect(input).toBeInTheDocument(); + expect(inputLabel).not.toBeInTheDocument(); + }); + + it("Should render InputText with label", () => { + const onChange = jest.fn(); + + const dom = renderWithTheme( + + ); + expect(dom).toBeDefined(); + if (!dom) { + return; + } + const input: HTMLSelectElement | null = + dom.container.querySelector("select"); + const inputLabel: HTMLLabelElement | null = + dom.container.querySelector("label"); + + expect(input).toBeInTheDocument(); + expect(input?.value).toBe("test1"); + expect(inputLabel).toBeInTheDocument(); + expect(inputLabel?.textContent).toEqual("Label Text"); + }); + + it("Should change InputText", () => { + let changedValue = ""; + const onChange = jest + .fn() + .mockImplementation((event) => (changedValue = event.target.value)); + + const dom = renderWithTheme( + + ); + expect(dom).toBeDefined(); + if (!dom) { + return; + } + const input: HTMLSelectElement | null = + dom.container.querySelector("select"); + if (input) { + fireEvent.change(input, { target: { value: "test2" } }); + } + + expect(changedValue).toBe("test2"); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it("Should handle value as prop", () => { + const onChange = jest.fn(); + + const dom = renderWithTheme( + + ); + expect(dom).toBeDefined(); + if (!dom) { + return; + } + const input: HTMLSelectElement | null = + dom.container.querySelector("select"); + + expect(input?.value).toBe("test1"); + expect(onChange).toHaveBeenCalledTimes(0); + }); +}); diff --git a/src/components/input-dropdown/input-dropdown.tsx b/src/components/input-dropdown/input-dropdown.tsx new file mode 100644 index 00000000..afce5555 --- /dev/null +++ b/src/components/input-dropdown/input-dropdown.tsx @@ -0,0 +1,119 @@ +import { type ChangeEvent, type FC, useId } from "react"; +import styled, { useTheme } from "styled-components"; +import { ExpandIcon } from "../expand-icon/expand-icon"; +import { CollapseDirection, ExpandState } from "../../types"; + +const InputWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; +`; + +const SelectDiv = styled.div` + position: relative; + width: 100%; + height: 46px; + border-radius: 8px; + background: ${({ theme }) => theme.colors.mainHiglightColor}; + &:hover { + background: ${({ theme }) => theme.colors.mainDimColor}; + } + magrin: 0; +`; + +const Input = styled.select` + width: 100%; + padding: 13px 30px 13px 16px; + border-radius: 8px; + color: ${({ theme }) => theme.colors.secondaryFontColor}; + border: 1px solid ${({ theme }) => theme.colors.mainHiglightColor}; + + &:hover { + border: 1px solid ${({ theme }) => theme.colors.mainDimColor}; + cursor: pointer; + } + &:focus { + color: ${({ theme }) => theme.colors.fontColor}; + outline: none; + } + appearance: none; + background: transparent; + magrin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const SelectOption = styled.option` + height: 20px; + background: ${({ theme }) => theme.colors.mainHiglightColor}; + &:hover { + background-color: ${({ theme }) => theme.colors.mainDimColor}; + box-shadow: 0 0 10px 100px green inset; + } + &:checked { + background-color: ${({ theme }) => theme.colors.mainDimColor}; + box-shadow: 0 0 10px 100px green inset; + } + box-shadow: 0 0 10px 100px green inset; +`; + +const ExpandIconWrapper = styled.div` + position: absolute; + top: 50%; + transform: translate(0, -50%); + right: 12px; +`; + +const Label = styled.label` + display: block; + font-style: normal; + font-weight: 500; + font-size: 16px; + line-height: 19px; + margin-bottom: 8px; + color: ${({ theme }) => theme.colors.fontColor}; +`; + +interface InputDropdownProps { + label?: string; + options: string[]; + onChange: (event: ChangeEvent) => void; +} + +export const InputDropdown: FC = ({ + label, + options, + onChange, + ...rest +}) => { + const inputId = useId(); + const theme = useTheme(); + return ( + + {label && } + + + {}} + /> + + + + + ); +}; diff --git a/src/components/layers-panel/base-map-icon/base-map-icon.spec.tsx b/src/components/layers-panel/base-map-icon/base-map-icon.spec.tsx deleted file mode 100644 index 6fa3865f..00000000 --- a/src/components/layers-panel/base-map-icon/base-map-icon.spec.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { renderWithTheme } from "../../../utils/testing-utils/render-with-theme"; -import { BaseMapIcon } from "./base-map-icon"; - -const validatePresetBaseMap = (id: string) => { - const { container } = renderWithTheme() ?? {}; - expect(container).toBeDefined(); - if (!container) { - return; - } - const component = container.firstChild; - expect(component?.nodeName).toBe("DIV"); - if (component) { - const background = getComputedStyle(component as Element).getPropertyValue( - "background" - ); - expect(background).toBe("rgb(35, 36, 48) url() no-repeat center"); - } - expect(component?.firstChild).toBeNull(); -}; - -describe("BaseMapIcon", () => { - it("Should render BaseMapIcons with background", () => { - const presetBaseMapIds = ["Dark", "Light", "Terrain"]; - for (const baseMapId of presetBaseMapIds) { - validatePresetBaseMap(baseMapId); - } - }); - - it("Should render SVG component", () => { - const { container } = - renderWithTheme() ?? {}; - expect(container).toBeDefined(); - if (!container) { - return; - } - const component = container.firstChild; - expect(component?.nodeName).toBe("DIV"); - if (component) { - const background = getComputedStyle( - component as Element - ).getPropertyValue("background"); - expect(background).toBe("rgb(35, 36, 48)"); - } - - const svgElement = component?.firstChild; - expect(svgElement).not.toBeNull(); - expect(svgElement?.nodeName).toBe("svg"); - }); -}); diff --git a/src/components/layers-panel/base-map-icon/base-map-icon.tsx b/src/components/layers-panel/base-map-icon/base-map-icon.tsx deleted file mode 100644 index 0fdb9ef9..00000000 --- a/src/components/layers-panel/base-map-icon/base-map-icon.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import styled from "styled-components"; -import DarkMap from "../../../../public/icons/dark-map.png"; -import LightMap from "../../../../public/icons/light-map.png"; -import TerrainMap from "../../../../public/icons/terrain-map.png"; -import CustomMap from "../../../../public/icons/custom-map.svg"; - -interface BaseMapIconProps { - baseMapId: string; -} - -const MapIcon = styled.div` - background: #232430; - width: 40px; - height: 40px; - border-radius: 8px; - display: flex; - justify-content: center; - align-items: center; -`; - -const MapIconWithBackground = styled(MapIcon)<{ url: string }>` - background: url(${(props) => props.url}) no-repeat center #232430; - width: 40px; - height: 40px; - border-radius: 8px; -`; - -export const BaseMapIcon = ({ baseMapId }: BaseMapIconProps) => { - switch (baseMapId) { - case "Dark": - return ; - case "Light": - return ; - case "Terrain": - return ; - default: - return ( - - - - ); - } -}; diff --git a/src/components/layers-panel/base-map-list-item/base-map-list-item.spec.tsx b/src/components/layers-panel/base-map-list-item/base-map-list-item.spec.tsx deleted file mode 100644 index c1d86ac1..00000000 --- a/src/components/layers-panel/base-map-list-item/base-map-list-item.spec.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { BaseMapListItem } from "./base-map-list-item"; -import { renderWithTheme } from "../../../utils/testing-utils/render-with-theme"; -import { SelectionState } from "../../../types"; - -describe("Base Map List Item", () => { - it("Should render base map list item", async () => { - const onChange = jest.fn(); - const onOptionsClick = jest.fn(); - - renderWithTheme( - - ); - const component = screen.getByText("san-francisco"); - expect(component).toBeInTheDocument(); - await userEvent.click(component); - expect(onChange).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/components/layers-panel/base-map-list-item/base-map-list-item.tsx b/src/components/layers-panel/base-map-list-item/base-map-list-item.tsx deleted file mode 100644 index d0bf6bf0..00000000 --- a/src/components/layers-panel/base-map-list-item/base-map-list-item.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import styled from "styled-components"; -import { type SelectionState } from "../../../types"; -import { BaseMapIcon } from "../base-map-icon/base-map-icon"; -import { ListItemWrapper } from "../list-item-wrapper/list-item-wrapper"; - -interface BaseMapsItemProps { - id: string; - title: string; - optionsContent?: JSX.Element; - selected: SelectionState; - isOptionsPanelOpen: boolean; - onMapsSelect: (id) => void; - onOptionsClick: (id: string) => void; - onClickOutside?: () => void; -} - -const Title = styled.div` - margin-left: 16px; - font-style: normal; - font-weight: 500; - font-size: 16px; - line-height: 19px; - color: ${({ theme }) => theme.colors.fontColor}; -`; - -export const BaseMapListItem = ({ - id, - title, - optionsContent, - isOptionsPanelOpen, - selected, - onOptionsClick, - onClickOutside, - onMapsSelect, -}: BaseMapsItemProps) => { - const handleClick = () => { - onMapsSelect(id); - }; - return ( - - - {title} - - ); -}; diff --git a/src/components/layers-panel/basemap-list-panel/basemap-list-panel.spec.tsx b/src/components/layers-panel/basemap-list-panel/basemap-list-panel.spec.tsx new file mode 100644 index 00000000..f64a9889 --- /dev/null +++ b/src/components/layers-panel/basemap-list-panel/basemap-list-panel.spec.tsx @@ -0,0 +1,206 @@ +import { screen, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { setupStore } from "../../../redux/store"; +import { renderWithThemeProviders } from "../../../utils/testing-utils/render-with-theme"; +import { BasemapListPanel } from "./basemap-list-panel"; + +import { DeleteConfirmation } from "../delete-confirmation"; +import { + addBaseMap, + selectSelectedBaseMap, + selectBaseMapsByGroup, +} from "../../../redux/slices/base-maps-slice"; +import { BaseMapGroup } from "../../../types"; + +jest.mock("../delete-confirmation"); + +const DeleteConfirmationMock = + DeleteConfirmation as unknown as jest.Mocked; + +beforeAll(() => { + DeleteConfirmationMock.mockImplementation(() => ( +
Delete Confirmation
+ )); +}); + +const callRender = (renderFunc, props = {}, store = setupStore()) => { + return renderFunc( + , + store + ); +}; + +describe("Basemap List Panel", () => { + it("Should render basemaps", async () => { + const store = setupStore(); + const { container } = callRender( + renderWithThemeProviders, + undefined, + store + ); + expect(container).toBeInTheDocument(); + + expect(screen.getByText("Dark")).toBeInTheDocument(); + expect(screen.getByText("Light")).toBeInTheDocument(); + }); + + it("Should select a map", async () => { + const store = setupStore(); + store.dispatch( + addBaseMap({ + id: "first", + name: "first name", + mapUrl: "https://first-url.com", + group: BaseMapGroup.Maplibre, + iconId: "Dark", + }) + ); + // Element "first" is added and made selected + const { container } = callRender( + renderWithThemeProviders, + undefined, + store + ); + expect(container).toBeInTheDocument(); + const el = screen.getByText("first name"); + expect(el).toBeInTheDocument(); + + const iconWrapperElement = el.parentElement; + // Select "first" element + await act(async () => { + iconWrapperElement && (await userEvent.click(iconWrapperElement)); + }); + + const state = store.getState(); + const baseMapId = selectSelectedBaseMap(state)?.id; + expect(baseMapId).toEqual("first"); + }); + + it("Should render options menu, keep or delete a map", async () => { + const store = setupStore(); + store.dispatch( + // Candidate to delete + addBaseMap({ + id: "custom", + name: "custom name", + mapUrl: "https://first-url.com", + group: BaseMapGroup.Maplibre, + iconId: "Light", + custom: true, + }) + ); + const { container } = callRender( + renderWithThemeProviders, + undefined, + store + ); + expect(container).toBeInTheDocument(); + const el = screen.getByText("custom name"); + expect(el).toBeInTheDocument(); + + let state = store.getState(); + let baseMaps = selectBaseMapsByGroup(state, ""); + + expect(baseMaps.find((item) => item.id === "custom")).not.toBeUndefined(); + + const iconWrapperElement = el.parentElement; + const optionsElement = iconWrapperElement?.lastElementChild; + + // Keep a map + // Click on options menu + await act(async () => { + optionsElement && (await userEvent.click(optionsElement)); + }); + + let optionsMenu = screen.getByText("Delete map"); + expect(optionsMenu).toBeInTheDocument(); + + // Click on Delete Map + await act(async () => { + optionsMenu && (await userEvent.click(optionsMenu)); + }); + + let confirmation = screen.getByText("Delete Confirmation"); + expect(confirmation).toBeInTheDocument(); + + const { onKeepHandler } = DeleteConfirmationMock.mock.lastCall[0]; + + act(() => { + onKeepHandler(); + }); + + state = store.getState(); + baseMaps = selectBaseMapsByGroup(state, ""); + expect(baseMaps.find((item) => item.id === "custom")).not.toBeUndefined(); + + // Delete a map + // Click on options menu + await act(async () => { + optionsElement && (await userEvent.click(optionsElement)); + }); + + optionsMenu = screen.getByText("Delete map"); + expect(optionsMenu).toBeInTheDocument(); + + // Click on Delete Map + await act(async () => { + optionsMenu && (await userEvent.click(optionsMenu)); + }); + + confirmation = screen.getByText("Delete Confirmation"); + expect(confirmation).toBeInTheDocument(); + + const { onDeleteHandler } = DeleteConfirmationMock.mock.lastCall[0]; + + act(() => { + onDeleteHandler(); + }); + + state = store.getState(); + baseMaps = selectBaseMapsByGroup(state, ""); + expect(baseMaps.find((item) => item.id === "custom")).toBeUndefined(); + }); + + it("Should close options menu if clicked outside", async () => { + const store = setupStore(); + store.dispatch( + // Candidate to delete + addBaseMap({ + id: "custom", + name: "custom name", + mapUrl: "https://first-url.com", + group: BaseMapGroup.Maplibre, + iconId: "Light", + custom: true, + }) + ); + const { container } = callRender( + renderWithThemeProviders, + undefined, + store + ); + expect(container).toBeInTheDocument(); + const el = screen.getByText("custom name"); + expect(el).toBeInTheDocument(); + + const iconWrapperElement = el.parentElement; + const optionsElement = iconWrapperElement?.lastElementChild; + + // Click on options menu + await act(async () => { + optionsElement && (await userEvent.click(optionsElement)); + }); + + const optionsMenu = screen.getByText("Delete map"); + expect(optionsMenu).toBeInTheDocument(); + + // Click on out of options menu + const elOutside = screen.getByText("Dark"); + await act(async () => { + elOutside && (await userEvent.click(elOutside)); + }); + + const optionsMenuClosed = screen.queryByText("Delete map"); + expect(optionsMenuClosed).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/layers-panel/basemap-list-panel/basemap-list-panel.tsx b/src/components/layers-panel/basemap-list-panel/basemap-list-panel.tsx new file mode 100644 index 00000000..8dbc32d2 --- /dev/null +++ b/src/components/layers-panel/basemap-list-panel/basemap-list-panel.tsx @@ -0,0 +1,235 @@ +import styled, { css, useTheme } from "styled-components"; +import { useState, type FC } from "react"; +import { useAppDispatch, useAppSelector } from "../../../redux/hooks"; + +import { OptionsIcon, Panels } from "../../common"; +import { + selectSelectedBaseMap, + setSelectedBaseMap, + selectBaseMapsByGroup, + deleteBaseMap, +} from "../../../redux/slices/base-maps-slice"; +import { basemapIcons } from "../../../constants/map-styles"; +import { Popover } from "react-tiny-popover"; +import { DeleteConfirmation } from "../delete-confirmation"; +import { BaseMapOptionsMenu } from "../basemap-options-menu/basemap-options-menu"; + +const BASEMAP_ICON_WIDTH = "100px"; +const BASEMAP_ICON_HEIGHT = "70px"; + +const BasemapContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: start; + align-items: start; + border-width: 0; + margin: 0; +`; + +const BasemapTitle = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + font-style: normal; + font-weight: 500; + font-size: 14px; + line-height: 17px; + color: ${({ theme }) => theme.colors.fontColor}; + + margin-bottom: 13px; +`; + +const BasemapPanel = styled.div` + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: start; + align-items: center; + border-width: 0; + border-radius: 8px; +`; + +const BasemapImageWrapper = styled.div<{ + active?: boolean; +}>` + position: relative; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + cursor: pointer; + + border-width: 0; + border-radius: 8px; + + width: ${BASEMAP_ICON_WIDTH}; + padding: 4px; + + ${({ active = false }) => + active && + css` + background-color: ${({ theme }) => theme.colors.mainHiglightColor}; + `} +`; + +const BasemapCustomIcon = styled.div` + display: flex; + position: relative; + justify-content: center; + align-items: center; + height: ${BASEMAP_ICON_HEIGHT}; + width: ${BASEMAP_ICON_WIDTH}; + margin: 0; + border-width: 0; + border-radius: 8px; + background-color: ${({ theme }) => theme.colors.customIconBackground}; +`; + +const BasemapIcon = styled.div<{ + icon: string; +}>` + display: flex; + position: relative; + height: ${BASEMAP_ICON_HEIGHT}; + width: ${BASEMAP_ICON_WIDTH}; + margin: 0; + background-image: ${({ icon }) => `url(${icon})`}; + background-size: cover; + background-repeat: no-repeat; + border-width: 0; + border-radius: 8px; +`; + +const BasemapImageName = styled.div` + flex-direction: column; + justify-content: center; + align-items: center; + + font-style: normal; + font-weight: 500; + font-size: 14px; + line-height: 17px; + color: ${({ theme }) => theme.colors.fontColor}; + + margin: 4px 0 0 0; + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +`; + +const OptionsButton = styled.div` + position: absolute; + display: flex; + justify-content: center; + align-items: center; + border-radius: 4px; + top: 6px; + right: 6px; + width: 24px; + height: 24px; + cursor: pointer; + + &:hover { + background: ${({ theme }) => theme.colors.mainDimColor}; + } +`; + +interface BasemapListPanelProps { + group: string; +} + +export const BasemapListPanel: FC = ({ group }) => { + const theme = useTheme(); + const dispatch = useAppDispatch(); + + const baseMapArray = useAppSelector((state) => + selectBaseMapsByGroup(state, group) + ); + const baseMapPicked = useAppSelector(selectSelectedBaseMap); + + const [optionsMapId, setOptionsMapId] = useState(""); + const [mapToDeleteId, setMapToDeleteId] = useState(""); + + return ( + <> + + {group} + + {baseMapArray.map((item) => { + const basemapIcon = basemapIcons[item.iconId]; + const iconUrl = basemapIcon?.iconUrl; + const IconComponent = basemapIcon?.IconComponent; + return ( + { + dispatch(setSelectedBaseMap(item.id)); + }} + > + {iconUrl && ( + + )} + + {IconComponent && ( + + + + )} + + {item.name || ""} + {item.custom && ( + { + setMapToDeleteId(optionsMapId); + setOptionsMapId(""); + }} + /> + } + containerStyle={{ zIndex: "2" }} + onClickOutside={() => { + setOptionsMapId(""); + }} + > + { + event.stopPropagation(); + setOptionsMapId(item.id); + }} + > + + + + )} + + ); + })} + + + {mapToDeleteId && ( + { + setMapToDeleteId(""); + }} + onDeleteHandler={() => { + dispatch(deleteBaseMap(mapToDeleteId)); + setMapToDeleteId(""); + }} + > + Delete map? + + )} + + ); +}; diff --git a/src/components/layers-panel/basemap-options-menu/basemap-options-menu.tsx b/src/components/layers-panel/basemap-options-menu/basemap-options-menu.tsx index cff64b36..ba77540e 100644 --- a/src/components/layers-panel/basemap-options-menu/basemap-options-menu.tsx +++ b/src/components/layers-panel/basemap-options-menu/basemap-options-menu.tsx @@ -3,10 +3,6 @@ import styled from "styled-components"; import { color_accent_primary } from "../../../constants/colors"; import DeleteIcon from "../../../../public/icons/delete.svg"; -interface BaseMapOptionsMenuProps { - onDeleteBasemap: () => void; -} - const MapSettingsItem = styled.div<{ customColor?: string; opacity?: number; @@ -16,8 +12,7 @@ const MapSettingsItem = styled.div<{ font-size: 16px; line-height: 19px; padding: 10px 0px; - color: ${({ theme, customColor }) => - customColor ?? theme.colors.fontColor}; + color: ${({ theme, customColor }) => customColor ?? theme.colors.fontColor}; opacity: ${({ opacity = 1 }) => opacity}; display: flex; gap: 10px; @@ -41,19 +36,28 @@ const SettingsMenuContainer = styled.div` color: ${({ theme }) => theme.colors.fontColor}; `; +interface BaseMapOptionsMenuProps { + onDeleteBasemap: () => void; +} + export const BaseMapOptionsMenu = ({ onDeleteBasemap, -}: BaseMapOptionsMenuProps) => ( - - - - - - Delete map - - -); +}: BaseMapOptionsMenuProps) => { + return ( + + { + event.stopPropagation(); + onDeleteBasemap(); + }} + > + + + + Delete map + + + ); +}; diff --git a/src/components/layers-panel/insert-panel/insert-panel.spec.tsx b/src/components/layers-panel/insert-panel/insert-panel.spec.tsx index 84f56139..fd20dc26 100644 --- a/src/components/layers-panel/insert-panel/insert-panel.spec.tsx +++ b/src/components/layers-panel/insert-panel/insert-panel.spec.tsx @@ -3,12 +3,18 @@ import userEvent from "@testing-library/user-event"; import { renderWithThemeProviders } from "../../../utils/testing-utils/render-with-theme"; import { InsertPanel } from "./insert-panel"; import { setupStore } from "../../../redux/store"; +import { BaseMapGroup } from "../../../types"; + import "@testing-library/jest-dom"; const onInsertMock = jest.fn(); const onCancelMock = jest.fn(); -const callRender = (renderFunc, props = {}, store = setupStore()): RenderResult => { +const callRender = ( + renderFunc, + props = {}, + store = setupStore() +): RenderResult => { return renderFunc( { expect(container).toBeInTheDocument(); expect(screen.getByText("Test Title")).toBeInTheDocument(); + expect(screen.queryByText("Basemap Provider")).not.toBeInTheDocument(); + expect(screen.getByText("Name")).toBeInTheDocument(); + expect(screen.getByText("URL")).toBeInTheDocument(); + expect(screen.getByText("Token")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + expect(screen.getByText("Insert")).toBeInTheDocument(); + }); + + it("Should render insert panel for BaseMaps", () => { + const { container } = callRender(renderWithThemeProviders, { + groups: [BaseMapGroup.Maplibre], + }); + + expect(container).toBeInTheDocument(); + expect(screen.getByText("Test Title")).toBeInTheDocument(); + expect(screen.getByText("Basemap Provider")).toBeInTheDocument(); expect(screen.getByText("Name")).toBeInTheDocument(); expect(screen.getByText("URL")).toBeInTheDocument(); expect(screen.getByText("Token")).toBeInTheDocument(); diff --git a/src/components/layers-panel/insert-panel/insert-panel.tsx b/src/components/layers-panel/insert-panel/insert-panel.tsx index 51fc18d8..cfd4ffe2 100644 --- a/src/components/layers-panel/insert-panel/insert-panel.tsx +++ b/src/components/layers-panel/insert-panel/insert-panel.tsx @@ -6,6 +6,7 @@ import { FetchingStatus, type LayoutProps, TilesetType, + BaseMapGroup, } from "../../../types"; import { getCurrentLayoutProperty, @@ -13,6 +14,8 @@ import { } from "../../../utils/hooks/layout"; import { ActionButton } from "../../action-button/action-button"; import { InputText } from "./input-text/input-text"; +import { InputDropdown } from "../../input-dropdown/input-dropdown"; + import { getTilesetType } from "../../../utils/url-utils"; import { LoadingSpinner } from "../../loading-spinner/loading-spinner"; import { useAppDispatch, useAppSelector } from "../../../redux/hooks"; @@ -24,13 +27,17 @@ import { const NO_NAME_ERROR = "Please enter name"; const INVALID_URL_ERROR = "Invalid URL"; +export interface CustomLayerData { + name: string; + url: string; + token?: string; + group?: BaseMapGroup; +} + interface InsertLayerProps { title: string; - onInsert: (object: { - name: string; - url: string; - token?: string; - }) => Promise | void; + groups?: string[]; + onInsert: (object: CustomLayerData) => Promise | void; onCancel: () => void; children?: React.ReactNode; } @@ -93,10 +100,12 @@ const SpinnerContainer = styled.div` export const InsertPanel = ({ title, + groups, onInsert, onCancel, children = null, }: InsertLayerProps) => { + const [group, setGroup] = useState(BaseMapGroup.Maplibre); const [name, setName] = useState(""); const [url, setUrl] = useState(""); const [token, setToken] = useState(""); @@ -128,7 +137,12 @@ export const InsertPanel = ({ } if (isFormValid) { - void onInsert({ name: name || layerNames[url]?.name, url, token }); + void onInsert({ + name: name || layerNames[url]?.name, + url, + token, + group: groups ? group : undefined, + }); } }; @@ -162,6 +176,10 @@ export const InsertPanel = ({ const { name, value } = event.target; switch (name) { + case "BasemapProvider": + setGroup(value); + setNameError(""); + break; case "Name": setName(value); setNameError(""); @@ -190,6 +208,13 @@ export const InsertPanel = ({
+ {groups && ( + + )} {
{children}
)); DeleteConfirmationMock.mockImplementation(() => ( -
Delete Conformation
+
Delete Confirmation
)); LayerOptionsMenuMock.mockImplementation(() =>
Layers Options
); }); @@ -150,7 +150,7 @@ describe("Layers Control Panel", () => { }); }); - it("Should render conformation panel", () => { + it("Should render confirmation panel", () => { callRender(renderWithThemeProviders, { layers: [ { id: "first", name: "first name", mapUrl: "https://first-url.com" }, @@ -159,7 +159,7 @@ describe("Layers Control Panel", () => { ], }); - expect(screen.getByText("Delete Conformation")).toBeInTheDocument(); + expect(screen.getByText("Delete Confirmation")).toBeInTheDocument(); const { onDeleteHandler, onKeepHandler } = DeleteConfirmationMock.mock.lastCall[0]; diff --git a/src/components/layers-panel/layers-panel.spec.tsx b/src/components/layers-panel/layers-panel.spec.tsx index 1f96b417..57d0c367 100644 --- a/src/components/layers-panel/layers-panel.spec.tsx +++ b/src/components/layers-panel/layers-panel.spec.tsx @@ -11,13 +11,13 @@ import { LayersPanel } from "./layers-panel"; // Mocked compnents import { LayersControlPanel } from "./layers-control-panel"; import { MapOptionPanel } from "./map-options-panel"; -import { InsertPanel } from "./insert-panel/insert-panel"; +import { InsertPanel, type CustomLayerData } from "./insert-panel/insert-panel"; import { WarningPanel } from "./warning/warning-panel"; import { LayerSettingsPanel } from "./layer-settings-panel"; import { load } from "@loaders.gl/core"; -import { PageId } from "../../types"; +import { BaseMapGroup, PageId } from "../../types"; import { setupStore } from "../../redux/store"; -import { selectSelectedBaseMapId } from "../../redux/slices/base-maps-slice"; +import { selectSelectedBaseMap } from "../../redux/slices/base-maps-slice"; import "@testing-library/jest-dom"; jest.mock("@loaders.gl/core", () => ({ @@ -275,16 +275,18 @@ describe("Layers Panel", () => { const { onInsert } = InsertPanelMock.mock.lastCall[0]; // Click insert baseMap - act(() => { - onInsert({ + await act(async () => { + const customMap: CustomLayerData = { name: "test-basemap", url: "https://test-base-map.url", token: "", - }); + group: BaseMapGroup.Maplibre, + }; + await onInsert(customMap); }); const state = store.getState(); - const baseMapId = selectSelectedBaseMapId(state); + const baseMapId = selectSelectedBaseMap(state)?.id; expect(baseMapId).toEqual("https://test-base-map.url"); }); diff --git a/src/components/layers-panel/layers-panel.tsx b/src/components/layers-panel/layers-panel.tsx index c6b2498c..cd9c1223 100644 --- a/src/components/layers-panel/layers-panel.tsx +++ b/src/components/layers-panel/layers-panel.tsx @@ -15,9 +15,10 @@ import { type Bookmark, type PageId, type ComparisonSideMode, + BaseMapGroup, } from "../../types"; import { CloseButton } from "../close-button/close-button"; -import { InsertPanel } from "./insert-panel/insert-panel"; +import { InsertPanel, type CustomLayerData } from "./insert-panel/insert-panel"; import { LayersControlPanel } from "./layers-control-panel"; import { ArcGisControlPanel } from "./arcgis-control-panel"; import { MapOptionPanel } from "./map-options-panel"; @@ -59,12 +60,6 @@ interface TabProps { $active: boolean; } -interface CustomItem { - name: string; - url: string; - token?: string; -} - const Tab = styled.div` position: relative; font-style: normal; @@ -211,11 +206,7 @@ export const LayersPanel = ({ setShowExistedError(false); }); - const handleInsertLayer = (layer: { - name: string; - url: string; - token?: string; - }) => { + const handleInsertLayer = (layer: CustomLayerData) => { const existedLayer = layers.some( (exisLayer) => exisLayer.url.trim() === layer.url.trim() ); @@ -272,11 +263,7 @@ export const LayersPanel = ({ }; // TODO Add loader to show webscene loading - const handleInsertScene = async (scene: { - name: string; - url: string; - token?: string; - }): Promise => { + const handleInsertScene = async (scene: CustomLayerData): Promise => { scene.url = convertUrlToRestFormat(scene.url); const existedScene = layers.some( @@ -353,17 +340,20 @@ export const LayersPanel = ({ } }; - const handleInsertMap = (map: CustomItem): void => { + const handleInsertMap = (map: CustomLayerData): void => { const id = map.url.replace(/" "/g, "-"); - const newMap: BaseMap = { - id, - mapUrl: map.url, - name: map.name, - token: map.token, - custom: true, - }; - - dispatch(addBaseMap(newMap)); + if (map.group !== undefined) { + const newMap: BaseMap = { + id, + mapUrl: map.url, + name: map.name, + token: map.token, + iconId: "Custom", + custom: true, + group: map.group, + }; + dispatch(addBaseMap(newMap)); + } setShowInsertMapPanel(false); }; @@ -418,6 +408,7 @@ export const LayersPanel = ({ )} {tab === Tabs.MapOptions && ( { setShowInsertMapPanel(true); }} @@ -550,6 +541,7 @@ export const LayersPanel = ({ { handleInsertMap(map); }} diff --git a/src/components/layers-panel/map-options-panel.spec.tsx b/src/components/layers-panel/map-options-panel.spec.tsx index dbf56d78..e6b1b71e 100644 --- a/src/components/layers-panel/map-options-panel.spec.tsx +++ b/src/components/layers-panel/map-options-panel.spec.tsx @@ -1,60 +1,37 @@ -import { act, screen } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithThemeProviders } from "../../utils/testing-utils/render-with-theme"; import { MapOptionPanel } from "./map-options-panel"; +import { PageId } from "../../types"; -import { BaseMapListItem } from "./base-map-list-item/base-map-list-item"; import { ActionIconButton } from "../action-icon-button/action-icon-button"; -import { DeleteConfirmation } from "./delete-confirmation"; -import { BaseMapOptionsMenu } from "./basemap-options-menu/basemap-options-menu"; import { setupStore } from "../../redux/store"; -import { - addBaseMap, - selectBaseMaps, - selectSelectedBaseMapId, -} from "../../redux/slices/base-maps-slice"; -jest.mock("@loaders.gl/i3s", () => { - return jest.fn().mockImplementation(() => { - return null; - }); -}); -jest.mock("./base-map-list-item/base-map-list-item"); jest.mock("../action-icon-button/action-icon-button"); -jest.mock("./delete-confirmation"); -jest.mock("./basemap-options-menu/basemap-options-menu"); -const BaseMapListItemMock = BaseMapListItem as unknown as jest.Mocked; const PlusButtonMock = ActionIconButton as unknown as jest.Mocked; -const DeleteConfirmationMock = - DeleteConfirmation as unknown as jest.Mocked; -const BaseMapOptionsMenuMock = - BaseMapOptionsMenu as unknown as jest.Mocked; beforeAll(() => { - BaseMapListItemMock.mockImplementation((props) => ( -
{`BaseMap ListItem-${props.id}`}
- )); PlusButtonMock.mockImplementation(({ children, onClick }) => (
{children}
)); - DeleteConfirmationMock.mockImplementation(() => ( -
Delete Conformation
- )); - BaseMapOptionsMenuMock.mockImplementation(() =>
BaseMap Options
); }); const onInsertBaseMapMock = jest.fn(); const callRender = (renderFunc, props = {}, store = setupStore()) => { return renderFunc( - , + , store ); }; describe("Map Options Panel", () => { - it("Should render without basemaps", async () => { + it("Should render panel", async () => { const store = setupStore(); const { container } = callRender( renderWithThemeProviders, @@ -67,47 +44,24 @@ describe("Map Options Panel", () => { // Insert Button should be present const insertBaseMapButton = screen.getByText("Insert Base Map"); expect(insertBaseMapButton).toBeInTheDocument(); - // Should be able to click on insert button - await userEvent.click(insertBaseMapButton); - expect(onInsertBaseMapMock).toHaveBeenCalled(); }); - it("Should render base maps", () => { + it("Should click Insert button", async () => { const store = setupStore(); - store.dispatch( - addBaseMap({ - id: "first", - name: "first name", - mapUrl: "https://first-url.com", - }) - ); - store.dispatch( - addBaseMap({ - id: "second", - name: "second name", - mapUrl: "https://second-url.com", - }) - ); const { container } = callRender( renderWithThemeProviders, undefined, store ); expect(container).toBeInTheDocument(); - - expect(screen.getByText("BaseMap ListItem-first")).toBeInTheDocument(); - expect(screen.getByText("BaseMap ListItem-second")).toBeInTheDocument(); + const insertBaseMapButton = screen.getByText("Insert Base Map"); + // Should be able to click on insert button + await userEvent.click(insertBaseMapButton); + expect(onInsertBaseMapMock).toHaveBeenCalled(); }); - it("Should be able to call functions", () => { + it("Should render base maps", () => { const store = setupStore(); - store.dispatch( - addBaseMap({ - id: "first", - name: "first name", - mapUrl: "https://first-url.com", - }) - ); const { container } = callRender( renderWithThemeProviders, undefined, @@ -115,77 +69,11 @@ describe("Map Options Panel", () => { ); expect(container).toBeInTheDocument(); - expect(screen.getByText("BaseMap ListItem-first")).toBeInTheDocument(); - - const { onOptionsClick, onMapsSelect, onClickOutside } = - BaseMapListItemMock.mock.lastCall[0]; - - act(() => { - onOptionsClick(); - }); - - act(() => { - onMapsSelect(); - }); - - const state = store.getState(); - const baseMapId = selectSelectedBaseMapId(state); - expect(baseMapId).toEqual("first"); - - act(() => { - onClickOutside(); - }); - }); - - it("Should render conformation panel", () => { - const store = setupStore(); - store.dispatch( - addBaseMap({ - id: "first", - name: "first name", - mapUrl: "https://first-url.com", - }) - ); - store.dispatch( - // Candidate to delete - addBaseMap({ - id: "", - name: "second name", - mapUrl: "https://second-url.com", - }) - ); - callRender(renderWithThemeProviders, undefined, store); - expect(screen.getByText("Delete Conformation")).toBeInTheDocument(); - - const { onDeleteHandler, onKeepHandler } = - DeleteConfirmationMock.mock.lastCall[0]; - - act(() => { - onDeleteHandler(); - }); - - const state = store.getState(); - const baseMap = selectBaseMaps(state); - expect(baseMap).toEqual([ - { - id: "Dark", - mapUrl: - "https://basemaps.cartocdn.com/gl/dark-matter-nolabels-gl-style/style.json", - name: "Dark", - }, - { - id: "Light", - mapUrl: - "https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json", - name: "Light", - }, - { id: "Terrain", mapUrl: "", name: "Terrain" }, - { id: "ArcGis", name: "ArcGis", mapUrl: "" }, - { id: "first", mapUrl: "https://first-url.com", name: "first name" }, - ]); - - act(() => { - onKeepHandler(); - }); + expect(screen.getByText("Dark")).toBeInTheDocument(); + expect(screen.getByText("Light")).toBeInTheDocument(); + expect(screen.getByText("Light gray")).toBeInTheDocument(); + expect(screen.getByText("Dark gray")).toBeInTheDocument(); + expect(screen.getByText("Streets")).toBeInTheDocument(); + expect(screen.getByText("Streets(night)")).toBeInTheDocument(); }); }); diff --git a/src/components/layers-panel/map-options-panel.tsx b/src/components/layers-panel/map-options-panel.tsx index b3e06b1b..09e4854d 100644 --- a/src/components/layers-panel/map-options-panel.tsx +++ b/src/components/layers-panel/map-options-panel.tsx @@ -1,32 +1,16 @@ -import { useState, Fragment } from "react"; import styled from "styled-components"; -import { BaseMapListItem } from "./base-map-list-item/base-map-list-item"; import PlusIcon from "../../../public/icons/plus.svg"; import { ActionIconButton } from "../action-icon-button/action-icon-button"; -import { DeleteConfirmation } from "./delete-confirmation"; -import { SelectionState, ButtonSize } from "../../types"; -import { BaseMapOptionsMenu } from "./basemap-options-menu/basemap-options-menu"; -import { useAppDispatch, useAppSelector } from "../../redux/hooks"; -import { - selectBaseMaps, - deleteBaseMaps, - selectSelectedBaseMapId, - setSelectedBaseMaps, -} from "../../redux/slices/base-maps-slice"; - -interface MapOptionPanelProps { - insertBaseMap: () => void; -} +import { ButtonSize, PageId, BaseMapGroup } from "../../types"; +import { BasemapListPanel } from "../layers-panel/basemap-list-panel/basemap-list-panel"; const MapOptionTitle = styled.div` width: 100; - height: 19px; font-style: normal; font-weight: 700; font-size: 16px; line-height: 19px; color: ${({ theme }) => theme.colors.fontColor}; - margin-bottom: 24px; `; const MapOptionsContainer = styled.div` @@ -35,12 +19,8 @@ const MapOptionsContainer = styled.div` width: 100%; overflow: auto; position: relative; -`; - -const MapList = styled.div` - display: flex; - flex-direction: column; - width: 100%; + gap: 16px; + margin-bottom: 8px; `; const InsertButtons = styled.div` @@ -49,71 +29,33 @@ const InsertButtons = styled.div` row-gap: 8px; `; -export const MapOptionPanel = ({ insertBaseMap }: MapOptionPanelProps) => { - const dispatch = useAppDispatch(); - const baseMaps = useAppSelector(selectBaseMaps); - const selectedBaseMapId = useAppSelector(selectSelectedBaseMapId); - const [settingsMapId, setSettingsMapId] = useState(""); - const [showMapSettings, setShowMapSettings] = useState(false); - const [mapToDeleteId, setMapToDeleteId] = useState(""); +interface MapOptionPanelProps { + pageId: PageId; + insertBaseMap: () => void; +} +export const MapOptionPanel = ({ + pageId, + insertBaseMap, +}: MapOptionPanelProps) => { return ( Base Map - - {baseMaps.map((baseMap) => { - const isMapSelected = selectedBaseMapId === baseMap.id; - return ( - - { - setShowMapSettings(true); - setSettingsMapId(baseMap.id); - }} - onMapsSelect={() => { - dispatch(setSelectedBaseMaps(baseMap.id)); - }} - isOptionsPanelOpen={ - showMapSettings && settingsMapId === baseMap.id - } - optionsContent={ - { - setMapToDeleteId(settingsMapId); - setShowMapSettings(false); - }} - /> - } - onClickOutside={() => { - setShowMapSettings(false); - setSettingsMapId(""); - }} - /> - {mapToDeleteId === baseMap.id && ( - { setMapToDeleteId(""); }} - onDeleteHandler={() => { - dispatch(deleteBaseMaps(settingsMapId)); - setMapToDeleteId(""); - }} - > - Delete map? - - )} - - ); - })} - + + {pageId !== PageId.comparison && ( + + )} + {pageId !== PageId.comparison && ( + + )} + - + Insert Base Map diff --git a/src/constants/map-styles.ts b/src/constants/map-styles.ts index 91b8e714..dd10be97 100644 --- a/src/constants/map-styles.ts +++ b/src/constants/map-styles.ts @@ -1,22 +1,86 @@ -import { type BaseMap } from "../types"; +import { type FC } from "react"; +import CustomMap from "../../public/icons/custom-map.svg"; + +import MaplibreDarkMap from "../../public/icons/basemaps/maplibre-dark.png"; +import MaplibreLightMap from "../../public/icons/basemaps/maplibre-light.png"; + +import TerrainMap from "../../public/icons/basemaps/terrain.png"; + +import ArcGisDarkGrayMap from "../../public/icons/basemaps/arcgis-dark-gray.png"; +import ArcGisLightGrayMap from "../../public/icons/basemaps/arcgis-light-gray.png"; +import ArcGisStreetsDarkMap from "../../public/icons/basemaps/arcgis-streets-dark.png"; +import ArcGisStreetsMap from "../../public/icons/basemaps/arcgis-streets.png"; +import { type BaseMap, BaseMapGroup } from "../types"; + +interface BasemapIcon { + IconComponent?: FC<{ fill: string }>; + iconUrl?: string; +} + +export const basemapIcons: Record = { + Dark: { iconUrl: MaplibreDarkMap }, + Light: { iconUrl: MaplibreLightMap }, + Terrain: { iconUrl: TerrainMap }, + ArcGisDarkGray: { iconUrl: ArcGisDarkGrayMap }, + ArcGisLightGray: { iconUrl: ArcGisLightGrayMap }, + ArcGisStreetsDark: { iconUrl: ArcGisStreetsDarkMap }, + ArcGisStreets: { iconUrl: ArcGisStreetsMap }, + + Custom: { IconComponent: CustomMap }, +}; export const BASE_MAPS: BaseMap[] = [ { id: "Dark", name: "Dark", + iconId: "Dark", + group: BaseMapGroup.Maplibre, mapUrl: "https://basemaps.cartocdn.com/gl/dark-matter-nolabels-gl-style/style.json", }, { id: "Light", name: "Light", + iconId: "Light", + group: BaseMapGroup.Maplibre, mapUrl: "https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json", }, - { id: "Terrain", name: "Terrain", mapUrl: "" }, + + { + id: "Terrain", + name: "Terrain", + group: BaseMapGroup.Terrain, + iconId: "Terrain", + mapUrl: "", + }, + + { + id: "gray-vector", + name: "Light gray", + group: BaseMapGroup.ArcGIS, + iconId: "ArcGisLightGray", + mapUrl: "", + }, + { + id: "dark-gray-vector", + name: "Dark gray", + group: BaseMapGroup.ArcGIS, + iconId: "ArcGisDarkGray", + mapUrl: "", + }, + { + id: "streets-vector", + name: "Streets", + group: BaseMapGroup.ArcGIS, + iconId: "ArcGisStreets", + mapUrl: "", + }, { - id: "ArcGis", - name: "ArcGis", + id: "streets-night-vector", + name: "Streets(night)", + group: BaseMapGroup.ArcGIS, + iconId: "ArcGisStreetsDark", mapUrl: "", }, ]; diff --git a/src/hooks/use-arcgis-hook/use-arcgis-hook.spec.ts b/src/hooks/use-arcgis-hook/use-arcgis-hook.spec.ts deleted file mode 100644 index 86413428..00000000 --- a/src/hooks/use-arcgis-hook/use-arcgis-hook.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { renderHook } from "@testing-library/react-hooks"; -import { loadArcGISModules } from "@deck.gl/arcgis"; -import { useArcgis } from "./use-arcgis-hook"; - -jest.mock("@deck.gl/arcgis", () => { - return { - loadArcGISModules: jest.fn().mockReturnValue(Promise.resolve({})), - }; -}); - -describe("ArcGis Hook", () => { - it("Should be able to call ArcGis hook", async () => { - const hook = renderHook(() => - useArcgis( - { current: null }, - { main: { longitude: 1, latitude: 2, pitch: 3, bearing: 4, zoom: 5 } }, - null - ) - ); - const renderer = hook.result.current; - expect(renderer).toBeNull(); - expect(loadArcGISModules).not.toHaveBeenCalled(); - }); -}); diff --git a/src/hooks/use-arcgis-hook/use-arcgis-hook.spec.tsx b/src/hooks/use-arcgis-hook/use-arcgis-hook.spec.tsx new file mode 100644 index 00000000..d4071ddb --- /dev/null +++ b/src/hooks/use-arcgis-hook/use-arcgis-hook.spec.tsx @@ -0,0 +1,45 @@ +import { loadArcGISModules } from "@deck.gl/arcgis"; + +import type { PropsWithChildren } from "react"; +import { Provider } from "react-redux"; +import { renderHook } from "@testing-library/react-hooks"; +import { useArcgis } from "./use-arcgis-hook"; +import { type AppStore, setupStore } from "../../redux/store"; + +const getWrapper = (store: AppStore) => { + // eslint-disable-next-line @typescript-eslint/ban-types + function Wrapper({ children }: PropsWithChildren<{}>): JSX.Element { + return {children}; + } + return Wrapper; +}; + +jest.mock("@deck.gl/arcgis", () => { + return { + loadArcGISModules: jest.fn().mockReturnValue(Promise.resolve({})), + }; +}); + +describe("ArcGis Hook", () => { + it("Should be able to call ArcGis hook", async () => { + const store = setupStore(); + const wrapper = getWrapper(store); + + const hook = renderHook( + () => + useArcgis( + { current: null }, + { + main: { longitude: 1, latitude: 2, pitch: 3, bearing: 4, zoom: 5 }, + }, + null + ), + { + wrapper, + } + ); + const renderer = hook.result.current; + expect(renderer).toBeNull(); + expect(loadArcGISModules).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/use-arcgis-hook/use-arcgis-hook.ts b/src/hooks/use-arcgis-hook/use-arcgis-hook.ts index 51ae0daf..16a5422d 100644 --- a/src/hooks/use-arcgis-hook/use-arcgis-hook.ts +++ b/src/hooks/use-arcgis-hook/use-arcgis-hook.ts @@ -1,4 +1,7 @@ import { loadArcGISModules } from "@deck.gl/arcgis"; +import { selectSelectedBaseMap } from "../../redux/slices/base-maps-slice"; +import { useAppSelector } from "../../redux/hooks"; +import { BaseMapGroup } from "../../types"; import { useState, useEffect, type MutableRefObject, useRef } from "react"; export function useArcgis( @@ -10,6 +13,15 @@ export function useArcgis( const [sceneView, setSceneView] = useState(null); const { longitude, latitude, pitch, bearing, zoom } = viewState.main; const isLoadingRef = useRef(false); + const selectedBaseMap = useAppSelector(selectSelectedBaseMap); + const selectedBaseMapId = selectedBaseMap?.id; + + useEffect(() => { + if (sceneView && selectedBaseMap?.group === BaseMapGroup.ArcGIS) { + // update Basemap Style + (sceneView as any).map.basemap = selectedBaseMapId; + } + }, [selectedBaseMapId]); // sceneView, useEffect(() => { if (!sceneView) { @@ -25,7 +37,16 @@ export function useArcgis( }, { animate: true } ); - }, [longitude, latitude, zoom, bearing, pitch, sceneView]); + }, [ + longitude, + latitude, + zoom, + bearing, + pitch, + sceneView, + (sceneView as any)?.map.basemap, + selectedBaseMapId, + ]); useEffect(() => { if (mapContainer.current == null) { @@ -47,7 +68,7 @@ export function useArcgis( const sceneView = new SceneView({ container: mapContainer.current, map: new ArcGISMap({ - basemap: "dark-gray-vector", + basemap: selectedBaseMapId, }), environment: { atmosphereEnabled: false, diff --git a/src/hooks/use-deckgl-hook/use-deckgl-hook.ts b/src/hooks/use-deckgl-hook/use-deckgl-hook.ts index 50adc619..420e6966 100644 --- a/src/hooks/use-deckgl-hook/use-deckgl-hook.ts +++ b/src/hooks/use-deckgl-hook/use-deckgl-hook.ts @@ -25,10 +25,7 @@ import { selectBoundingVolumeColorMode, selectBoundingVolumeType, } from "../../redux/slices/debug-options-slice"; -import { - selectBaseMaps, - selectSelectedBaseMapId, -} from "../../redux/slices/base-maps-slice"; +import { selectSelectedBaseMap } from "../../redux/slices/base-maps-slice"; import { selectViewState } from "../../redux/slices/view-state-slice"; import type { Tileset3D } from "@loaders.gl/tiles"; @@ -51,9 +48,7 @@ export function useDeckGl( const tileColorMode = useAppSelector(selectTileColorMode); const boundingVolumeColorMode = useAppSelector(selectBoundingVolumeColorMode); const wireframe = useAppSelector(selectWireframe); - const baseMaps = useAppSelector(selectBaseMaps); - const selectedBaseMapId = useAppSelector(selectSelectedBaseMapId); - const selectedBaseMap = baseMaps.find((map) => map.id === selectedBaseMapId); + const selectedBaseMap = useAppSelector(selectSelectedBaseMap); const showTerrain = selectedBaseMap?.id === "Terrain"; const mapStyle = selectedBaseMap?.mapUrl; const boundingVolume = useAppSelector(selectBoundingVolume); diff --git a/src/pages/comparison/comparison.tsx b/src/pages/comparison/comparison.tsx index 278f8f96..f703a325 100644 --- a/src/pages/comparison/comparison.tsx +++ b/src/pages/comparison/comparison.tsx @@ -14,6 +14,7 @@ import { type LayerViewState, type StatsData, PageId, + BaseMapGroup, type LayoutProps, } from "../../types"; @@ -35,9 +36,7 @@ import { useAppDispatch, useAppSelector } from "../../redux/hooks"; import { setDragMode } from "../../redux/slices/drag-mode-slice"; import { setColorsByAttrubute } from "../../redux/slices/symbolization-slice"; import { - deleteBaseMaps, - setInitialBaseMaps, - selectSelectedBaseMapId, + selectSelectedBaseMap, } from "../../redux/slices/base-maps-slice"; import { selectViewState, @@ -46,10 +45,6 @@ import { import { WarningPanel } from "../../components/layers-panel/warning/warning-panel"; import { CenteredContainer } from "../../components/common"; -interface ComparisonPageProps { - mode: ComparisonMode; -} - const INITIAL_VIEW_STATE = { main: { longitude: 0, @@ -92,12 +87,16 @@ const Devider = styled.div` background-color: ${color_brand_primary}; `; +interface ComparisonPageProps { + mode: ComparisonMode; +} + export const Comparison = ({ mode }: ComparisonPageProps) => { const loadManagerRef = useRef( new ComparisonLoadManager() ); - const selectedBaseMapId = useAppSelector(selectSelectedBaseMapId); + const selectedBaseMap = useAppSelector(selectSelectedBaseMap); const globalViewState = useAppSelector(selectViewState); const [layersLeftSide, setLayersLeftSide] = useState([]); const [layersRightSide, setLayersRightSide] = useState([]); @@ -118,10 +117,7 @@ export const Comparison = ({ mode }: ComparisonPageProps) => { ); compareButtonModeRef.current = compareButtonMode; - const [disableButton, setDisableButton] = useState([ - true, - true, - ]); + const [disableButton, setDisableButton] = useState([true, true]); const [leftSideLoaded, setLeftSideLoaded] = useState(true); const [hasBeenCompared, setHasBeenCompared] = useState(false); const [showBookmarksPanel, setShowBookmarksPanel] = useState(false); @@ -151,10 +147,6 @@ export const Comparison = ({ mode }: ComparisonPageProps) => { dispatch(setColorsByAttrubute(null)); dispatch(setDragMode(DragMode.pan)); dispatch(setViewState(INITIAL_VIEW_STATE)); - dispatch(deleteBaseMaps("Terrain")); - return () => { - dispatch(setInitialBaseMaps()); - }; }, [mode]); useEffect(() => { @@ -463,18 +455,22 @@ export const Comparison = ({ mode }: ComparisonPageProps) => { loadNumber={loadNumber} buildingExplorerOpened={buildingExplorerOpenedLeft} pointToTileset={pointToTileset} - onChangeLayers={(layers, activeIds) => { onChangeLayersHandler(layers, activeIds, ComparisonSideMode.left); } - } + onChangeLayers={(layers, activeIds) => { + onChangeLayersHandler(layers, activeIds, ComparisonSideMode.left); + }} onLoadingStateChange={disableButtonHandlerLeft} onTilesetLoaded={(stats: StatsMap) => { loadManagerRef.current.resolveLeftSide(stats); setLeftSideLoaded(true); }} - onBuildingExplorerOpened={(opened) => { setBuildingExplorerOpenedLeft(opened); } - } + onBuildingExplorerOpened={(opened) => { + setBuildingExplorerOpenedLeft(opened); + }} onShowBookmarksChange={onBookmarkClick} onInsertBookmarks={updateBookmarks} - onUpdateSublayers={(sublayers) => { setSublayersLeftSide(sublayers); }} + onUpdateSublayers={(sublayers) => { + setSublayersLeftSide(sublayers); + }} /> { onSelectBookmark={onSelectBookmarkHandler} onCollapsed={onCloseBookmarkPanel} onDownloadBookmarks={onDownloadBookmarksHandler} - onClearBookmarks={() => { setBookmarks([]); }} + onClearBookmarks={() => { + setBookmarks([]); + }} onBookmarksUploaded={onBookmarksUploadedHandler} onDeleteBookmark={onDeleteBookmarkHandler} onEditBookmark={onEditBookmarkHandler} @@ -536,14 +534,16 @@ export const Comparison = ({ mode }: ComparisonPageProps) => { (mode === ComparisonMode.withinLayer && buildingExplorerOpenedLeft) } pointToTileset={pointToTileset} - onChangeLayers={(layers, activeIds) => { onChangeLayersHandler(layers, activeIds, ComparisonSideMode.right); } - } + onChangeLayers={(layers, activeIds) => { + onChangeLayersHandler(layers, activeIds, ComparisonSideMode.right); + }} onLoadingStateChange={disableButtonHandlerRight} onTilesetLoaded={(stats: StatsMap) => { loadManagerRef.current.resolveRightSide(stats); }} - onBuildingExplorerOpened={(opened) => { setBuildingExplorerOpenedRight(opened); } - } + onBuildingExplorerOpened={(opened) => { + setBuildingExplorerOpenedRight(opened); + }} onShowBookmarksChange={onBookmarkClick} /> @@ -554,14 +554,16 @@ export const Comparison = ({ mode }: ComparisonPageProps) => { onZoomOut={onZoomOut} onCompassClick={onCompassClick} bottom={layout === Layout.Mobile ? 8 : 16} - isDragModeVisible={selectedBaseMapId !== "ArcGis"} + isDragModeVisible={selectedBaseMap?.group !== BaseMapGroup.ArcGIS} /> )} {wrongBookmarkPageId && ( { setWrongBookmarkPageId(null); }} + onConfirm={() => { + setWrongBookmarkPageId(null); + }} /> )} diff --git a/src/pages/comparison/e2e.comparison.spec.ts b/src/pages/comparison/e2e.comparison.spec.ts index 87145228..1c5a71e5 100644 --- a/src/pages/comparison/e2e.comparison.spec.ts +++ b/src/pages/comparison/e2e.comparison.spec.ts @@ -2,7 +2,7 @@ import puppeteer, { type Page } from "puppeteer"; import { checkLayersPanel, - inserAndDeleteLayer, + insertAndDeleteLayer, } from "../../utils/testing-utils/e2e-layers-panel"; import { PageId } from "../../types"; import { configurePage } from "../../utils/testing-utils/configure-tests"; @@ -231,13 +231,13 @@ describe("Compare - Layers Panel Across Layers mode", () => { }); it("Should insert and delete layer", async () => { - await inserAndDeleteLayer( + await insertAndDeleteLayer( page, "#left-layers-panel", "https://tiles.arcgis.com/tiles/z2tnIkrLQ2BRzr6P/arcgis/rest/services/Rancho_Mesh_mesh_v17_1/SceneServer/layers/0" ); - await inserAndDeleteLayer( + await insertAndDeleteLayer( page, "#right-layers-panel", "https://fake.layer.url" @@ -391,7 +391,10 @@ describe("Compare - Comparison Params Panel", () => { // Horizontal Line expect( - await page.$eval(`${panelId} > :nth-child(2)`, (node) => (node as HTMLElement).innerText) + await page.$eval( + `${panelId} > :nth-child(2)`, + (node) => (node as HTMLElement).innerText + ) ).toBe(""); // Draco @@ -484,7 +487,10 @@ describe("Compare - Statistics", () => { // Horizontal Line expect( - await page.$eval(`${panelId} > :nth-child(2)`, (node) => (node as HTMLElement).innerText) + await page.$eval( + `${panelId} > :nth-child(2)`, + (node) => (node as HTMLElement).innerText + ) ).toBe(""); }; diff --git a/src/pages/debug-app/debug-app.tsx b/src/pages/debug-app/debug-app.tsx index 657c234d..e393bc84 100644 --- a/src/pages/debug-app/debug-app.tsx +++ b/src/pages/debug-app/debug-app.tsx @@ -15,6 +15,7 @@ import { type MinimapPosition, type TileSelectedColor, PageId, + BaseMapGroup, type TilesetMetadata, } from "../../types"; @@ -22,7 +23,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; // eslint-disable-next-line react/no-deprecated import { render } from "react-dom"; import { lumaStats } from "@luma.gl/core"; -import { type PickingInfo, type InteractionStateChange, type ViewState } from "@deck.gl/core"; +import { + type PickingInfo, + type InteractionStateChange, + type ViewState, +} from "@deck.gl/core"; import { v4 as uuidv4 } from "uuid"; import { type Stats } from "@probe.gl/stats"; @@ -82,14 +87,20 @@ import { } from "../../redux/slices/flattened-sublayers-slice"; import { useAppDispatch, useAppSelector } from "../../redux/hooks"; import { setDragMode } from "../../redux/slices/drag-mode-slice"; -import { setColorsByAttrubute, setFiltersByAttrubute } from "../../redux/slices/symbolization-slice"; +import { + setColorsByAttrubute, + setFiltersByAttrubute, +} from "../../redux/slices/symbolization-slice"; import { resetDebugOptions, setDebugOptions, selectDebugOptions, selectPickable, } from "../../redux/slices/debug-options-slice"; -import { setInitialBaseMaps, selectSelectedBaseMapId } from "../../redux/slices/base-maps-slice"; +import { + setInitialBaseMaps, + selectSelectedBaseMap, +} from "../../redux/slices/base-maps-slice"; import { clearBSLStatisitcsSummary } from "../../redux/slices/i3s-stats-slice"; import { selectViewState, @@ -107,7 +118,7 @@ export const DebugApp = () => { const tilesetRef = useRef(null); const layout = useAppLayout(); const debugOptions = useAppSelector(selectDebugOptions); - const selectedBaseMapId = useAppSelector(selectSelectedBaseMapId); + const selectedBaseMap = useAppSelector(selectSelectedBaseMap); const [normalsDebugData, setNormalsDebugData] = useState(null); const [trianglesPercentage, setTrianglesPercentage] = useState( @@ -139,7 +150,9 @@ export const DebugApp = () => { const [buildingExplorerOpened, setBuildingExplorerOpened] = useState(false); const MapWrapper = - selectedBaseMapId === "ArcGis" ? ArcgisWrapper : DeckGlWrapper; + selectedBaseMap?.group === BaseMapGroup.ArcGIS + ? ArcgisWrapper + : DeckGlWrapper; const [stateUrlViewStateParams, setStateUrlViewStateParams] = useState({}); @@ -244,7 +257,11 @@ export const DebugApp = () => { setColoredTilesMap({}); setSelectedTile(null); dispatch(resetDebugOptions()); - dispatch(setDebugOptions({ minimap: !(selectedBaseMapId === "ArcGis") })); + dispatch( + setDebugOptions({ + minimap: !(selectedBaseMap?.group === BaseMapGroup.ArcGIS), + }) + ); dispatch(clearBSLStatisitcsSummary()); dispatch(setFiltersByAttrubute({ filter: null })); }, [activeLayers, buildingExplorerOpened]); @@ -366,7 +383,9 @@ export const DebugApp = () => { setColoredTilesMap(updatedColoredMap); }; - const handleClearWarnings = () => { setWarnings([]); }; + const handleClearWarnings = () => { + setWarnings([]); + }; const onShowNormals = (tile) => { if (normalsDebugData === null) { @@ -425,8 +444,12 @@ export const DebugApp = () => { onChangeTrianglesPercentage={onChangeTrianglesPercentage} onChangeNormalsLength={onChangeNormalsLength} handleClosePanel={handleCloseTilePanel} - deactiveDebugPanel={() => { setActiveButton(ActiveButton.none); }} - activeDebugPanel={() => { setActiveButton(ActiveButton.debug); }} + deactiveDebugPanel={() => { + setActiveButton(ActiveButton.none); + }} + activeDebugPanel={() => { + setActiveButton(ActiveButton.debug); + }} > {isShowColorPicker && ( { selectedLayerIds={selectedLayerIds} onLayerInsert={onLayerInsertHandler} onLayerSelect={onLayerSelectHandler} - onLayerDelete={(id) => { onLayerDeleteHandler(id); }} - onPointToLayer={(viewState) => { pointToTileset(viewState); }} + onLayerDelete={(id) => { + onLayerDeleteHandler(id); + }} + onPointToLayer={(viewState) => { + pointToTileset(viewState); + }} type={ListItemType.Radio} sublayers={sublayers} onUpdateSublayerVisibility={onUpdateSublayerVisibilityHandler} - onClose={() => { onChangeMainToolsPanelHandler(ActiveButton.options); }} - onBuildingExplorerOpened={(opened) => { setBuildingExplorerOpened(opened); } - } + onClose={() => { + onChangeMainToolsPanelHandler(ActiveButton.options); + }} + onBuildingExplorerOpened={(opened) => { + setBuildingExplorerOpened(opened); + }} /> )} {activeButton === ActiveButton.debug && ( { onChangeMainToolsPanelHandler(ActiveButton.debug); }} + onClose={() => { + onChangeMainToolsPanelHandler(ActiveButton.debug); + }} /> )} @@ -821,8 +853,9 @@ export const DebugApp = () => { { onChangeMainToolsPanelHandler(ActiveButton.validator); } - } + onClose={() => { + onChangeMainToolsPanelHandler(ActiveButton.validator); + }} /> )} @@ -835,7 +868,9 @@ export const DebugApp = () => { tilesetStats={tilesetsStats} contentFormats={tilesetRef.current?.contentFormats} updateNumber={updateStatsNumber} - onClose={() => { onChangeMainToolsPanelHandler(ActiveButton.memory); }} + onClose={() => { + onChangeMainToolsPanelHandler(ActiveButton.memory); + }} /> )} @@ -850,7 +885,9 @@ export const DebugApp = () => { onSelectBookmark={onSelectBookmarkHandler} onCollapsed={onCloseBookmarkPanel} onDownloadBookmarks={onDownloadBookmarksHandler} - onClearBookmarks={() => { setBookmarks([]); }} + onClearBookmarks={() => { + setBookmarks([]); + }} onBookmarksUploaded={onBookmarksUploadedHandler} onDeleteBookmark={onDeleteBookmarkHandler} onEditBookmark={onEditBookmarkHandler} @@ -861,13 +898,15 @@ export const DebugApp = () => { onZoomIn={onZoomIn} onZoomOut={onZoomOut} onCompassClick={onCompassClick} - isDragModeVisible={selectedBaseMapId !== "ArcGis"} + isDragModeVisible={selectedBaseMap?.group !== BaseMapGroup.ArcGIS} /> {wrongBookmarkPageId && ( { setWrongBookmarkPageId(null); }} + onConfirm={() => { + setWrongBookmarkPageId(null); + }} /> )} diff --git a/src/pages/debug-app/e2e.debug-app.spec.ts b/src/pages/debug-app/e2e.debug-app.spec.ts index 9b710c48..ad0eb06b 100644 --- a/src/pages/debug-app/e2e.debug-app.spec.ts +++ b/src/pages/debug-app/e2e.debug-app.spec.ts @@ -2,7 +2,7 @@ import puppeteer, { type Browser, type Page } from "puppeteer"; import { checkInserLayerErrors, checkLayersPanel, - inserAndDeleteLayer, + insertAndDeleteLayer, } from "../../utils/testing-utils/e2e-layers-panel"; import { configurePage } from "../../utils/testing-utils/configure-tests"; @@ -159,7 +159,7 @@ describe("Debug - Layers panel", () => { }); it("Should insert and delete layers", async () => { - await inserAndDeleteLayer( + await insertAndDeleteLayer( page, "#debug--layers-panel", "https://tiles.arcgis.com/tiles/z2tnIkrLQ2BRzr6P/arcgis/rest/services/Rancho_Mesh_mesh_v17_1/SceneServer/layers/0" diff --git a/src/pages/viewer-app/e2e.viewer-app.spec.ts b/src/pages/viewer-app/e2e.viewer-app.spec.ts index 78592ed9..6dc6bb99 100644 --- a/src/pages/viewer-app/e2e.viewer-app.spec.ts +++ b/src/pages/viewer-app/e2e.viewer-app.spec.ts @@ -1,7 +1,7 @@ import puppeteer, { type Browser, type Page } from "puppeteer"; import { checkLayersPanel, - inserAndDeleteLayer, + insertAndDeleteLayer, } from "../../utils/testing-utils/e2e-layers-panel"; import { configurePage } from "../../utils/testing-utils/configure-tests"; @@ -134,7 +134,7 @@ describe("Viewer - Layers panel", () => { }); it("Should insert and delete layers", async () => { - await inserAndDeleteLayer( + await insertAndDeleteLayer( page, "#viewer--layers-panel", "https://tiles.arcgis.com/tiles/z2tnIkrLQ2BRzr6P/arcgis/rest/services/Rancho_Mesh_mesh_v17_1/SceneServer/layers/0" diff --git a/src/pages/viewer-app/viewer-app.tsx b/src/pages/viewer-app/viewer-app.tsx index 92f897f9..d44e006c 100644 --- a/src/pages/viewer-app/viewer-app.tsx +++ b/src/pages/viewer-app/viewer-app.tsx @@ -32,6 +32,7 @@ import { type Bookmark, DragMode, PageId, + BaseMapGroup, type TilesetMetadata, } from "../../types"; import { useAppLayout } from "../../utils/hooks/layout"; @@ -72,7 +73,7 @@ import { import { useAppDispatch, useAppSelector } from "../../redux/hooks"; import { setDragMode } from "../../redux/slices/drag-mode-slice"; import { - selectSelectedBaseMapId, + selectSelectedBaseMap, setInitialBaseMaps, } from "../../redux/slices/base-maps-slice"; import { ArcgisWrapper } from "../../components/arcgis-wrapper/arcgis-wrapper"; @@ -119,7 +120,7 @@ export const ViewerApp = () => { const [isAttributesLoading, setAttributesLoading] = useState(false); const flattenedSublayers = useAppSelector(selectLayers); const bslSublayers = useAppSelector(selectSublayers); - const selectedBaseMapId = useAppSelector(selectSelectedBaseMapId); + const selectedBaseMap = useAppSelector(selectSelectedBaseMap); const [tilesetsStats, setTilesetsStats] = useState(initStats()); const [memoryStats, setMemoryStats] = useState(null); const [updateStatsNumber, setUpdateStatsNumber] = useState(0); @@ -143,7 +144,9 @@ export const ViewerApp = () => { const [, setSearchParams] = useSearchParams(); const dispatch = useAppDispatch(); const MapWrapper = - selectedBaseMapId === "ArcGis" ? ArcgisWrapper : DeckGlWrapper; + selectedBaseMap?.group === BaseMapGroup.ArcGIS + ? ArcgisWrapper + : DeckGlWrapper; const filtersByAttribute = useSelector((state: RootState) => selectFiltersByAttribute(state) ); @@ -725,7 +728,7 @@ export const ViewerApp = () => { onZoomIn={onZoomIn} onZoomOut={onZoomOut} onCompassClick={onCompassClick} - isDragModeVisible={selectedBaseMapId !== "ArcGis"} + isDragModeVisible={selectedBaseMap?.group !== BaseMapGroup.ArcGIS} /> {wrongBookmarkPageId && ( diff --git a/src/redux/slices/base-maps-slice.spec.ts b/src/redux/slices/base-maps-slice.spec.ts index b0b7e44e..d9a6d413 100644 --- a/src/redux/slices/base-maps-slice.spec.ts +++ b/src/redux/slices/base-maps-slice.spec.ts @@ -1,13 +1,14 @@ import { setupStore } from "../store"; import reducer, { type BaseMapsState, - selectBaseMaps, - selectSelectedBaseMapId, + selectBaseMapsByGroup, + selectSelectedBaseMap, setInitialBaseMaps, addBaseMap, - setSelectedBaseMaps, - deleteBaseMaps, + setSelectedBaseMap, + deleteBaseMap, } from "./base-maps-slice"; +import { BaseMapGroup } from "../../types"; import { BASE_MAPS } from "../../constants/map-styles"; jest.mock("@loaders.gl/i3s", () => { @@ -19,29 +20,39 @@ jest.mock("@loaders.gl/i3s", () => { describe("slice: base-maps", () => { it("Reducer should return the initial state", () => { expect(reducer(undefined, { type: "none" })).toEqual({ - baseMap: BASE_MAPS, - selectedBaseMap: "Dark", + basemaps: BASE_MAPS, + selectedBaseMapId: "Dark", }); }); it("Reducer setBaseMaps should add base map", () => { const previousState: BaseMapsState = { - baseMap: [ + basemaps: [ { id: "Dark", mapUrl: "https://basemaps.cartocdn.com/gl/dark-matter-nolabels-gl-style/style.json", name: "Dark", + group: BaseMapGroup.Maplibre, + iconId: "Dark", }, { id: "Light", mapUrl: "https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json", name: "Light", + group: BaseMapGroup.Maplibre, + iconId: "Light", + }, + { + id: "Terrain", + mapUrl: "", + name: "Terrain", + group: BaseMapGroup.Terrain, + iconId: "Terrain", }, - { id: "Terrain", mapUrl: "", name: "Terrain" }, ], - selectedBaseMap: "Dark", + selectedBaseMapId: "Dark", }; expect( @@ -51,158 +62,236 @@ describe("slice: base-maps", () => { id: "first", mapUrl: "https://first-url.com", name: "first name", + group: BaseMapGroup.Maplibre, + iconId: "Dark", }) ) ).toEqual({ - baseMap: [ + basemaps: [ { id: "Dark", mapUrl: "https://basemaps.cartocdn.com/gl/dark-matter-nolabels-gl-style/style.json", name: "Dark", + group: BaseMapGroup.Maplibre, + iconId: "Dark", }, { id: "Light", mapUrl: "https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json", name: "Light", + group: BaseMapGroup.Maplibre, + iconId: "Light", + }, + { + id: "Terrain", + mapUrl: "", + name: "Terrain", + group: BaseMapGroup.Terrain, + iconId: "Terrain", + }, + { + id: "first", + mapUrl: "https://first-url.com", + name: "first name", + group: BaseMapGroup.Maplibre, + iconId: "Dark", }, - { id: "Terrain", mapUrl: "", name: "Terrain" }, - { id: "first", mapUrl: "https://first-url.com", name: "first name" }, ], - selectedBaseMap: "first", + selectedBaseMapId: "first", }); }); - it("Reducer deleteBaseMaps should remove base map", () => { + it("Reducer deleteBaseMap should remove base map", () => { const previousState: BaseMapsState = { - baseMap: [ + basemaps: [ { id: "Dark", mapUrl: "https://basemaps.cartocdn.com/gl/dark-matter-nolabels-gl-style/style.json", name: "Dark", + group: BaseMapGroup.Maplibre, + iconId: "Dark", }, { id: "Light", mapUrl: "https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json", name: "Light", + group: BaseMapGroup.Maplibre, + iconId: "Light", + }, + { + id: "Terrain", + mapUrl: "", + name: "Terrain", + group: BaseMapGroup.Terrain, + iconId: "Terrain", }, - { id: "Terrain", mapUrl: "", name: "Terrain" }, ], - selectedBaseMap: "Dark", + selectedBaseMapId: "Dark", }; - expect(reducer(previousState, deleteBaseMaps("Dark"))).toEqual({ - baseMap: [ + expect(reducer(previousState, deleteBaseMap("Dark"))).toEqual({ + basemaps: [ { id: "Light", mapUrl: "https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json", name: "Light", + group: BaseMapGroup.Maplibre, + iconId: "Light", + }, + { + id: "Terrain", + mapUrl: "", + name: "Terrain", + group: BaseMapGroup.Terrain, + iconId: "Terrain", }, - { id: "Terrain", mapUrl: "", name: "Terrain" }, ], - selectedBaseMap: "Light", + selectedBaseMapId: "Light", }); }); - it("Reducer setSelectedBaseMaps should update selected base map", () => { + it("Reducer setSelectedBaseMap should update selected base map", () => { const previousState: BaseMapsState = { - baseMap: [ + basemaps: [ { id: "Dark", mapUrl: "https://basemaps.cartocdn.com/gl/dark-matter-nolabels-gl-style/style.json", name: "Dark", + group: BaseMapGroup.Maplibre, + iconId: "Dark", }, { id: "Light", mapUrl: "https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json", name: "Light", + group: BaseMapGroup.Maplibre, + iconId: "Light", + }, + { + id: "Terrain", + mapUrl: "", + name: "Terrain", + group: BaseMapGroup.Terrain, + iconId: "Terrain", }, - { id: "Terrain", mapUrl: "", name: "Terrain" }, ], - selectedBaseMap: "Dark", + selectedBaseMapId: "Dark", }; - expect(reducer(previousState, setSelectedBaseMaps("Light"))).toEqual({ - baseMap: [ + expect(reducer(previousState, setSelectedBaseMap("Light"))).toEqual({ + basemaps: [ { id: "Dark", mapUrl: "https://basemaps.cartocdn.com/gl/dark-matter-nolabels-gl-style/style.json", name: "Dark", + group: BaseMapGroup.Maplibre, + iconId: "Dark", }, { id: "Light", mapUrl: "https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json", name: "Light", + group: BaseMapGroup.Maplibre, + iconId: "Light", + }, + { + id: "Terrain", + mapUrl: "", + name: "Terrain", + group: BaseMapGroup.Terrain, + iconId: "Terrain", }, - { id: "Terrain", mapUrl: "", name: "Terrain" }, ], - selectedBaseMap: "Light", + selectedBaseMapId: "Light", }); }); it("Reducer setInitialBaseMaps should return initial base maps", () => { const previousState: BaseMapsState = { - baseMap: [{ id: "Terrain", mapUrl: "", name: "Terrain" }], - selectedBaseMap: "Terrain", + basemaps: [ + { + id: "Terrain", + mapUrl: "", + name: "Terrain", + group: BaseMapGroup.Terrain, + iconId: "Terrain", + }, + ], + selectedBaseMapId: "Terrain", }; expect(reducer(previousState, setInitialBaseMaps())).toEqual({ - baseMap: BASE_MAPS, - selectedBaseMap: "Dark", + basemaps: BASE_MAPS, + selectedBaseMapId: "Dark", }); }); - it("Selectors should return initial value", () => { + it("Selectors should return initially selected value", () => { const store = setupStore(); const state = store.getState(); - expect(selectSelectedBaseMapId(state)).toEqual("Dark"); - expect(selectBaseMaps(state)).toEqual(BASE_MAPS); + const mapId = selectSelectedBaseMap(state)?.id; + expect(mapId).toEqual("Dark"); + expect(selectBaseMapsByGroup(state, "")).toEqual(BASE_MAPS); }); it("Selectors should return updated value", () => { const store = setupStore(); - store.dispatch(deleteBaseMaps("Dark")); - store.dispatch( - addBaseMap({ - id: "first", - mapUrl: "https://first-url.com", - name: "first name", - }) - ); - store.dispatch(setSelectedBaseMaps("Terrain")); + store.dispatch(deleteBaseMap("Dark")); + const newItem = { + id: "first", + mapUrl: "https://first-url.com", + name: "first name", + group: BaseMapGroup.Maplibre, + iconId: "Dark", + }; + store.dispatch(addBaseMap(newItem)); + store.dispatch(setSelectedBaseMap("Terrain")); + const state = store.getState(); + const mapId = selectSelectedBaseMap(state)?.id; + expect(mapId).toEqual("Terrain"); + + const expectedArray = BASE_MAPS.filter((item) => item.id !== "Dark"); + expectedArray.push(newItem); + + expect(selectBaseMapsByGroup(state, "")).toEqual(expectedArray); + // set wrong id of basemap + store.dispatch(setSelectedBaseMap("Dark")); + const newState = store.getState(); + // it doesn't use wrong id and keeps previous one + expect(selectSelectedBaseMap(newState)?.id).toEqual("Terrain"); + }); + + it("Selector should return value selected by group", () => { + const store = setupStore(); const state = store.getState(); - expect(selectSelectedBaseMapId(state)).toEqual("Terrain"); - expect(selectBaseMaps(state)).toEqual([ + const mapId = selectSelectedBaseMap(state)?.id; + expect(mapId).toEqual("Dark"); + expect(selectBaseMapsByGroup(state, BaseMapGroup.Maplibre)).toEqual([ + { + id: "Dark", + mapUrl: + "https://basemaps.cartocdn.com/gl/dark-matter-nolabels-gl-style/style.json", + name: "Dark", + group: BaseMapGroup.Maplibre, + iconId: "Dark", + }, { id: "Light", mapUrl: "https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json", name: "Light", - }, - { id: "Terrain", mapUrl: "", name: "Terrain" }, - { - id: "ArcGis", - name: "ArcGis", - mapUrl: "", - }, - { - id: "first", - mapUrl: "https://first-url.com", - name: "first name", + group: BaseMapGroup.Maplibre, + iconId: "Light", }, ]); - // set wrong id of basemap - store.dispatch(setSelectedBaseMaps("Dark")); - const newState = store.getState(); - // it doesn't use wrong id and keeps previous one - expect(selectSelectedBaseMapId(newState)).toEqual("Terrain"); }); }); diff --git a/src/redux/slices/base-maps-slice.ts b/src/redux/slices/base-maps-slice.ts index eedf6bef..1fa20ddc 100644 --- a/src/redux/slices/base-maps-slice.ts +++ b/src/redux/slices/base-maps-slice.ts @@ -2,15 +2,16 @@ import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; import { type BaseMap } from "../../types"; import { BASE_MAPS } from "../../constants/map-styles"; import { type RootState } from "../store"; +import { createSelector } from "reselect"; // Define a type for the slice state export interface BaseMapsState { - baseMap: BaseMap[]; - selectedBaseMap: string; + basemaps: BaseMap[]; + selectedBaseMapId: string; } const initialState: BaseMapsState = { - baseMap: BASE_MAPS, - selectedBaseMap: BASE_MAPS[0].id, + basemaps: BASE_MAPS, + selectedBaseMapId: BASE_MAPS[0]?.id ?? "", }; const baseMapsSlice = createSlice({ name: "baseMaps", @@ -20,34 +21,53 @@ const baseMapsSlice = createSlice({ return initialState; }, addBaseMap: (state: BaseMapsState, action: PayloadAction) => { - state.baseMap.push(action.payload); - state.selectedBaseMap = action.payload.id; + state.basemaps.push(action.payload); + state.selectedBaseMapId = action.payload.id; }, - setSelectedBaseMaps: ( + setSelectedBaseMap: ( state: BaseMapsState, action: PayloadAction ) => { - const newMap = state.baseMap.find((map) => map.id === action.payload); + const newMap = state.basemaps.find((map) => map.id === action.payload); if (newMap) { - state.selectedBaseMap = action.payload; + state.selectedBaseMapId = action.payload; } }, - deleteBaseMaps: (state: BaseMapsState, action: PayloadAction) => { - state.baseMap = state.baseMap.filter( - (keepMap) => keepMap.id !== action.payload + deleteBaseMap: (state: BaseMapsState, action: PayloadAction) => { + const idToDelete = action.payload; + state.basemaps = state.basemaps.filter( + (keepMap) => keepMap.id !== idToDelete ); - state.selectedBaseMap = state.baseMap[0].id; + if (state.selectedBaseMapId === idToDelete) { + state.selectedBaseMapId = state.basemaps[0]?.id ?? ""; + } }, }, }); -export const selectBaseMaps = (state: RootState): BaseMap[] => - state.baseMaps.baseMap; -export const selectSelectedBaseMapId = (state: RootState): string => - state.baseMaps.selectedBaseMap; +export const selectSelectedBaseMap = createSelector( + [ + (state: RootState) => state.baseMaps.basemaps, + (state: RootState) => state.baseMaps.selectedBaseMapId, + ], + (maps, selectedId): BaseMap | null => { + const el = maps.find((item) => item.id === selectedId); + return el ?? null; + } +); + +export const selectBaseMapsByGroup = createSelector( + [ + (state: RootState) => state.baseMaps.basemaps, + (_: RootState, group: string) => group, + ], + (maps, group): BaseMap[] => { + return group ? maps.filter((item) => item.group === group) : maps; + } +); export const { setInitialBaseMaps } = baseMapsSlice.actions; export const { addBaseMap } = baseMapsSlice.actions; -export const { setSelectedBaseMaps } = baseMapsSlice.actions; -export const { deleteBaseMaps } = baseMapsSlice.actions; +export const { setSelectedBaseMap } = baseMapsSlice.actions; +export const { deleteBaseMap } = baseMapsSlice.actions; export default baseMapsSlice.reducer; diff --git a/src/types.ts b/src/types.ts index 76437cb1..c51c1012 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,7 @@ -import type { BuildingSceneSublayer, StatsInfo } from "@loaders.gl/i3s/src/types"; +import type { + BuildingSceneSublayer, + StatsInfo, +} from "@loaders.gl/i3s/src/types"; import type { OrientedBoundingBox, BoundingSphere } from "@math.gl/culling"; import type { DefaultTheme } from "styled-components"; import type { Vector3, Matrix4 } from "@math.gl/core"; @@ -189,12 +192,20 @@ export interface Sublayer extends BuildingSceneSublayer { sublayers: Sublayer[]; } +export enum BaseMapGroup { + Maplibre = "Maplibre", + ArcGIS = "ArcGIS", + Terrain = "Terrain", +} + export interface BaseMap { id: string; name: string; mapUrl: string; token?: string; custom?: boolean; + group: BaseMapGroup; + iconId: string; } export interface PositionsData { diff --git a/src/utils/testing-utils/e2e-layers-panel.tsx b/src/utils/testing-utils/e2e-layers-panel.tsx index 5d06363b..5bd60613 100644 --- a/src/utils/testing-utils/e2e-layers-panel.tsx +++ b/src/utils/testing-utils/e2e-layers-panel.tsx @@ -3,27 +3,27 @@ import { PageId } from "../../types"; import type { Page } from "puppeteer"; export const checkLayersPanel = async ( - page, + page: Page, panelId: string, hasSelectedLayer = false, appMode = "" ): Promise => { // Tabs const tabsContainer = await page.$(`${panelId} > :first-child`); - expect((await tabsContainer.$$(":scope > *")).length).toBe(2); - expect(await tabsContainer.$(":first-child::after")).toBeDefined(); - expect(await tabsContainer.$(":last-child::after")).toBeNull(); + expect((await tabsContainer?.$$(":scope > *"))?.length).toBe(2); + expect(await tabsContainer?.$(":first-child::after")).toBeDefined(); + expect(await tabsContainer?.$(":last-child::after")).toBeNull(); // Close button const closeButtonIcon = await page.$( `${panelId} > :nth-child(2) > :first-child > :first-child` ); - expect(await closeButtonIcon.$(":scope::after")).toBeDefined(); - expect(await closeButtonIcon.$(":scope::before")).toBeDefined(); + expect(await closeButtonIcon?.$(":scope::after")).toBeDefined(); + expect(await closeButtonIcon?.$(":scope::before")).toBeDefined(); // Horizontal Line expect( - await page.$eval(`${panelId} > :nth-child(3)`, (node) => node.innerText) + await page.$eval(`${panelId} > :nth-child(3)`, (node) => node.innerHTML) ).toBe(""); // Layers @@ -47,38 +47,55 @@ export const checkLayersPanel = async ( await expect(panel).toMatchTextContent("Insert scene"); // Open map options - const mapOptionsTab = await tabsContainer.$(":last-child"); - await mapOptionsTab.click(); - expect(await tabsContainer.$(":first-child::after")).toBeNull(); - expect(await tabsContainer.$(":last-child::after")).toBeDefined(); + const mapOptionsTab = await tabsContainer?.$(":last-child"); + await mapOptionsTab?.click(); + expect(await tabsContainer?.$(":first-child::after")).toBeNull(); + expect(await tabsContainer?.$(":last-child::after")).toBeDefined(); // Header await expect(panel).toMatchTextContent("Base Map"); - // Base maps list - const baseMapsNames = await page.$$eval( - `${panelId} > :nth-child(4) > :first-child > :nth-child(2) > div`, - (nodes) => nodes.map((node) => node.innerText) - ); - - if (appMode === PageId.comparison) { - expect(baseMapsNames.length).toBe(3); - expect(baseMapsNames).toEqual(["Dark", "Light", "ArcGis"]); - } else { - expect(baseMapsNames.length).toBe(4); - expect(baseMapsNames).toEqual(["Dark", "Light", "Terrain", "ArcGis"]); + // Basemap list + let names = ["Dark", "Light"]; + const namesForViewDebug = [ + "Light gray", + "Dark gray", + "Streets", + "Streets(night)", + "Terrain", + ]; + if (appMode !== PageId.comparison) { + names = [...names, ...namesForViewDebug]; } + let successCount = 0; + for (const text of names) { + const element = await page?.$(`text/${text}`); + if (element) { + successCount++; + } + } + expect(successCount).toBe(names.length); // Dark is selected - const darkMapBackground = await page.$eval( - `${panelId} > :nth-child(4) > :first-child > :nth-child(2) > :first-child`, - (node) => getComputedStyle(node).getPropertyValue("background-color") - ); - expect(darkMapBackground).toBe("rgb(57, 58, 69)"); + const elementDark = await page?.$("div ::-p-text(Dark)"); + + let darkMapBackground; + if (elementDark) { + darkMapBackground = await page.evaluate((el) => { + const parent = el.parentElement; + if (parent) { + const computedStyle = window.getComputedStyle(parent); + return computedStyle.backgroundColor; + } else { + return null; + } + }, elementDark); + } + expect(darkMapBackground).toBe("rgb(57, 58, 69)"); // "#393A45" // Insert Base Map button await page.waitForSelector("#map-options-container"); - const optionsContainer = await panel.$("#map-options-container"); + const optionsContainer = await panel?.$("#map-options-container"); await expect(optionsContainer).toMatchTextContent("Insert Base Map"); }; @@ -170,7 +187,7 @@ export const checkInserLayerErrors = async ( expect(anyExtraPanel).toBeNull(); }; -export const inserAndDeleteLayer = async ( +export const insertAndDeleteLayer = async ( page: Page, panelId: string, url: string