From 613f4a9bda2396e2deae9ad83ca5d9446b363b74 Mon Sep 17 00:00:00 2001 From: Johannes Ebeling Date: Wed, 24 Jul 2024 12:01:10 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20Initial=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 21 ++ .gitignore | 4 + .tool-versions | 1 + bun.lockb | Bin 0 -> 30447 bytes example/config.json | 569 ++++++++++++++++++++++++++++++++++ example/index.html | 13 + example/radar.css | 10 + jsconfig.json | 27 ++ package.json | 23 ++ src/index.js | 50 +++ src/radar_visualization.js | 560 +++++++++++++++++++++++++++++++++ 11 files changed, 1278 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .tool-versions create mode 100755 bun.lockb create mode 100644 example/config.json create mode 100644 example/index.html create mode 100644 example/radar.css create mode 100644 jsconfig.json create mode 100644 package.json create mode 100644 src/index.js create mode 100644 src/radar_visualization.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..14963ae --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,21 @@ +name: Release +on: + push: + tags: + - "**" +jobs: + release: + name: Create Release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - name: Install Dependencies + run: bun install + - name: Build Release + run: bun release + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: dist/index.js + make_latest: "true" diff --git a/.gitignore b/.gitignore index c6bba59..bc1a104 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,7 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* + +# Custom + +example/index.js diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..8155350 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +bun 1.1.20 diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..17d6150d8f8d8106bf3cb0f7a792aa1e6866f5fd GIT binary patch literal 30447 zcmeHw30RFy_;=-`T`9?y7G&$R@2fVVQg$Lb)oE8b=d_7Lwur3Bl7!b%c2ah-FA>?- zWQl|DsecyL||LZcY`<-Xzp5Jfoxu1Dv=9%ZbQx7fuXf8+JFC;>r z9TB7H7abuB!VHh}4+{(lXEOsMIg?pDW{iQXgqWDvn%-N*)%ulW=ABz{Dv7(bc$CxK zt!eKhzxKFjakPC$r9G2kpb!KPek3L>6ekJNtK9_`R3s}VmK0zdV}gkQW9HPz$jM@t z;aY*pXYxWOvpIVqEf47gh}-e={UgI8IZ&R>;_ap%4q|WpOyH_^kpSXUY0{1GvyiPAn)OtOr3H@J)$k zN3)kh8tq7d80(LO80`jq5E~K@gG>Ffe*a)FA7VdFG&dMxR$K@d^Z0kUn+7J1Z zKScFI6YZzkH3y6o*!_z?a^aRK^X=u*{X*K=iuMJvdH&cg%3oa+`TmH482!;6Vyu4y z)QA2uQR3^TLfjtG>5$Lw7ft)A1NaE_l~5RYEWv2xNrV{r4IsvRC_|Mq8WFQG>cr%u z``g*6vBEx0cF0VW&Rp_#NoVm#j;HEYzE4v*XIS?2MPlkk*>zKvZFOLt$WssMpJaS? z#wV}T__12A<@+7j$I|>-qVwRep6m0W7vHxp(tgtOX~O<7|*;tx^%Al!b^uBYL>?*20*{*tgi zR&Ja7J^j1?@c50UN4rx3i2;R&^V^ zcv_T70ayF*sZIY(k9J-3<)p&ZZPWLJKa=@(?bY~`o%{dJPnX*EJ?3UG^K(LFP|j(+MKHxw&uvD%Ng-mp*KRZdL`-hpTqVO$A|Y10*E~8I&>uJh7)uv#16o!FK|nBMnb@8cH^OMeyDLu%O{de$%LiTvBcs;0OEx z{s!Qk1$eX_MU+75?+6!k`&)}23V6EzTZ^9!cspAATZ=FJ!}`Ag-iB5`Bt;6OoC$O| z^gl_HxFr%&E)MW?|B-xA@w)&|zkjX8zZ9y!wf^r04=%lapc5$&J4XWE>JR+C81RlX zd~5A57P7y!cxhNK{Yn2B4tQsQ{bUs>iUW!Nt^ghul=b6ZYwZ_nD<-BZz~jAZ?fugQ zJpKN)w*ElCWB(!bl6JM^15$21;QwU&cnEm<{cElLouvM3{2mKtK-s@zXIu{i4=ODj@hzfOn+z-`4gY zQ!sh}4Nv%6ib?&^fTuq{t*w72;BovR?Qbpq1Fe2hvDh|Ze_wcU!TbMT>z@R8eE$C{ zK2xavmi$ii#QqY%qyJzU5qa%}90achFLR{*|HXeEfG77KInfSL0;zv7;BovWY2wG0 zI7s;mLiM+VBYJ|zm)JjPzZu}&!G5&+zxZz!;L(4?Z}oN&Lw^&Ka<>7G{`;@>E5RlV z!DCgTwt>_?1n_^-|K|f9=Rb1aTU-Abzz?9cpV;*qx7BBpatzqy!ugN*ueJ3%0G{;! zdU6piNd56b_}13{h-N=l+gf~2*dIdwBNmn-V&pzxy178?p9c5=fX6h}P4b(=qK=e1 z3V7^)IDeA7maugE7r;t z8dC0-pnhUE$rlyh9`@zv{jat54;I3=R4>sJJ7)r(jGsg&N=)$ifHwvIq1`CtKDNX` z@YR5~rr{weQXqI|*uTX7hx?o`l;WPZNP*xN0Nw%c*nhf!kbcw>3Bg|mJdQuGjH9qH zorx3(UQVT<|6<*wABjSOWl_3=XoDcWHwfMXCI|+4jQ2;6%56GEn_w@E3K(N~Lw=Hq zF>NGBV~o$dr6Ao@jJDYd^cW)^o^>j;CPth+2=Y0Bpq(xt*j{*csem#1As7VnCxf6L zxF8tlG4k=K+@@pXj{(7Q`1u0kI6)i_F$Rn=e<~&*V2rqFAXxuQ5DcQk$TJHB$DlbN zn4T+0rwGyuAjUwCvD`usj8j1{V2tTSf_O2+Xb*mmz<@F8SAd|s>4Nksh%sP{`V346 z!ry-Ox1asr?`L(2b=dL#PX^!<7vuBNsTp#iMy!YVp9_55E3S-pw6xNH(e6!2(VDqZ zy=_uPMXoHht=S!+d~8hC4DSSk{eS;d4qg>Bou{yHiImd!ZpS!1htq7SHneU-vrPnRD=y zcIi{r@xXD_RCz}?&?LX`Y6B3bCBfVw0~dBn%!lN^`LQid3S#*_$n;9 z|8{syVY_?@v3a8^%A8Z;(r%UYS$%Vk&1t>;dZh{bGEdTY@mVH;A>T2+I7^I~R}`8S zthmL&yTDL4&uh!I_HVuAJB&V};}ZFE-l}VTbKdw>s$0Z{x1Z~A__g1a^EtM+RIRUe ze?2mR#*1SN35<0qMKR}D>6UGBTA;;^pW zW=+4CZN>MN1~{y{Gu9(?pq+qqu!2f{ zY%WyXq^3Sc$>;Fes;3E+wNr*4d!60iR4q1j%c+#N5j&)(yU%AEZ@gHhjGH1;{JLX&$=%XN>MP3n?0isHUU0?T`@6@$F`5teUbbncHX!`^ z4gHJDW-U+o7}2={Yw5Y`-D$kI4j_SH*)=dBx4*HnLZJP$f;oqkHbg}J94srDV#9o6 zytrW3m$iNT$NMH(i6v<+IOceDF<0GbTkTILoewL5Kev1K@zGrxFKm<42Zm&a*I!p} zn{2Y+{3+(~?xNb(~bSd+Cb{-K>}A8N24!`W{eyX4d1e!zo$m^vkP# z7}vCCG0RnUuBGwH*0VxCJH+z6H~pHT+v0<_jeD7o_@O$)NNl#^T#NnkHDUe}KHV#j zY*$%ql=t3bVr9N|kke}k=IS&(lMTb=+s58mvPi3f#!FuZF$@@UUWNMZued#9)rN$` z?%Ts3Uwl)zIU%ejL*~`0{GeOQ;*+)p7F&B98Wi!ctGV>9`)fMJ)odtLk1_tTPt|%; zD2*4_cqA|?4xUjs^F+qH;A!2wC+k9Lin2bv)gJbe_oo7`?LD-TPLCYY&|0$oQZ@NFinnbMe0YJ)~<+ke&DXeaqCMFZ_Jl zW|8+O_qR1?O)FS>CDZb2fBGM0^wq8mOdB|87i(se$GUdi28B4RusUBBYoOp{ENOek*KxGpmGMh?9imS7hdSHZPyQZ~ud6tL zdup)V*=ZHQW(gy`4rE5qc;U6FJ}|UK7^^ytet0rvdzDJ|<8dnQQaj%ot`V4;cAPUX z!og%&jQ+V>r9a25QTmcKc%k{N6X&0d-0AkzYu)y}lO7nSX6>Qz!s|?ZV1%l#%GK5T zx~OX3N#EPKUpp6UG)Zc_bmY8| zFEX^1EzH}$%ItO1{=M7b8zm-pR@@l3(>G`1^|>@&Sbo?rOhY zTWGv0^{mhzMt(kIkvJxD(c2~C47&}o*u5@&u2iCzZx1ID?&H1>yIm|^^8Db!cqyH#P068CCVFYkKEsG`STXMIM@bs5Dj|iK zQC_I?uZPY`FE`^E3eg$oFLg~l&~MF~iB?}cGVH6m*d8g&p5#4%-8X!R|1Cz~gZ{%( zMv5zbtvtQ&OZd{{QE~HKwewP$#a<=;1Tyxhsi<7N77@k06cG~Uj1-b3b-#(GWbKAllqxMWtJtmW(9Cr8vh zeUet{sIL}WR1yAKUj4D>5V39f|7!Felw)REP})YD6MwMl(6!aht`AcN(|GZnlLW@6 zyaWE>`7^o<8(O?jJF9=0isjUz(els3WHxM0-8jNde)E1i;~e>Dzwn>m^A6rxb3IKp zcft{u$`5Z2rLkRh+$f>(b|s_`Gup#tmtEzT!BhR*7RPQfarPs>G)!pbi>FTT0=QQ4KblxSSLp=3MC-0sW zqH(mMTl%z^$x?I1Oxc>))_uah^;=yeH}&k-pYzr3_*rpP<$(NGi=|nQKW?w>^rXM> z_|J3u4yvK?s?&MRWJ)|{$ZlBR!kd{L`leLo#l=u_@tjxRlj^qJG1j}aT=$J1Z}%qe z6&38O+tvJH{JpLpH{J4ZXvt#FX~xC;-q&|5z4UqU5dB>bQ%9{h@>FsDsV6rL?6>IOdGJt%#;Zl=-FIfexUohL ztHt+P>^%2PV)bU3Zd(*eQw{EqOPwFq)8*l$4L7o*Jub`FC`*mndADX*O6k~}noL_} zA5YHPamC`+G+u2wZ^9^x3RBP8b{7smk;%VQc59)c)rXMe#hbtFR@toc*}n3btm|{T z=!Eg+r+XVqT^?TErn1M&WhHl{uYUFLWya2)P2=rB=Z!q6GbUccAtznp<+||NMd8PH zhjzZ&t7hn&O1CSg74+I3e(=70o+I~YW*6%^@8@TIy@oBTOvtk@`TTg%6P*Etmub8` z>AVulb|+dZ56bl|*%79{=$o9&KHlNlUX|if(TP823^PdR;$u9w#B%44QnjgZmd49Y ztsT;1t#a6wlqI7wz3r;pp3!)F(RmXCu8-UAzUE?j?vBA@PF7cYmY)7nwIjA@QkM(n zPoD(JeA;gK`it?FJ{Q70T<=({zGr!RY_-DC%c<+bcT~u4pR$t1t3&6_ymRr#x+#W_ z%1=*^xRBH7e$2+BlX4&2N@N!49$Po@=GymKoi~0|)!lLVk+rn)?Zvwo@f)u_@{Lz8 zTJh;~sO=~(8ZYh>k-&I#Q1|x!9I;QRgC z@+0?_{DETi zvh2ypo0%7Gga+E)I(~^aBJD?vyky3Xxz^iXx_0j!d`&W@EJ8cwV$D|Cbm_+GtR=Z>T`wp+on8^vQ9|YChuin#2g$0g*Gx8W zV}*Vf7aQn00)OL0)~UL5Ufw#-wi5~~A8e5FKdmoo>s09Hp?>AjJNpT30(O=y8_|B+ z@#;%ClgB3RQhIT3T0}YfFmKdKmo5sflKJ^bRd$Q?XuM>f5a}7CH*UJ3{CLrURL@zO z5p3UfRsnwdM-Q776t1=H({a7Z)3RD&(oEGltCc@zTG|+Gd+q6E&&|GjuQ=)bL5~~b zl5iVgD>RDm%t7hlggg-_k{w4dL zl)ZyDo;o7F<>Bd-DP0bVbvBPXYI{7rF60buJIHmAm3IVZB+QdT?kFwDclg|?GrN{X9bIqEZ%*6{oE*TKBVRVUssZa8V zwrXm{o=)l^m*7a+* z$a~$MbFOSqv)t^OoN}4_*m-&Fo_pq_l)Ltg?UTGd&;EXglJB=Pa!)&#YL1}sn%A?! z^Y-}5$#>OvPkqfBJl|RIeU`0ruW>pm?jdtlORve&FO4oppXphq($n~I-#z*_H|rR> z6!a|k>M_&Je9y=*sqU<|6KTBgEvY^*RA>B~n6DSPzH6LXL{i*onY)|MS~%xeUjNEt zR@N4~KmBm2hoi*!D?8#QqyA1+sCRLe%gV(xOjPH-NdLXvu`P@AIu3D zb?JcCk&*ZGd(g)Rt9omJ_nPXAAt&udr0z^8vbO02ePKhjZ~hS zoFlb!kXE;%4;GdxSB?9m9ZVnKeQ1D_-Jom>Z}*b&H^Hf9)|brpS8bD!?9$_DcK@%} zeR64jfNzcUf#DUg_}C50kg5wEenyo})-l_~i|(;w0>|6gv&#;~p-shoy*Z5ZthPZ4 zPKRgc+n?Uo?d6OCD`mab87|PAmFccSU;mQtBuLJ9TDAH8L*3cuxkj0}8bfou_c@pK z>$R=G(QC%y1!bBe-Y=E%JXU(oNx#VZz`Jvk)<<~duHWP~rQ3r@6>TfUyiEH3B>B!l z@v_#K_b*zec);%xlX25oaih_vcS9y_;VtQL!^n2;`Q%Y|eBL~Gal`f9eU7FBeKe;It5k2vS_@HbY87EW67}FPU2Q}doGkL z-@Q6ode?ImPI8-~uI*x%Bp;1h$t*l?Xp(WX?2v4P)zZ*^#!O3!c)ne7)!>)OHI_q^ z>Hfv?Z-LYuDz_Co)vjZ zF*6s)j1@CJJ!&cUqEz6bOd9V%Ixq zre9^zXP+}Ok5(|Zu&T9Xx=lQN;-$D!fStM2&nMgiOFdT{KTqQwMCUEYkX>XxrZ}Xd zQViG0FkHuFd-*$aw%&QU%bjh;T`lBoyD6uzD)iW>$@4b%LkXAO)8uwcYA$gKN{~~I`7_#r^|1K_}WLGi|g+oac+s{;o*C4 z+M6$62eVQ;ta-@WTxeFzone>D3)UKDa8`WjX|7#bMuQk=wRcwXRWW;Kdp}8<(hBXtM$@r<G;61?pTz09;@%yp zc{u+54gWhmK)z$qzqkDFY{_4%|9U`l4`9E?K2QJdAv*eRN%SA?xs&nZzj);DwDDiO z_80Tl1Ajg6*8_h&@Ye%>J@D59e?9Qm1Ajg6*8_h&@JAjf73_$1^Wf9S+Hg7kdLa>9 z9xFUtkDO1YHOx+`Avga2D7XiW`^CtIR6{{LLA*eQ zfeZ&30WuO~6v$|hF(6|>#({W)j0c$j;sY`fWDy@g{y#A*LE=CbfXoG%2QnW7zx$Arg zp%1W~*j{Wm`UUSR`T_4T@eA@`S=5nwu|ATIIFgU$kPqucyNRu67nUb=VLtMs?Zls? zJm#al$cuT%)s!uIkUtQ_1;iPo4~P@U0FZtl{Xy{F_XWZGfcL`$1iyz{fb<421i|n5 zW+0{@#vn!@y+H8yBuo%p5FL=7AU!|`H{!7z=40KMk9A=_mPZ}Q$7|$5oAp7kUesY( z%tO2In$(AR$cJ_z7q$bx+v2xo?0fia*a5^51j`~{Q+ByRz8lD3kRgJ2z%aOgu*a8* zpg)c`GV_VHfHB6b{<$}77-M9i7)Vl<;QTKo@rkB-272Hg>`AC0Cx}T)gtK^TE)UQ4 z<8zZU*d)X!n(J92hMa;(PN@TpiJp-jI33Ri#PbNrxoQ&PhENT*3^jOiAUWBMufZxn zL!B*1&VJ)-@D758I(?9wde)#B3R>ugN#uOGU+SSwTqGyrHE6~I2A+!~ayDLr2I`?s zV%J;Kj|K+~*k z!u?u+`7gFL;p;!J?e{(q)FVdg2MzTIKMM^S;jv1fX<~F~!2HIeO+4EI%^-GUH>HhY0yc79EIH2;dapo3PL3rfdp2mm>Q?*h_qH`L?g%Vsw(tDL0>J#{ zZvWl_!2G{@8ilOJQ33;c^w+tOrV*MOX&Rxqk*>irhZ|-4azlxPy5zF+54q5(FcUPUyG=To9t4s5^9r$p0u)+8FJM`&&&_lKN!OwOZi@J{0~ zk;r;nY#Y%)J;Ecopq^%J6Y9Ohg5TTL*lz?QY_*d3pV%hM*3d%)7SQL$244$}NW=ni z?lw6W8AgA=2-a^x&%#7wSawox1;6uXv)&Q1;4mx!ai!Jx*(QE#%ytGaxDIXH(jtMT zalgivV&4XgkOdYn2q4?<`@!-?&o;5`_jemK!gH|TE|4?E$r;cR;`la;ft*H8PK%Zh zH_+pc%fFwU$;sv9WN8U;UwHXeg7%QJ%gNc&65{x_jDehHPEMPK_c_55zp-~f0}E-& zZ$kZm_~7?`Lm%L&_YLn^q`x)3r-beI{krk9O-2`T+B!K68`tFU58O)5TPNpXONb|0 z=$Yyn8i=0vca7Gtr&|v-I;2bT-&FEn z@p^C8N&klWP;O+z9z~n!w?<{H+yr`@%hWomcK?_nEe}{DC?mG-$lM;166GWI3h?K{ z4(##elcLtzG=q=`1A^g@@`kNtA+XZ0y%W6q|x%0%mvwxPYBz067*O< z+bTCIg%Qvly1o&1{{w!n%B61~zt)r<>({liWqHkAW-rW-I&8qCts&>S6fXSds-S+m zy#wmR--K_c^iaW_DXukZ!=BD-QV)$Zn_3!hXV^gxVZE?_gzXddkFb3_u0M63PS$q# zhqGcy2mC`7n5X!7lg&HzDTjGnE}H}Q+^0ly=I}tbZ$Fj!@4B-sOs|N?wX0L&!^OtH zUIPZ$Uxd9J!=|=!C_RtuAI#*i0$3bgd=#7O&k2d*4WKUd;qPJu^LSBQD}8+qJ1B(9 zP2#b^l|G(pGiebYR^WG7c>=~OPCA8+5Wtc$OwE$L%8s4Q-2i!R$lOj zH6SlIgw0`b{Db4+IwS%*Zd4?06hR6u;)5vMlxS7}4|cjClf#M#h@6ZUY}h>DA_JG* zPy;R{1b?&xG(0HH4G~lx6Um8;hzx1A4b-xNY6f-1#7!n+Y)SK|hI;tehBU=sC;|ii zkq-eFFDaZ+%eeZs^Re|wQSmg7^BHIvz=8i-Y(2Hy;iIWbQQM459zG1OL`9K4#7B~p z$Y_`*1W33N6(#68e4rpfVMVw@v>wUF(=wRU)=|qIMhFdzy;6jlsXuJOhf$ZJT8z^# zABI<=`iOeL5Y&c$IfcTB@a*sh0zQy`EvlUjLkl0>kQUWuYJtWFQZ?xpgt+G1;R@Md>llmmo}60Yo=w0YI~!p zoq8p2xpN9l?IK!CCUd^IBqge?_y#CI;?$0HigJI!yMhHHA55Q~O~B70a?rpc`l!+5 zgD=1)=v(SWMNDQzHPbcg%Rt5T*VKBV<)@4PC*b%N^H&DcyLZdCRcK`(VncW_z^~tCRQLKTGPZFCt_Fs z&0HlSy2-gpL~Q-!#J>;qN$L)?cCJD{QA3dtY<5sSZ_(BMf|V?P(h~70rFQW722+;} zV*(@+SE zZAdU-dyPM*+u3SjuQbxM(WIKrWd*{^o?mnzM9uKhivcq|3C}!s_~zfz>RSQe`fH{+ zJ}509X%jBr|77}QngNy?t^_%}$mUwmPzY)GQ#c@)_-hfW2XP|(qPaXKESm#D{PC9} z&A6jcS>SAxNA;1G90Sc0tStDg6C_{@L!?)%MvJNXe$$NgP-mmXl!(uP&|*pyRKxBK zv=INcDL%1gc-JvOZS6E;Yke63m?}?E7@8x8fIXHgxQK`hXzux-l>$O6%;ax|8d5ze zIs)!}NQ*|%ivbNi3H=FnP)%Ft)b&;aI+n|%c510k$lqS$Z>crom4>}BAZSQH<)-{c Mg4#>_|L{Nm1EDPqkpKVy literal 0 HcmV?d00001 diff --git a/example/config.json b/example/config.json new file mode 100644 index 0000000..a1e2803 --- /dev/null +++ b/example/config.json @@ -0,0 +1,569 @@ +{ + "date": "2024.06", + "svg_id": "radar", + "colors": { + "background": "#fff0", + "grid": "#dddde0", + "inactive": "#ddd" + }, + "entries": [ + { + "quadrant": 3, + "ring": 1, + "label": "AWS Athena", + "active": true, + "moved": 1 + }, + { + "quadrant": 3, + "ring": 0, + "label": "AWS EMR", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 2, + "label": "AWS Glue", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 2, + "label": "AWS Lake Formation", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 0, + "label": "Airflow", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 0, + "label": "Databricks", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 1, + "label": "Flink", + "link": "https://engineering.zalando.com/tags/apache-flink.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 1, + "label": "Google BigQuery", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 1, + "label": "Presto", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 0, + "label": "Spark", + "link": "https://engineering.zalando.com/tags/apache-spark.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 2, + "label": "Streamlit", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 1, + "label": "dbt", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 0, + "label": "AWS DynamoDB", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 0, + "label": "AWS S3", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 0, + "label": "Amazon ElastiCache", + "link": "https://engineering.zalando.com/tags/redis.html", + "active": true, + "moved": 1 + }, + { + "quadrant": 2, + "ring": 2, + "label": "Amazon MemoryDB", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 1, + "label": "Amazon Redshift", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 1, + "label": "Amazon Feature Store", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 3, + "label": "Apache Cassandra", + "link": "https://engineering.zalando.com/tags/cassandra.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 3, + "label": "Consul", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 1, + "label": "Druid", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 0, + "label": "Elasticsearch", + "link": "https://engineering.zalando.com/tags/elasticsearch.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 0, + "label": "Exasol", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 3, + "label": "HBase", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 1, + "label": "HDFS", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 3, + "label": "Hazelcast", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 3, + "label": "Memcached", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 3, + "label": "MongoDB", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 3, + "label": "MySQL", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 3, + "label": "Oracle DB", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 0, + "label": "PostgreSQL", + "link": "https://engineering.zalando.com/tags/postgresql.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 3, + "label": "Redis", + "link": "https://engineering.zalando.com/tags/redis.html", + "active": true, + "moved": -1 + }, + { + "quadrant": 2, + "ring": 2, + "label": "RocksDB", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 3, + "label": "Solr", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 2, + "label": "Valkey", + "link": "https://engineering.zalando.com/tags/redis.html", + "active": true, + "moved": 2 + }, + { + "quadrant": 2, + "ring": 3, + "label": "ZooKeeper", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 0, + "label": "AWS CloudFormation", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 0, + "label": "AWS CloudFront", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 1, + "label": "AWS Elemental MediaConvert", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 1, + "label": "AWS Lambda", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 2, + "label": "AWS Service Catalog", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 1, + "label": "AWS Step Functions", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 2, + "label": "Amazon Bedrock", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 2, + "label": "Amazon Pinpoint", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 0, + "label": "Amazon SageMaker", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 0, + "label": "Docker", + "link": "https://engineering.zalando.com/tags/docker.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 2, + "label": "GraalVM", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 2, + "label": "Kotlin Multiplatform", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 0, + "label": "Kubernetes", + "link": "https://engineering.zalando.com/tags/kubernetes.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 1, + "label": "Open Policy Agent", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 0, + "label": "OpenTelemetry", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 0, + "label": "Skipper", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 2, + "label": "Slurm", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 1, + "label": "WebAssembly", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 0, + "label": "ZMON", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 3, + "label": "Clojure", + "link": "https://engineering.zalando.com/tags/clojure.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 1, + "label": "Dart", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 0, + "label": "Go", + "link": "https://engineering.zalando.com/tags/golang.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 0, + "label": "GraphQL", + "link": "https://engineering.zalando.com/tags/graphql.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 3, + "label": "Haskell", + "link": "https://engineering.zalando.com/tags/haskell.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 0, + "label": "Java", + "link": "https://engineering.zalando.com/tags/java.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 0, + "label": "JavaScript", + "link": "https://engineering.zalando.com/tags/javascript.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 0, + "label": "Kotlin", + "link": "https://engineering.zalando.com/tags/kotlin.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 0, + "label": "OpenAPI (Swagger)", + "link": "https://engineering.zalando.com/tags/openapi.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 0, + "label": "Python", + "link": "https://engineering.zalando.com/tags/python.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 2, + "label": "R", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 3, + "label": "Rust", + "link": "https://engineering.zalando.com/tags/rust.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 0, + "label": "Scala", + "link": "https://engineering.zalando.com/tags/scala.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 0, + "label": "Swift", + "link": "https://engineering.zalando.com/tags/swift.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 0, + "label": "TypeScript", + "link": "https://engineering.zalando.com/tags/typescript.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 0, + "label": "AWS Kinesis", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 0, + "label": "AWS SNS", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 0, + "label": "AWS SQS", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 0, + "label": "Kafka", + "link": "https://engineering.zalando.com/tags/apache-kafka.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 0, + "label": "Nakadi", + "link": "https://nakadi.io", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 1, + "label": "RabbitMQ", + "link": "https://engineering.zalando.com/tags/rabbitmq.html", + "active": true, + "moved": 0 + } + ] +} diff --git a/example/index.html b/example/index.html new file mode 100644 index 0000000..6f4041e --- /dev/null +++ b/example/index.html @@ -0,0 +1,13 @@ + + + + + + Tech Radar + + + + + + + diff --git a/example/radar.css b/example/radar.css new file mode 100644 index 0000000..201682c --- /dev/null +++ b/example/radar.css @@ -0,0 +1,10 @@ +body { + font-family: "Source Sans Pro", arial, helvetica, sans-serif; + background-color: white; +} + +@media (prefers-color-scheme: dark) { + body { + background-color: black; + } +} diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..74a2c6f --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "tech-radar", + "version": "1.0.0", + "type": "module", + "author": "sipgate GmbH", + "license": "ISC", + "description": "", + "main": "index.js", + "scripts": { + "dev": "bun build ./src/index.js --outdir ./example --watch & bunx serve example", + "release": "bun build --minify ./src/index.js --outdir ./dist" + }, + "dependencies": { + "d3": "^7.9.0", + "d3v4": "^4.2.2" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..0956664 --- /dev/null +++ b/src/index.js @@ -0,0 +1,50 @@ +import { + drawRadarVisualization, + removeRadarVisualization, +} from "./radar_visualization.js"; + +window + .matchMedia("(prefers-color-scheme: dark)") + .addEventListener("change", async () => { + const config = await fetchConfig(); + removeRadarVisualization(config); + renderRadar(config); + }); + +window.addEventListener("load", async () => { + const config = await fetchConfig(); + renderRadar(config); +}); + +const fetchConfig = async () => { + try { + const config = await fetch("./config.json"); + return config.json(); + } catch (error) { + console.error("Error fetching config.json", error); + return {}; + } +}; + +const renderRadar = async (config) => { + drawRadarVisualization({ + repo_url: "https://github.com/zalando/tech-radar", + title: "Zalando Tech Radar", + date: config.date, + quadrants: [ + { name: "Languages" }, + { name: "Infrastructure" }, + { name: "Datastores" }, + { name: "Data Management" }, + ], + rings: [ + { name: "ADOPT", color: "#5ba300" }, + { name: "TRIAL", color: "#009eb0" }, + { name: "ASSESS", color: "#c7ba00" }, + { name: "HOLD", color: "#e09b96" }, + ], + entries: config.entries, + colors: config.colors, + print_ring_descriptions_table: true, + }); +}; diff --git a/src/radar_visualization.js b/src/radar_visualization.js new file mode 100644 index 0000000..bd431f2 --- /dev/null +++ b/src/radar_visualization.js @@ -0,0 +1,560 @@ +import * as d3 from "d3v4"; + +const checkIsDarkSchemePreferred = () => + window.matchMedia("(prefers-color-scheme:dark)").matches; + +const drawRadarVisualization = (config) => { + storedConfig = config; + + config.svg_id = config.svg || "radar"; + config.width = config.width || 1450; + config.height = config.height || 1000; + config.colors = + "colors" in config + ? config.colors + : { + background: "#fff", + grid: "#dddde0", + inactive: "#ddd", + }; + config.print_layout = "print_layout" in config ? config.print_layout : true; + config.links_in_new_tabs = + "links_in_new_tabs" in config ? config.links_in_new_tabs : true; + config.repo_url = config.repo_url || "#"; + config.print_ring_descriptions_table = + "print_ring_descriptions_table" in config + ? config.print_ring_descriptions_table + : false; + + // custom random number generator, to make random sequence reproducible + // source: https://stackoverflow.com/questions/521295 + var seed = 42; + function random() { + var x = Math.sin(seed++) * 10000; + return x - Math.floor(x); + } + + function random_between(min, max) { + return min + random() * (max - min); + } + + function normal_between(min, max) { + return min + (random() + random()) * 0.5 * (max - min); + } + + // radial_min / radial_max are multiples of PI + const quadrants = [ + { radial_min: 0, radial_max: 0.5, factor_x: 1, factor_y: 1 }, + { radial_min: 0.5, radial_max: 1, factor_x: -1, factor_y: 1 }, + { radial_min: -1, radial_max: -0.5, factor_x: -1, factor_y: -1 }, + { radial_min: -0.5, radial_max: 0, factor_x: 1, factor_y: -1 }, + ]; + + const rings = [ + { radius: 130 }, + { radius: 220 }, + { radius: 310 }, + { radius: 400 }, + ]; + + const title_offset = { x: -675, y: -420 }; + + const footer_offset = { x: -155, y: 450 }; + + const legend_offset = [ + { x: 450, y: 90 }, + { x: -675, y: 90 }, + { x: -675, y: -310 }, + { x: 450, y: -310 }, + ]; + + function polar(cartesian) { + var x = cartesian.x; + var y = cartesian.y; + return { + t: Math.atan2(y, x), + r: Math.sqrt(x * x + y * y), + }; + } + + function cartesian(polar) { + return { + x: polar.r * Math.cos(polar.t), + y: polar.r * Math.sin(polar.t), + }; + } + + function bounded_interval(value, min, max) { + var low = Math.min(min, max); + var high = Math.max(min, max); + return Math.min(Math.max(value, low), high); + } + + function bounded_ring(polar, r_min, r_max) { + return { + t: polar.t, + r: bounded_interval(polar.r, r_min, r_max), + }; + } + + function bounded_box(point, min, max) { + return { + x: bounded_interval(point.x, min.x, max.x), + y: bounded_interval(point.y, min.y, max.y), + }; + } + + function segment(quadrant, ring) { + var polar_min = { + t: quadrants[quadrant].radial_min * Math.PI, + r: ring === 0 ? 30 : rings[ring - 1].radius, + }; + var polar_max = { + t: quadrants[quadrant].radial_max * Math.PI, + r: rings[ring].radius, + }; + var cartesian_min = { + x: 15 * quadrants[quadrant].factor_x, + y: 15 * quadrants[quadrant].factor_y, + }; + var cartesian_max = { + x: rings[3].radius * quadrants[quadrant].factor_x, + y: rings[3].radius * quadrants[quadrant].factor_y, + }; + return { + clipx: function (d) { + var c = bounded_box(d, cartesian_min, cartesian_max); + var p = bounded_ring(polar(c), polar_min.r + 15, polar_max.r - 15); + d.x = cartesian(p).x; // adjust data too! + return d.x; + }, + clipy: function (d) { + var c = bounded_box(d, cartesian_min, cartesian_max); + var p = bounded_ring(polar(c), polar_min.r + 15, polar_max.r - 15); + d.y = cartesian(p).y; // adjust data too! + return d.y; + }, + random: function () { + return cartesian({ + t: random_between(polar_min.t, polar_max.t), + r: normal_between(polar_min.r, polar_max.r), + }); + }, + }; + } + + // position each entry randomly in its segment + for (var i = 0; i < config.entries.length; i++) { + var entry = config.entries[i]; + entry.segment = segment(entry.quadrant, entry.ring); + var point = entry.segment.random(); + entry.x = point.x; + entry.y = point.y; + entry.color = + entry.active || config.print_layout + ? config.rings[entry.ring].color + : config.colors.inactive; + } + + // partition entries according to segments + var segmented = new Array(4); + for (var quadrant = 0; quadrant < 4; quadrant++) { + segmented[quadrant] = new Array(4); + for (var ring = 0; ring < 4; ring++) { + segmented[quadrant][ring] = []; + } + } + for (var i = 0; i < config.entries.length; i++) { + var entry = config.entries[i]; + segmented[entry.quadrant][entry.ring].push(entry); + } + + // assign unique sequential id to each entry + var id = 1; + for (var quadrant of [2, 3, 1, 0]) { + for (var ring = 0; ring < 4; ring++) { + var entries = segmented[quadrant][ring]; + entries.sort(function (a, b) { + return a.label.localeCompare(b.label); + }); + for (var i = 0; i < entries.length; i++) { + entries[i].id = "" + id++; + } + } + } + + function translate(x, y) { + return "translate(" + x + "," + y + ")"; + } + + function viewbox(quadrant) { + return [ + Math.max(0, quadrants[quadrant].factor_x * 400) - 420, + Math.max(0, quadrants[quadrant].factor_y * 400) - 420, + 440, + 440, + ].join(" "); + } + + // adjust with config.scale. + config.scale = config.scale || 1; + var scaled_width = config.width * config.scale; + var scaled_height = config.height * config.scale; + + var svg = d3 + .select("svg#" + config.svg_id) + .style("background-color", config.colors.background) + .attr("width", scaled_width) + .attr("height", scaled_height); + + var radar = svg.append("g"); + if ("zoomed_quadrant" in config) { + svg.attr("viewBox", viewbox(config.zoomed_quadrant)); + } else { + radar.attr( + "transform", + translate(scaled_width / 2, scaled_height / 2).concat( + `scale(${config.scale})`, + ), + ); + } + + var grid = radar.append("g"); + + // define default font-family + config.font_family = config.font_family || "Arial, Helvetica"; + + // draw grid lines + grid + .append("line") + .attr("x1", 0) + .attr("y1", -400) + .attr("x2", 0) + .attr("y2", 400) + .style("stroke", config.colors.grid) + .style("stroke-width", 1); + grid + .append("line") + .attr("x1", -400) + .attr("y1", 0) + .attr("x2", 400) + .attr("y2", 0) + .style("stroke", config.colors.grid) + .style("stroke-width", 1); + + // background color. Usage `.attr("filter", "url(#solid)")` + // SOURCE: https://stackoverflow.com/a/31013492/2609980 + var defs = grid.append("defs"); + var filterLight = defs + .append("filter") + .attr("x", 0) + .attr("y", 0) + .attr("width", 1) + .attr("height", 1) + .attr("id", "solidLight"); + filterLight.append("feFlood").attr("flood-color", "rgb(0, 0, 0, 0.8)"); + filterLight.append("feComposite").attr("in", "SourceGraphic"); + + var filterDark = defs + .append("filter") + .attr("x", 0) + .attr("y", 0) + .attr("width", 1) + .attr("height", 1) + .attr("id", "solidDark"); + filterDark.append("feFlood").attr("flood-color", "rgb(255, 255, 255)"); + filterDark.append("feComposite").attr("in", "SourceGraphic"); + + // draw rings + for (var i = 0; i < rings.length; i++) { + grid + .append("circle") + .attr("cx", 0) + .attr("cy", 0) + .attr("r", rings[i].radius) + .style("fill", "none") + .style("stroke", config.colors.grid) + .style("stroke-width", 1); + if (config.print_layout) { + grid + .append("text") + .text(config.rings[i].name) + .attr("y", -rings[i].radius + 62) + .attr("text-anchor", "middle") + .style("fill", config.rings[i].color) + .style("opacity", 0.35) + .style("font-family", config.font_family) + .style("font-size", "42px") + .style("font-weight", "bold") + .style("pointer-events", "none") + .style("user-select", "none"); + } + } + + function legend_transform(quadrant, ring, index = null) { + var dx = ring < 2 ? 0 : 140; + var dy = index == null ? -16 : index * 12; + if (ring % 2 === 1) { + dy = dy + 36 + segmented[quadrant][ring - 1].length * 12; + } + return translate( + legend_offset[quadrant].x + dx, + legend_offset[quadrant].y + dy, + ); + } + + // draw title and legend (only in print layout) + if (config.print_layout) { + // title + radar + .append("a") + .attr("href", config.repo_url) + .attr("transform", translate(title_offset.x, title_offset.y)) + .append("text") + .attr("class", "hover-underline") // add class for hover effect + .text(config.title) + .style("font-family", config.font_family) + .style("font-size", "30") + .style("font-weight", "bold") + .style("fill", checkIsDarkSchemePreferred() ? "#fff" : "#000"); + // date + radar + .append("text") + .attr("transform", translate(title_offset.x, title_offset.y + 20)) + .text(config.date || "") + .style("font-family", config.font_family) + .style("font-size", "14") + .style("fill", checkIsDarkSchemePreferred() ? "#777" : "#999"); + + // footer + radar + .append("text") + .attr("transform", translate(footer_offset.x, footer_offset.y)) + .text("▲ moved up ▼ moved down ★ new 〇 no change") + .attr("xml:space", "preserve") + .style("font-family", config.font_family) + .style("font-size", "12px") + .style("fill", checkIsDarkSchemePreferred() ? "#fff" : "#000"); + + // legend + var legend = radar.append("g"); + for (var quadrant = 0; quadrant < 4; quadrant++) { + legend + .append("text") + .attr( + "transform", + translate(legend_offset[quadrant].x, legend_offset[quadrant].y - 45), + ) + .text(config.quadrants[quadrant].name) + .style("font-family", config.font_family) + .style("font-size", "18px") + .style("font-weight", "bold") + .style("fill", checkIsDarkSchemePreferred() ? "#fff" : "#000"); + for (var ring = 0; ring < 4; ring++) { + legend + .append("text") + .attr("transform", legend_transform(quadrant, ring)) + .text(config.rings[ring].name) + .style("font-family", config.font_family) + .style("font-size", "12px") + .style("font-weight", "bold") + .style("fill", config.rings[ring].color); + legend + .selectAll(".legend" + quadrant + ring) + .data(segmented[quadrant][ring]) + .enter() + .append("a") + .attr("href", function (d, i) { + return d.link ? d.link : "#"; // stay on same page if no link was provided + }) + // Add a target if (and only if) there is a link and we want new tabs + .attr("target", function (d, i) { + return d.link && config.links_in_new_tabs ? "_blank" : null; + }) + .append("text") + .attr("transform", function (d, i) { + return legend_transform(quadrant, ring, i); + }) + .attr("fill", checkIsDarkSchemePreferred() ? "#fff" : "#000") + .attr("class", "legend" + quadrant + ring) + .attr("id", function (d, i) { + return "legendItem" + d.id; + }) + .text(function (d, i) { + return d.id + ". " + d.label; + }) + .style("font-family", config.font_family) + .style("font-size", "11px") + .on("mouseover", function (d) { + showBubble(d); + highlightLegendItem(d); + }) + .on("mouseout", function (d) { + hideBubble(d); + unhighlightLegendItem(d); + }); + } + } + } + + // layer for entries + var rink = radar.append("g").attr("id", "rink"); + + // rollover bubble (on top of everything else) + var bubble = radar + .append("g") + .attr("id", "bubble") + .attr("x", 0) + .attr("y", 0) + .style("opacity", 0) + .style("pointer-events", "none") + .style("user-select", "none"); + bubble.append("rect").attr("rx", 4).attr("ry", 4).style("fill", "#333"); + bubble + .append("text") + .style("font-family", config.font_family) + .style("font-size", "10px") + .style("fill", "#fff"); + bubble.append("path").attr("d", "M 0,0 10,0 5,8 z").style("fill", "#333"); + + function showBubble(d) { + if (d.active || config.print_layout) { + var tooltip = d3.select("#bubble text").text(d.label); + var bbox = tooltip.node().getBBox(); + d3.select("#bubble") + .attr("transform", translate(d.x - bbox.width / 2, d.y - 16)) + .style("opacity", 0.8); + d3.select("#bubble rect") + .attr("x", -5) + .attr("y", -bbox.height) + .attr("width", bbox.width + 10) + .attr("height", bbox.height + 4); + d3.select("#bubble path").attr( + "transform", + translate(bbox.width / 2 - 5, 3), + ); + } + } + + function hideBubble(d) { + var bubble = d3 + .select("#bubble") + .attr("transform", translate(0, 0)) + .style("opacity", 0); + } + + function highlightLegendItem(d) { + var legendItem = document.getElementById("legendItem" + d.id); + legendItem.setAttribute( + "filter", + checkIsDarkSchemePreferred() ? "url(#solidDark)" : "url(#solidLight)", + ); + legendItem.setAttribute( + "fill", + checkIsDarkSchemePreferred() ? "#000" : "#fff", + ); + } + + function unhighlightLegendItem(d) { + var legendItem = document.getElementById("legendItem" + d.id); + legendItem.removeAttribute("filter"); + legendItem.setAttribute( + "fill", + checkIsDarkSchemePreferred() ? "#fff" : "#000", + ); + } + + // draw blips on radar + var blips = rink + .selectAll(".blip") + .data(config.entries) + .enter() + .append("g") + .attr("class", "blip") + .attr("transform", function (d, i) { + return legend_transform(d.quadrant, d.ring, i); + }) + .on("mouseover", function (d) { + showBubble(d); + highlightLegendItem(d); + }) + .on("mouseout", function (d) { + hideBubble(d); + unhighlightLegendItem(d); + }); + + // configure each blip + blips.each(function (d) { + var blip = d3.select(this); + + // blip link + if (d.active && Object.prototype.hasOwnProperty.call(d, "link") && d.link) { + blip = blip.append("a").attr("xlink:href", d.link); + + if (config.links_in_new_tabs) { + blip.attr("target", "_blank"); + } + } + + // blip shape + if (d.moved == 1) { + blip + .append("path") + .attr("d", "M -11,5 11,5 0,-13 z") // triangle pointing up + .style("fill", d.color); + } else if (d.moved == -1) { + blip + .append("path") + .attr("d", "M -11,-5 11,-5 0,13 z") // triangle pointing down + .style("fill", d.color); + } else if (d.moved == 2) { + blip + .append("path") + .attr("d", d3.symbol().type(d3.symbolStar).size(200)) + .style("fill", d.color); + } else { + blip.append("circle").attr("r", 9).attr("fill", d.color); + } + + // blip text + if (d.active || config.print_layout) { + var blip_text = config.print_layout ? d.id : d.label.match(/[a-z]/i); + blip + .append("text") + .text(blip_text) + .attr("y", 3) + .attr("text-anchor", "middle") + .style("fill", "#fff") + .style("font-family", config.font_family) + .style("font-size", function (d) { + return blip_text.length > 2 ? "8px" : "9px"; + }) + .style("pointer-events", "none") + .style("user-select", "none"); + } + }); + + // make sure that blips stay inside their segment + function ticked() { + blips.attr("transform", function (d) { + return translate(d.segment.clipx(d), d.segment.clipy(d)); + }); + } + + // distribute blips, while avoiding collisions + d3.forceSimulation() + .nodes(config.entries) + .velocityDecay(0.19) // magic number (found by experimentation) + .force("collision", d3.forceCollide().radius(12).strength(0.85)) + .on("tick", ticked); +}; + +const removeRadarVisualization = (config) => { + var svg = d3.select("svg#" + config.svg_id); + svg.selectAll("*").remove(); +}; + +module.exports = { + drawRadarVisualization, + removeRadarVisualization, +};