From 27be04a468c92b48256d0c01b5849913e1d37665 Mon Sep 17 00:00:00 2001 From: widlestudiollp Date: Fri, 24 Apr 2026 13:08:25 +0530 Subject: [PATCH] component added --- components/cohort-analysis-chart/README.md | 106 ++ components/cohort-analysis-chart/cover.png | Bin 0 -> 51719 bytes .../cohort-analysis-chart/metadata.json | 17 + components/cohort-analysis-chart/package.json | 47 + .../src/components/cohorts.tsx | 1172 +++++++++++++++++ .../cohort-analysis-chart/src/index.tsx | 1 + 6 files changed, 1343 insertions(+) create mode 100644 components/cohort-analysis-chart/README.md create mode 100644 components/cohort-analysis-chart/cover.png create mode 100644 components/cohort-analysis-chart/metadata.json create mode 100644 components/cohort-analysis-chart/package.json create mode 100644 components/cohort-analysis-chart/src/components/cohorts.tsx create mode 100644 components/cohort-analysis-chart/src/index.tsx diff --git a/components/cohort-analysis-chart/README.md b/components/cohort-analysis-chart/README.md new file mode 100644 index 0000000..b62a02b --- /dev/null +++ b/components/cohort-analysis-chart/README.md @@ -0,0 +1,106 @@ +# Create a downloadable README.md file for the Cohort Analysis Chart + +content = """# 📊 Cohort Analysis Chart + +An interactive, data-driven cohort heatmap component built with React and Retool Custom Components. +It automatically detects dimensions and metrics from your dataset and renders a responsive cohort table with dynamic heatmap visualization and cell-level insights. + +--- + +## ✨ Features + +- Automatic field detection (X, Y, Value) +- Manual field override +- Heatmap visualization with intensity scaling +- Cohort table layout (rows = cohorts, columns = time) +- Interactive cells with selection panel +- Fully customizable UI (colors, fonts, layout) +- Max column control (1–12) +- Optimized performance using React hooks +- Seamless Retool integration + +--- + +## 🏗️ Tech Stack + +- React +- TypeScript +- Retool Custom Component API + +--- + +## 📦 Installation + +npm install @tryretool/custom-component-support + +--- + +## 🚀 Usage + +### Import Component + +import CohortAnalysisChart from "./CohortAnalysisChart"; + +--- + +## 📊 Sample Data + +[ + { "cohort": "Jan 2024", "month": 1, "retention": 100 }, + { "cohort": "Jan 2024", "month": 2, "retention": 78 }, + { "cohort": "Jan 2024", "month": 3, "retention": 65 }, + { "cohort": "Jan 2024", "month": 4, "retention": 52 }, + + { "cohort": "Feb 2024", "month": 1, "retention": 100 }, + { "cohort": "Feb 2024", "month": 2, "retention": 72 }, + { "cohort": "Feb 2024", "month": 3, "retention": 60 }, + { "cohort": "Feb 2024", "month": 4, "retention": 48 }, + + { "cohort": "Mar 2024", "month": 1, "retention": 100 }, + { "cohort": "Mar 2024", "month": 2, "retention": 70 }, + { "cohort": "Mar 2024", "month": 3, "retention": 58 }, + { "cohort": "Mar 2024", "month": 4, "retention": 45 } +] + +--- + +## ▶️ Render + + + +--- + +## 🧠 Smart Detection + +- Y-axis → cohort (e.g., Jan 2024) +- X-axis → time (month/week) +- Value → numeric metric (retention, revenue) + +--- + +## 📊 Visualization + +- Heatmap grid +- Color intensity based on values +- Handles missing values gracefully + +--- + +## ⚠️ Notes + +- Requires Retool environment +- Data must be an array of objects +- Only flat data supported + +--- + +## 📄 License + +MIT License +""" + +file_path = "/mnt/data/Cohort_Analysis_Chart_README.md" +with open(file_path, "w") as f: + f.write(content) + +file_path \ No newline at end of file diff --git a/components/cohort-analysis-chart/cover.png b/components/cohort-analysis-chart/cover.png new file mode 100644 index 0000000000000000000000000000000000000000..6be2d63e2ef300bcad93d7f3ac3a9c74795c5633 GIT binary patch literal 51719 zcmeFZcT|&E7dNU1HbhheRH~vNN|oLc6a|&0^p1jn)JQJ@A{G!3P(fONfb=fChQQF2 z7J6^eLJuvFkU;JeXXbq!XWqH@pZl%v4_%AplP9O`efHVqxA#fl12rYO;}?z}IBjjee^K!26954^T2_Fy4DH`5AG=_a6WK?SX$dz95`_Gz5Tla<$C#Z zji$CK23O9yK701%Qij(1r&VQaL7aNo8m`Jcv1hwIE}zD6JnI^&`+AeQ?#`*H%b^{| zudNCgzm|R~g|h7}U1a0HHcvb5tUv#pB5Zl@KvpScrpVgD)!sO;m-F#CWVE7Ik8xzn z>>Te4$cilP$g5Z5>Op1K_Q&H{#n79N31;LBCL$aw*`+bmcMtrO+hxu}{^?uEvK8Sv znFfWE{CB)L2an?Qo(WI3T3SjWF4kNLT}acp*HT8-5et!;_WR(2C3@>ShXJ2zB)p~>vWDUA`tUPq514o!!@z2OT5ziW5%u7 zone9OFUlgV9)NYNYA4NFS>z5LW_rHFoF+A_o}5%U0WsI&>-D9}(?Qun^QkWCKX&no_dzPxI2d)9nM-Lnfus%QyJRJo7E&zWA4jlU6 zf8Yr4{~6%#b|TfE-yQ)c9{Tf{Dx1>qrl!KZd%*vi=1vwC4$e;@F0ognfX@d9thMx9 z^i)-(%^~&zkDoxyECf94pHaFTkoAxT9_=k$9&>uw+c`K(d&ph--a;C9ro0xs#QD96 zi>=%xJ=F)C3J@m?PH_Pt0ijEv{8ps@8e`oxmr`~^i3QLNK{n+(~uYT^T?QG$s0I>(=bOHT$YyRl`C+Fqh$v4?&2`qJtiP>} zx#O2tmThG=<7daliO3jhac}%u;KkFN^3M+(q@q50>YpAGU(on_OI(iCeMVr6|KH6h zBfNe-X?63W!oLjmT>c8}QC}UWC0?2TIpMF7fNl82r(g2)OA5a0rC(a*m(KiUTYg!D zUw*+azuO@374;yxD4(AH>0;~5}+oFuwx9|k=lf_{yzi9ozXP05)dYS!_qf8L&>h$kzJo& z*W3=NK=y?k@N44Y#1DB4Yp5PQ_4G0o?-wcWnR(gCp}ZBlt_sbOWm>oErk`2v5wi~e zgdsmTPo*u978dd_S+V&uhvJ-H=k`!Z?Afy)DJnA2n(y14JhSo*x{alj0;NrRForxEP9j6+e z!e_5gu=lIrgD)eZOyO5gAEW|OF*v-6A9{6c?kwK!#Y%q5Ps@_GIGU0f*o}j`u<;n9 z-+!l%L`@BD%~y?hbHI?+!RpxYq5;7D(CJ7G?Rh zqUvD3*ctnhQTf z4LA9@qn{$@|LoV@d3A2r?gic7Ih=gz?tp7I?hpEu0Q$U#Z`}jvdqKs(t@4(MKQ0LV zs3-fzPmJw6O5N=m@8Y9Gq1aqXb253xo>J-WDK!Wk#nD^tjSO3fNf0;7zAk_;5U2W0zf1Pj^IIQ|0`=rZ1 zPVhHkMdbko9`ta#@;7<-p=qKwMTFD&BVGZ7h-f|POoUAL93%$|n>VZNID!c?J@d%A zNWye)4_SJwIfhXtltlvMPtWmCwkeDQtQqXsY`nwd#!GfiX-0TR?=F8JnSXBLsD)?Gh98TYYLqf z9dMn=c-Vzqao=oyPWBORC!*i5DTcD1ulM)i0AwOMtv(()-dIp87|G8*FyXNzx-!uj zas);+ek|d!_RgO@Z+s7pS}XLJ%Cwq_U2+H-Fo6|~+w)!iC!WY_o&h%Uer|3aa2jp= zco1IV{d!j6<%E8jY52{THhs9%DAN$)DlDVF@V*B{k?7?PG!=B3U2j-|REw$Jso8Hb zw6TXxtCkITjh!>Gt7>QR6pJ*m3DmP(xNK(>SF!MiE_zUp$DM&A34QNmbR=K+K9cg$Ze;_ljaeHu=?*bE=7 z3Ev!d#jKb&uat$v+H3Bd3m++4bu(f3K|=l`t%<_GX%P3<(KU4c;hGOU3s40PVwV}x!1%f@0~?yy|ida|VS zI`U~>mTMezA2mG%GmSwFY_tk6jNk|stXoxk+{a$2wHNewesXc1H*s~?$ZWj3|z8nntEGsDWc2kYc6lKWyqJ-=YXI?515wo&El z!>z~5AT{UD%3yW0^Th2(8sf`LSWJEPTt`3PrhFD-Fh$~Cm!w_hPbu@&JhiK6%QP=o#hniWZEN|qwcaR7pzT0luoXLX9$V`?o35E}<(Q2)bxiGNtL+C&Y(urETV3WoU_eR13o@&pxTjJd-kv@IgEt5w5D%RK1rp0v!Hv4R=<{*_Tcr=fB35W{!l5@MjB#aDYMAZTsGp@jmkSa+AnhhZeu|c{XA7pnXWvFQfHd_&f@eeW*Il8N8xn$j0y)mn?b^E%h7}0+3`X4J&Kuct?^S^gU~SJ z%{|ZpIB`(0MB%=~m^YNPJz3*fv&BUt-(#MXKUAtFIR<6AFv(+ea41^^ZcM#{ z1AzezcjuIII$LRAsr^Wknv5MB0{1elA&wuxtwz0ww=SQA#qSMBZY55f9Z7lo61<-R zTi?y#>#SPoH*8{ziepv>c|nZu`6%Y455^Y`m(@i0H|@3WLz%NR5-TDckSlo_;@J2h z?8;71EhgwXA9EPk+kMn`vu~aF62{QX?yBdq*jq4I<1n|Iqie&PTI#iha&M%;?8}J{ zKSJ7Forzb@mi=cP@bEaG16nM#c>x{pp>|%_Jm`^WmV1|S3+Y|!> z@o!H+(2^JgOdt}x*+zS(#&WKuRq)N2`v|XqbiVXtl-HBAZ)Tb{UkV`A+Z*LO9V}Cy zHp!=H1xH21ho0`u$KbU1Qe6uZSDE0DIJ!GkkF6gf(Ph0yFFO9pk?Cs&|ve)`owdG`&pC3dp!v%`AJ~%EkVj8$v@;O#U%7OtY6YqXd zpBmx+Io@d4A|2G21$U_7Y6@ZDF!d1=Vj149@b)}jvu)s95?!RolIE!nsoGl|y^Q-J zH?{UM*t|Dtp$iEuz!$dW2MQK?s`acjNEkt_x8}QY^?rB9e>TZ}O;-WW`kbM5Ja8^G zsESG)jJJkW7&&C|RlNORTnAt3M_q%Oh>(vuIUV55(Udy0wvI``(U#Ql2URSl}rk)VIs>l*M3`>AM<}2$ScAS6Q+z|01dqEz$QlRWuEgG?!zby@@CEH~0 zyVGW`PyEyIqP~y`xL92FdVQDV6RnTsl>b(E=QVh@9QHn6P&1b0MsBv0w@olpAL@)h zeg5KCeP?n7CU5m&bfiRnfsb|7f}Go!)IH^9ct1vo1De$@o5Yi_(JZ+U9X`DU-p`fn zWj6JVX@~2x9T3oY5)5VC1f8SK1dUd<`z>>SSG_ts9ii zfdZEjW|IA#g(FwBd8;d-4;Gortg2tCp^n) zd?6ikg~>EWPs4e?wO6??g2`};In{1`^b;YauGVZeEQUhybnlKA3BQZ!&Z$^YEz?4S z4V*p4NV!8og|31s}JA75nu2nee}^|~dBVivgCoed=DShjt7puxNbjK z*f+BSpOI(!IWGEs%oC|-jA(Le6*Zp#Aw(~u-qR$S>0z=Ft0AP!ux2sSDj|3)BSu^g z7EhY3L=FV8m5xX|)ZjYqZmO+rF1@sMU{V$=xxHLL%qxjhT?sz#v;5KK1O|#Lkx=wX z*{YKfREk|HwyY%k1q4U2t%bUF2)jW_kh)AzVoqT-JPh}FtcxAF~uv#TXpH3 zjokNo)WN!2+__bGbI@L6jMzUP=9kYC5Fts7eH0fqrq%$d6ULH{l-1GqY`iC{WAwN} z&*tlupjnJXeMW-YhT|PbSMep9zLR*|_|Stq3B_y<=+QKjpwx)Dq?{Oo;tbfi(esYW z-9)?Uu3d^z()5Y}r%MSL_VsM8m&#-X6pon{h1jlWdKKVw;8BGHZ+K&{*Nt}D-u#(o zli2Oxc=u&Z+a+VSVGuj4d>5%a(sXuN+I{t`7}RUEa&x;2u&Yz$&h33-^u0{9zHN5J zbzbXj3_D8JpzbMTAue@W-yEmCxEhJR=hbqrxxoagMbpePR7>@bp`hp)Kg;?6dW4Hc zk)noFAK)9ySJ0lgK=R=eGTk|E(gt83jRQmPa&ikjxiHa4=iFL@F$osY`4R8XPIkG< zKI6X00g=T)^QO=cK@j@6-qK0(X!G>hZAlgcJM z^FJ{!aM6#SZWSzRy9_6<)vui+jk)W8F4K0wf^<($#yzQ*rUk4PqJ&&~PQ**4#r&%(#Wha^(Iwnm!wY^+XyLI-P)esM1+vwS%w=L6T)kTGk=QCmhV@A{>^>`0mpFe+~kLj{!bJTE7-Eh1*wc{U+E4CD^@Y z3Jfero9FwSVf_Z*XnvyOk8n+;NPgwmXqueY1!iWm_;~r@N-=UnJ4gWzZaM<(55E#3z(!WOF zkGO%DxZ}w)h|&*g_HX3Zq5-{2*esX6_P4Koq*z2>e$AQtB~<1g1^G{!eAxl~2hrU3 z@Lv&&=&t}4A##3nye_ZX)Eb~JEn9nB0 z(N*hTmv344d=jH_c*pJM{(oT&NfuZ>I4nE)%s&OQe=c7roW`JIW@q#JSVG~{{$c0N zb18*}c9}Od;nx`~yQ{odeqIa!3l%@@lC`uQyEa@J&p%vdYY3vzJVR*)fML{Nn7F|a zZu#f6Ij8U5L|;^Zy>VL^z8!FK<&$|)?^oDV4)0s{D?*2;YuF_vE8UlF915K|^%qJy zx2R(q8v+^3W-^3@XIdd<+Z}AwH{s&}@*&44Sliw1Q$)_+2Q1t4_B^w5+jzk}-V93P*bjh6!!>NP z0njPLfRhH4tbaQ_bcMxLPWx}WC4{6RxP~w9-t%R%@S-;ZQ)s?w3rJI;1N&<(facy~ zDM=2RVQ4q|5tXr%rGLXBMIZXP>^&L9AA6sdewRXZCm!G!izz(ocMP_DJ+s-0!OlY) zVBlXwB~3X|v)Sz*d#P}ar4Hs9k8tbQ_WkXsiK-+TSBZPU#NT#~Qx>aCS*#t?YhY!y zy|iyxrKE>Tt>thsgCG7f9Ys@eRwdjpOj)}2T_1=F03Dn95^NNn%QKukCueCVYCGpL z{!iHF;iD-_ybF~<-_=5ha&6znfBS)bZ|N)rs(zl!`VRN}xbIKj)HY;P=5@a_AB>`` zr9~CM??w%7ds5c&S7!Y1CYrLY1+yZCpSJz4J!ZK9#L$8NlkATZ{%us1DBxhZ6byaG zmw%Y!FAe>i7*3J+%9tLq|9md~!oV~Do_4MrTn3on*TNhv^!Qc;`$tn2F+c%(QQyZr zk43wNiMTBf;$%Ek&HKPn49Z8bOQx#ka0#_i z-$$SUkh||%{bwvH3=6|2^%s6co|E(1(t^lTnCq!MQasixD>2YM;2l=?5*3`J5YT5N z+UUJNLL^+Fn&p15n=`<#yIZZcAoJz(<9f4(H}75DH)DTacLoJiJ<^~IP$kOf$Eah% zGq+TswXbKF`OzGWgt0Iix#;&I-)(EM=Ogzmt2Y)|z$Gy_zy>7M0`R3l+>!g3msSX< zu;2oIeR?{2S}hPj3%MeV3v{=J9`8`>Ov`7cpxc~g_Yt#XBcHv?h|PGnv2>GC08qTz zKk_Ua-#B%>At-x0RBT{evSYW|({rtOoQ>}?pq09%q2%x)X&i8?&q$o2m~3<+&Tu9&6+C>vn%v ziH6&a^9cSHA~D8vJ-^0;ujseff|YRy= zq2ev~>_X*#0mB!F%Iz+*+5?T(Y;zE>Bl=Ke`}F=1_G>!n&*xX6Z0~$9`ut4#W{r|- zlQr)_m_H2Z1C)I0F0P;b#vk`j9>2O31YW%O)ZzpKWl3UT=^^03>m zD^}0EM0*;nYlIIU8Tk(A-<6Oi0WMIre0F~6{fD1J*<@x2*?Bb=QJv$i9a840jJj`l z61=5-VS~bs82bUG)=2)K6^o;7Hhzd*zVw7!y!_?0q^@M89a+@Onc!KpE9?ZDwCFf_ zvK~Jh37^egt;Fo(N5CB+sZl1E#K)f0SHhvHkJ66C`8LHku3yomDDO?Q*{-Z$ap#No z0-rcs=1;FLJSR7H$T&n9ALTgjKJH_>CAavhd?ydi%{zfD44w^>+ke7Wy>?4@qIz=% zvmNg~B9Y)r+JD-U)_!@!5b#2d=9H~U3kwjTDG}p7*q7(FR>}xcGCrl`d~5+Bi>@Eb zwg=ubttRN82FYasO$zj*4~H#2U_|z}%JBGU^^MsDa^USmR%#6_i7Qn0V)ssPW zUSe;t{rxm^0*8A`7&!3q49t?#TMXtgsZnhK{X=d<)7gWaws}>&fKP8S)cE?~dx26U ze(E}b=1mDS_q5DPFOS=x1Du3At&|XbL|tQ26n9&%Y{y_715P+&>Pyg$^V&6?is5@V zoaLH5k95nI#Lbw0(o9!VH8R8u2FM!bR=M=by_J3K_L71b#cTqU zoeu^Oy4|Z3lOZqgZuP?6%0Xg70Z$wg zT6WW$owO`IV6<+{bYRRWShXh&!lJ7-_f#pk1_XjK#R8wggB_^I(><`mZQClMAc{w8-bO!Ci_KJdojLqul z`a|qX$1ibA74;fk!g<*C=ji9uEZ+B|4^l5;XMTYws@aQ3G{6==ZIiObFCWlIjq6(I4Csvs;rXJRI7403u`^}#6VKSB2%d^UUpEPXx zvKBSuty99(cj>R@Zm2wpJTALFBjSnfXk;?JEVGjr9(5%|OY?qy&8}C1DrRn%3ULT^ zK7#Iumq$)@_wj;3v)~5TfeEAGJ`q2CfoE-90eA9EvYT%x^Pfk0K{T>u7i8S-FBl=Z zk6-75gJE%W>m&lkbcV21fsNiXPGDbvto17F1`DFz(##R0pnwT5eNbN zxCP%)CGsKx^p;d%TvfVWi!5s*VqxbHuyqUB9a8Z6#`XM01pr2=S}jyq4j&7Nf01N8 zACKCvuz8b!Nr3Uvd#8b$O2;g-Ns`uj2&)7iP`9jRyv)*GOfvpvq6&34xlcnq5e`9e z)tnc->Ne^-^2udoSclFp-r@OOUSpT2qY=sSK8RUUY?YOp-)A3_QNKe+X60UYKc&fZ zZxm4nMbiU#l7JwRWW(w^vrd>2ZJ38_Psj|H+8h3{JzH#islCY}KA7DU7+F?wYskv9 zfiWtcV`OJYnj@{+rKd$hS%4i5DeVGwWumV8^`_{P`CS%BJUx^+rJO%Aq8csa`~8$( zr|#IrcoIg~ojMN^+I7WR*MMd~<_8fixVy=ro>y@Voo3)x93bS=Y&{-0z z2YQXh*V3_E+5@kXOedINnJQf!)8;kiL#(3kqDW(&^xeV;e7sMSWFZxU) zcfenw(l7RNN8KMu0XMjf+apL1V|CYpa{^*+KbQAB%C`~x+Pt=TZz@C=5vmUM5;?g$ zw(nU-!Rd2&9yWh^$EB!)Tid?~PrjNO%WNDJ^5kgSEk9nLNX>%oXUnPRU1xQ0M3m@W z=rtOjMJAsjRq>!^_8OkNs@-mq*;E}0e8Zt;HnbA%3D_*!6e+nL*#vs9ht>W#J!tE$ zcf9e|xFvCH;}IH6-cOg@nw*%sxi9+hW-J`(P#5S^zqWz-8xGOHyDTgQcF}-d2(Jz(L#g zzUjd9gs+fEmDZjKNo!c3yc8{fq*Z&a-%q|y#~~~W8Fal#} z<*bn|D!{@m>eHlozwT4-h;zXm*n6}&aw@15(~4;Ee;IYO?V8`~BF~eW7r-@H?sKn> zF$8_8UROkss)m8gVs!hl0iIB)Me@dY65=G@bPGyEdRN8Pk8RI)$#uwJ+B~*1$xZrJ z`xnQ(vTcgPN(vvJyFyiDFuCk3*b@BgOWl#)3*Fl>R;F)qONV$(-2peJ@$k}4o_24y z6)E0*0v(lp+A36PHNv|gws>+|1{(;V=AZgOCa11S_`POcjbY}+JyTmK8OTn6ZW{@a zN2K|c<#u{P8cktxLRef(WV=ilT=&Dv-=U1fqCjp2zfy}-aXjyHMCcL}U z5E4UYUF9PR8JC?)s8UKH9OyC9v_4nc-5Gu569JQ7DT52-L4-1>fw<-XD2%i!L7d$T zO9^&B?YAd&L0814gYBZ*ZcdyCqD7OHtaZ`UN88N&L>pEJ%tjyDS;{o_$w-&Y`Z>6L zT$CMoZ#$^IFlpL%vx?k0w`nYjz>44+^mvG1{?k`^D9&AeLBC=_+YMz4KceS!2rw67 z_UxpQbt7ZpX*$qboW$@oj^kVVP=`>E%yI%fSJ?x%B;j^~{w@;my^t-LEiK&*)ZLyy zmBaqog`xzit-bNg<2GqnyHpl!ivJ`zTMWk3k$|EB5$5}tRnZ`&${2p!DtLde#xuVh zFfmfQJ7iII0EQ)9@|vhW8QR@@MD?wXYwKN3ABP?(iFkzjkSFoe6Ku`S&E?OV34|r7 zDwjZ1JTBjC>~sJJD+S9QpWbX~6?nsS5`4LvwLH~jaRK%wOcZ^eDMXnqI{a zGSnYEFrEN>EHdDvBSv{y=OOzq6wJkDH|D#I3Ddtyafg>X|G92@5{@$h4|V!75S~Nnlx2ecs1?f|ZgE)_5Q)r!nk~klU~VQqouH z{dy0rj?}%}48EFqd%-dQxiiX-m=Hp$$-M!Q=EJ=m8br&mX2Akym`O`meR^qAj_@!o z5EU4wujYI?%@d?-y<%e9U`lo}5D&A0d(WebWycOgc=hn3MsGL*5ywaIc4*)#ke@1; zKH0Z9$#flX?=Sle6~S8_s#k;XsQR!F(J-(3hL+AvdpLCZtluYP!F^pfhGCAWDdoH` z1F4%heXE4`?JT--x+{~*-Utl-bTJPXlDG)M@+}#qQ>5R)aWy6 z*WP@aK9Ct;$eRkk-jf{Ri^Q@EKlavEpHHgypFQ|Vw9KB@mE?GiEa2RB+B)wJMc<`7 ztfk%&smF^X1A$s{?VRQJ+E)+AC_=T|byfTVXQtsQ-!cu}1<8${gYaukDT7 z4&)iLbMo6`V&tWco@k|#T`1C@*vWc40prLuD2<(QhR@Ngi0ck`-`#ZiChIfT<6b&s z3D0HCN;yG&;qp5Nqw`xUlO&Px#6v?d^5>78U^8HB%g?_Hc;zk3W!61uHGPq)>GHd9 zXGJ6U!f;~>Xefl2T3r$tXW-hKyH}nBu-K{nVD#08;T*!u9s= zQOh6bNCr^9vvrSRvZv47twYa+WZwtin%~2F;O*ZZuFQ5#NE(9`ZUoL1-vW5S&xbMpq)dmi6 z+VIAr>OU<1Pl6KPQxNsS!`f_rVaHFSs%isxQf6tj*FR?V{}}%1698Te$+@ZhKY;Y_ zYyE#=+y5V|>MVTUr}RCqdYJ|QPRsB08>YfTpQAl`tZl{q2KoW8;wgY{{_{f=B+cT| z=UVTv)|AUZHy<_8p1?0J0hTG?JnWIue%<1Q!Qn#o~ zE-y7l>Ia?;sC~;g=o!8(vi&?%F|mz$xcHfZXN*|j6tHQDbyPqaMJVaezic4jN}dE* zI@@`?<$rE6rMvt~KxSA}<`4X%B>uUT5ibGplPZ33<6md&d;}1Gm_hna`S@qindJmx zj+42GC;oLtQ9VFv>Rz4v_r*(53MCb;Up@3M0uK~Q0!aY7U+j7g=+Xag?0Pyh$tC%p zU6fBgTfW*Pv*DFn+H!n7_%*X#gsWC&)Ai26J4NRte5XXUKf7jI3R^B0uZXBZ9+^{# zJWQMz!8Wr%kPMU*AIK0Q%`h=!cskk@s6@W~mIJ&rJ|V*pZQvKkz@z9T3IXBvG}5dP znfzfZzn^n+01dLUd~w$t__rMd3#?%(_lp#lYrD{r?(4lxFM zbpxJq+5&yO*U87H{esSV%&c2Y`2{3=X&p}{8aWQu5gBpkBpi%bVT%(R1J<4fV6Gl# z!RlBJhgJ5mG%0*c2OEDO8ND)60)KzUiH}P2te!)c&U(Nh%$tPG;vkoUvNT}uIU(_F zDbEMqV6%;z?I>1h=$1*Qy~|>!Yz+tKB0JRc39EC*JKmb8kJM?3iR5%dBTbZ1JY@I= z@d3MYgI`yH0?};1S+M*Ph&Yu!=+zoxmMf;i8;O3?7Qg|!upm2rgBU82TZcZ)rPwsM z?5=`QW3#&bko9G)=&`nbX|n-UnTG1ItbGt+>7zm+dU}SwtH7sdzR0&KT5zQ1j@z`d zbQpMNE@N5PazjR6;StlZ6Zoz0@`W_psc!+Rt;(P4{NYUDuO$aj z{(h=k6dx@@1h2s_T|Y`WdZJp7X1=_49Ol@+ForSYi!^r3ofjrgIW8YdS32&W7O{F& zZao6r^hr7IOt2u*20*66=E)1>4OfKE{+;D2@YU>WN0Iy(m9p|Gd&dv+6d-e47(~%?CT%7oV;`?IXC)-z}B3>k<$E}A+ z^2&rM13;d9Wne0HHQs!moqyBWQyQTjCrQ9cw#TZzHHC}_GsVe_ZFTc4;iQDYVUTLY zDn0AKdyv(*A>6tzwaxJp=QoU5oz_5(o2q>i?;#b0VM%-~(rj%KV(VZq18kL<)%ov6 z8MwOO&0zOiNd6+QOU8a-V1lRsPy~tJt3w3>RcYL-HJG|`{DL8q*?B+79<1TOWov@q%461|Z&PpOCn^9^L z6Xck`4D1!6^9m?sAsjHGIbY$ITl(lOsij6Wq!J9;`OUKv_i3{us5!xdTMe|LUNBmy zdorf264PxqG3mUT{ZbDMl0Ny4TdL!jYk!9O2oJ*dVuIIp>Rs%6 zuP5$LCqQ2LB?flY-=qoK9IzXl0>}-2SpYJvc{8mpC4xq{neC!L0;^=~^m>aer13Kr z_C|-_unB;Tn2`N?QcSHKjA#7(k9uzmy}`F$K{RXxmxU#Fcnd0qp69@@qqOYCs|?%3 zl!BwJ@+{XIB}yieJd4%~_OrB+;Rv6Vs=?{`foW2BWTs!$IVqKG{zTwYAQMlSsHjMCG?LHA{8evT#5%r;v zm~drf!QM;K#o5y86W%bg$JFcmY5IWth_TM@YvNxzu!1O&%Pgr)p59Krk!T}7;wqBs z@JZ4&SPvZHBSaL4^O=({7BO|hut_wI6G?6#s8rFQaMp(3D_?+H9o*z7O@@XC78GT| z=$9I0a0%qK^U>_h5CFD{J0SP+Jg|H8PA_cpQBrd;&{ceWX0rt|o5762K!VF6$Mm%N zZgZXtghR#wWnX|Tw>yzF!PYGSBH_bj@t(ikv$`$4j~g)B2v-=Z0l=j7Z%T*;0*1uz zHs}i<1R&|DJm_i|CCfnXJMOx3$dhv`gX?4nrJhgInjLrfL4K{7{^Z8={-F)pZuhaB z2g{#Vm~aj|KK(`WInDxs-|CglE=~AglkbKp&=2#VJdATP-dErYZ8slT8$|5t%HpRV zREddyEy_o(yLsdKYRw!MOI&t*=#{G6?9Fqpp@W@$45EB>tJk`1g>O_{Tp563&nz5{5^a&^{n+s>_0hcgis>*~ z#Lt>BTGH{B?B0e%K}S$gBb0+a@EccivvhWv2{ByV8X+aEoev|d6bKXJXd8%o0gcyM z+s*^EtC4$Z(eL7kG~n!yGnT2fwfWf&i^k?5u7%hRZNGgnD6S)*c5f;ey~sbjwXBKt z`{Sa8stVvl28xw00<{>4mmfGgw5KCH1x%}lv#w*%SS0x|fUR$MPKxmBnXTX1oCN?f zJ|^QC<~zU}@8!Vs4cAuwA}w!;CwHrX`tRg%q0@v z5@Tfdls?2C00I+(1qz?JJOYg`dlNQk0?H*|IT5SpOm7 z>*}&uR_8X*v9AhYvf9)ji4l)6@4OCWva>lTX9_1Ve!iK*=gVbcib-5%)YJRMRc>GB z5=JBwyyhA`z)KgkBvL)6Yrc4$;4f?YK=6JB!?#ut3N#;x(tu$_6Aq5|`ZqUw>=LS0 z_4AVt6%O#GvhZp|TZb9%-X7t5Q2FIITE)flyJ60P?E|R2)gvzu6|XpY_&am%Fm1(g zjmQPNSQ1`i?3tEwV@;I2NvLJZ=XUxlqQXq^s9kgt#9utjvb~7K*I$P|zg!DFr@tmb zcMKeJReR--*PTa&RGCq;Y9sdNVFV(LC+B7bvq^_yd&cg#wsX4Zj2-VIZu??ql{{6k6hUKw6`A8U_Q<+`i4_tm}f`OQ`|> zK^*LWavw)WLCW_HJoERJ*t278CL7G@+oRl9jTj&AX6`9tr(Kh<&H=x8Qu7i9#w&&l9f0_vr=e_$&*aaO-ua)qAZ7zHkM)DF+i>@ zRi3wYDAO6|j_&}^fyp_$P2sT?aS1*)tAgs^y`b2v=OYube+1mVXYV>YumiV{3Wm5@ zD7QM^p?3)Fi8s44HJg9SaH+GMtR0@FFd?qSB3PuV`NuWN2${emy2*|23=@I9R);u1R_UPWGXjd43^1o#l z5vTWv+(#KX*Ryq1CpakV6~WJHEQ$}r6%f*vloo|!1zx2`c%1~UODr(f3fAPTeyvy$ zlmKkM@r~_aAEGy+5S{&2QUZAcjz8*f*b`D*Rc? zHw9i~vSD4m-pf^Exho47sfZ{YvI*C_QFSv1-49o4)zS9bRrig1i7ZxKr~r}xW3?YT z_N?bOJ{D%uEvAV+*)xRrv`qSVd};($#zJ58B2=$1DPGwYAQMN$ zrKlAWs^NTevp?6Wl~&wt@SPW}q#Usq%1QgZs!g7OA!C;|El}IQmGU|9K2X@C`KWMb z7&$dXyRUyDx!<{t#_Y?xB$}RgN!kQ8w(LWn$Q8T(^U&U7gzBUtdk!e?5f^vY`vI&T z@^MbxU7jG^oGhj~xRBih(Of!$W#0p{y%PkJc8)ZCP!>qQ^n8v<-C9Yk>#A_Z5hEg@ z-13~bM^D*DAXT15p15|x=U01$?DDRf7np=oJuxc}a~3s{1Lh@$$OY_UwmS{%vF+ItgOo@v1=sTXZ}ek&d`?Wp`buWvAHWrF~>%&~yTQ`t6b&Ne$GY<`)HPvaY> zA>eGoV#tVWm?68s7S#cGh!WgY^}6ZNDTl zuJcrMXYV30n(71>2jpe~dCr+*K=w&&a8bm4%=+0wi*aLm8!KfKjaCSC=?MGb8c|vM z1=fVfcJRa~;aw>&LgTo@{?c#PgA?|(mDBgF6m}51?u8aTj87xSPIca3$#UyvDdD!d z;c>Umv(hrB*k@!>*|@$2eJM#lf1fW_xuy=*dZokZ)@7 zS;$etSqL+%n%MWcjCG!(F=D zdY}1{M(14Cb%D15t0;cha?wt{Ssl%sGQ42~TO9>X_e=-1%rP3-$yvGn?82jfob4`)y1MGJGDtqx4AV50k%wxIft#bs7!l!xd zX;mXtkCIYqmy6sx9Hb59CIoPa93-$WW^-Z{2 zVxz~lKJYK?POI`|PVeK7Efng;Z<0l8!rG6uUG?*v%iJaIY@)Lqj;b~9kv7pA_2{lt zWh6x8r!!i^mKUgPL^-3K5i}+q>mLWrmf}At1e`1wue5Cj%x|P~v9*ZdGEmiBfCZs~ z#XY+*C?F|!A8J-kQhgi*Ghvy|OC(zVsN|Ui7l})!cb!Y_htM`h| z34PYi`P|Do6e8I^U6kLfRGE&!?@2dHOoW7?jK%o;!yIDh++{8j*?MK&#$7_oXTRl} zVePZ#j;Yqqg7&T-?LsobzUj}nCgoM-vToElNd%ID-fZPC`@xh znU_XEbIrLYMB)q7HH|%iw>*X_Uf0CSw`NFz9-IK)ffC0gr{)=ytC}b|&m&-UxrJ!T zbx~H=WnMxhP=zGSqOrr#=q4sY`9X!v4k-~J``U{iyXdp2-ONgW74${I`$u!mg_iZ- z509n2TBZE)k!Rn`d~ z3DFUWs>u&>i^|rLjqVe~tMqyaPEFvAN0yZyrV{)@WZCsOL+l(gj>r!6GKthwljK8X zkWtH@NHSR8Q|WsP%ic$uD~VpJkGyEa4V_E%9160=Aq(fA_A;j18K7bNFw!Z7!f*6t zi^i@GN#B^28m?pC2E&ZHz@g)?Z_(Mx*<>p$=)iK;;wwKyX3W;9TOY3_xt~f&o;?2P zD&MtFOO=&wjW5jV3P0sl`Z!HcJx>I3^34W4jsOdN3)9`nTH zi!%3D=%z389FnMpLdkfFIFqq)EO`~5^}ccT?Jli^1u$NR-ZHefx3eF)e0osGuWOzA z)dF&tN7n?Ys1VMhe*8!i@x?f6RFf*lt@HSs5iY29pxDPHM4I>8U@|?rNV*5Q!2@=# zduP!dVBWH#i9lTy;IF*FXV?$6&1)O^#=AG$K_0Z4YFog6doGd?71hPxnv?6R-z4Jq zjY*F&-6_HMx9HEa4IM(R&8yFP#+2)G2=@nWJ2hxu%qFq7GKy-)X@4NzCAE= ziwl}sJgQc>IpNev>3QpKY4H6sCdAmH!*tX5>EIYV_D#_SrX9`zaTMl{+o=ijcg+;Z zxG4C0rpJ%((U9T9Q2-@3mF~&%S__#k7A5HO!;mnQ+!D-`3TA zW$ZpCh4CR*G3P!MEOG6kx%1BARy9wbE^2FGZT_YEtAMnz3%2P3_u>Zm_XXSsla9>YP@Znv8!xXW=nMM#;(RJ{;z08sA{+V3wm=E1 z-)i!3tzHSo?B278IEI{e4NScmUfH*vq(^;k&=j`3?@mz8l&8mAQE! zgAMgiM!Km<&(eL+V#MOx`s?fUBn!b)( zrr=`6C$fh0bY0h44dh`8SZ8Q@op*3A-*&HWgym~K80tvw&gLRWO7s=v2DQ$D3!drd zl|H#i0<9hmFC7(jadE>~Y%zC`p@R zuI>Ee5&JSz^W~DooQDr?jZw+5IZNOx@9$Q*w#?LFidOwHz9+@%FA0Ii{l7x|>ThM9 z{_e3-641v|i1@U8uhOK|W0hWe1?IsP3cBj1d#<@o;oNF^$czDMaTQJ)i7GSB^z7{; zSAt?(?Z4Fxt+PK%k~29aXnC=$#Jy48io~fA_i$@+nK8-Sg~Y%+&LwL6tnBn)9F?^q znJJvRU+k4=tM62Jb#vNKa*5@;lgoS`V`MAv?uA~vmAxE#%^*JjTpE^fnsiz&H)9Ok z#{0yVZbyTBr%E61f3SEwnzBlgu6Z1gm-q1T)C}8HfT_-Fi}n3tL%Y87!#zX~J_uK5 z29=!Lg2HoK-%~<92zo}mO7p{PwPf~W$M4sI{Mvar@fA`y+zoTwZQP|Jf83ZI?b3OXm{_$r3{>Xm%od)h{OHrw3Dg5ou$`85LUqewc(f`--s}~k z!Vn>TP$GbyDAZH#rM#gcu^&d8pzW$bH%u`?KqWtR7$bI$La>U3T2b-jPQ z@AdN6bB*V@pYLXE8E>B7Z57E&@(U}I`D9OzrASyGORe(>o?zRRyF6yo}J5nb?^E26m1KY zzqqoQZ)|G|I4c*R*Evp6k@~{kJ1HFrn_DdvG&Up>6 zIu_5hv|+KV8gS~h12R`0o;*K5$IZ>RymG)Rt&cz3XHVvOvj;BcSYB0&+}xos*^#zp z@qOjL>uUx8`q881&Y_BVqE=UOi|;|ri{E@U%K)$4fHQJ$F;^GFtTwpfyjxd#cIWGJ zZ`16WdG}+W(CP>DOx3SN1hT$-ZckLJ9`GSnd-glB_VsD~ai`g#H`mI%YgWb_^Vuj6 zNFvBGFjH!5iq5MV`}|7T#->bA%1jOZ6+k|cdv;9x%rj0YPo*#ux;@iuQSxsU$n{1A z`!t80bS9_mLpY2tN6BP?;wXzUxK-Qoi?MRk_iy0_^wgQPy`~$jk`x9w6{#QBaT`X6 z?sWc#l_iX2o4aWSA{)Vo_`|EccBJVU6lJ6v7c)%dn=K{H<4_RaD;NExJ?A(3AL3Zx zC1UP%DEP`=FaiYOVFwu;ud7F?ai5F1!=L?>9_gl|DeX-_F8jQ8t|hQPik;7-@F>FU zjif2jmQ@Fhyy^(u1=-GD8W)0$5Op8|E_N+Hv|*md+u3(*>CtdYeb=#JmSJLx)Htbb z$qY=xX9Z1#E3zYL12&J|hkF}F%L}){D{zqus`gOoDAV*oSQ6Src4U~_a&-8pBwei5 z*S8D=7KHIqyz|O!aPuJ2dE^?d2~l=(wrI5FVo;8v$34k254R;i{*%fQc-aRPC9X5T zni*a)`~31Wu9}yUmEC+08c1PLl(jD%MdHc{G*#74ZTX4!M;R2CbFw#wG?}3DPunBJP!B_KA*ANTouQXWz-Psjs@Z~9BCdN zA5v*EZA&`3hcdmH95{$PIhiNA)R=B!X3Kms5;9!87UZI9NXySx2|b;3M6PP^L&ni0tA=*~m+)TAv^eTLtJXZKI zgE33d!LP@sG?NSPk%&|4!yQ;?&NWfa4W@Rmg^WkG+#9}(>H0bEHh8EttRYn_FmL>u zNUi5$<@KPakkH&4@G$~zH!tW2{cSbL~ z5`rBTEn1kt0TvRIO~u;s0vbU+?>8mH@u<6E15)*gVV+mfn+gw(MRk z=8oaRVQ0T|qo?Ay+$qx>U^bZloCj`Lk<5#`Pa*9O;_@?F8$o~=>v_F&q^EiX9@}E) zS!nA|TLa;8G;lSC;^rb>|0~t|N&tSNIr$^a^Q!lRkk`Hm*huEWf~8uuTiBqWACIIclXWgJ zv^4$QTPwgFN7h4Eh?1+H)@Od4H6f{ofW8?0@=7onIbA%x zTo*zHZ!CgbVX1zfRE9%^1l%9l@Oa(v?%H1;SZZ00qAf|`UZ7j|*?LY+7A4GAKnaI6 z;GeFUH@!-~pIv8m>X7~ovyq}hrkmlcuJ;%%k__T(jXnzco0(0ryq_(#yLwbAX|0Kv z2!@_8-b_A*dq2VT`t4B*n$lWN|NXyt3B^qTqt z1v~<1X0LkEl8npg=(*A1BSGB0O49RD$=&I%bkmAPZ1jR=sLsmz?86Y!03}5^(m$7k zLl4q!7EN*vw|ej*sdv4HgA`woz~lh85p+t}LjN%B9C>~N9oXwOUS~zs8%OP~=k{NF zMKwO|dj9wQ>1P(OLhvKjEq=nurWMgf%j9gR}*RYV}%2S1~icDQPz!{Ghl z{ofdx9>|I`T1b%FLRgtCZ8bITGl6G_ib22b+6 zK=Kv1<2EoHes(uofa;^j-7g6Wq#F+-jf9yL*4p@ScEcqJe$j|)>T0JmD?~yuACn6j zMVj?XR=wcNQUl(0YYhy;hk3le;{43Znji)CKB^EVcG0G5EDB`RD0T!^q!92w1c52| zBra9|6?9pG`l`B_!XDk)6JK(jNyF@cB88I&5ytCE-qn~@Lp!-CiciQ%-5AWE^Hry) ze7pt@$-oYX!$Pa0o!kg;_^0>+iANn*%y!fJULn==o7;2GhZDXGQ-^4e0I)$%GR3U< z3U*qQMgb3p!oA2DG0{VE*zP5K`TE0CozHg3=~1Q&aR@zHLCnkfe)*2oJA`WR8ctJr zThVldLi6J)j$HVQW_I;S>ZS#Yg2h# znw+gnBo2}WI1Va|RdTJV?}yGA48Z3;1trz@UMxMps7l@F8^+5r%ob`!Nx@+NLS%mx zrp5P`G+{UI0n(71NUQJ-arMF>S^67$ov@?H8Q6jeq&Cs51T;CXjXDnE0zZeaZ~>KL zH$q4(@!Y1%RUBjTEC7a(P#b$+Y2BlO7BQblO{g*BVtLk(qSVXC%5rLC5tm8-+QsAX zxhjh@BkxZ8i;2n#YA&g7+Q!O@;n^?~<9k^4NGg5pk4PA7%Ih`C06Qoj!mjmZkwedfvqgGN#;&7uI%dS?cBAw}U$C4rapsm_hlc;u}A8sOz@cQ%h(UpF(BF^esttF#-P$&Vm8X^`&2 zBwB{RX6SOFJuMtYCazCtF$OcjGBf8q%IU1`yH(X(>r7=8+6-5R7sW_A+xSm2r#qp( zsZ^==!@UcpqrC4Q?H|P zkuqs#!H&j@Dcw5OAtGlUT{s(Knk+InL1fGF)$Y01_}aOpe@Oq7?-N%U8d zsFpKDv*s?h$PZDaQtA4=to=s*!F8!^gSW{gM+F#e=OIY!q)5)rIla#-()&|84yon^ zXh4VZ+~%7XPZB9Ekyg->x48Qv5$9~iI+kx&R#P|){ia~q8Y{ak_U{k7oM)+P%Y`QngilZ# z58}FCuE-;4>}-pxn$T|vdKiWp^5`7}&H(enssy)wr@|Yig7O!=ZR5;~-KTPo=7V6} zXXUPmFJk6Urmns{b18BYX4cV>k?&0W)x>A9{>oU-F!*jpb;mGMUya!j|}7U_r{Z4rux%H8Ei^M+sAFyz`O1DNa~m^j#zZLZ1}$$C@CEc zVj3+l`$WEA+eQSGwT}B7-_AA_`VDwr!P)!O0M?0;5ED=_#Hh~W)pQH@00ckNioC{C zI$Ne4#S0=w_LJ*FDEmh@cfT7Zuac_ehE}M4!);RKIEdR9iH9c~Y|x7zamu`YUPGFB zGGJ0|*5jaL@b4OOqo}IgEbJUftv)lH)k9E1G$z4{9^=q5TWmg3LxpKoF7rZ`m(EM= zRe~?QCp;24vjTA!o$)@S^hPnD6oLh8cev*fH-A8D@0# znc%~l4(95bE_`miODFfq-cfnFXBr5lggwCwQpu^qfj&eYmf?!E+-a5N+>~YWC|79X zYJX&DC)8h2@`S!Y^D{Yo?qj*S=y-g==)r#cX*NkeT5zNd`Guk$0 z{+r*H@-}^Antp~aIV={~_##3J^+iWI^E4C{xZFCh)m~_iOVmKOr(zz3$#qcV;Bmxl zp{`4%lvO#mE)BW4C=`f{a(YyVCYCJ>d6g^pEl>!8X^*(Qysr;I*b(J|g-aGjG3CUJ zEcj{Tl|0N+w-60)Wj+z_*-$%QM4TtbMGD7x(srAN4si);L^PSyHpFe?#ZuHgQJF+jOj{$#zx9Q;-F7MN zw_yLHm^@dxw7TE^iIQQ}s)~zDb$)Xg@8fkW#F3|CV9kX>+GWB{Xf<$T5+XTqvVxt( zJ0$Pv9G*pqCxg9Zov05ksc3UreRb>i9xjopm`9B=_?ce((9~x-;JZ24SvkSSK;UTc z-<+u;BHc#%QXBZQZ_&43ZMSZjqQ%>{udSBdTz`G77V2#Xpz2zcoN5EhxxYWCIx50< z-nPAF?Ng)0{sjrB>wMX#yxzu585S}W_jFB4sW|b? z4h1AS0xxCK@b_G+gbx4i^f_l{x_U9D(wE5|?Y~GO@MC#X3lIvOKNU+_>xs`PpdrehwPJ%HVCXjgIt8 zlXz8{>3Z%$I|6;CY{H+Haof=8ut9UJ4p@?$E2*1-w2H}CF$y`Gpa^{=GWz>1$Y!0_ zmZdx-CQm$8{}@h9Mg%g6l6|Q)o-y&xfC2JRQ(R!mW|#Ho^+gGUVx)Vcj$&tk11F;VElc)*N6qJJLD% zeJOGtzgF8c-9K+~v|?pc2KSXx7?M{ZS3N*4RK0x6AJ<_Ct-5wKd_Sbz-Mk1tRN+iC z(G2827eYPN0 zv|`wAhWlZ#2v>Fj?#orKL6bdUT6lxJ@y98egQtZFbKLF$;6Xk2 znT(`B0)>EoxUuk{vX1=;y7AGga4v47ewiS7DLkZlclfGqsXRVP442$3aOHv=^{f%)IF}o?=31^Bdra(`fAGN@Ye|akTa0A?MKy-1CR4Ox?y%|BtGMo}McX zfOY5cz!mBU>XaF8NUp_y=FqWu5s7EmYuLT27#CT3!$=VAicekZPCzbNlMnmiDq{h zVaR0`fiZ)4+s8JHj!8-rQlcALqMn_d=+CW%!!CN>6Q$srx(AXKXvH#XK2(NbR(6mn z*U-uk3%)Fw+t=Axi-hfT-R&hSCs)M*COut15?ffDq&K-`W)vnWMC1v@0*^SUzSOwLn5lUlFB!A9rd^2$& zJLY$QS<0|s{Xs@fr9$xMcLdlB*m$fx2$+(-qz>y5DR59S=rE0iZYOW_^4=r zWtajVmwOww>V_2MblK`fn*;Y&flPzWlS7B~!lI;{G`znC*y74a{u1eG@s|$xT>2I- zxU-9wxE#6_#b7_OrnwJ(DSClR|)# z-d&tdED$aE>LvrP3S7jcK{njI0YK6!Wvt@?FnXlDsLUb9c>ZR`kBJ6uIm#?DcN<_@ z5a?X-=FJ<|_?65==v+*z4PBL)JC+|sr0E9IGldvj4hI<{5m%YxS*v3eU2USoO5snx z;LC-ojliBZ-p*IzWX!wT3{9-M_z%6Gk8ntqCb|v#2lpO6cTOb8$nh!{?Cx4UM;ups zQ=F?>-X#d75fP~4^kMGrxWBGu*?C$ORx_nSLynu98`s2vjKQ|o&P!-Z(BB~SxfNc* zx8H2>{^XWwYrCoy6OnI3mWdXfPIj=36X__mP?4UgZOt0OW*UAv7+`)#Rjn)1s2D9c z-xSpecd98Sas-8C-okMr6prx37fTNb<$rjAgu|OLlaEJ_&xFN#>jm1^hpPv&d}~862s?R{=kzu6!en-MctTUY{QCaZ zv(}9fVE(QZ|FsX&rD$@cnlDg;4%|j|I^(Q6{MJ`rZ z8Et-Olkx386iHKPT1 zK04t6s(Pv1mC}9YfSO!JSHpuM9S1KxJylFW7N)Da8)3rUl#@#s+M4&tkc*go}v21Q|$x)sL1-CqOS0dO2~Ilw5r@G=#@J)%Uy&af-U& zqf^E*+1@rNuK0`-eVcisgUU0!@S;G2$;tm3&9x`Pt%DPmFr^?<4kThk4ZZ4~qqt7B zTbZjs7T>-y9BD{`)}4tt?V+@|HpF6u>a+wC+BgKpvm)3STh;MW$|Y z306i@IE=#*h*7ZhPd)Q-*>yOe^^D9WzgN`MQ2dI$t#xaS_X~dqBSw9Vp^?uIzu2Cs z8~bb*sYZG!L=Y$}fQ=p2hXfosHO+HTQXQGA=J-wo28txjb17I`=;yu@fGH*NS9m3x zsTfTL=Ya(@hWP5P={>R%yPIFD8}9<3iVFHV@x280hR{nGg{R0oZ@WmDoa`V z%%-#9MY9hXx*IpG61QH1L`sO4z&+i2HK2Y@Jc0|+OLm>-W_v+z)g|a?FP1@%^#CDAUrzilcbN)-j z!A8=ebPyPwm5h(qgTjXw-roIJca8%B3uAhf#~mB?euG4TJB5z?g$1y+SzPEFnZ`K_ zIWo4Y=_~6F>Gwm|`j#Z?^z;82stvdSI@B$c_ZOl6%w71OK<=A$;B#VWT(UohAO0)- z;NAVN$xgff3h8}Q2L5M|Z8o?P2n27RNQWMLu&fq5`$=w}%j^%}p0Fp_$rC^S|DW>#phzfuKlVSU0oMD;p}R%^25jnK zms8twedFwT=zLW+_F%t#TLW*5qUFQsv>yRJtpadGlHyiNYr1FLXMqX3do*C3I3CpU z_*iJb*7#fI$RucLW-urC`{@ig!@lr#>5QV!V-4UiKTa1}p0SJnhh&C-5*;wU)y*c8 zXqEr{SpQnn4xo8a`p!^0bG5}4JIny=pq-8LO$_cZ!wxh2ed_Hn!wxg-FvE_1^*0av zeer+2WN1Fw_T>wY?()h-jC1I#?+v52Lv&NV*Gn=jw&3^zUY!0K?|&y=^D2Py&&2p_ zdY%yzxl-`;#diM5-=Dm7QMtkN4dp*{Ys4#&fZ^33iwb+Z2>tW?$|Q%6f1w< z$6#MNTB!v2GBd4b%LFmI10c7Br4y&_ZWjaPSY8RjW1yq6t#}dd<%2)y_W+W5N!ZKG zWS}X5o84rJ``2JC|LHhQUSMJ=k>8&18+FN}Rnoj}H zb?P=4eIVgDn>|3}Oem1Wz>Uj^b`uULWfw5`&f-wAV@ybN#Cp7Pr<^jm`X>cPCb^=6 z@E?F#Xp5nn;vj9s1$y0hSq2A9%$p{W=kMw0Q4=dJs&IYZkO;`w7TVYt395lskDOK` zDkx&L3a$MLmb?NjfsVjzfTd`J6PCLI%*gc<6gpIe)*<(6_TifhpXIQ>Y6|`-34r-B zyj7_HHd;)Vv)+0VnBni)Z2K4fn?O&5JxHDZY8bQq=o_}Jl&77c{x6-YH*R})UTxQi z1T>$>Hp9Kk_Ws+*T^OzeAvHaPU~&v-$n3b>SIV@7jOH4HEf4O{y}A=OG|+ zlt!7-^}ldfH%Id_^d0V#t9y(TGcoT>Jv2fy$2Tq?MPX<7(7Je6D^qxJR7I)??DmqL zZXbre-vW9L{!!8rT8xd? z_ug^6e$IDjs%whpl2HG-dil!J;dUR$xwH2UDnCn}&2l%jgGVfmxztvKlqjQu#%?OK z-0Qs_|2iVxF0S6VLR!;S-gd@|yA7fa`54oBd?FhoM1iI6x^d)(v7~3a<`O$Pl;-&gC8W{qu9@0X>1QK^#&eR( zIv^g*#h<8>7_!b8MA{g~`-Z3I6v~A~g2fVQUYja%tf!cL(fDHdB34kZv99SP`lXJ< z?^xG>4%HUWh7cy9Kv3jBs&U7;y4$ZCn#UUPJ%V$$4bKwY;Kdm|SH8HfzgCN^H++qG z*kq{&t-ZT<84=T<{F$W|CVD$zVkx4+HRC$GxKl@p!a)pCbi3SZo~U|VsGZ<_B9O(T z=%n9F0^@bjZ&mVa(M@V{V&x4DpHo7}5g|HE zkt)FxI%n|p4|ZQ4J6@b3-y*?!F+%9N4hA0|cZ&;pFKOwv?Z-Zi=ohh=6f3)w%Zatd zG78J2?qO5GZiwg2h^V*=*k`@Qca;+JV+%TrJkZin*RNd9h)XsWjm%(I-;WQ`DQmx? zk-Drm&LXUP&cO4PH9}WTv$-aN>qcs1?Aeepjw$K(u3-qgIM@2tJFWDy(S08Z#805n zH}THFm%(c*K-{mRB6}q=qzk@(V7l_M^z8H9RYOp9lXA&3>tCZ9ol*B)uruEjOh(t=;Vqo(SA=!1wxGTl~V3r(z-Z#R~jA75mi z1+_A&Q<7e~*vpt9Vy|!J_<5A2kOuDYXyW-uhzH@dlY*4m4N6YgJ5zPRKDCD*^0g&Q zfPN&pgscn5cgFW5LIuL|XF*cOd0lmV;&N^Z>aD(5-GGdZs;}c+5d5v9niSymBa31Y zY4$P#7jJ*UWTvF~{VvGO+yI(cfkTd*D@N;N9{odIJv717LSyKL1KWBhQRmvZ;JVBB zUAH&VA~AC{FYum?7R7n*uBdCmQsQalB_J-hw)Pbk*ti%Qi`S3`WbIQpj9qa7nOB`_ zg{kHMt?3p~TA)>p6%pn-tsmNW!spP68%X1|aie+Wtz>=NQzgZy{jcIJHpARKX48%maxh{dU9;;KgXBDj*W^h(hWYmtSt< zH^0SPl3<1*WNy0YXMcTqp7H`6#Er}~SyN*U4fn!{@WFT zV(BOO3Vwe@RQ46c9I;&&!)Hc9_N-{(avcS4&Uep(BF<7lNG$B$`xsqYOC@-*-@L%% zuZ`4pXRX9sOeTZjx2Q^01|V6FoBgIgQ;Tk*fTPgg^g0kkd1Jdr z>-;mC#dGFH+@bjr8$)j(jch#HCWtE^QiMzD zuaY!!ir6g)OA2qP5yAyUdaqM+ep6j#hxbVp6X8aDr*s2sq#irFnTEJz#Ms4lF&-d`lC)qQ2 zyc*Bey~5!2Qc`*#f_gqCwtkgwkE|@MX~R2Tsw7(Er?|r5D$oe>6Y*trtaPM)V$^zc zP>fN_9Gl7bVLY|{tKdWR_1g9HWkjwkK?Pp>VoWQ-RZPLAHh&idU#G3xDIoQ-UOTQe z>R91Dc*K_o#oFE`m98lq6hZdBSuCNp@pU)p*y~8srYIR9d5`XR*KBeBhBxK!6{tob zYASjK6ABYG+5OsHEm=Tsf^26(K0~800LlO3Ql%#Y299r4$`#-Mxxx{}Y4biYgE=#S z;!xNtXzbBUXm{*!8dj)wC>& za(+oBd-qDN*dben@ApSrDul`An7N4oN`wm6! zD*J?Yeakh{=)eXFae1u}X**S10x5_LuB*vUOfG}GM;oW#HM<#c9e*-gcWk1daoiP4 zp;#5_2#^q-T7@bwjq5D7oC_8WRIT5f&92-^)AIO8-CGbHEBgCh_gfMX{X%q4Y=0}( z@l%E3o7yfoD0xMYEu=p#XDw#F~gLKsiPlO5i?0a?#R7iIff@!w8*@u zc8zKAxp`v8p?1F3LvH z3X{Y7F$YR4YD-!bGbAm3hPNGXiEi$p>9<@C{wrqWHJ5dLc2U1~zUsCzEsGJSrLavc z?w~4nT5B?|=S^udS~>AqE1JC0RVM8CvE9(KuI9v4@X%?WD<(0;`mn@aAC<{eX0yb= z*C?c5Ys%p3=W4o3ZiX-7TwUX@tEm*f^S;MVdeN6U2B;?+5lt62F^SrrbQ0ralt@ zv#~^oC0(#0ro@k^Xk_HS+PH4yD8k-Q|AH}cJ!0aW1}r@+**?qu?cLK-y0)N|c%#gG zJqLSdcuL*~?OjTvcl&+J;xbFhQRQ3aO3)M-HFX(bbUX@mK|7kdsN?G9ds=)VnUaeZ z*PLaZg8CE{?q2Pedln+DNGK*yvj+=|g2LnBj){bK$r7v`0ONjd*WRyOU@V%JEJGS@ zq0L*fPKM^ywKwG~rInTn&E{jZt61RVkZf0cW48C=%LOcoCYykFz!tO)l?E9sDh9GB zl2xV8iuTHQHck4WTOqVn7Cw51F00t{F#9-#ZVm#Xs3^(-VLfm=37dG#qt?Dof+h#1Z;PjFai z2fPc_k(@((Bx2qr4rrXt>N8D^w&U;+^uSOBLsutm*G0B#yX_{1=msYYKoc|k;!^B+ zRA8xL0^#Bpb;uB%%-6!i;EC(H_rxiPnS}C_{x^H6brz0-&l`_;EV-QqcZyuVT8c`% zG2WL9ey_k)boDB5a8#GyD5bs4w)b)Jp8qTmSiU+SvRD5qix)@VmUrd*`8T^5-~kzR z_!w-umByfCx!-RokEU=}2|^*)SH+rcE-g&8LXMac$4QSP{DrCm7-qTK{r6%cAH>@p z8xULcan}@^nafsY*@plW;v(jGo`-#g{UI3zaYweyDPKg!6OzR2Q;5mAn%AQ@Z1jtJguEOKv=H;fTTkjmC8Q;=6|%)UL(^$!DV_ z4&;oyI^EtD!f8~uH+b<*_`n_5lZ-KqMxK#0{k(Sd>67^6m|}OwcOMX1#NyAzDZZ(% zH$qAQrSHXKR$eW`i{P$jws@b$%@kw7mxV=oI?cB0(%KY14I@iZz>V28nZ+_^oXRmq4 zyHImq`_|{SWIkb|5@8yr`VEj#g?!x7tj%L_4LKQ72b}D)R^3r*{gWdgzd)(~ok~a} z=5dAyZgR`>b8I_d$g|HpHgw1>W+mGCfv2ZqS!`!!4 zJSf|g{!p<|@dKHKUMk)cqhSukh50-{jUzm-ht`{r9&5tptDL_e6e~WBcK@4MB*H3{ zu{MF{Ue!yr+60?i^ZgYF1wdLI;J+L2E-?JTyZFzO z+l&=x1?PC#-sER5`g=cSF9IGjDP%_E&n%(8Jy|`0TPD#VBfXy~2YJuf{` zrMm4aZD|S1E-Ngg48A&7mjg0CrF4c~oT<-S9z-5%{L+?-n?P9hA@X-DIj_<^;jrh11* zdjG?NK&+WfwcD-zW#QCD*tR42_|?i%*FPUtp|1<$VH*ci1EKJO$#t)u?_R4!+@ApT zPnwaLD*5wapyz5{PD{`i;G-YV&CNNO5A-0@Ly-|$Z|?)n8tjEb9}uXVe$ zuW)_)e&p8o$FuUERC=RAe}&{8gj`$cc~<;MLCI)2w$l9j)jA;bJhMAtMsjN7MoF0E zwi~|g$fSLf`LQhqG1NZcZ;@S^CN6B4D=h41;c}Ff!1)C$E|}!;DC{6g&$gMsc=aFP zGu!mp9}LJZbBLMRs`Sa0HcgwFodx!PXkN$Mxb(4&Q-HMKrgI2k7csnj)kkHE@6A~E zA3>XC?JF1Bs0v>D=$cqKbK{S!A3HxEbWOT%qes5Zt4sRiK70h2319+%93BR7Fegp^ z7#|>*1fRp+@(dnE+_L>L`oog_HG6MNciElo5=xT)_iX;X-M(EM0I+p>pC{M8`Nd`e z3d{6>RW}vG|8oD{RRwnDV|kIiDnFjX|MQDdIk5OE!RNlA>i)-Dm6QRoA~|L&cJ^PK z@Ez>{6a$c0q^JH3!}D)x^WDX9Er51D&5%pK6j3*`0FElRX#AdY{>g|Z+MWZnAMi0` z{?`5K2dVSzYyRmG_}AnABrM?Z5g>J{0c&ydKX2Juw+l2&^qe{LewQ#@7KpKEfvF z*S1y4{k7=e=q>!t|0L;+mJ&H#J|yu!tmkhJssgj9)AVcWKLYr8yn*nq4R*m3u%sOp z`VV7&YmIhTXorRVd$;aLp&cpopOPF%b=Wz`b`G-tK3sNmq5nU1A$fhH(%*jDOLy&$ zOFBR-X-Bo*QLTTW?eAD;|3|DdW7wE(R#q18y5~@u&exHVckbeLzp!TgYy#lHD2oXa zxx07BgqeZ_0GNK78vo7RbK`5)$b=;9qyO1Wzs$-t_@PVS?m@`UvX=f-CcT9Y)wUik z9;&GACxk`&Y;sn}2?QtyxD8O-nq>TSOaDS<`3YmhAu7Ktx&>y;)%TP9kaE`6NK%Qi znO@Dnkn3<$<^(sYk^L8zD&+Rp4{OB-Ooi%lS`U7j^9@%f!aR=$_`h`>_8(st69*d= z8l#+0@zzkn_5W6UmA)b>OBda0GLG59B+;fe1;edqDYT-eBb7T#lRtZCq~~UFOLHM7 zx^PCpQ$em-U?KAtH0_DRn~~%AyDMW*q1~f1vf^kSwA=20.0.0" + }, + "scripts": { + "dev": "npx retool-ccl dev", + "deploy": "npx retool-ccl deploy", + "test": "vitest" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@types/react": "^18.2.55", + "@typescript-eslint/eslint-plugin": "^7.3.1", + "@typescript-eslint/parser": "^7.3.1", + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.34.1", + "postcss-modules": "^6.0.0", + "prettier": "^3.0.3", + "vitest": "^4.0.17" + }, + "retoolCustomComponentLibraryConfig": { + "name": "CohortAnalysisChart", + "label": "Cohort Analysis Chart", + "description": "Dynamic Chart", + "entryPoint": "src/index.tsx", + "outputPath": "dist" + } +} \ No newline at end of file diff --git a/components/cohort-analysis-chart/src/components/cohorts.tsx b/components/cohort-analysis-chart/src/components/cohorts.tsx new file mode 100644 index 0000000..213b394 --- /dev/null +++ b/components/cohort-analysis-chart/src/components/cohorts.tsx @@ -0,0 +1,1172 @@ +import React, { FC, useEffect, useMemo } from "react"; +import { Retool } from "@tryretool/custom-component-support"; + +type GenericRow = Record; + +type CohortCell = { + raw: GenericRow; + value: number | null; +}; + +type FieldMeta = { + allFields: string[]; + numericFields: string[]; + dimensionFields: string[]; +}; + +type MaxColumnsOption = + | "one" + | "two" + | "three" + | "four" + | "five" + | "six" + | "seven" + | "eight" + | "nine" + | "ten" + | "eleven" + | "twelve"; + +type FontFamilyOption = + | "inter" + | "roboto" + | "openSans" + | "lato" + | "poppins" + | "montserrat" + | "playfairDisplay"; + +function clampNumber(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function maxColumnsFromOption(option: MaxColumnsOption): number { + switch (option) { + case "one": + return 1; + case "two": + return 2; + case "three": + return 3; + case "four": + return 4; + case "five": + return 5; + case "six": + return 6; + case "seven": + return 7; + case "eight": + return 8; + case "nine": + return 9; + case "ten": + return 10; + case "eleven": + return 11; + case "twelve": + return 12; + default: + return 12; + } +} + +function fontFamilyFromOption(option: FontFamilyOption): string { + switch (option) { + case "inter": + return "'Inter', sans-serif"; + case "roboto": + return "'Roboto', sans-serif"; + case "openSans": + return "'Open Sans', sans-serif"; + case "lato": + return "'Lato', sans-serif"; + case "poppins": + return "'Poppins', sans-serif"; + case "montserrat": + return "'Montserrat', sans-serif"; + case "playfairDisplay": + return "'Playfair Display', serif"; + default: + return "'Inter', sans-serif"; + } +} + +function toStartCaseLabel(value: string): string { + if (!value) return ""; + return value + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .replace(/\b\w/g, (char) => char.toUpperCase()); +} + +export const CohortAnalysisChart: FC = () => { + Retool.useComponentSettings({ + defaultHeight: 28, + defaultWidth: 8, + }); + + const [data] = Retool.useStateArray({ + name: "data", + initialValue: [], + inspector: "text", + label: "Data", + description: "Array of objects used to generate the cohort heatmap.", + }); + + const [selectedXAxisField, setSelectedXAxisField] = Retool.useStateString({ + name: "selectedXAxisField", + initialValue: "", + inspector: "text", + label: "X axis field", + description: "Field used for cohort columns. Leave empty to auto-detect from data.", + }); + + const [selectedYAxisField, setSelectedYAxisField] = Retool.useStateString({ + name: "selectedYAxisField", + initialValue: "", + inspector: "text", + label: "Y axis field", + description: "Field used for cohort rows. Leave empty to auto-detect from data.", + }); + + const [selectedValueField, setSelectedValueField] = Retool.useStateString({ + name: "selectedValueField", + initialValue: "", + inspector: "text", + label: "Value field", + description: "Numeric field used for the heatmap cell values. Leave empty to auto-detect.", + }); + + const [title] = Retool.useStateString({ + name: "title", + initialValue: "Cohort Analysis", + inspector: "text", + label: "Title", + description: "Main heading displayed above the cohort chart.", + }); + + const [subtitle] = Retool.useStateString({ + name: "subtitle", + initialValue: "", + inspector: "text", + label: "Subtitle", + description: "Optional helper text displayed below the title.", + }); + + const [showDetectedKeys] = Retool.useStateBoolean({ + name: "showDetectedKeys", + initialValue: true, + inspector: "checkbox", + label: "Show detected keys", + description: "Shows available fields and resolved field mappings for debugging or setup.", + }); + + const [showCellValues] = Retool.useStateBoolean({ + name: "showCellValues", + initialValue: true, + inspector: "checkbox", + label: "Show cell values", + description: "Controls whether the value is shown inside each heatmap cell.", + }); + + const [maxColumnsSelect] = Retool.useStateEnumeration< + [ + "one", + "two", + "three", + "four", + "five", + "six", + "seven", + "eight", + "nine", + "ten", + "eleven", + "twelve" + ] + >({ + name: "maxColumnsSelect", + initialValue: "twelve", + enumDefinition: [ + "one", + "two", + "three", + "four", + "five", + "six", + "seven", + "eight", + "nine", + "ten", + "eleven", + "twelve", + ], + enumLabels: { + one: "1", + two: "2", + three: "3", + four: "4", + five: "5", + six: "6", + seven: "7", + eight: "8", + nine: "9", + ten: "10", + eleven: "11", + twelve: "12", + }, + inspector: "select", + label: "Max columns", + description: "Select the maximum number of cohort columns to display.", + }); + + const [roundDecimals] = Retool.useStateNumber({ + name: "roundDecimals", + initialValue: 1, + inspector: "text", + label: "Decimals", + description: "Maximum number of decimal places used when formatting cell values.", + }); + + const [fontFamilySelect] = Retool.useStateEnumeration< + ["inter", "roboto", "openSans", "lato", "poppins", "montserrat", "playfairDisplay"] + >({ + name: "fontFamilySelect", + initialValue: "inter", + enumDefinition: ["inter", "roboto", "openSans", "lato", "poppins", "montserrat", "playfairDisplay"], + enumLabels: { + inter: "Inter", + roboto: "Roboto", + openSans: "Open Sans", + lato: "Lato", + poppins: "Poppins", + montserrat: "Montserrat", + playfairDisplay: "Playfair Display", + }, + inspector: "select", + label: "Font family", + description: "Select a predefined font family for the component.", + }); + + const [titleColor] = Retool.useStateString({ + name: "titleColor", + initialValue: "#111827", + inspector: "text", + label: "Title color", + description: "Color used for the chart title and stronger heading text.", + }); + + const [mutedTextColor] = Retool.useStateString({ + name: "mutedTextColor", + initialValue: "#6b7280", + inspector: "text", + label: "Muted text color", + description: "Color used for helper text, subtitles, and lower emphasis text.", + }); + + const [backgroundColor] = Retool.useStateString({ + name: "backgroundColor", + initialValue: "#ffffff", + inspector: "text", + label: "Background color", + description: "Background color of the overall component container.", + }); + + const [tableHeaderBg] = Retool.useStateString({ + name: "tableHeaderBg", + initialValue: "#f9fafb", + inspector: "text", + label: "Header background", + description: "Background color used for the table header row.", + }); + + const [borderColor] = Retool.useStateString({ + name: "borderColor", + initialValue: "#e5e7eb", + inspector: "text", + label: "Border color", + description: "Border color used throughout the table and surrounding panels.", + }); + + const [emptyCellColor] = Retool.useStateString({ + name: "emptyCellColor", + initialValue: "#f3f4f6", + inspector: "text", + label: "Empty cell color", + description: "Background color used when a cohort cell has no value.", + }); + + const [heatmapBaseColor] = Retool.useStateString({ + name: "heatmapBaseColor", + initialValue: "#2563eb", + inspector: "text", + label: "Heatmap base color", + description: "Base color used to generate the heatmap intensity scale.", + }); + + const [cellTextLightColor] = Retool.useStateString({ + name: "cellTextLightColor", + initialValue: "#111827", + inspector: "text", + label: "Cell text light color", + description: "Text color used on lighter heatmap cells.", + }); + + const [cellTextDarkColor] = Retool.useStateString({ + name: "cellTextDarkColor", + initialValue: "#ffffff", + inspector: "text", + label: "Cell text dark color", + description: "Text color used on darker heatmap cells.", + }); + + const [rowLabelBg] = Retool.useStateString({ + name: "rowLabelBg", + initialValue: "#ffffff", + inspector: "text", + label: "Row label background", + description: "Background color used for the sticky row label column.", + }); + + const [selectedBorderColor] = Retool.useStateString({ + name: "selectedBorderColor", + initialValue: "#111827", + inspector: "text", + label: "Selected cell border", + description: "Border and highlight color used for the selected heatmap cell.", + }); + + const [selectedPanelBg] = Retool.useStateString({ + name: "selectedPanelBg", + initialValue: "#f8fafc", + inspector: "text", + label: "Selected panel bg", + description: "Background color of the selected cell summary panel.", + }); + + const [selectedPanelTextColor] = Retool.useStateString({ + name: "selectedPanelTextColor", + initialValue: "#111827", + inspector: "text", + label: "Selected panel text", + description: "Text color used inside the selected cell summary panel.", + }); + + const [validationMessage, setValidationMessage] = Retool.useStateString({ + name: "validationMessage", + initialValue: "", + inspector: "hidden", + label: "Validation message", + }); + + const [resolvedConfig, setResolvedConfig] = Retool.useStateObject({ + name: "resolvedConfig", + initialValue: {}, + inspector: "hidden", + label: "Resolved config", + }); + + const [selectedCell, setSelectedCell] = Retool.useStateObject({ + name: "selectedCell", + initialValue: {}, + inspector: "hidden", + label: "Selected cell", + }); + + const [selectedValue, setSelectedValue] = Retool.useStateString({ + name: "selectedValue", + initialValue: "", + inspector: "hidden", + label: "Selected value", + }); + + const safeRows: GenericRow[] = useMemo(() => { + return Array.isArray(data) + ? (data.filter((row) => row && typeof row === "object") as GenericRow[]) + : []; + }, [data]); + + const fieldMeta: FieldMeta = useMemo(() => { + const keyOrder: string[] = []; + const keySet = new Set(); + const numeric = new Set(); + const dimension = new Set(); + + for (const row of safeRows) { + for (const key of Object.keys(row)) { + if (!keySet.has(key)) { + keySet.add(key); + keyOrder.push(key); + } + } + + for (const [key, value] of Object.entries(row)) { + if (typeof value === "number" && Number.isFinite(value)) { + numeric.add(key); + dimension.add(key); + } else if (typeof value === "string" || typeof value === "boolean") { + dimension.add(key); + } + } + } + + return { + allFields: keyOrder, + numericFields: keyOrder.filter((k) => numeric.has(k)), + dimensionFields: keyOrder.filter((k) => dimension.has(k)), + }; + }, [safeRows]); + + const autoDetectedFields = useMemo(() => { + const allFields = fieldMeta.allFields; + const numericFields = fieldMeta.numericFields; + const dimensionFields = fieldMeta.dimensionFields; + + const nonNumericDimensionFields = dimensionFields.filter( + (field) => !numericFields.includes(field) + ); + + const detectedValue = numericFields[0] || ""; + const detectedY = + nonNumericDimensionFields[0] || + dimensionFields[0] || + allFields[0] || + ""; + const detectedX = + allFields.find((field) => field !== detectedY && field !== detectedValue) || + allFields.find((field) => field !== detectedY) || + allFields[1] || + ""; + + return { + detectedX, + detectedY, + detectedValue, + }; + }, [fieldMeta]); + + useEffect(() => { + if (!selectedYAxisField && autoDetectedFields.detectedY) { + setSelectedYAxisField(autoDetectedFields.detectedY); + } + }, [selectedYAxisField, autoDetectedFields.detectedY, setSelectedYAxisField]); + + useEffect(() => { + if (!selectedXAxisField && autoDetectedFields.detectedX) { + setSelectedXAxisField(autoDetectedFields.detectedX); + } + }, [selectedXAxisField, autoDetectedFields.detectedX, setSelectedXAxisField]); + + useEffect(() => { + if (!selectedValueField && autoDetectedFields.detectedValue) { + setSelectedValueField(autoDetectedFields.detectedValue); + } + }, [selectedValueField, autoDetectedFields.detectedValue, setSelectedValueField]); + + useEffect(() => { + if (selectedXAxisField && !fieldMeta.allFields.includes(selectedXAxisField)) { + setSelectedXAxisField(autoDetectedFields.detectedX || ""); + } + }, [selectedXAxisField, fieldMeta.allFields, autoDetectedFields.detectedX, setSelectedXAxisField]); + + useEffect(() => { + if (selectedYAxisField && !fieldMeta.allFields.includes(selectedYAxisField)) { + setSelectedYAxisField(autoDetectedFields.detectedY || ""); + } + }, [selectedYAxisField, fieldMeta.allFields, autoDetectedFields.detectedY, setSelectedYAxisField]); + + useEffect(() => { + if (selectedValueField && !fieldMeta.numericFields.includes(selectedValueField)) { + setSelectedValueField(autoDetectedFields.detectedValue || ""); + } + }, [selectedValueField, fieldMeta.numericFields, autoDetectedFields.detectedValue, setSelectedValueField]); + + const resolveAxisFields = ( + meta: FieldMeta, + rawX: string, + rawY: string, + rawValue: string, + autoX: string, + autoY: string, + autoValue: string + ) => { + let resolvedX = rawX || autoX; + let resolvedY = rawY || autoY; + let resolvedValue = rawValue || autoValue; + + const numericFields = meta.numericFields; + const dimensionFields = meta.dimensionFields; + const allFields = meta.allFields; + + if (!resolvedY || !allFields.includes(resolvedY)) resolvedY = autoY; + if (!resolvedX || !allFields.includes(resolvedX)) resolvedX = autoX; + if (!resolvedValue || !numericFields.includes(resolvedValue)) resolvedValue = autoValue; + + if (resolvedY === resolvedValue) { + resolvedY = + dimensionFields.find((f) => f !== resolvedValue) || + allFields.find((f) => f !== resolvedValue) || + ""; + } + + if (resolvedX === resolvedY || !resolvedX) { + resolvedX = + allFields.find((f) => f !== resolvedY && f !== resolvedValue) || + allFields.find((f) => f !== resolvedY) || + ""; + } + + if (resolvedValue === resolvedX || resolvedValue === resolvedY) { + resolvedValue = + numericFields.find((f) => f !== resolvedX && f !== resolvedY) || ""; + } + + if (resolvedX === resolvedY) { + const alternativeY = + allFields.find((f) => f !== resolvedX && f !== resolvedValue) || ""; + if (alternativeY) resolvedY = alternativeY; + } + + return { resolvedX, resolvedY, resolvedValue }; + }; + + const resolvedFields = useMemo(() => { + return resolveAxisFields( + fieldMeta, + selectedXAxisField, + selectedYAxisField, + selectedValueField, + autoDetectedFields.detectedX, + autoDetectedFields.detectedY, + autoDetectedFields.detectedValue + ); + }, [ + fieldMeta, + selectedXAxisField, + selectedYAxisField, + selectedValueField, + autoDetectedFields, + ]); + + const resolvedXAxisField = resolvedFields.resolvedX; + const resolvedYAxisField = resolvedFields.resolvedY; + const resolvedValueField = resolvedFields.resolvedValue; + + const normalizedMaxColumns = useMemo(() => { + return maxColumnsFromOption(maxColumnsSelect); + }, [maxColumnsSelect]); + + const safeRoundDecimals = useMemo(() => { + if (!Number.isFinite(roundDecimals)) return 1; + return clampNumber(Math.round(roundDecimals), 0, 6); + }, [roundDecimals]); + + const resolvedFontFamily = useMemo(() => { + return fontFamilyFromOption(fontFamilySelect); + }, [fontFamilySelect]); + + const validation = useMemo(() => { + if (!Array.isArray(data)) { + return { ok: false, message: "Data must be an array of objects." }; + } + + if (safeRows.length === 0) { + return { ok: false, message: "No rows found. Pass an array of objects." }; + } + + if (!resolvedXAxisField) { + return { ok: false, message: "Could not resolve X axis field." }; + } + + if (!resolvedYAxisField) { + return { ok: false, message: "Could not resolve Y axis field." }; + } + + if (!resolvedValueField) { + return { ok: false, message: "Could not resolve value field." }; + } + + if (resolvedXAxisField === resolvedYAxisField) { + return { + ok: false, + message: "Could not resolve different X and Y axis fields from the data.", + }; + } + + if (!fieldMeta.allFields.includes(resolvedXAxisField)) { + return { + ok: false, + message: `X axis field "${resolvedXAxisField}" is not present in the data.`, + }; + } + + if (!fieldMeta.allFields.includes(resolvedYAxisField)) { + return { + ok: false, + message: `Y axis field "${resolvedYAxisField}" is not present in the data.`, + }; + } + + if (!fieldMeta.numericFields.includes(resolvedValueField)) { + return { + ok: false, + message: `Value field "${resolvedValueField}" is not present as a numeric field in the data.`, + }; + } + + return { ok: true, message: "" }; + }, [ + data, + safeRows, + resolvedXAxisField, + resolvedYAxisField, + resolvedValueField, + fieldMeta, + ]); + + useEffect(() => { + setValidationMessage(validation.message); + }, [validation.message, setValidationMessage]); + + useEffect(() => { + setResolvedConfig({ + selectedXAxisField, + selectedYAxisField, + selectedValueField, + resolvedXAxisField, + resolvedYAxisField, + resolvedValueField, + availableFields: fieldMeta.allFields, + numericFields: fieldMeta.numericFields, + dimensionFields: fieldMeta.dimensionFields, + autoDetectedFields, + maxColumnsSelect, + normalizedMaxColumns, + roundDecimals: safeRoundDecimals, + fontFamilySelect, + resolvedFontFamily, + }); + }, [ + selectedXAxisField, + selectedYAxisField, + selectedValueField, + resolvedXAxisField, + resolvedYAxisField, + resolvedValueField, + fieldMeta, + autoDetectedFields, + maxColumnsSelect, + normalizedMaxColumns, + safeRoundDecimals, + fontFamilySelect, + resolvedFontFamily, + setResolvedConfig, + ]); + + const chartData = useMemo(() => { + if (!validation.ok) { + return { + xValues: [] as (string | number)[], + yValues: [] as (string | number)[], + matrix: {} as Record>, + maxValue: 0, + validRowCount: 0, + }; + } + + const xValueSet = new Set(); + const yValueSet = new Set(); + const matrix: Record> = {}; + let maxValue = 0; + let validRowCount = 0; + + for (const row of safeRows) { + const xRaw = row[resolvedXAxisField]; + const yRaw = row[resolvedYAxisField]; + const vRaw = row[resolvedValueField]; + + if (xRaw == null || yRaw == null || vRaw == null) continue; + if (!(typeof vRaw === "number" && Number.isFinite(vRaw))) continue; + + validRowCount += 1; + + const xKey = String(xRaw); + const yKey = String(yRaw); + + xValueSet.add(xRaw as string | number); + yValueSet.add(yRaw as string | number); + + if (!matrix[yKey]) matrix[yKey] = {}; + matrix[yKey][xKey] = { + raw: row, + value: vRaw, + }; + + if (vRaw > maxValue) maxValue = vRaw; + } + + const sortMixed = (a: string | number, b: string | number) => { + const aNum = Number(a); + const bNum = Number(b); + const aIsNum = !Number.isNaN(aNum); + const bIsNum = !Number.isNaN(bNum); + + if (aIsNum && bIsNum) return aNum - bNum; + return String(a).localeCompare(String(b)); + }; + + return { + xValues: [...xValueSet].sort(sortMixed).slice(0, normalizedMaxColumns), + yValues: [...yValueSet].sort(sortMixed), + matrix, + maxValue, + validRowCount, + }; + }, [ + validation.ok, + safeRows, + resolvedXAxisField, + resolvedYAxisField, + resolvedValueField, + normalizedMaxColumns, + ]); + + const hasRenderableData = + validation.ok && + chartData.validRowCount > 0 && + chartData.xValues.length > 0 && + chartData.yValues.length > 0; + + const selectedCellKey = useMemo(() => { + if (!selectedCell || typeof selectedCell !== "object") return ""; + const xValue = (selectedCell as Record).xValue; + const yValue = (selectedCell as Record).yValue; + if (xValue == null || yValue == null) return ""; + return `${String(yValue)}__${String(xValue)}`; + }, [selectedCell]); + + const parseHexToRgb = (hex: string): [number, number, number] => { + const clean = hex.trim().replace("#", ""); + if (!/^[0-9a-fA-F]{3,8}$/.test(clean)) return [37, 99, 235]; + + let r = 37; + let g = 99; + let b = 235; + + if (clean.length === 3) { + r = parseInt(clean[0] + clean[0], 16); + g = parseInt(clean[1] + clean[1], 16); + b = parseInt(clean[2] + clean[2], 16); + } else { + r = parseInt(clean.slice(0, 2), 16); + g = parseInt(clean.slice(2, 4), 16); + b = parseInt(clean.slice(4, 6), 16); + } + + return [r, g, b]; + }; + + const getCellBackground = (value: number | null) => { + if (value == null || chartData.maxValue <= 0) return emptyCellColor; + const [r, g, b] = parseHexToRgb(heatmapBaseColor); + const ratio = value / chartData.maxValue; + const alpha = 0.18 + ratio * 0.82; + return `rgba(${r}, ${g}, ${b}, ${Math.min(alpha, 1)})`; + }; + + const getCellTextColor = (value: number | null) => { + if (value == null || chartData.maxValue <= 0) return mutedTextColor; + const ratio = value / chartData.maxValue; + return ratio >= 0.35 ? cellTextDarkColor : cellTextLightColor; + }; + + const getCellTextShadow = (value: number | null) => { + if (value == null || chartData.maxValue <= 0) return "none"; + const ratio = value / chartData.maxValue; + return ratio >= 0.35 ? "0 1px 1px rgba(0,0,0,0.18)" : "none"; + }; + + const formatValue = (value: number | null) => { + if (value == null) return "No data"; + return Number(value).toFixed(safeRoundDecimals); + }; + + const selectedCellSummary = useMemo(() => { + if (!selectedCell || typeof selectedCell !== "object") return null; + const cell = selectedCell as Record; + if (cell.xValue == null || cell.yValue == null) return null; + return { + xValue: cell.xValue, + yValue: cell.yValue, + value: cell.value, + }; + }, [selectedCell]); + + return ( +
+ + +
+
+
+ {title} +
+ + {subtitle ? ( +
{subtitle}
+ ) : null} +
+ + {showDetectedKeys ? ( +
+
+ Fields:{" "} + {fieldMeta.allFields.length + ? fieldMeta.allFields.map((field) => toStartCaseLabel(field)).join(", ") + : "None"} +
+
+ Numeric:{" "} + {fieldMeta.numericFields.length + ? fieldMeta.numericFields.map((field) => toStartCaseLabel(field)).join(", ") + : "None"} +
+
+ Resolved: Y = {toStartCaseLabel(resolvedYAxisField || "-")}, X ={" "} + {toStartCaseLabel(resolvedXAxisField || "-")}, Value = {toStartCaseLabel(resolvedValueField || "-")} +
+
+ Max columns: {normalizedMaxColumns} +
+
+ Font: {fontFamilySelect} +
+
+ ) : null} + + {!validation.ok ? ( +
+
+ Invalid data +
+
+ {validation.message} +
+
+ ) : !hasRenderableData ? ( +
+
+
+ No data +
+
+ Rows are present, but no valid values were found for the selected X, Y, and Value fields. +
+
+
+ ) : ( + <> + {selectedCellSummary ? ( +
+
Selected
+
+ {toStartCaseLabel(resolvedYAxisField)}: {String(selectedCellSummary.yValue)} +
+
+ {toStartCaseLabel(resolvedXAxisField)}: {String(selectedCellSummary.xValue)} +
+
+ {toStartCaseLabel(resolvedValueField)}: {formatValue(selectedCellSummary.value as number | null)} +
+
+ ) : null} + +
+ + + + + + {chartData.xValues.map((xVal) => ( + + ))} + + + + + {chartData.yValues.map((yVal) => { + const yKey = String(yVal); + + return ( + + + + {chartData.xValues.map((xVal) => { + const xKey = String(xVal); + const cell = chartData.matrix[yKey]?.[xKey]; + const value = cell?.value ?? null; + const key = `${yKey}__${xKey}`; + const isSelected = key === selectedCellKey; + + const tooltipText = + value == null + ? `${toStartCaseLabel(resolvedYAxisField)}: ${yKey}\n${toStartCaseLabel( + resolvedXAxisField + )}: ${xKey}\nNo data` + : `${toStartCaseLabel(resolvedYAxisField)}: ${yKey}\n${toStartCaseLabel( + resolvedXAxisField + )}: ${xKey}\n${toStartCaseLabel(resolvedValueField)}: ${formatValue(value)}`; + + return ( + + ); + })} + + ); + })} + +
+ {toStartCaseLabel(resolvedYAxisField)} + + {String(xVal)} +
+ {yKey} + { + const payload = { + xField: resolvedXAxisField, + yField: resolvedYAxisField, + valueField: resolvedValueField, + xValue: xVal, + yValue: yVal, + value, + row: cell?.raw ?? null, + }; + setSelectedCell(payload); + setSelectedValue(value == null ? "No data" : formatValue(value)); + }} + style={{ + padding: "14px 12px", + textAlign: "center", + verticalAlign: "middle", + borderBottom: `1px solid ${borderColor}`, + background: getCellBackground(value), + color: getCellTextColor(value), + fontSize: 15, + fontWeight: 700, + lineHeight: 1.2, + letterSpacing: "0.01em", + textShadow: getCellTextShadow(value), + cursor: "pointer", + outline: isSelected ? `2px solid ${selectedBorderColor}` : "none", + outlineOffset: isSelected ? "-2px" : undefined, + boxShadow: isSelected + ? `inset 0 0 0 2px ${selectedBorderColor}` + : "none", + fontFamily: "inherit", + }} + > + {showCellValues ? formatValue(value) : ""} +
+
+ +
+ Low +
+ High +
+ + )} +
+
+ ); +}; + +export default CohortAnalysisChart; \ No newline at end of file diff --git a/components/cohort-analysis-chart/src/index.tsx b/components/cohort-analysis-chart/src/index.tsx new file mode 100644 index 0000000..dd00342 --- /dev/null +++ b/components/cohort-analysis-chart/src/index.tsx @@ -0,0 +1 @@ +export { CohortAnalysisChart } from './components/cohorts';