From 98c45bb26fde788578636b93545ffbfa2816ff8b Mon Sep 17 00:00:00 2001 From: Nate Emmert Date: Tue, 17 Mar 2026 12:25:40 -0400 Subject: [PATCH] WIP: Begin unified map refactor - HTML structure, storage, CSS, state updates --- .tmp/imgdiff/app.diff | Bin 0 -> 124372 bytes .tmp/imgdiff/index.diff | Bin 0 -> 40660 bytes .tmp/imgdiff/new/app.js | 1435 ++++++++++++++++++++++++++++++++ .tmp/imgdiff/new/index.html | 405 +++++++++ .tmp/imgdiff/new/server.js | 286 +++++++ .tmp/imgdiff/new/style.css | 1304 +++++++++++++++++++++++++++++ .tmp/imgdiff/new/sw.js | 19 + .tmp/imgdiff/old/app.js | 1569 +++++++++++++++++++++++++++++++++++ .tmp/imgdiff/old/index.html | 322 +++++++ .tmp/imgdiff/old/server.js | 474 +++++++++++ .tmp/imgdiff/old/style.css | 1031 +++++++++++++++++++++++ .tmp/imgdiff/old/sw.js | 123 +++ .tmp/imgdiff/server.diff | Bin 0 -> 45004 bytes .tmp/imgdiff/style.diff | Bin 0 -> 111354 bytes .tmp/imgdiff/sw.diff | Bin 0 -> 8488 bytes app/public/app.js | 46 +- app/public/index.html | 303 +++++-- app/public/style.css | 121 +++ docker-compose.yml | 4 +- served.html | Bin 0 -> 27186 bytes 20 files changed, 7354 insertions(+), 88 deletions(-) create mode 100644 .tmp/imgdiff/app.diff create mode 100644 .tmp/imgdiff/index.diff create mode 100644 .tmp/imgdiff/new/app.js create mode 100644 .tmp/imgdiff/new/index.html create mode 100644 .tmp/imgdiff/new/server.js create mode 100644 .tmp/imgdiff/new/style.css create mode 100644 .tmp/imgdiff/new/sw.js create mode 100644 .tmp/imgdiff/old/app.js create mode 100644 .tmp/imgdiff/old/index.html create mode 100644 .tmp/imgdiff/old/server.js create mode 100644 .tmp/imgdiff/old/style.css create mode 100644 .tmp/imgdiff/old/sw.js create mode 100644 .tmp/imgdiff/server.diff create mode 100644 .tmp/imgdiff/style.diff create mode 100644 .tmp/imgdiff/sw.diff create mode 100644 served.html diff --git a/.tmp/imgdiff/app.diff b/.tmp/imgdiff/app.diff new file mode 100644 index 0000000000000000000000000000000000000000..3e37c1bd021691447e2b10ee75194fbdb20a3b4e GIT binary patch literal 124372 zcmeIbOOqW(mfv}0wHvef4vA`2CrV&~MG}0q>LC;e6xjuk6cQ|&2BHxp@gSN2$OceF zq9N!zNcC-cqm{bW+G{Zz$!r!{j5OAZS&ZiV;-CLK$0I!A-kX^qscsq!K<2&S?#Ivj zXUIURk{ne?MEj6!V^qe_xBg zug4R&WB#4^`DgLxwbi-!!)RdMI}*H%J~R9|D&+rS^)F)Oy@2+m)e8aBtMS{JgWq0` z-*@7Bp7<>A^&l_=UiVkmR`0E@uFkK1v%0wYc=g-(_g}@QJF8#B->1W#cH$=}@cS6? zE~ugLYrV|DP>FIrnrs=e+|slNRTL9=BFk;`0YH`(Ga8`FB>& zR{#8f`jU~`!AlPY4!S#FWoOvM!@$d};Jr^)e~ABY9^n3N{QPKjdcfP)tDgpi#Fe5J z_25%ozn?BM-;6nc`(fgkx} zLCnLxne`W|U&g-`Cz7#*GoRmC{dxS^DKnfsj!E3lHH}}?;CP}P$9RDL1Naf9-h4fRMq5amBek(?y(ar{5(MqRd zA4_Z8i|5gvzYCgUL*`bSd(P`V2wu1sf0ovQtI)WqWzZYH4mh!S=-memM~DyG5%asT zT(-D;c3{aWUN@s&pK^2iYpKD$wxPrvc9~}#X8TwL$M*u?x4IOW;_6xO$=RUH-53S` zJRB%tiG-hw56f2l>}-sJ@31ewA9!{rGzwB>=;uaZz2z76$F&;kSG;0ym7E>yhGs<9SX2K#R!D0jJ<#`7P6CFWe*-j7Pg*fKsfEg(N8j%`3E1XW*bYMb zMo801!sU6_K_iVc=M9|{RCDtKUD`No?eBv#O-sQx=T!mQyYc+}u#D0PCxO4Y)qqq| zfWO~r`epCz`M|lnWX_t$^;cSclnnL*JnF9>%}F4{xdEj7Ra!)PrSQdUSJM?n&@Ue|>yVVem{q zvJ*R)gR<`(KTpWa!|8KZT4OPgBrL?ufO|iFgG)qbJTT%|eCzbw#3k0$SL55;F$0mr z=V4pOA28z_r`We>$DNNA81b{lKbUg-<-qKdnfZnL>6j}6H+?aTlC{JVS1f!TP?MQZ zjDepzeolIcxDb!zNksDB3>>F?#?1ltv`m^?W4;P51~dBuH-9zQW^C~$V=e|%pMVNJP);Mr>VXig5Z~d|3F_AW{cU_tjEHSM2`(nS07AU? z%n+p4<@j(@xD=D6A9XhN(UVO)M(}ngR>hOkxKqQf<$=wQd=yxQ=ZPMxhdT9y6|2_x zY&~Sc?fU03icKg^N9?*2J|KVayop&PnxyEow7R#FSqB*RcZmAGFbNSN?RED_3;}_@&4IlLLM! za@XX5Uthf(zwz@l1FXE#qsYZ7IwK+_!esYHh0=5G#T@Od32TcXk8pf&GOOEW&aFDN z!`A}dJMjlid(_=eXWa`r<-Bvy7}}G7!E?>6ycZmwRe&kP`vEaF1;5}V@n9RFh&}ah zg~qU-1Pq>Q@6bxNE1zlbkiJu;!SN(E=WJ=Q7d{i3V2&Gd+}K0Hza+Lix_su`d*R8D ziAnzpnf3e$Tgxvwehgd1+fn%@XYWDRm!uXx`?J?#UvS*)Q2d`IIgR=wA-4DqjbBB*lZdkR%YNOM`er3o5(f@&m{l?&Lzr1=gew+H(n#&&L zX7H#wHV;YbKRfS#G4}Cr*zHc(oJS#f-Z@b=-1~5__g9AR=%}I7g)9c%Aiwdi@TT$q zJbv@+MBVKScyKj4BYlX?1wOoJ>|TY69Np1j+6~IEqA|K2BDmvBK=CMS)PsO&XV3=x z*4i3k`WI?Gn zLRGAc@uq#NT9`WoqPph$F^1d|)PiJF_>r zwz4?+dt#gHBC$XITjJNz0ol||Fbfwvi5aGtt@EE<`8nV`DtWhLNTo9xT=BIXF9T6kRN6r~Md{e7qCiArrv*FtTU~QSy zhWb_T2sxKW@kgG6s{_DRv1w}if7yX)ZxPVbH;4`(vq;_Nbm(@tI(I8cg6nFJ&TiE5 zfQt!=qibGikET_GfR(E95>U9ld=*Hz4$W{X04w+1KY6o8MF!Q{}Bbm7i-={!YJ4 z&PL@fe|vY}9cIg?j)S{12e>9pxJav&~ z=Sq&X_Vlb(TlRQOGpd6CpW;{XcQ?F>m*fBQ!98d#s!u%k^Z2W8cr81w>J)?Eq$I>gG0$Gz8+7U8J^|$mj@q6Pc38kLg*;+ zfnRiSJSV^CG~^e_eH8!WyFG~+{xG4C_5B|LK4H?eUF(o{L-JgiSFhB&G26Qbvmqs| zWb6+8y!aeI=gNQ85q-G^wyLWt?>PGl-@|uU#q5+36G1|i0#U5 z#DI#e>~BnUdCLAt!-0Os^N^0)4GVEUd?G$kd^u+uM1hls=I#iV77p2hDSq%;Dh@^nsQ{dNw$3T+19?H<9qgvC>?+F=78(8 za-LDvD*Y05o7GQ|aFFww6-dASl-4tFt>fX%fLh^br;|<=K!Ncj0 zyq(A)VVPeI?dARpKK=Rd>1*K^F{^e`pMPWUkkqe{&%ZHP?;adP*>wBNV@`0q-i71! z12|gCPsTz0?dxWJW5C)J+Sj@`+jQ19W7ghY(O5(p_eZ-OL*L_5X{x;g-)t(6IuAw_ zhBzZ7^Vjh+Ug(`cF7dsH#VlFGq0;O4nZPXlJ(WpAH(reM{3$ z0e?R?K4Ejd;(B1XN+tW$CzdCDlH8}?lfwQzDW*>>H*N2Dg@8VJs7&-o9_DmV9h{Mq z^-ea)%ib6w!=&Wi5c@oi*yl!2${vL%wLGu$t8+72cWdT~<>h(7jXMa)kF-3o7rf>( zZ$!r9Ug4JS9KHRl?5y&9&p#)FNd*;;!FKUcR8r`70XDKW$q zgwl@76M7hX{wzKf7PTf)r%rxVdGN&MbI}Qv=CCjIa%eze&^HH7NKE=#{K2=vrp$R$ z6-VS8$Y|aUSzpGIE!U~QAo(DVV{hPFeKYJG-AyKp|B+m>i({OF1@v*QO*g!3d^Ym-|~IPO{%Z_R;h4M?>H%HC=v$yZ->8kE%GLCe}xqodvx9e6VhXK zmwmi`dl;7=zTKX(o44Y{JTxaqoXy0GR^@>1UUV0HDPMGVu-=@1LvO(6Nz=qz5A#cU zrfNIm`eLk6B}19H_e0XCi|I6FexJIpj!TI!cVv}fTNszM02RCnO~pBxJ?aEhVp58a zP61*WwD=hR@Q41K?B38$${$WOTf4WX2N~a^!_iVN_t9#5@^dYQ6+unoD09;R&D_&_+w&FI)OeU~-M%y^z7wI`4%i zbNi4h_YNu`^D&b8)QE`C3fyFIEhzFq;0(?BMsue9)AAI1#joQ$!dUZa-|RtWp6H{{ zlkzKeLx=B%uY-L@%Y!rW+UMeVXg;^j_yh!%vU9`g{zfqy(fPq{CKXb~yg}3F$ zx>I7g$Aka%xX?~>xmwkyZo7Jm>vlcGa?~PJbH{btQC!Xx##?QC93wfCD7{Tu0Ycsz+0XjWD zjZf)cBRB7eV5-rqKNLwIcUeKN{U#YpVAH3aAj&jp7DGXWho|Yedl=j{AC0uQ8GC8g z7+BECU+O}*8ncrPHjieH$?f>&af~3w-wnxvad{s@T5oJ4z~WIM z(Kt&Qz3`kOOP|YLA9ffi_+8M82tv^<_L7_+)l_U4XS?fZNRM{*u=V)%hZ*S-d>FSS z!9mQkjTP;2%y@lHr&VmWx(NttkJWman_^79&lqduGLb@bD*M_E?e<>8wAbRNF^tG6 zTm~j|7WL^c0*$PwjDPoHtZtOqJ(#hN*B1|^9G&%SvYNAQK$Rzy@iFX$Ad~b3jQ5nB zW{yeS%xuEVcz28%pI*|_S+(zDr$W}9{OZn7d+;fX%fl07S56PqLRxi38QTE;(S{cWxl~n2-rH{IEc{aD z8|$0hp|0VYxH+xn6InP+e#=qqdVhL%%zBqNS>cv7gXec z*KJyY*Rc=08*wcs;4)ukY#_5|LZ+x+v|ai1ihma4xZ{JGhH?_r4qKQ$FKa8WT6u_Q zL~%+T+3TR|_0QFrfQLR6$L85hhs4|oZh08rY8KrL3QmBCey7)ii(VV*QuK=f72k5x z!0Gt(T=b1`zv#>HJ0tYHxM#X2ysoc9n|NRCc;-)7bCn7MIgoRVSRx}h}t7|}$_<1J5ZXK#;tJ`aAPc0lHd^NE*YPtwlN zD0x-5wSE%)$X(Dq##1dWb|aQR6A(AvZ1{O@o>yk(X{BLqG#$4+C@uuQ-9NBH_@jz! zEL(H4>5i!Bp031f^5dmr*3I+!3{-C);=G+<9*0S#93j1+p6woGnGFvNI$Vu^kx)+9 z>Qi>0S`i)B(wd)4=v}4yv!ErJHb09MsW!VxMjdA#HOKLj?%tUjW6h-%KMRTXC+r5; zS@~An#QSpWXl@puVE;Kw+hy?afD@jWo8k9E53zQAHrD(@tcZ4H%s27#{eb10IJNMz zA^-NPL9)s1Nmq}pw7rK4k7oNgG=uwvKMw8ovxQwDr|5OO7$D2L2+a$uYe_ZU+qlSye&NH8Kb*vTX_EK>c7vxgEyiZoWT=TP&qu=gN*%p z6ucgg$U}K$@KfmH;C5~P$GOk$Fo;?YWRf z6#tmZmign4Kl-1m|7-QrAAj(Z|2VVv4}w=e9i)|<<0oBuY_lR9`JYzTf1)^V%#j}s zJcyRSXTv|dz53U&cb&~70_yRDb!nBH>w!zK{Q7`pbU8l@zx@10VEcJuc6p!su_t-s zL~ig?e`nib_HS4J_Y56>Gw>~OQh%i$4$Wik2z7Vkrc(aLAN}Xmf1814KlV)LA=H%? z1rOxddK9)k#Fmid{CKcq=q+_{O4CdB)xr6+Xz^{a_}^Eb{$xoe_CpTIA;GYvWYg;uP$2K2DhqhVStVTXumTg~`)tN$=V#Saf?Ck^q*ptDtvsakPcJpA42 zZ)ew&??$wY_cUp0$n)Em_bj&DEi*B#H5euIMndtjr83(tX z>8aoptvmK{tEb^-r0a>%9}eACSK{=(;H^@J=fXYde8#uG9C7=Ns7w+Qa7qNbAwK=Z z>c=rFd*D7VS%@FU*q;Xvll!3aXzpqD{UFBwa>xfU8c6l&vzXzlfG2S{wiQYhfs^FRIohobgzq4IeO)i$Xi_w9LPVaF*I3#@spQ|uMKxk zJ+@2nnL6h2jjX4p^Y|%yRE#5P)tNV_l`$&$LFr29L>C7(*)hdD9QKKp;m}hR4*0ka zhdf7kPw?PR8;;D>=p7w8|HHdEDeS66?kD@+0-;cQ;6vNhy z->-lK$}$LB&H+wu>qe_@c3XG<9!t1pxq?$O4*faCnLJ4wUdWRwGxn{fojt%)hu^lCpkMUXAoKVJ+zmyhq;^nho+?Aai6imM$tf>DHk1A3 z=QY=N`k;deiIFd_+8C=CezJd@merrJGANvVD{q8591)i`;cVeGDzdqd^ z8UN|t>K;6w@|0RxK8a>Cgq{ib`lrf#ww@KA4cXy!(2+&TTSodg_PXGa+;6LgTKWqD z&-yGleu^Ra1Mc~m+7S7Ft?o-plDcSG1#MwDY3=J;jH}V6(YpQA8gjcUYqF|wQq#25 zKQ+?H9*dtm=2Sz5b*+*nJuTa`4VLSCt*)<7D8D#&!;{9L2dz`9t~74dZ;r;WAG5xz zR6arK`hZF#qH3exD@`TEt?Ns;eM8_z@+PI|xcUAT3J_{!P&u}oE&fY;zzQ8{N) zWsK;povZH9nmOk{H{;*?gt6zASHGUu#+|Tj{iLp4_dx9A%Jub-F#fDxt35$^J$RQ{ z=!11`p0@&^zxBCy`O#sYY>vtA>WDg1yV`o_L=UZ9_k9#DJ~}7%;?*h>`(b5)k@Jt- z!P@gl+lcVgq5kV@Z9QG7OHP1#O;#(wHa!F%g!guNxD|^hh(+Ho&z_EFL;-S~y-o`> zr$<`u?LnUlJ|<7=DlzvBQt{UZSl zQ7NU4%eH)!*(;s{d_`h9QYeNK=t2x=br|T`z-kKxOqu3 zl{2yy-&C$63&&2d_{v2K0>vYqp-PiK`)Nc<+_C$o)&Ge9x!r8D=(zH;=stZ*BXhTr zVT*S%0yjg)q5Y6F-w?}hWS-$XXhVkgi})ESRMgX#K4#wpF5%{_;7WM0M$Ti*GUHJ! zt~rA&6}vniyQtQYowRb{e3pH-l(nRDRx=zudYvliZG^IXuk*6hx7=$Bneg;h+Ekj_ zv6hW>b8jMF>*r`?q;d>Zz19->AXhR3}UM4 z|7UQS;p6xJ62FgmyrqP1h&ZlnZW@&ukB(CPJ9(<)xHeX-+>Q_NJb66l-;}c*sZB9m z=h}{w+mS8*?nZ7fU z>paKhFynbYV5wD>X++Fjo11ZyZV*{_8~$;%Ch>^ZI6hakFmL9ABt@p9=ef(zPC3lq zSbHo-25{N`)8Vr5ESq!MCf+mkbowReno@Q1rH(FaY4h#zXywrCpi0m3xR4yr6R{?C z<+JB^5RF+TH7XtS?Y|NnaVGxd zEnfUi4(p`jjS2};x}=A*jy<>gqw#~zGF0z5=jSK9VPuKQm2Q(K`=UGX)#;1>J@z0N zxn2Ur6YHNF*Zw|>*#)tEgY*^P0`!&g+I}mvKg;L*jA2g+`|e;Ti?BJwEuFTDN!qdS z;ih@%9GS64&8zTz^~R9&yiZ|U`FH*u=&9vFWiLWpr!NJ;OGzSFP#s+rV#Xdu*l|H-wRX;wkQ$wXKkC8g`!SY>ls zZx62IE|1K}em8iSy-_{OcfNs1*S3;U>`)N6w@P;daSK(gq~PCs&PyRVWK8`=9(S(J zKcOnwA7Tdb#n!m{@$=sdw1b0m*9B)G>24ZRz!i8?WP6;b|ADgu+u;ElcRO}Z2fyxu z`6_;T@1T#2yNIZFk~5}@uLpTO9rbjmOcY>L-F}phP>5S(sm8{p7?UU0-CjtHObsKL4A+yR8P8YPzws_oqMv~ zY1QFJ*=bK#uiITqC2P}ldb)+q^gy4U3EGb{WY1IY$+|n6B0C}IZ1*LnN3Y1vX@Ade zT{GvAz{l1I&NWVPw9X%L-}PScD6HLN5stghYGbGH&C?K1(0*87^Y$bV%`3RG8lHJL zQE(3P^PZagpB~10(dPNc2fh=&LmP!Yk7(+LIgf(@^onZG^EYZDKjA@~azNs|JNL(+WBI&=pOZ~DhF*s8cZ^mS4pA`u zSiFNxu?X~Xhk@?UYO(4(7Q4cJx?hjq+S^7`ABl(g*OeM>FW|jhyn#ZM8l9`BOVgDa z;@o+jXKkv)oDI3vV2(Y}!Ss087j-h;-{d$})Yu;Kb?-sWdnErdAYx5URYLP|l&qV> zz&Rc$`Ao&nyjMEm6c1PK)X$u#;^Y)OZ%KCl?D-*P;^wsr0|f1}V+&K~ZXtOmI7$4! zKF;q!MHY+b4MI%Z7qd_U zV_tQgaia51%uaTJZlC@x>QmLO8e(Wb#$-(4DK=Av7kFov<-l4p*sUjI7loV?n;No%U5Bqx7Uw1BccRZ=bU z)iUJ@r$c+E^=@xA`c?2cT2U5UKR=6`E~Fv-DKf{Wk0Z~vjjiW-PKl7~rAPM5!NX;y zy!ULI`OFzquycl!^e0+sz_C~}bVtj*cC4nQ zdp@QtQp`KFd_LFvm*;^Uk(Dy9aV~)qNj=yZ(@H-UNB)I4NMqZxhwj(gjk=WW@Wo*b zY#>zyy@#i@Q|>G^Jj{uA(tDe{;2rM_Xl>QTbkm73y_d2_3C6DXkjFBcxNl_^zPUYc z6#5eXw}l59w4dk}Zh6;<;~Kq4rjv@v&;G3Pk9{(A-EbxvPlj7A!6+Ua-YYbuCe`<| z+GpV_vSfJ3x}~zpOwETH9$P7Xx8LsnfgT(z`Piuu&jMI=T& z3cn)P{ch|S??@EC%LjjL_qh$ZV&jvM}>HAV=YDh zfo@cM5I@m5vjor7x27}lR&?JpR#NqDXdYE{IK5;F;gga-Y1ca6jMj;1@*C#N>3Zup zvwDZq^|sT4+M7J^y3cCij;GnC*xy8}Y0XXexV<@nxrqz%RJ_jU;6?IT-qOVV#aNGf z;R}%)_+pVOdR!`hMUe76c>_EnG6%@K;+?&N{9CT?xL2_j*~2dbmb-Dr7U;OSns^4g zj^Cj;Z0@O_#!9*mA^ngR4!Y6m?x%O+KOcC6G5*y)$$I7cIrNzzuTK&g)%Rt9OJQLA zEH)$W64Kc$G=HnjbUzZebdyg!7r0$_FPO5WNiFy9ZO<*w4m^Wa)AnvEYCXC!#oj&O zD>{?F8}CYkJIHcZ{!N|Q(pwT$ZHtpJpG{9FD|%2r?7HZI(;w=59`m^+ZOJ-ISJ+>4MzKnP=x`h<7Czb& z>zp9ro(SuaSB9H@^PaV3ujnN9%+6c7$o{1E4|9sd?{?#`G{*S#tql+gzdf-KV6Nt zah#7E8a|HC_X7&5-Q1<>Grr5ST#B)84fEw_?$XQA!Uq))9+)9jP)IU z5Pk%^MW(NZPJS~+lE#Q{Hv2-(%8}j8d>c0GpfWI#k zm6mp{+-Th6~Tr*wFoU%eNcWrE-FckmTk7(`=da(FKi?|ksh zR_8(<;B-6}=W?d|`)+iTc#MDT(Vhz28@ppa*1Vj$q#c<$@KNXlWBsG}Ip<8P|52e_ z#`4U#?L6Wq^cT7to=*;^@80{lcJS>7@d>xZ>wTy{i+S~CiJd_| zezW>N;wkGebRv!o&r0VCm9Kcp=XmG!nBEW}`*k*I#>54c3%JD;e)bb+2%m@=KPNs< zXnNG$90ya~@ec(TEmu^(0>6oy>k_k)(QV~nOo`M|rsI^-)3rtUr{#SDme1+4WCXC% z?=^aWt~_Trk}sI0Hv@Krx2k>_I!17Mtk)f9ru_NlT(!AmrEZ_c*>=(a^xjcw?#*G} zULwA&J#5RLXG71-(K`90$0KuGhOK$y5WDp(4LoB0U#AD>zqclx!sJsi^IS4F>%4r; zjhL6RvqKhsEcfia>KFP9=rW%B| zTNREPgSOG5B|G`I?*+{7kl1y~UxKB2`QXwqG``#XjV(L{t^MAX^xV>)G~CRpKiixf zIUha@KH-hkpAFTYUKW5eus4T(Ay&N?kuFbB!J-a4J-0{aj&cfj;yUMAnOv$*$u!nlnr{KdVg3GG}n7+J9Vq5O37d zENPE8f8L9-CHL*Mu=hqg0_&-&QcT|!gb zCx_{cc*?4fPv@TH?<aAtteV%E^%1`HihVF)V6$*WIuk^%E{a8b)8PvtpPolGq+BZ z4TYRP%_^p8tN*J#N3VVp>ti{tgchOmkZdHmBknw})k{u8d^1StQch^h9q0w_fa}5T zQ_;yOryD4Umw5|-*CiJ4Oetk=3|SuTF(-TZYDgG$D{{EsPhCFMFUz$w=kpRv#QT|j zOU>JM_~z%(y;9wIPb$B3m#M1g&MfM&**l*);Kjk}N(YR4!OdZH6`zR}ch$A}C!|s$ ztb@GsUM5o&_S;1-T@BtRD$t#2Q|*%TFU^Z2wDz%%Qyy7!(*Z#4Mf=N)e-9V<_TLP7 zTvfJn+{=fOK~&$;EOB3Hul4fH#_gD8J-j?N-*sIxNYvxW&EavlQpoB(&x?Ed<-_-3 zAHOfx-X7JA+p&&+#}{Bt^pU^w?|esA{ZhpFP=f3{u<1E+Vt5*gm8~sW9L?`j@0#+A z-}l{`PdDA`lsBL)(j_?po1A!)Y^|s9biT@`-xaM>UR!;bpCh4P2WKf~O*hzBqIus- zPi9l=wzDm*G}gY-k&HN*^NZFu<8>@cI#r4Fmh`WWzk3jpaq$@ntPfJ?vmJ9eIv{Fu zm4>z2iIcEKKtmOwo>W91^mkI9syVfmrotyxjcw01dYAscj{{q|wsZdJ491vi{kf2< z0jv8V^3+0Gmy?X^ga`Q`PI}X+$oc8)tDfS**$D5-|5sXWZ}a=O>06Jn^_s%CtdZ7_ zpSKMEt6RV+uIBgp55Wp9?{~^4|1d3KU%IO1vA$?6H(D?66jzOJinIAKs`zE~-){vO zN9Mg)7`<{d2v3C$Tk02RW79cXKGthKqeHCpaEOcb4k)y$u}A$HJ2qV(zn13|H_$zDO+FnXqcSt-c2+ z{MoR;#3!b&{jTSmQJa$_xMK?8QqKP*^IDtn<`}-jc*`yQy?l4r-UPaD6VsdI0;f8r z`fshSA{Vlh>zm`b&GLG6tyVMpEf#q7HRm^lz0PrU@YJ(Q&e8E4lL zXj@NC`>Z87F7E;yIF3Y*DV7v{baNUuj~TxnzQqP|Mrms0mLrSlJ+D*z_V!4wUihJc zc${6*IjVfX&B!Ei#!PRGkljX`bE3xiq$zZB(b2L8*7D3woPkH?w#htzm35feoxx1q zGu+^t0(73Z2L)K+W|PV{x;J!=*MV)Fr18vl_^a7uD&BD4`vWJO)C~i?50yVzaS)#U zui&jW;`^ITv~X)VEV;_VS8s7M4cyMw)4o+t_I0f}JLg)~fB78y_71Fjs$6cued4&D ze2w`6sBVX(qGRYBpeOoiCud?U*Q*_X)qbZqd(n!brhi6?U2%Fn@?PB8_C`E=W~ej0 z6u-R^pM5{;P1+OLdThW_e_Y+I=xZ!q3fXcDlXXQsQ>9y3I5daq`D9HV3>|rBj%m+= zKdrTNC{VS@wEB*BePvkVcY~+x{`y)KOAXz^C)hYW!tG+W4>*O@A&My$5gn@xoH8WI{`LilX?)hC-!W-y@gZ5Ug!4B$hy-C2^^q(E8D+( zn|#8{xMdCymZv+_S*;I)My@U@KF&HfwpP8~J41Jo?n0t&gLHG7A==QZg><4;T6N5c_ZqBf4zweC3 zw|91qcg&o*+c~e+Jh~*AJ>F(^{!Hay%)fq;n}U^*pY9{~YipKTfbrN4`Xj(Y=9qGK zt^bwuT7vv|@U*g~4nfhn-GRQ5PmS=xS;se)OINxV91Wh7G(XwfZqJr*8cw?t->kEqJS%Hh zuIq;&>8N$ZgnVH?N0{GUv&EhYH6R+?z98V;w`#e>fM+=}$@qN(++``M*m>1*I zm*d}D`^9*Y_{>%9j{~bO4jSdP`2T6>(PzW|-yHn^WcZ&sUW!wZFU8DX#SC}C?}sa- zAJY57u1rt02z&X|^mGrjakr8Bd^qo;+bVj!NPJ8GoV^G%VC_<9&kdke={vFqU*VyL ze#sZAKE3rl87eH&)%aihogUHq2RjjE+tYw`E@oK1$2UEJT*o}E99i`c61!-y^n>$r zc(>(8!C0fGtP&ol@5lPTtz}lNBz-;)+qRX%`I*3?eO2xE`XI~g2ymmj1F{ZA4JhSi zzVD@SQ|ggT=4&Of?X>94`q6+ffh#pZKstpua;QA&za9$=5tj)IC8I5F4<4K(8LBgK;1VEp3ypEKcQxo#&LbF zt(ZZ-`{%Tx?VML*C28HMHmtOp^KUns4cmg>!JU%4Z$>|59>^=DUtsHi4O}`CUe8`k zV?gMy78m0AKP7fa2cn|jbmt-d2LO?eZeA&;#5FO899iH1vVlpOBoW-d15ag&flIYzN zmeJMapk;f{U}CX)MZI^oEzJT?Sa#dKYSAq%n6Np0f1PjdSh7u?MHw*pu5jnvj#Ac> z$Jeco@6tb8z4@wRp51h$?CIF|1%u8F@$>R*EhVO>y4b%d#d>7vj*gQ$Tju!1$J@ZE zoo6?;cWj+M)p^N{&q)y}V9311y-kR#PClH=I74+)8rcV zumq2H<=Vmg-0cnSW2ik(s~P5JOa3(eTJLMB9q_Rp9?;`LV8S_NPJMC9%EOSx`W_zf zi#^^)!ISrglfmSfi8`GD?>`4eE< zE$n9o=ru~XON;9#1-+l-xABt|em%!q8&Y%OG1uSCL#GC8Yv<;H^q_k7wDqyB2aPHv z@IVt5X$*a@J+$9voZlGOP76lcK39^NY6uKSI3!iTg&ClORMwOYZcR`(lT}0 zRM)gVsz)VX%w_JN;e$alaho%C;FE!$sR#AW>`hljj#XdlTax-MYt`E&TV9pkPI$-| z0uddld5+Cck`bCdktd3b15qrsDfeHs^S0Tlv2=Kz#N`-HWZE|(NvounmNGtFRh7t# zF`9fk)v@-OdCfA0fQ}(@@>;!}1w8qCbzD7ZG=%X}IYDoFLsQSq$Xygfr&I*+9iR^@ z)sdpbZdBzzA9OA^w5i^zxEUVPZQ^7FxQ)wamJ@5^of5~(({fE$naD~}zDerr5X_ScU8~p zCG7CubS1AMDLlGYSnt_U&j7yv{oqSkWa_l{;*&N`RwS7|95{zJ$U-v$zD2)r4+Xd< zXUWM_e{bD~?hx!+`<-DH$Te%mSnQjGm!f(rhyODRaHQJH5 zroRJsPqI5a(Ve)zl%IKj82CoNqLmZIstND}S&NYu253?%sCHUwXk?zSuE3^vj~u7o z9i^QUG5HCypnF4ilUA{(+=qmChm*}@XzvEhmJaH5+(c>)$_bA!ofYRV z`+MYFSa*YJ{dZhd9I>N2fm3kH>_jHeP&FR!Ht~_)^G0B0e|`!sAepj2W2nJgJ$7_yp<293;$25b&j(@d2wSM;JXbT?k6N+8H zfnpcdq;$xLL<_9Zc(K5G-&sY@Avrd_GUe?zI)C7Si4^B?VQI~??%%)PxZ6XMi{r%8s*Ng zMcN7e1~_pi-}=4uW`^goX11jBD&%JeUL{6A=RjNQgI1=|zj%d%TjHzf6cV|omc z-c?C2eP6HDIWn4_V#hd4eybzv&+N39?RKqZzE)Ckw7kx<*iCj5KJDn4F+|q-?jzU_ z>OTps!}}1a*YzoYZW?1W^GQ>#W7S%9sZhAXP<>I-1@(K6Y-0w~HKT%FL~|u{!@bzi z-9Zm#)O0ejO_j73I^a*pkhy8RGR+z~20~tXS;C4H@Be&ADmJ(mn|VH>Tt`RnyFYIu z$f`;Gghz=g(>*55+wa=kU4`2-kJp}NIQ%5OfUx&y@It7`d^5=HGM1{QBQC>sS`#UP zt}|N-(Ra(aTfQB8N0!PK3+%f>;T@$9=1+tT?tBrP>1|DC^sG}~VL%lbUux9IraXL={j(N^Lah_Nxr~qn2xX9QQxE?4yhh`TMsecR1VIys=V>D zbwynv?cNkBbC|QeHRevhq95!6|=_qk{SIV`=0RSD#macRRj@Cggj>+0vVef?kT5dR=;~;XiY{ zUAbYN=WGEy8~^vLMg6SZx4HJzpl?&k@J-O=`0Q{od+gOfSaCU$xO^zR$ZT{2kqA6Q(=9zUd11VFcItQg9H;2wX`ukozxb5sj0+EaEX3WEccs%{$RD< z3`#~6TS`9Eb)Xz+?2-W*k5eo(*LBg>$j3by)lW+P9j~A|0(I63F*Fv$9|zx3FQH42 z(+-@n>T|m2w{E3v)db;p#b`1CIJ5BFeew)t*_NA0dR_iqAo$o9IK?;Je$ z=n#*tvbr}wJL#?QR#3yI)aEfrt`!ZGd3jT;zf$Traqn9(n)<2o^Tr+L57FMA#^>02 z_<|@#J}m!d&SDCIYYRCW_oq+`>0+)>XU z!;S^4e0H3jn9tVl!@Exo_Q~E~1+<&)(xW%oqwjg%3;)xVNzwi|JDzgg`n@A%_k%vl zb3(y~abv3PkxmNg^VUKozm26vbva}M-Xxc245tiH|3R9t=CTOR_u0l^1=44A=Fhzs zbxwId>45;*REOX%SUYWBT@Y9HW+g8sowt5go^2}#)jCy;SN(6zt}J+co_)%L5d|*9 z4(|;XOnU|M;212U&A<@0H?^1OL43vM+DX!C%1^ms4^}EauIr6?N7W``64lnIongn+ z3y)t?cUET?)RznOOi3iB#S;~+T!UGtdifc_XD$}zdMhPGi6Wrxx|Lk}T3RV_HjxYm+{z1uLoN@y{%EK$B^=E4 zbTl`g0bd(!oeNsM85F%0{mA`zZ?g!ipX1=9Zwpn8Yp_|pI?IjKhXZE0wP$RB&~C@K zTPqcainGkhf~P$!q8Y(<9{?%+e<*qBTXV*ope}| zI{HDF4ftf4h*R&!Z&W_v@vO`847jqrl77l{`dH=`5UpUjt#g9m%+;)uOYl3EL~9sxh4xDvSS4?YSx z09H7jJ`Z>Q)%)(IR`u%`!#vvUbSHlu5{%qG8T#h%7nv7*L1gl0@iRAMs=^L5oS!yE z_rkBy?Ch(05E*~_iGI{9c>N5y1H21r{$zB}O~gQ7hS#PZAzC{Hr6!jM=eK<6|SA= zI;ErHVpNZa0m&jycb4zP=|Sq#Lyn_FQA;D5L)?5VRv^}%?*~X-ImXafE~(xL4`9d` z89(J`&c@2z!htnee|o8Rhs#*IjN$9yrB6Y_IzPg?^x~j7@Cz&n$UTyZ3?L_Zx0-oA zZ>>8xGSAiOpCEiWi|e6qVMUd(49!Fru7_HhPj--rLm7q<3rsRvIl71 z-VWz*Hg`h8&{dpnvmO|&y3_M*iZ$TEzRl0Fgf~_pW0n-1>SIGB|8_t08(I#n^yLr< zDFgFqz@d|)NHA8Eb^Vs;oi46-qN|)Z9Y0>wzZWy2zrTo2^sYzdtaqht+O?ZL+=W3S zqh~hn*{wJ7Nqg}AFLfaGW>#$Sa!^5e2z4pFaX?wc7~rvLr@F>m_Mtu_F4<(RaeiSu z5-G`fMAvU8x09<@OgOeUiTqA~g{0Z#;7V5#MOkR2x41JFDwE}z>ocoxD>AFJtMu~c z%sRLJHuN|!np(XjIwHJ3mJUiX#ALsYl%xu|0D#~$I_Na}O;Ej99!ps{*_oqPYY zn5X8c#!^@zu92#^k`wh{TYBdw1GLuhb5y7^Cbc!+d?qhvLqn0xYdOf7z4lZ1N*&_s zvI#uZ(u#GSc0RL)%xqs$vNs3MSIm)@sK3vRS>KXwzfiC&Q}^3cnXV~xv!uKH>%lvAIO%+k$>qKNh9JocW4ijRm^ zr#>h@e$IEVylekSoI8c)`w-D@LcuBIJshp~;-xu=(X0V{pPy{^AAj`st3O3Y8~^S^ z-cmLJKMc;G1F}Bx!_VJ71|?vysEa)YJi`8l~+SEZ*LK&z1J(Ol!aS6z?175eAi0Q-|z+beq>@)+!vh=HGp z71H}sPt1H}4!jT-sGl4BYUc-!tUw=D z;)OgCRanuCd_LYo4-eRhKKz#Ev2d60(PM^` zRhd7;|1D)%MYC3W`EEd{c$JkH3m5GjJc*x|UY7GG>D!668e^XjTFkGE#_dt?MyW3t zA3s0X30&!{@Gn+BK7{eX0qj4HlL7Q8i|S<99v?{De%M!ZMXMuH=1hraagz1LgVla{ z1W3rlp|K1Jd;z!W)8oz#&yeMAXZNSaogEpb1Le`-{QhKImrXebgBpcq2H(16;^T-J z?gytsgKrM`JTj_3`FB70d8{DaFh@`PTK1@H0TlHr$AJWnOF9dZ7Y||i$N(m z_2|(!&PgYp$9@JG3uWDUS*m`?2ZhDp+dF@;B-b`THJ!I8dj`!3;RBgMh!#uw0*kz1w=EbBZ;aAEO#yakiR7|l9^*y(&TzbDP^?{6x_4veN$_q~* z8_?|N6cCqXE?~az=aV1*`+xFxtN#$IemLlv=hXpZ{e_N_-nF&`+gKB<@#ejj^)yKC z6ME)vnMHEd4&L*D`!72 z$~}+LQp)^tE6M!GgwTB&%J!1A!_SnCb_N*UoUXM=?pNMv#yWW;h_lvH4AmX>;KYv` z!>MR=6FB?%;pYJ--URQmI$3~|Yai}ZGjfv*)O5u5@y9s3)$Z_Qym>7UU@!iu4tuB2WhIIaiXSQg$59^nL{GRn%xCj59*WM0*~snaCH z^yKI8tQg4?@I73YJV)nxUt)xxDv^3~ujVD{=2km&k?fng*va{hb8(jAR%a!rUmwwd z+~~7_D^~^y&P2MS*@%z}-t4`@N2c7q|mhwaGDeG_*FW>FY$f_dn`P$Qm!RdHf z$P)dR#NyZ@w71X8r4&nNRE_*uJcrfrI5O~Pe59QGk8I9#9K9gzxT?eFMj?akDArZw z%41SPDQkutx4ZmxjG@zvUKGnV_gKu=HG~cF-kdE~z9Mae@XQ{k^^A3uH+5F;1+G-B zpu(`6*)dhl9**(Oj|Tx>8Mp1NTmSkYU&!~AC!lB`VS`6>_J{uak77?c?Ll4;KKpfi zuiFl&sj=o?#qVff>O9~(Yc1^^1a#oMG58nU34_1!Mw|(IEylbuyq#j{Z4|D!w*0Ue z*Kbc-NrJlP*W-1*A|QSszcW{p0B`N>O_I2y!(4r+^{^v5bwpbeu)m_rqH@9Y>2dCT zdA52AT9pSXweT9NsuC>^WH&zL-SEz6f~Bkh+>eae?vPQeu-u5J$*S$f&5ZaZ^}BXg z_AQQ!Y3{%Vl67(iR?w={3-P4s$oV+*HlxXTy3RtKN*Y0a+R=Hy*}Cs3*brs$1vCeykZ`f*1Fdj%8OPvKh_n8&`AGLYNvUKk~ArQ7h{29z1o;L^cJ!M!QX zY)_5~x3!J&yq;>up_e%9q+q2608Q7!+~bc+Ap@LQ+J;X~2L8=&f{=G%4)(ZuQ^~2p zv++$V(pj5QXgVK0oAem>o&9}a_GETUZRP8Y=tOtWXHnVX`MeiEG_tMh*)#iE+se&x z^{_NO_F|nT&Me5-f5eA+7@7nB67B3c^?9DvI4-QJq^^fl&-Ju%)?%|pZw+)@=zf3D zqqFSC_OW<#u;qUU+(}l5?X$+tn+1>zDwN#jDxN+ry!7DUt#U)*I@%-0`Mf%xbPiUq z%-P)K&QAncRzqH{#i@E1V^^gui3$;3CBQ$5@# z38iK{85niu;6Y&VtIz?ulVWpNJ^KB_vBIXa?_(IrUWiY9&JRkGUsblAe5g+GV9n^J z!7_7>JxA7zP)oHd@tkrt%t1%UTLZ1tp)p5eq?P>awF4OPtfU~FuIH|NR$ISbORa5I zBTxNx)RDE0q=k%>cSTl)Czc@2E|{E$IVx}J8?4C`pVgC_VnXjI7G%OiT3Y1nn}CgX zch+jSt2uhtG~aPEa@}hm5;Q*dEVVo0Q+gaUTb@3d(r(OA7Y}srQ8H=56Ef3sDLQBl zV?>9#y-t|gK3epunrXb2Yb0Zs*e6)(eJ)=>x3bHc-3%C=FV%XcF&L%SvGwk~YVMA; z^0#&c>VLOm1z}*5`76&U7mAM18wjf#f%lyQsL>$e7ks3tUgr(zJ)KG( zkl7Pf?@BP4Z>KQvHYUBjVfr02`Xq_(seLIZ@>0|TxvBQG`2I{}h|di-7xC$)_tb7* z8L`%#>+g4sWA6~V?ykNYQ27+XbdT!Dd^h-n*jtf(^_*%QGAWn6;p47j!>Rw*L+gHj zL;HEav2+Sx+xeXj@cjOL9Jz`w=lk<>$L@wFuk?rDJETUjnPFSMYi9s;e!y8hEdNpm zKQOBDM1PB_U>^s)dr)Uz0GZ1k%!YPTN?}pZD0JotLs{>RIS1clWSu0p}sORlRw1LJ1!%v3W zXR&ZkM^s)Ov1|{pgI=NEqlA|MuB#<^E3lzT=%tufT#%;-4cYOcJnl!AMZ)gw#fpyy+q{HJB(Lgas&u?LURwFJ zp^}GgetudRT-+Wo0?zSeiTrj1s4_+H=q9YypQ4 zgYx%>Qy0X#EsTB_CsMTTS;0+*4jjpSr>6Cp_)K1h{yo`Ks8es==T(mCOS4mPaPJhV z*0VM^6`DimRNWnQYQU7nFZKSRDZsY-@f;fnN61z6t1_~AE1=6$s0nM@+qSRG^Nwd! z4A#zeG`)aV05=mIa3dITI=(2i@EVhOEp|>#U^-^q3EKKzHRB!o-#_t~XI4csc`ChL zL;N<~yn=_sQJbiJ&Cib{h5g*eGYaS8*X=2HCNEA-`-i(>B@7IyHRpFvM}_0 z%7>ae+vv^}dmGg+73b+R&{58+Cgeb~4wA8#y6&v%cC%CY+UBh4cC*_*{Nb#seD&?H z_rqCL*}bfO^tvpvj;2%k&qAi~{QEVG>FMjFi9P=OB&~vCpQ*nut(U$D65aYId}8~a z_I2#d_Rgv%9Gi?=@>cEBZ|kEgLdW*bpJsLBzB`<#ly01!d2Mrfn_%c#SSg=z9pBBF zm@#I19u0Y%*TZjg43(pxN&3Kv-QJqj<6#_smPR~3I>_R*XIB&Z`K{;$j88^6RA5Iw zh*7B)^fA*rZMq3&OjWId=V@7R?6K^tpAR{CxcZk7VbEov&aav)G_*DDqDztUz4KW-RK4T2 zyZFMv-W73pz8WE$>mSA1b^iG|x#!YPYTi0lbNd!1;H%NXqu3wa0Pa(+tK@fr`ffbu zx84%_Q+Z}RWYIhMZG6UwDDnmV3@LS{(4YA^|DH9MHiwchq^xIFEWg!T(1HGUfnn-) z?CE^`E{veNq#>2d&Dc3-Q@1LoMww^-5HlABnrZjFal~fMj&C+f(j=OQW9ZNzS|`S) z7d_X}_bIpJdC;BELX{v}sPU6a@xPu;J!wwI&%SezpE6fU+8Hr-vux$4>!Z8_vO<*n zaES4ceoN#>2ehg8(Y_zgQCm<|o?M*zf2ge6v!OV3du2wa_{jZ3aphZ(?Y{{72en%K2@&&&JdCE+qT#w6d_sEnP8q>fE*huggCIBb-(1V{W|{0{rBFw{(xZTxTY?)d?VvHw#w z5u9|=dMa%j5LIY$_w={QAKPAAQZ-Gz&}NSPes;+S{t5C(Ht+h-6UhycQ+@ip;2ht7 zns>g*e@)r1+#}wxP9F1-x)Pw9-@D^e9C(uV0^dlE_yb>_h-=D?X9pR(7bB1^@bfhO zwi-d7B3I9v&JeI=J=L4pI!5r(ul*?Abo_&qMJQX=Q%6N-o` zW4^2Md{X#Ic-HgZy7l|G@_yv`rN)!ysr&Hztau-+&di{psi_lj)o8J=g-d{$n)2+pXnMYAxm=iZSOpx zf{IJ4dzWKu`}0kAKh?IfFn+_CIO6CU;>uvJ#vImiNKZp7L9xX5)BQ~I*WlJVTRS7a za48_g+A3R2Ri;|w))e-Dna_nUeWt=VL&_)x*i=d2(UUL=`N!L*sv;T*zMigIj$8hG_2)51wMp&s7h+vH zXp_RpU)4UfULoAJSw<9m&Oe*0kLg&~J|8en>xT2Q>UqmcS_|3s4?=>eor)KwLFtYj zTX$O)a+K7wb^`{uRc8_X7KS$K*rSgn)HcUml3v+1gn6l1`7N1Ak=y~a$tsa&Y^~UI z-;ApG?2ntr=a@ND4%!w#QTQXgf$$$~|w~^7f1PYp>9?)Xk|Vt6=!* zIAe!f+tCiHdXo1-lXC|0vtjjK%~vPQQ>VD*E11dl_Vy@PHh}@_i$sxGZvAg#f;?%R zsju&p3r@=@EsX^t)509RJj-~Lb2a07^QPmeaB|lKc?Q)b#?a5v0&7Yf;WKv1>T*Id zv67M~i9LB*%SvhOWeN*|N|(8vC57wLeJZA=K}xo(-Ak^jUfd5Vs1Ks@eXYVLMypnP zW6)vp0;JXGXMZ6&SL_uip9KD&JXgIY4b0B$Usvye&#N90zEo!?hep<2TBX*WIlqMt zppv1Clb#&Eo%a3U$cG12d(W1*OPf*akU2Ma!C%BMy=?!kUPf& zEdG<(65TwdP7GG7b`>n5uhfGDC3S9;4sH2iy0sMhVksVTCDEGS%*XuvEG}n9L=+F> z_ms1iSLs+v*)a5`#U^^jJmj(Yf0d|qpJV=+tG%vYzh9NzY#cPM-7Uwj*N%O4p?;3&o_rtu=M=tsif=k)t}x;>(`3k%-ds)I+e={!N=oD0=S_< zs*bIBPVMvmR4R$ok!H>@P+!I$97A>#P3xWaEZw=VYUEO|YtV@}nH#e+w}Kag2FIsC z|8m#ewfNnAzMExPs;tlFS%{e1oa*yh39hKJW{OX9wz8hnp5bA#hDiyhfZjt z`1x_;zRcW9})emt*Ho zf;zQdGrgUqkz{=z4qU(+=+k4c7D}IIGwyS2v>vO=4abfT3zk*9>{}I&6&Q# zw@T$WD%ot*rnVl9u$1{uEo`Wla%nAIsUIY?#&Vlq%lRvQbJX-5y7=`i4D(M*d*&L| zM}BJ-`%9X+-C?`Gx>|a0yop;`A^mGSDssUe_++pIOEh14c2i1c1UKi^C7voRsORA6 z;a3ya8(wEX+fVB*=uDmZTDi(e_*-+(EoOr-Ciy{)Hxc1FDy zc{6T3QO?$PQ9Uhr#HD99qy752Y0{)per{z^{@w65br%J`*W9!0sO_)Sd%Rx@zr@~6 zu6`06F$c*qN6d}tam0M>NO8i|peQ=&VR+D$x~*QBUr~C*?@Yl@RhJxI{-?o_M6%M_ zfhYX&d05cNXX2^bfvbE|<WnUhoDYcw*sWa<&J zmYd<)8ZDuMnyp+^P8wTC(Q31`#*{J-*U|l`(*ChAcJvzfl-sTZ?s`#mVg%0W!Ma54 zwt_xgbw8*@US<2pI^lA34See@_>`{ahg?z5f-gP0sa80u_cpiomB5zkO|2!L8!>0e zUwUqqd=3)9zN|Zz$EN;B-(x$Tv<$76wzL+x7CcsCk;@Y;aa@j8ZD@b6-^iQZbVJ4G zUaXx^%l-k6wZRmYvBvr~^rxiJClgE?!`Sl{63>$9rDr#lI=E;lI!^787|k58p_`Fh~xabB^FGv}?S=N->#{;E`b-nP@Q zv{|O92eHmxdY<(fsm)zDALD$r=V{?tj}b!*ochzZq~fBZ>m|eYytI+$&;58GZQjNm zGS@=4f}Qd0Inwg|r;es~@K&FM2LCMZORfR_&fe6tD)>NIiOkQTO6{Z|N`>m)fx9Wj zc?{EYZZFgrd5fwuLy8Y2eY=M}8dw{BU>*$y>>oQNPIm3r zGXV{r9$FC}rgj}Cgj9OllU}t~Kc53v)}R-I4&+3r9QN-(sMtx!eBO<^WUV=8e43fg zDg|Q>l%y>^do(_r@?KhoswWZ;aEB76S~~=K1)40y*)`(q4VebAXi@ww=m?nR#8-VP<6tm zKkHj)>Bg*Gl1PN|mAdO*ofXnOcuWr>4y)%<>RkUgf5w8KyO%Y&II(JK*T)_N^}dSk z0qWdzs&nJjhoLJSX}=RVM_P0j7JYDhH?3;do)o`BiL(;(y?=GYru~ApR_3|6ew1o& zWMh#GdkD68a8#Q?c`^F*;WG3(ooM|3LF6Rq-r;mGeZ9Q<0xY=q#XV@kaYjAsSIaeW z)jD&Hrj&t|d{5!7I{WyUAK9MiD*LhT@1`5X_&M4>?SDzLYj>XUczg29n^eEGuHyk^ zsHD_+Lj4qc_WO|L=5fAA)thO~$8gS7HJle) z`hFWY=jQP(Q`7p=_H{f*yVLxreAcTWXUDJe=nlHnOVS?I4_V(C&zMbYy*SH#2&rwa zhV5WHR)bSk_K`g+w#8XnGDtkd-ibDPoz&|0ZDnaUo}9B^+|7eVr!$jN0BC%481Oi* zO`rKne8+R@JI=Az7Tmv$bjq(06zp{=KB_e-c6TS9M^dUap2DWiz`jjZhrX~>C0g{g z?f~nYE&gV(TGSlq4OCzI6aqoUd$ZMh-eThHfD!IZtiA2)yW+(;&^TwlIAB;ej$vQH zMf?7B;+r1S_F@z7;}~j8m*n2{Fc2f@cGWRX6|Lv0+j%Y;=Q-rB0axXVzYY!-Cj9=Q z55k|mJosf)J;*}6AMl}@?#Hv-wIM$Rj--p6bGPud&u}0S9%;OZKiYNaa)eKyLYu7z zCexkD6}efxUt@Vbx;8eOk0+_#dcKTGUXA`^y)Vfp3eOFZw9e?BOiW^Fv3+rm^W{Ta z5?Lbc$VS^+q_J%lXH3(ZF)Gi0rKS~bRR3Xh}qDtKXT{+yk63&M@J4mwFwKB{JeGtpG%gMWA@_WF6rn(U6A+Y4Uw87|&M zs~9mW|GD;*R-4G5>JH?a9DRZV(Fh4akNLZ#h`7KM8PAc}*K_>4DfY*6jdA_|18XaS A_W%F@ literal 0 HcmV?d00001 diff --git a/.tmp/imgdiff/index.diff b/.tmp/imgdiff/index.diff new file mode 100644 index 0000000000000000000000000000000000000000..e8fdf3e6ed6886cd3e841524bf3d430e3209dbd5 GIT binary patch literal 40660 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 literal 0 HcmV?d00001 diff --git a/.tmp/imgdiff/new/app.js b/.tmp/imgdiff/new/app.js new file mode 100644 index 0000000..1617dfb --- /dev/null +++ b/.tmp/imgdiff/new/app.js @@ -0,0 +1,1435 @@ +'use strict'; + +// ── WMO codes ──────────────────────────────────────────────────────────────── +const WMO = { + 0:{desc:'Clear sky',icon:'☀️'},1:{desc:'Mainly clear',icon:'🌤️'}, + 2:{desc:'Partly cloudy',icon:'⛅'},3:{desc:'Overcast',icon:'☁️'}, + 45:{desc:'Foggy',icon:'🌫️'},48:{desc:'Icy fog',icon:'🌫️'}, + 51:{desc:'Light drizzle',icon:'🌦️'},53:{desc:'Drizzle',icon:'🌦️'}, + 55:{desc:'Heavy drizzle',icon:'🌧️'},56:{desc:'Freezing drizzle',icon:'🌨️'}, + 57:{desc:'Heavy freezing drizzle',icon:'🌨️'}, + 61:{desc:'Light rain',icon:'🌧️'},63:{desc:'Rain',icon:'🌧️'}, + 65:{desc:'Heavy rain',icon:'🌧️'},66:{desc:'Freezing rain',icon:'🌨️'}, + 67:{desc:'Heavy freezing rain',icon:'🌨️'}, + 71:{desc:'Light snow',icon:'🌨️'},73:{desc:'Snow',icon:'❄️'}, + 75:{desc:'Heavy snow',icon:'❄️'},77:{desc:'Snow grains',icon:'🌨️'}, + 80:{desc:'Light showers',icon:'🌦️'},81:{desc:'Showers',icon:'🌧️'}, + 82:{desc:'Heavy showers',icon:'⛈️'},85:{desc:'Snow showers',icon:'🌨️'}, + 86:{desc:'Heavy snow showers',icon:'❄️'}, + 95:{desc:'Thunderstorm',icon:'⛈️'},96:{desc:'Thunderstorm w/ hail',icon:'⛈️'}, + 99:{desc:'Severe thunderstorm',icon:'⛈️'}, +}; + +function wmo(code) { return WMO[code] || { desc: 'Unknown', icon: '🌡️' }; } + +const DAYS = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; +const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; +const STORAGE_KEYS = { + lastCity: 'weather.lastCity', + savedCities: 'weather.savedCities', + units: 'weather.units', + autoDetect: 'weather.autoDetect', + showLightning: 'weather.showLightning', + showWind: 'weather.showWind', +}; + +const state = { + units: localStorage.getItem(STORAGE_KEYS.units) || 'us', + autoDetect: localStorage.getItem(STORAGE_KEYS.autoDetect) !== 'false', + showLightning: localStorage.getItem(STORAGE_KEYS.showLightning) !== 'false', + showWind: localStorage.getItem(STORAGE_KEYS.showWind) !== 'false', + currentCity: null, + currentWeather: null, + currentAir: null, + currentSource: 'startup', + savedCities: JSON.parse(localStorage.getItem(STORAGE_KEYS.savedCities) || '[]'), + alertIdsSeen: new Set(), + autoRefreshTimer: null, + lastRefreshAt: 0, + lightningMarkers: [], + windArrows: [], + showExtendedForecast: false, +}; + +// ── DOM ────────────────────────────────────────────────────────────────────── +const searchInput = document.getElementById('search-input'); +const searchBtn = document.getElementById('search-btn'); +const locateBtn = document.getElementById('locate-btn'); +const suggestEl = document.getElementById('suggestions'); +const errorMsg = document.getElementById('error-msg'); +const loadingEl = document.getElementById('loading'); +const weatherDiv = document.getElementById('weather'); +const unitsBtn = document.getElementById('units-btn'); +const unitsBtnSettings = document.getElementById('units-btn-settings'); +const shareBtn = document.getElementById('share-btn'); +const saveCurrentBtn = document.getElementById('save-current-btn'); +const savedLocationsEl = document.getElementById('saved-locations'); +const notifyBtn = document.getElementById('notify-btn'); +const autoDetectToggle = document.getElementById('auto-detect-toggle'); +const alertsEmptyEl = document.getElementById('alerts-empty'); +const settingsLocationEl = document.getElementById('settings-location'); +const mapsLocationEl = document.getElementById('maps-location'); +const mapsWindEl = document.getElementById('maps-wind'); +const lightningToggle = document.getElementById('lightning-toggle'); +const windToggle = document.getElementById('wind-toggle'); +const dailyToggleBtn = document.getElementById('daily-toggle-btn'); +const dailyForecastLabel = document.getElementById('daily-forecast-label'); + +// ── Helpers ────────────────────────────────────────────────────────────────── +const show = el => el.classList.remove('hidden'); +const hide = el => el.classList.add('hidden'); +const showErr = msg => { errorMsg.textContent = msg; show(errorMsg); }; +const clearErr = () => { errorMsg.textContent = ''; hide(errorMsg); }; + +async function cleanupServiceWorkerInterceptors() { + try { + if ('serviceWorker' in navigator) { + const regs = await navigator.serviceWorker.getRegistrations(); + await Promise.all(regs.map(reg => reg.unregister())); + } + if ('caches' in window) { + const keys = await caches.keys(); + await Promise.all(keys.map(key => caches.delete(key))); + } + } catch (e) { + console.warn('Service worker/cache cleanup failed:', e?.message || e); + } +} + +function compassDir(deg) { + return ['N','NE','E','SE','S','SW','W','NW'][Math.round(deg / 45) % 8]; +} + +function compassDirFull(deg) { + return ['North','Northeast','East','Southeast','South','Southwest','West','Northwest'][Math.round(deg / 45) % 8]; +} + +function formatHour(iso) { + const h = new Date(iso).getHours(); + return h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h-12} PM`; +} + +function formatDayShort(iso) { + const d = new Date(iso + 'T00:00:00'); + const today = new Date(); + const tomorrow = new Date(today); + tomorrow.setDate(today.getDate() + 1); + if (d.toDateString() === today.toDateString()) return 'Today'; + if (d.toDateString() === tomorrow.toDateString()) return 'Tomorrow'; + return DAYS[d.getDay()]; +} + +function formatClock(iso) { + return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +function tempDisplay(v) { + if (v == null || Number.isNaN(v)) return '--'; + const n = state.units === 'us' ? v : ((v - 32) * 5) / 9; + return Math.round(n); +} + +function speedDisplay(v) { + if (v == null || Number.isNaN(v)) return '--'; + const n = state.units === 'us' ? v : v * 1.60934; + return Math.round(n); +} + +function precipDisplay(v) { + if (v == null || Number.isNaN(v)) return '--'; + const n = state.units === 'us' ? v : v * 25.4; + return n.toFixed(1); +} + +function visDisplay(vMeters) { + if (vMeters == null || Number.isNaN(vMeters)) return '--'; + const miles = vMeters / 1609; + if (state.units === 'us') return miles.toFixed(1); + return (miles * 1.60934).toFixed(1); +} + +function tempUnit() { return state.units === 'us' ? 'F' : 'C'; } +function speedUnit() { return state.units === 'us' ? 'mph' : 'km/h'; } +function visUnit() { return state.units === 'us' ? 'mi' : 'km'; } + +function updateUnitsButton() { + const label = state.units === 'us' ? 'US' : 'Metric'; + unitsBtn.textContent = label; + if (unitsBtnSettings) unitsBtnSettings.textContent = label; +} + +function syncAutoDetectToggle() { + autoDetectToggle.checked = state.autoDetect; +} + +function updateNotifyButton() { + if (!('Notification' in window)) { + notifyBtn.textContent = 'Notifications unsupported'; + notifyBtn.disabled = true; + return; + } + if (!window.isSecureContext) { + notifyBtn.textContent = 'Needs HTTPS'; + notifyBtn.disabled = true; + return; + } + + notifyBtn.disabled = false; + if (Notification.permission === 'granted') { + notifyBtn.textContent = 'Notifications enabled'; + } else if (Notification.permission === 'denied') { + notifyBtn.textContent = 'Notifications blocked'; + } else { + notifyBtn.textContent = 'Enable notifications'; + } +} + +function moonPhase(dateLike) { + const date = new Date(dateLike); + const synodic = 29.53058867; + const knownNewMoon = new Date('2000-01-06T18:14:00Z'); + const days = (date - knownNewMoon) / 86400000; + const phase = ((days % synodic) + synodic) % synodic; + const index = Math.floor((phase / synodic) * 8) % 8; + return ['New', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous', 'Full', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent'][index]; +} + +function precipType(code, tempF, rain, snowfall) { + if ((snowfall ?? 0) > 0) return 'Snow'; + if ([56,57,66,67].includes(code) || ((rain ?? 0) > 0 && tempF <= 32)) return 'Sleet'; + if ((rain ?? 0) > 0 || [51,53,55,61,63,65,80,81,82].includes(code)) return 'Rain'; + return 'Dry'; +} + +function calcDewpoint(tempF, rh) { + const tc = (tempF - 32) * 5 / 9; + const alpha = Math.log(rh / 100) + (17.62 * tc) / (243.12 + tc); + const dewC = (243.12 * alpha) / (17.62 - alpha); + return state.units === 'us' ? Math.round(dewC * 9 / 5 + 32) : Math.round(dewC); +} + +function humidityFeel(rh) { + if (rh < 25) return 'Very dry'; + if (rh < 35) return 'Dry'; + if (rh < 55) return 'Comfortable'; + if (rh < 65) return 'Moderate'; + if (rh < 75) return 'Humid'; + return 'Very humid'; +} + +function renderRecommendations(data) { + const c = data.current; + const d = data.daily; + const temp = c.temperature_2m; + const code = c.weather_code; + const uv = d.uv_index_max[0] ?? 0; + const wind = c.wind_speed_10m; + const items = []; + + if (temp <= 32) items.push({ icon: '🧥', label: 'Heavy coat' }); + else if (temp <= 50) items.push({ icon: '🧣', label: 'Jacket' }); + else if (temp >= 85) items.push({ icon: '👕', label: 'Light clothes' }); + + if ([51,53,55,61,63,65,66,67,80,81,82].includes(code)) { + items.push({ icon: '☂️', label: 'Umbrella' }); + items.push({ icon: '🥾', label: 'Waterproof\nshoes' }); + } + if ([71,73,75,77,85,86].includes(code)) { + items.push({ icon: '🥾', label: 'Snow boots' }); + items.push({ icon: '🧤', label: 'Gloves' }); + } + if (code >= 95) items.push({ icon: '⚡', label: 'Stay\nindoors' }); + if (c.is_day && uv >= 6) { + items.push({ icon: '🕶️', label: 'Sunglasses' }); + items.push({ icon: '🧴', label: 'Sunscreen' }); + } + if (wind >= 20) items.push({ icon: '💨', label: 'Windbreaker' }); + items.push({ icon: '💧', label: 'Water bottle' }); + + const grid = document.getElementById('recommendations'); + if (!grid) return; + grid.innerHTML = items.map(it => + `
${it.icon}${it.label}
` + ).join(''); +} + +function apparentLabel(tempF, apparentF) { + if (tempF <= 50 && apparentF <= tempF - 2) return 'Wind chill'; + if (tempF >= 80 && apparentF >= tempF + 2) return 'Heat index'; + return 'Feels like'; +} + +function cityKey(city) { + return `${Number(city.latitude).toFixed(3)},${Number(city.longitude).toFixed(3)}`; +} + +function persistSavedCities() { + localStorage.setItem(STORAGE_KEYS.savedCities, JSON.stringify(state.savedCities)); +} + +function persistLastCity(city) { + localStorage.setItem(STORAGE_KEYS.lastCity, JSON.stringify(city)); +} + +function getLastCity() { + try { return JSON.parse(localStorage.getItem(STORAGE_KEYS.lastCity) || 'null'); } + catch { return null; } +} + +function cityLabel(city) { + return [city?.name, city?.admin1, city?.country].filter(Boolean).join(', '); +} + +function updateShareUrl(city) { + if (!city) return; + const params = new URLSearchParams(window.location.search); + params.set('lat', city.latitude); + params.set('lon', city.longitude); + params.set('name', city.name || 'Location'); + history.replaceState(null, '', `${window.location.pathname}?${params.toString()}`); +} + +function updateSourceLocation(city) { + const label = cityLabel(city) || (city ? `${Number(city.latitude).toFixed(2)}, ${Number(city.longitude).toFixed(2)}` : '--'); + if (settingsLocationEl) settingsLocationEl.textContent = label; + if (mapsLocationEl) mapsLocationEl.textContent = label; +} + +function renderSavedCities() { + savedLocationsEl.innerHTML = ''; + if (!state.savedCities.length) { + savedLocationsEl.textContent = 'None yet'; + return; + } + + state.savedCities.forEach(city => { + const wrap = document.createElement('div'); + wrap.className = 'saved-item'; + + const chip = document.createElement('button'); + chip.className = 'saved-chip'; + chip.type = 'button'; + chip.textContent = city.name; + chip.addEventListener('click', () => loadAll(city, 'saved-location')); + + const remove = document.createElement('button'); + remove.className = 'saved-remove'; + remove.type = 'button'; + remove.textContent = 'x'; + remove.addEventListener('click', (e) => { + e.stopPropagation(); + state.savedCities = state.savedCities.filter(c => cityKey(c) !== cityKey(city)); + persistSavedCities(); + renderSavedCities(); + }); + + wrap.appendChild(chip); + wrap.appendChild(remove); + savedLocationsEl.appendChild(wrap); + }); +} + +// ── Tab switching ───────────────────────────────────────────────────────────── +function activateTab(tabName) { + document.querySelectorAll('.tab, .bottom-tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.tab-panel').forEach(p => hide(p)); + document.querySelectorAll(`[data-tab="${tabName}"]`).forEach(t => t.classList.add('active')); + show(document.getElementById('tab-' + tabName)); + + if (tabName === 'conditions') { + setTimeout(() => { radarEmbed.refresh && radarEmbed.refresh(); }, 60); + } + if (tabName === 'radar-full') { + setTimeout(() => { radarFull.refresh && radarFull.refresh(); }, 50); + } + if (tabName === 'maps') { + setTimeout(refreshMapsOverview, 80); + } +} + +document.querySelectorAll('.tab, .bottom-tab').forEach(btn => { + btn.addEventListener('click', () => activateTab(btn.dataset.tab)); +}); + +document.getElementById('open-radar-tab').addEventListener('click', () => { + activateTab('radar-full'); +}); + +// ── APIs ───────────────────────────────────────────────────────────────────── +async function geocode(q) { + const r = await fetch(`/api/geocode?q=${encodeURIComponent(q)}`); + if (!r.ok) { + const detail = await r.text().catch(() => ''); + throw new Error(`Geocoding failed (${r.status})${detail ? `: ${detail.slice(0, 180)}` : ''}`); + } + return r.json(); +} + +async function reverseGeocode(lat, lon) { + const r = await fetch(`/api/reverse-geocode?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}`); + if (!r.ok) throw new Error('Reverse geocoding failed'); + return r.json(); +} + +async function fetchJsonWithTimeout(url, timeoutMs) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + const response = await fetch(url, { cache: 'no-store', signal: controller.signal }); + clearTimeout(timeoutId); + + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new Error(`Request failed (${response.status})${detail ? `: ${detail.slice(0, 180)}` : ''}`); + } + return response.json(); +} + +async function fetchWeather(lat, lon, tz) { + const proxyUrl = `/api/weather?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}&units=${encodeURIComponent(state.units)}`; + + for (let attempt = 1; attempt <= 3; attempt++) { + try { + return await fetchJsonWithTimeout(proxyUrl, 22000); + } catch (err) { + const reason = err?.name === 'AbortError' ? 'timeout' : (err?.message || err); + if (attempt >= 3) { + throw new Error(`Weather proxy failed after ${attempt} attempts: ${reason}`); + } + await new Promise(resolve => setTimeout(resolve, 500 * attempt)); + } + } +} + +async function fetchAirQuality(lat, lon, tz) { + const url = `/api/air-quality?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}`; + console.log('Fetching air quality from:', url); + const r = await fetch(url); + if (!r.ok) throw new Error(`Air quality fetch failed with status ${r.status}`); + return r.json(); +} + +async function fetchAlerts(lat, lon) { + const r = await fetch(`/api/alerts?lat=${lat}&lon=${lon}`); + if (!r.ok) throw new Error('Alerts fetch failed'); + return r.json(); +} + +function detectedCityFallback(lat, lon) { + return { + name: `Detected location (${lat.toFixed(2)}, ${lon.toFixed(2)})`, + latitude: lat, + longitude: lon, + country: '', + admin1: '', + timezone: 'auto', + }; +} + +async function buildDetectedCity(lat, lon) { + const fallback = detectedCityFallback(lat, lon); + + try { + const place = await reverseGeocode(lat, lon); + return { + ...fallback, + ...place, + latitude: lat, + longitude: lon, + timezone: place.timezone || 'auto', + }; + } catch { + return fallback; + } +} + +// ── Radar engine ────────────────────────────────────────────────────────────── +function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) { + const FRAME_INTERVAL_MS = 650; + const FRAME_FADE_MS = 240; + + const map = L.map(mapEl, { + center: [39.5, -98.35], + zoom: 4, + zoomControl: true, + attributionControl: false, + }); + + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + maxZoom: 19, + }).addTo(map); + + let radarFrames = []; + let satelliteFrames = []; + let mode = 'radar'; + let currentIdx = 0; + let layers = { radar: {}, satellite: {} }; + let playing = false; + let animTimer = null; + let locationMarker = null; + let visibleLayer = null; + let framesReady = false; + let loadingFrames = false; + + function activeFrames() { + return mode === 'radar' ? radarFrames : satelliteFrames; + } + + function tsLabel(unixSec) { + const d = new Date(unixSec * 1000); + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + + ' ' + MONTHS[d.getMonth()] + ' ' + d.getDate(); + } + + function frameTileUrl(frame) { + if (mode === 'satellite') { + return `https://tilecache.rainviewer.com${frame.path}/256/{z}/{x}/{y}/0/0_0.png`; + } + return `https://tilecache.rainviewer.com${frame.path}/256/{z}/{x}/{y}/2/1_1.png`; + } + + function showFrame(idx) { + const frames = activeFrames(); + currentIdx = idx; + const frame = frames[idx]; + if (!frame) return; + + if (!layers[mode][frame.time]) { + layers[mode][frame.time] = L.tileLayer(frameTileUrl(frame), { opacity: 0, maxZoom: 15 }); + } + + Object.values(layers.radar).forEach(l => { + if (l !== visibleLayer && map.hasLayer(l)) map.removeLayer(l); + }); + Object.values(layers.satellite).forEach(l => { + if (l !== visibleLayer && map.hasLayer(l)) map.removeLayer(l); + }); + + const nextLayer = layers[mode][frame.time]; + if (!map.hasLayer(nextLayer)) { + nextLayer.setOpacity(0); + nextLayer.addTo(map); + } + + nextLayer.setOpacity(0.78); + + if (visibleLayer && visibleLayer !== nextLayer && map.hasLayer(visibleLayer)) { + visibleLayer.setOpacity(0.2); + const prevLayer = visibleLayer; + setTimeout(() => { + if (prevLayer !== visibleLayer && map.hasLayer(prevLayer)) map.removeLayer(prevLayer); + }, FRAME_FADE_MS); + } + + visibleLayer = nextLayer; + + tsEl.textContent = tsLabel(frame.time); + document.querySelectorAll(`#${timelineEl.id} .tl-tick`).forEach((el, i) => { + el.classList.toggle('active', i === idx); + }); + } + + function buildTimeline() { + timelineEl.innerHTML = ''; + activeFrames().forEach((f, i) => { + const tick = document.createElement('div'); + tick.className = 'tl-tick' + (f.isForecast ? ' forecast' : ''); + tick.title = tsLabel(f.time); + tick.addEventListener('click', () => { stopPlay(); showFrame(i); }); + timelineEl.appendChild(tick); + }); + } + + function step() { + const frames = activeFrames(); + if (!frames.length) return; + const next = (currentIdx + 1) % frames.length; + showFrame(next); + if (next === 0) { + stopPlay(); + setTimeout(startPlay, 800); + } + } + + function startPlay() { + playing = true; + playBtn.textContent = '⏸'; + animTimer = setInterval(step, FRAME_INTERVAL_MS); + } + + function stopPlay() { + playing = false; + playBtn.textContent = '▶'; + clearInterval(animTimer); + } + + playBtn.addEventListener('click', () => playing ? stopPlay() : startPlay()); + + if (layerBtn) { + layerBtn.addEventListener('click', () => { + if (!(radarFrames.length && satelliteFrames.length)) return; + mode = mode === 'radar' ? 'satellite' : 'radar'; + layerBtn.textContent = mode === 'radar' ? 'R' : 'S'; + visibleLayer = null; + buildTimeline(); + const idx = Math.min(currentIdx, activeFrames().length - 1); + showFrame(Math.max(idx, 0)); + }); + } + + async function loadFrames() { + if (loadingFrames || framesReady) return; + loadingFrames = true; + let retries = 0; + const maxRetries = 2; + + try { + while (retries < maxRetries) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 20000); + + const r = await fetch('/api/radar/times', { signal: controller.signal }); + clearTimeout(timeoutId); + + if (!r.ok) { + throw new Error(`Radar fetch returned ${r.status}`); + } + const data = await r.json(); + + radarFrames = []; + satelliteFrames = []; + (data.radar?.past || []).forEach(f => radarFrames.push({ time: f.time, path: f.path, isForecast: false })); + (data.radar?.nowcast || []).forEach(f => radarFrames.push({ time: f.time, path: f.path, isForecast: true })); + (data.satellite?.infrared || []).forEach(f => satelliteFrames.push({ time: f.time, path: f.path, isForecast: false })); + + if (!radarFrames.length && satelliteFrames.length) mode = 'satellite'; + if (!radarFrames.length && !satelliteFrames.length) { + console.warn('No radar or satellite frames available'); + return; + } + + if (layerBtn) { + layerBtn.disabled = !(radarFrames.length && satelliteFrames.length); + layerBtn.textContent = mode === 'radar' ? 'R' : 'S'; + } + + buildTimeline(); + const frames = activeFrames(); + showFrame(Math.max(frames.length - 1, 0)); + framesReady = true; + return; + } catch (e) { + retries++; + if (e.name === 'AbortError') { + console.warn(`Radar load attempt ${retries} timed out - API slow or unavailable`); + } else { + console.error(`Radar load attempt ${retries} failed:`, e.message); + } + + if (retries >= maxRetries) { + console.error('Radar load failed after', maxRetries, 'attempts'); + return; + } + + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + } finally { + loadingFrames = false; + } + } + + function panTo(lat, lon, zoom) { + map.setView([lat, lon], zoom || map.getZoom()); + if (locationMarker) map.removeLayer(locationMarker); + locationMarker = L.circleMarker([lat, lon], { + radius: 6, + fillColor: '#e07848', + fillOpacity: 1, + color: '#fff', + weight: 2, + }).addTo(map); + } + + function isMapVisible() { + return mapEl.offsetWidth > 0 && mapEl.offsetHeight > 0; + } + + function refresh() { + if (!isMapVisible()) return; + map.invalidateSize(); + loadFrames(); + } + + // Wait for visibility before first load to avoid zero-size Leaflet rendering. + setTimeout(() => { + let tries = 0; + const maxTries = 40; // ~10 seconds at 250ms interval + const timer = setInterval(() => { + tries++; + if (isMapVisible()) { + refresh(); + clearInterval(timer); + } else if (tries >= maxTries) { + clearInterval(timer); + } + }, 250); + }, 100); + + return { map, panTo, startPlay, stopPlay, refresh }; +} + +const radarEmbed = createRadar( + document.getElementById('radar-embed'), + document.getElementById('embed-timeline'), + document.getElementById('embed-play'), + document.getElementById('embed-timestamp'), + document.getElementById('embed-layer') +); + +const radarFull = createRadar( + document.getElementById('radar-full'), + document.getElementById('full-timeline'), + document.getElementById('full-play'), + document.getElementById('full-timestamp'), + document.getElementById('full-layer') +); + +function createOverviewMap(mapEl) { + const map = L.map(mapEl, { + center: [39.5, -98.35], + zoom: 4, + zoomControl: true, + attributionControl: false, + }); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 18, + }).addTo(map); + + let marker = null; + + function panTo(lat, lon, zoom) { + map.setView([lat, lon], zoom || map.getZoom()); + if (marker) map.removeLayer(marker); + marker = L.circleMarker([lat, lon], { + radius: 7, + fillColor: '#e07848', + fillOpacity: 1, + color: '#fff', + weight: 2, + }).addTo(map); + } + + return { map, panTo }; +} + +function clearLightningMarkers() { + if (!mapsOverview.map) return; + state.lightningMarkers.forEach(marker => mapsOverview.map.removeLayer(marker)); + state.lightningMarkers = []; +} + +function clearWindArrows() { + if (!mapsOverview.map) return; + state.windArrows.forEach(arrow => mapsOverview.map.removeLayer(arrow)); + state.windArrows = []; +} + +function refreshMapsOverview() { + if (!mapsOverview.map) return; + + mapsOverview.map.invalidateSize(); + + if (!state.currentCity) return; + + mapsOverview.panTo(state.currentCity.latitude, state.currentCity.longitude, 6); + + clearLightningMarkers(); + clearWindArrows(); + + if (state.showLightning) renderLightningStrikes(state.currentCity); + if (state.showWind && state.currentWeather) renderWindArrows(state.currentCity); +} + +function renderLightningStrikes(city) { + if (!mapsOverview.map || !state.showLightning) return; + clearLightningMarkers(); + + // Simulate lightning strikes in the vicinity of the city + // In production, this would fetch from a real lightning API like Blitzortung + const strokeCount = 5 + Math.random() * 3; // Random 5-8 strikes + const maxRadius = 0.35; + + for (let i = 0; i < strokeCount; i++) { + const angle = Math.random() * Math.PI * 2; + const radius = Math.random() * maxRadius; + const latOffset = Math.cos(angle) * radius; + const lonOffset = Math.sin(angle) * radius; + const lat = parseFloat(city.latitude) + latOffset; + const lon = parseFloat(city.longitude) + lonOffset; + + const marker = L.circleMarker([lat, lon], { + radius: 3 + Math.random() * 3, + fillColor: '#ffeb3b', + fillOpacity: 0.7 + Math.random() * 0.3, + color: '#ff6f00', + weight: 1.5, + className: 'lightning-marker', + }).bindPopup('⚡ Lightning strike (simulated)'); + + marker.addTo(mapsOverview.map); + state.lightningMarkers.push(marker); + + // Animate the marker to fade out + setTimeout(() => { + if (marker && mapsOverview.map.hasLayer(marker)) { + const fade = setInterval(() => { + const opacity = parseFloat(marker.options.fillOpacity) - 0.05; + if (opacity <= 0) { + clearInterval(fade); + mapsOverview.map.removeLayer(marker); + } else { + marker.setStyle({ fillOpacity: opacity }); + } + }, 200); + } + }, 3000); + } +} + +function renderWindArrows(city) { + if (!mapsOverview.map || !state.showWind || !state.currentWeather) return; + clearWindArrows(); + + // Get current wind data + const current = state.currentWeather.current; + if (!current || current.wind_speed_10m == null) return; + + const windSpeed = current.wind_speed_10m; + const windGust = current.wind_gusts_10m || 0; + const windDir = current.wind_direction_10m || 0; + + // Update wind data display + document.getElementById('wind-speed').textContent = `${speedDisplay(windSpeed)} ${speedUnit()}`; + document.getElementById('wind-gust').textContent = windGust > 0 ? `${speedDisplay(windGust)} ${speedUnit()}` : '--'; + document.getElementById('wind-direction').textContent = compassDirFull(windDir); + + // Draw multiple wind barbs around the city center to show wind strength + const lat = parseFloat(city.latitude); + const lon = parseFloat(city.longitude); + const arrowCount = Math.min(Math.ceil(windSpeed / 5), 5); // More arrows = stronger wind + const baseRadius = 0.15; + + for (let i = 0; i < arrowCount; i++) { + const offset = (i - arrowCount / 2) * 0.08; + const offsetLat = lat + offset; + const offsetLon = lon; + + // Calculate arrow end point based on wind direction + const radians = (windDir * Math.PI) / 180; + const arrowLength = 0.15 + i * 0.05; + const endLat = offsetLat + arrowLength * Math.cos(radians); + const endLon = offsetLon + arrowLength * Math.sin(radians); + + // Draw arrow line with gradient color based on wind speed + const color = windSpeed > 20 ? '#ff4444' : windSpeed > 10 ? '#ffaa00' : '#4a90e2'; + const weight = 1 + (i % 2); + + const arrow = L.polyline( + [[offsetLat, offsetLon], [endLat, endLon]], + { + color: color, + weight: weight, + opacity: 0.7, + smooth: true, + } + ).bindPopup(`💨 Wind: ${speedDisplay(windSpeed)} ${speedUnit()}
Direction: ${compassDirFull(windDir)}`); + + arrow.addTo(mapsOverview.map); + state.windArrows.push(arrow); + + // Draw arrow head + const headSize = 0.03; + const arrowHeadLat1 = endLat - headSize * Math.cos(radians + Math.PI * 0.15); + const arrowHeadLon1 = endLon - headSize * Math.sin(radians + Math.PI * 0.15); + const arrowHeadLat2 = endLat - headSize * Math.cos(radians - Math.PI * 0.15); + const arrowHeadLon2 = endLon - headSize * Math.sin(radians - Math.PI * 0.15); + + const arrowHead = L.polyline( + [[arrowHeadLat1, arrowHeadLon1], [endLat, endLon], [arrowHeadLat2, arrowHeadLon2]], + { + color: color, + weight: weight, + opacity: 0.7, + } + ); + + arrowHead.addTo(mapsOverview.map); + state.windArrows.push(arrowHead); + } +} + +const mapsOverview = createOverviewMap(document.getElementById('map-overview')); + +// ── Rendering ──────────────────────────────────────────────────────────────── +function renderWeather(data, city) { + const c = data.current; + const h = data.hourly; + const d = data.daily; + + document.getElementById('loc-name').textContent = city.name; + document.getElementById('loc-sub').textContent = [city.admin1, city.country].filter(Boolean).join(', '); + + const now = new Date(); + document.getElementById('loc-time').innerHTML = + `${DAYS[now.getDay()]}, ${MONTHS[now.getMonth()]} ${now.getDate()}
` + + now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + + const w = wmo(c.weather_code); + document.getElementById('cur-icon').textContent = w.icon; + document.getElementById('cur-desc').textContent = w.desc; + document.getElementById('cur-temp').textContent = tempDisplay(c.temperature_2m); + document.querySelector('.deg').textContent = `°${tempUnit()}`; + document.querySelector('.feels').innerHTML = `${apparentLabel(c.temperature_2m, c.apparent_temperature)} ${tempDisplay(c.apparent_temperature)}°${tempUnit()}`; + document.getElementById('cur-hi').textContent = tempDisplay(d.temperature_2m_max[0]); + document.getElementById('cur-lo').textContent = tempDisplay(d.temperature_2m_min[0]); + document.getElementById('cur-precip').textContent = c.precipitation > 0 ? `${precipDisplay(c.precipitation)} ${state.units === 'us' ? 'in' : 'mm'} precip` : 'No precipitation'; + document.getElementById('cur-hum').textContent = c.relative_humidity_2m; + document.getElementById('hum-feel').textContent = humidityFeel(c.relative_humidity_2m); + document.getElementById('cur-wind').textContent = speedDisplay(c.wind_speed_10m); + document.getElementById('cur-wind-dir').textContent = compassDirFull(c.wind_direction_10m); + document.getElementById('cur-gust').textContent = c.wind_gusts_10m != null ? speedDisplay(c.wind_gusts_10m) : '—'; + document.getElementById('cur-dew').textContent = calcDewpoint(c.temperature_2m, c.relative_humidity_2m); + document.getElementById('cur-pres').textContent = Math.round(c.surface_pressure); + document.getElementById('cur-vis').textContent = visDisplay(c.visibility); + document.getElementById('cur-uv').textContent = Math.round(d.uv_index_max[0] ?? 0); + document.getElementById('cur-cloud').textContent = c.cloud_cover; + document.querySelectorAll('.wind-unit, .gust-unit').forEach(el => el.textContent = speedUnit()); + document.querySelectorAll('.vis-unit').forEach(el => el.textContent = visUnit()); + + document.getElementById('sunrise-val').textContent = formatClock(d.sunrise[0]); + document.getElementById('sunset-val').textContent = formatClock(d.sunset[0]); + document.getElementById('moon-phase-val').textContent = moonPhase(d.time[0]); + + // Sun timeline progress + const sunriseMs = new Date(d.sunrise[0]).getTime(); + const sunsetMs = new Date(d.sunset[0]).getTime(); + const nowMs = Date.now(); + const totalMs = sunsetMs - sunriseMs; + const sunPct = Math.max(0, Math.min(100, ((nowMs - sunriseMs) / totalMs) * 100)); + const progressEl = document.getElementById('sun-progress'); + const dotEl = document.getElementById('sun-dot'); + if (progressEl) progressEl.style.width = sunPct + '%'; + if (dotEl) dotEl.style.left = sunPct + '%'; + const daylightEl = document.getElementById('daylight-info'); + if (daylightEl) daylightEl.textContent = `${(totalMs / 3600000).toFixed(1)}h daylight`; + + renderRecommendations(data); + + const nowTs = Date.now(); + let startIdx = h.time.findIndex(t => new Date(t).getTime() >= nowTs); + if (startIdx < 0) startIdx = 0; + + const track = document.getElementById('hourly-track'); + track.innerHTML = ''; + for (let i = startIdx; i < Math.min(startIdx + 24, h.time.length); i++) { + const hw = wmo(h.weather_code[i]); + const pp = h.precipitation_probability[i]; + const ptype = precipType(h.weather_code[i], h.temperature_2m[i], h.rain?.[i], h.snowfall?.[i]); + const cell = document.createElement('div'); + cell.className = 'hour-cell' + (i === startIdx ? ' now' : ''); + cell.innerHTML = ` +
${i === startIdx ? 'Now' : formatHour(h.time[i])}
+
${hw.icon}
+
${tempDisplay(h.temperature_2m[i])}°
+
${pp > 0 ? `${pp}% ${ptype}` : ptype}
+ `; + track.appendChild(cell); + } + + state.showExtendedForecast = false; + renderDailyForecast(d); + + document.getElementById('updated-at').textContent = + new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + + radarEmbed.panTo(city.latitude, city.longitude, 6); + radarFull.panTo(city.latitude, city.longitude, 6); + mapsOverview.panTo(city.latitude, city.longitude, 6); + if (mapsWindEl) mapsWindEl.textContent = `${speedDisplay(c.wind_speed_10m)} ${speedUnit()} ${compassDir(c.wind_direction_10m)}`; + updateSourceLocation(city); + updateShareUrl(city); + persistLastCity(city); + refreshMapsOverview(); + + show(weatherDiv); + setTimeout(() => { radarEmbed.refresh && radarEmbed.refresh(); }, 80); +} + +function renderDailyForecast(daily) { + const list = document.getElementById('daily-list'); + if (!list || !daily?.time?.length) return; + + const totalDays = daily.time.length; + const maxDays = state.showExtendedForecast ? Math.min(16, totalDays) : Math.min(7, totalDays); + + list.innerHTML = ''; + for (let i = 0; i < maxDays; i++) { + const dw = wmo(daily.weather_code[i]); + const row = document.createElement('div'); + row.className = 'day-row' + (i === 0 ? ' today' : ''); + row.innerHTML = ` +
${formatDayShort(daily.time[i])}
+
${dw.icon}
+
${dw.desc}
+
${tempDisplay(daily.temperature_2m_max[i])}° / ${tempDisplay(daily.temperature_2m_min[i])}°
+
${daily.precipitation_probability_max[i] > 0 ? daily.precipitation_probability_max[i] + '% rain
' : ''}${speedDisplay(daily.wind_speed_10m_max[i])} ${speedUnit()}
+ `; + list.appendChild(row); + } + + if (dailyForecastLabel) { + dailyForecastLabel.textContent = state.showExtendedForecast ? '16-Day Forecast' : '7-Day Forecast'; + } + + if (dailyToggleBtn) { + if (totalDays > 7) { + show(dailyToggleBtn); + dailyToggleBtn.textContent = state.showExtendedForecast ? 'Show 7-day' : 'Show 16-day'; + } else { + hide(dailyToggleBtn); + } + } +} + +function renderAirQuality(data) { + const cur = data?.current || {}; + const pollen = ['alder_pollen', 'birch_pollen', 'grass_pollen', 'mugwort_pollen', 'olive_pollen', 'ragweed_pollen'] + .map(k => cur[k] || 0) + .reduce((max, v) => Math.max(max, v), 0); + + document.getElementById('aqi-us').textContent = cur.us_aqi ?? '--'; + document.getElementById('pm25').textContent = cur.pm2_5 != null ? `${Number(cur.pm2_5).toFixed(1)} ug/m3` : '--'; + document.getElementById('pollen').textContent = pollen > 0 ? pollen.toFixed(1) : '--'; +} + +function renderAlerts(data) { + const wrap = document.getElementById('alerts-wrap'); + const list = document.getElementById('alerts-list'); + const features = data?.features || []; + list.innerHTML = ''; + + if (!features.length) { + hide(wrap); + if (alertsEmptyEl) show(alertsEmptyEl); + return; + } + + if (alertsEmptyEl) hide(alertsEmptyEl); + + features.slice(0, 5).forEach(f => { + const p = f.properties || {}; + const item = document.createElement('div'); + item.className = 'alert-item'; + item.innerHTML = `${p.event || 'Alert'}${p.severity || 'Unknown severity'}${p.headline || p.description || ''}`; + list.appendChild(item); + }); + show(wrap); + + if (typeof Notification !== 'undefined' && Notification.permission === 'granted') { + features.forEach(f => { + const id = f.id || f.properties?.id; + if (!id || state.alertIdsSeen.has(id)) return; + state.alertIdsSeen.add(id); + const p = f.properties || {}; + new Notification(`Weather alert: ${p.event || 'Severe weather'}`, { + body: p.headline || p.description || 'An alert was issued for your selected location.', + }); + }); + } +} + +async function loadAll(city, source) { + state.currentCity = city; + if (source) state.currentSource = source; + clearErr(); + hide(suggestEl); + hide(weatherDiv); + show(loadingEl); + + try { + let weather; + try { + weather = await fetchWeather(city.latitude, city.longitude, city.timezone); + } catch (weatherErr) { + console.error('Primary weather fetch failed:', weatherErr?.message || weatherErr); + if (state.currentWeather) { + weather = state.currentWeather; + showErr('Live weather update failed. Showing last available data.'); + } else { + throw weatherErr; + } + } + + const [airResult, alertsResult] = await Promise.allSettled([ + fetchAirQuality(city.latitude, city.longitude, city.timezone), + fetchAlerts(city.latitude, city.longitude), + ]); + + const air = airResult.status === 'fulfilled' ? airResult.value : { current: {} }; + if (airResult.status !== 'fulfilled') { + console.warn('Air quality fetch failed:', airResult.reason?.message || airResult.reason); + } + + const alerts = alertsResult.status === 'fulfilled' ? alertsResult.value : { features: [] }; + if (alertsResult.status !== 'fulfilled') { + console.warn('Alerts fetch failed:', alertsResult.reason?.message || alertsResult.reason); + } + + state.currentWeather = weather; + state.currentAir = air; + state.lastRefreshAt = Date.now(); + + // Keep weather load resilient: map/radar/render hiccups should not fail the whole page. + try { + renderWeather(weather, city); + } catch (renderErr) { + console.error('Weather render error:', renderErr); + show(weatherDiv); + } + + try { + renderAirQuality(air); + } catch (airRenderErr) { + console.warn('Air quality render error:', airRenderErr); + } + + try { + renderAlerts(alerts); + } catch (alertsRenderErr) { + console.warn('Alerts render error:', alertsRenderErr); + } + + try { + if (state.showLightning) renderLightningStrikes(city); + if (state.showWind) renderWindArrows(city); + } catch (mapOverlayErr) { + console.warn('Map overlay render error:', mapOverlayErr); + } + } catch (e) { + showErr('Failed to load weather data. Please try again.'); + console.error(e); + } finally { + hide(loadingEl); + } +} + +function getPosition() { + return new Promise((resolve, reject) => { + if (!navigator.geolocation) { + reject(new Error('Geolocation not supported')); + return; + } + navigator.geolocation.getCurrentPosition(resolve, reject, { + enableHighAccuracy: false, + timeout: 8000, + maximumAge: 10 * 60 * 1000, + }); + }); +} + +async function autoLocateAndLoad() { + try { + const pos = await getPosition(); + const { latitude: lat, longitude: lon } = pos.coords; + const city = await buildDetectedCity(lat, lon); + searchInput.value = city.name; + await loadAll(city, 'auto-detect'); + return true; + } catch { + return false; + } +} + +// ── Search autocomplete ─────────────────────────────────────────────────────── +let debounceTimer = null; + +searchInput.addEventListener('input', () => { + clearTimeout(debounceTimer); + const q = searchInput.value.trim(); + if (q.length < 2) { hide(suggestEl); return; } + + debounceTimer = setTimeout(async () => { + try { + const data = await geocode(q); + const results = data.results || []; + suggestEl.innerHTML = ''; + if (!results.length) { hide(suggestEl); return; } + + results.forEach(r => { + const li = document.createElement('li'); + li.innerHTML = `${r.name}${r.admin1 ? r.admin1 + ', ' : ''}${r.country || ''}`; + li.addEventListener('click', () => { + searchInput.value = r.name; + hide(suggestEl); + loadAll(r, 'search-result'); + }); + suggestEl.appendChild(li); + }); + show(suggestEl); + } catch (err) { + console.warn('Autocomplete geocode failed:', err?.message || err); + hide(suggestEl); + } + }, 300); +}); + +document.addEventListener('click', e => { + if (!e.target.closest('.search-wrap')) hide(suggestEl); +}); + +function doSearch() { + const q = searchInput.value.trim(); + if (!q) { showErr('Please enter a city name.'); return; } + clearErr(); + hide(suggestEl); + + geocode(q).then(data => { + const results = data.results || []; + if (!results.length) { showErr(`No results found for "${q}".`); return; } + loadAll(results[0], 'search-result'); + }).catch(() => showErr('Search failed. Please try again.')); +} + +searchBtn.addEventListener('click', doSearch); +searchInput.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); }); + +// ── Geolocation ─────────────────────────────────────────────────────────────── +locateBtn.addEventListener('click', () => { + if (!navigator.geolocation) { showErr('Geolocation not supported.'); return; } + clearErr(); + hide(weatherDiv); + show(loadingEl); + + navigator.geolocation.getCurrentPosition(async pos => { + const { latitude: lat, longitude: lon } = pos.coords; + try { + const city = await buildDetectedCity(lat, lon); + await loadAll(city, 'gps-button'); + } catch { + showErr('Could not fetch weather for your location.'); + } finally { + hide(loadingEl); + } + }, () => { + hide(loadingEl); + showErr('Location access denied.'); + }); +}); + +unitsBtn.addEventListener('click', () => { + state.units = state.units === 'us' ? 'metric' : 'us'; + localStorage.setItem(STORAGE_KEYS.units, state.units); + updateUnitsButton(); + if (state.currentWeather && state.currentCity) { + renderWeather(state.currentWeather, state.currentCity); + renderAirQuality(state.currentAir); + // Update maps overlays if they're visible + if (state.showWind && mapsOverview.map) { + clearWindArrows(); + renderWindArrows(state.currentCity); + } + } +}); + +if (unitsBtnSettings) { + unitsBtnSettings.addEventListener('click', () => unitsBtn.click()); +} + +shareBtn.addEventListener('click', async () => { + if (!state.currentCity) return; + try { + await navigator.clipboard.writeText(window.location.href); + shareBtn.textContent = 'Copied link'; + setTimeout(() => { shareBtn.textContent = 'Share location'; }, 1500); + } catch { + showErr('Could not copy URL to clipboard.'); + } +}); + +saveCurrentBtn.addEventListener('click', () => { + if (!state.currentCity) return; + const key = cityKey(state.currentCity); + if (state.savedCities.some(c => cityKey(c) === key)) return; + state.savedCities.push(state.currentCity); + persistSavedCities(); + renderSavedCities(); +}); + +notifyBtn.addEventListener('click', async () => { + if (!('Notification' in window)) { + showErr('Notifications are not supported in this browser.'); + return; + } + if (!window.isSecureContext) { + showErr('Notifications require HTTPS (or localhost).'); + updateNotifyButton(); + return; + } + + try { + const permission = await Notification.requestPermission(); + updateNotifyButton(); + if (permission === 'granted') { + // Re-fetch current alerts immediately so permission change can trigger notifications. + if (state.currentCity) { + const alerts = await fetchAlerts(state.currentCity.latitude, state.currentCity.longitude).catch(() => ({ features: [] })); + renderAlerts(alerts); + } + } else if (permission === 'denied') { + showErr('Notifications are blocked in browser settings for this site.'); + } + } catch { + showErr('Could not request notification permission.'); + updateNotifyButton(); + } +}); + +autoDetectToggle.addEventListener('change', () => { + state.autoDetect = autoDetectToggle.checked; + localStorage.setItem(STORAGE_KEYS.autoDetect, String(state.autoDetect)); +}); + +lightningToggle.addEventListener('change', () => { + state.showLightning = lightningToggle.checked; + localStorage.setItem(STORAGE_KEYS.showLightning, String(state.showLightning)); + if (state.currentCity && mapsOverview.map) { + clearLightningMarkers(); + if (state.showLightning) renderLightningStrikes(state.currentCity); + } +}); + +windToggle.addEventListener('change', () => { + state.showWind = windToggle.checked; + localStorage.setItem(STORAGE_KEYS.showWind, String(state.showWind)); + if (state.currentCity && mapsOverview.map) { + clearWindArrows(); + if (state.showWind && state.currentWeather) renderWindArrows(state.currentCity); + } +}); + +// Maps tab quick toggles +const mapsLightningToggle = document.getElementById('maps-lightning-toggle'); +const mapsWindToggle = document.getElementById('maps-wind-toggle'); + +if (mapsLightningToggle) { + mapsLightningToggle.addEventListener('click', () => { + state.showLightning = !state.showLightning; + localStorage.setItem(STORAGE_KEYS.showLightning, String(state.showLightning)); + mapsLightningToggle.classList.toggle('active', state.showLightning); + lightningToggle.checked = state.showLightning; + if (state.currentCity && mapsOverview.map) { + clearLightningMarkers(); + if (state.showLightning) renderLightningStrikes(state.currentCity); + } + }); + // Set initial state + mapsLightningToggle.classList.toggle('active', state.showLightning); +} + +if (mapsWindToggle) { + mapsWindToggle.addEventListener('click', () => { + state.showWind = !state.showWind; + localStorage.setItem(STORAGE_KEYS.showWind, String(state.showWind)); + mapsWindToggle.classList.toggle('active', state.showWind); + windToggle.checked = state.showWind; + if (state.currentCity && mapsOverview.map) { + clearWindArrows(); + if (state.showWind && state.currentWeather) renderWindArrows(state.currentCity); + } + }); + // Set initial state + mapsWindToggle.classList.toggle('active', state.showWind); +} + +if (dailyToggleBtn) { + dailyToggleBtn.addEventListener('click', () => { + state.showExtendedForecast = !state.showExtendedForecast; + if (state.currentWeather) { + renderDailyForecast(state.currentWeather.daily); + } + }); +} + +function startAutoRefresh() { + clearInterval(state.autoRefreshTimer); + state.autoRefreshTimer = setInterval(() => { + if (state.currentCity) loadAll(state.currentCity, state.currentSource || 'auto-refresh'); + }, 10 * 60 * 1000); + + // Refresh shortly after users return to the tab if data is stale. + document.addEventListener('visibilitychange', () => { + if (document.visibilityState !== 'visible') return; + if (!state.currentCity) return; + if (Date.now() - state.lastRefreshAt > 10 * 60 * 1000) { + loadAll(state.currentCity, state.currentSource || 'tab-resume-refresh'); + } + }); +} + +// ── Default city / query link load ─────────────────────────────────────────── +(async () => { + await cleanupServiceWorkerInterceptors(); + updateUnitsButton(); + updateNotifyButton(); + syncAutoDetectToggle(); + + // Sync lightning and wind toggle states + lightningToggle.checked = state.showLightning; + windToggle.checked = state.showWind; + + renderSavedCities(); + startAutoRefresh(); + + const qs = new URLSearchParams(window.location.search); + const qLat = Number(qs.get('lat')); + const qLon = Number(qs.get('lon')); + const qName = qs.get('name'); + if (!Number.isNaN(qLat) && !Number.isNaN(qLon) && qs.get('lat') && qs.get('lon')) { + const city = { + name: qName || `${qLat.toFixed(2)}, ${qLon.toFixed(2)}`, + latitude: qLat, + longitude: qLon, + timezone: 'auto', + country: '', + admin1: '' + }; + searchInput.value = city.name; + await loadAll(city, 'shared-link'); + return; + } + + if (state.autoDetect) { + const autoLocated = await autoLocateAndLoad(); + if (autoLocated) return; + } + + const lastCity = getLastCity(); + if (lastCity) { + searchInput.value = lastCity.name; + await loadAll(lastCity, 'last-city'); + return; + } + + try { + const data = await geocode('New York'); + const city = (data.results || [])[0]; + if (city) { + searchInput.value = city.name; + await loadAll(city, 'default-city'); + } + } catch { + // silent startup fallback + } +})(); diff --git a/.tmp/imgdiff/new/index.html b/.tmp/imgdiff/new/index.html new file mode 100644 index 0000000..15d3ffb --- /dev/null +++ b/.tmp/imgdiff/new/index.html @@ -0,0 +1,405 @@ + + + + + +NeCloud Weather + + + + + + + + + + + + +
+ +
+

NeCloud Weather

+ + Open-Meteo · RainViewer +
+ + +
+
+ + + + +
+ + +
+ + + + + +
+ +
+ + + + + + + + + + + + + +
+ + + + + + + + diff --git a/.tmp/imgdiff/new/server.js b/.tmp/imgdiff/new/server.js new file mode 100644 index 0000000..2aa7df2 --- /dev/null +++ b/.tmp/imgdiff/new/server.js @@ -0,0 +1,286 @@ +const express = require('express'); +const path = require('path'); + +const app = express(); +const PORT = process.env.PORT || 3000; +const weatherCache = new Map(); + +app.use(express.static(path.join(__dirname, 'public'))); + +// Prevent browser favicon requests from showing noisy 404s. +app.get('/favicon.ico', (_req, res) => { + res.status(204).end(); +}); + +// Geocoding proxy +app.get('/api/geocode', async (req, res) => { + const { q } = req.query; + if (!q) return res.status(400).json({ error: 'Missing query' }); + try { + const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(q)}&count=5&language=en&format=json`; + + let openMeteoData = null; + for (let attempt = 1; attempt <= 2; attempt++) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 9000); + try { + const r = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + if (!r.ok) throw new Error(`Open-Meteo geocode status ${r.status}`); + openMeteoData = await r.json(); + break; + } catch (err) { + clearTimeout(timeoutId); + if (attempt === 2) { + console.warn('Open-Meteo geocode failed after retries:', err.message); + } else { + await new Promise(resolve => setTimeout(resolve, 300 * attempt)); + } + } + } + + if (openMeteoData?.results?.length) { + return res.json(openMeteoData); + } + + // Fallback to Nominatim when Open-Meteo geocoding is empty/unavailable. + const nomParams = new URLSearchParams({ q: String(q), format: 'jsonv2', limit: '5', addressdetails: '1' }); + const nom = await fetch(`https://nominatim.openstreetmap.org/search?${nomParams}`, { + headers: { + 'User-Agent': 'weather-app-v2 (nate@necloud.us)', + Accept: 'application/json', + }, + }); + + if (!nom.ok) { + throw new Error(`Nominatim geocode status ${nom.status}`); + } + + const nomData = await nom.json(); + const results = (nomData || []).map(item => { + const address = item.address || {}; + return { + name: address.city || address.town || address.village || address.hamlet || address.county || item.display_name?.split(',')?.[0] || 'Unknown', + latitude: Number(item.lat), + longitude: Number(item.lon), + country: address.country || '', + admin1: address.state || address.region || address.county || '', + timezone: 'auto', + }; + }).filter(r => Number.isFinite(r.latitude) && Number.isFinite(r.longitude)); + + return res.json({ results }); + } catch (e) { + res.status(500).json({ error: 'Geocoding failed', detail: e.message }); + } +}); + +// Reverse geocoding proxy +app.get('/api/reverse-geocode', async (req, res) => { + const { lat, lon } = req.query; + if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' }); + + try { + const params = new URLSearchParams({ + format: 'jsonv2', + lat: String(lat), + lon: String(lon), + zoom: '10', + addressdetails: '1', + }); + + const r = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, { + headers: { + 'User-Agent': 'weather-app-v2 (nate@necloud.us)', + Accept: 'application/json', + }, + }); + + if (!r.ok) { + throw new Error(`Reverse geocoding failed with status ${r.status}`); + } + + const data = await r.json(); + const address = data.address || {}; + const name = address.city || address.town || address.village || address.hamlet || address.municipality || address.county || address.state || data.name || 'Detected location'; + + res.json({ + name, + admin1: address.state || address.region || address.county || '', + country: address.country || '', + latitude: Number(lat), + longitude: Number(lon), + timezone: 'auto', + display_name: data.display_name || '', + }); + } catch (e) { + res.status(500).json({ error: 'Reverse geocoding failed', detail: e.message }); + } +}); + +// Weather proxy +app.get('/api/weather', async (req, res) => { + const { lat, lon, tz, units } = req.query; + if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' }); + const timezone = tz || 'auto'; + const useMetric = units === 'metric'; + const cacheKey = `${Number(lat).toFixed(3)},${Number(lon).toFixed(3)},${timezone},${units || 'us'}`; + try { + const params = new URLSearchParams({ + latitude: lat, longitude: lon, timezone, + current: [ + 'temperature_2m','relative_humidity_2m','apparent_temperature', + 'is_day','precipitation','weather_code','cloud_cover', + 'wind_speed_10m','wind_direction_10m','wind_gusts_10m','surface_pressure','visibility' + ].join(','), + hourly: [ + 'temperature_2m','precipitation_probability','weather_code', + 'wind_speed_10m','apparent_temperature','rain','snowfall' + ].join(','), + daily: [ + 'weather_code','temperature_2m_max','temperature_2m_min', + 'precipitation_sum','precipitation_probability_max', + 'wind_speed_10m_max','sunrise','sunset','uv_index_max' + ].join(','), + forecast_days: 16, + wind_speed_unit: useMetric ? 'kmh' : 'mph', + temperature_unit: useMetric ? 'celsius' : 'fahrenheit', + precipitation_unit: useMetric ? 'mm' : 'inch' + }); + + let lastError = null; + const upstreamTimeoutMs = 12000; + for (let attempt = 1; attempt <= 3; attempt++) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), upstreamTimeoutMs); + try { + const r = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`, { signal: controller.signal }); + clearTimeout(timeoutId); + const body = await r.text(); + + if (!r.ok) { + throw new Error(`Open-Meteo status ${r.status}: ${body.slice(0, 200)}`); + } + + const data = JSON.parse(body); + weatherCache.set(cacheKey, { data, at: Date.now() }); + return res.json(data); + } catch (err) { + clearTimeout(timeoutId); + lastError = err; + const reason = err?.name === 'AbortError' ? `timeout after ${upstreamTimeoutMs}ms` : (err?.message || 'unknown error'); + console.warn(`Weather upstream attempt ${attempt}/3 failed: ${reason}`); + if (attempt < 3) { + await new Promise(resolve => setTimeout(resolve, 500 * attempt)); + } + } + } + + throw lastError || new Error('Weather upstream failed'); + } catch (e) { + const cached = weatherCache.get(cacheKey); + if (cached && Date.now() - cached.at < 45 * 60 * 1000) { + res.setHeader('X-Weather-Cache', 'stale-fallback'); + console.warn('Weather proxy using stale cache for', cacheKey); + return res.json(cached.data); + } + console.error('Weather proxy failed:', e.message); + res.status(500).json({ error: 'Weather fetch failed', detail: e.message }); + } +}); + +// Open-Meteo Air Quality proxy +app.get('/api/air-quality', async (req, res) => { + const { lat, lon, tz } = req.query; + if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' }); + try { + const params = new URLSearchParams({ + latitude: lat, + longitude: lon, + timezone: tz || 'auto', + current: ['us_aqi', 'pm2_5', 'alder_pollen', 'birch_pollen', 'grass_pollen', 'mugwort_pollen', 'olive_pollen', 'ragweed_pollen'].join(','), + forecast_days: 1, + }); + let lastError = null; + for (let attempt = 1; attempt <= 2; attempt++) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 12000); + try { + const r = await fetch(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`, { + signal: controller.signal, + }); + clearTimeout(timeoutId); + if (!r.ok) { + throw new Error(`Open-Meteo status ${r.status}`); + } + const data = await r.json(); + return res.json(data); + } catch (err) { + clearTimeout(timeoutId); + lastError = err; + if (attempt < 2) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + } + + throw lastError || new Error('Air quality upstream failed'); + } catch (e) { + console.error('Air quality fetch error:', e); + res.setHeader('X-Air-Quality-Fallback', 'empty'); + res.json({ current: {} }); + } +}); + +// NWS alerts proxy (US only) +app.get('/api/alerts', async (req, res) => { + const { lat, lon } = req.query; + if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' }); + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 12000); + + const r = await fetch(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, { + headers: { + 'User-Agent': 'weather-app-v2 (nate@necloud.us)', + Accept: 'application/geo+json', + }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!r.ok) { + throw new Error(`NWS API returned ${r.status}`); + } + const data = await r.json(); + res.json(data); + } catch (e) { + console.error('Alerts fetch error:', e.message); + res.setHeader('X-Alerts-Fallback', 'empty'); + res.json({ features: [] }); + } +}); + +// RainViewer timestamps proxy +app.get('/api/radar/times', async (req, res) => { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 25000); + + const r = await fetch('https://api.rainviewer.com/public/weather-maps.json', { + signal: controller.signal, + }); + clearTimeout(timeoutId); + if (!r.ok) { + throw new Error(`RainViewer API returned ${r.status}`); + } + const data = await r.json(); + res.json(data); + } catch (e) { + console.error('Radar times fetch error:', e.message); + res.status(500).json({ error: 'Radar times failed', detail: e.message }); + } +}); + +app.listen(PORT, () => console.log(`Weather app running on port ${PORT}`)); diff --git a/.tmp/imgdiff/new/style.css b/.tmp/imgdiff/new/style.css new file mode 100644 index 0000000..92ea4d9 --- /dev/null +++ b/.tmp/imgdiff/new/style.css @@ -0,0 +1,1304 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --ink: #1a1a1a; + --ink-mid: #3d3730; + --ink-light: #5f5649; + --paper: #f5f2ec; + --paper-dark: #ece7db; + --paper-darker: #ddd6c7; + --border: #b9ae9b; + --accent: #c25a2a; + --cool: #2a6b8a; + --mono: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', 'Segoe UI', sans-serif; + --serif: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', 'Segoe UI', sans-serif; + --card-bg: var(--paper-dark); + --card-bg-soft: var(--paper-darker); + --card-ink: var(--ink); + --card-sub: var(--ink-light); + --surface-border: 1px solid rgba(255, 255, 255, 0.08); + --surface-radius-lg: 16px; + --surface-radius-md: 12px; + --surface-shadow: 0 8px 18px rgba(0, 0, 0, 0.16); +} + +@media (prefers-color-scheme: dark) { + :root { + --ink: #ece7dc; + --ink-mid: #d2c8b1; + --ink-light: #beb399; + --paper: #191510; + --paper-dark: #201c16; + --paper-darker: #2b2620; + --border: #3a342a; + --accent: #e07848; + --cool: #5ab0d8; + } +} + +html, body { + min-height: 100%; + background: var(--paper); + color: var(--ink); + font-family: var(--mono); + font-size: 16px; + line-height: 1.6; + overflow-x: hidden; +} + +.app { + max-width: 900px; + margin: 0 auto; + padding: 2rem 1.5rem 4rem; +} + +a { color: var(--cool); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ── Masthead ── */ +.masthead { + display: grid; + grid-template-columns: 1fr; + align-items: center; + justify-items: center; + gap: 0.8rem; + border: var(--surface-border); + border-radius: var(--surface-radius-lg); + background: linear-gradient(180deg, var(--card-bg-soft), var(--paper-dark)); + color: var(--card-ink); + padding: 1rem 1.1rem; + margin-bottom: 1.5rem; + box-shadow: var(--surface-shadow); +} + +.masthead h1 { + font-family: var(--serif); + font-size: 2.35rem; + font-weight: 700; + letter-spacing: -0.5px; + color: var(--card-ink); + white-space: nowrap; + text-align: center; +} + +.tagline { + font-size: 0.74rem; + letter-spacing: 0.12em; + color: var(--card-sub); + text-transform: uppercase; + white-space: nowrap; +} + +/* ── Tabs ── */ +.tabs { + display: flex; + gap: 0; + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; +} + +.tab { + background: transparent; + border: none; + border-right: 1px solid var(--border); + padding: 0.4rem 1rem; + font-family: var(--mono); + font-size: 0.84rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-mid); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.tab:last-child { border-right: none; } +.tab:hover { background: var(--paper-dark); color: var(--ink); } +.tab.active { background: var(--ink); color: var(--paper); } + +/* ── Search ── */ +.search-wrap { + position: relative; + margin-bottom: 2rem; + background: var(--card-bg); + border: var(--surface-border); + border-radius: var(--surface-radius-md); + padding: 0.55rem; + box-shadow: var(--surface-shadow); +} + +.search-row { display: flex; } + +.search-row input { + flex: 1; + min-width: 0; + background: rgba(0, 0, 0, 0.18); + border: 1px solid rgba(255, 255, 255, 0.14); + border-right: none; + padding: 0.6rem 1rem; + font-family: var(--mono); + font-size: 0.95rem; + color: var(--card-ink); + outline: none; + border-radius: 0; + transition: background 0.15s; +} + +.search-row input::placeholder { color: var(--card-sub); } +.search-row input:focus { background: rgba(0, 0, 0, 0.25); } + +.search-row button { + background: rgba(0, 0, 0, 0.24); + color: var(--card-ink); + border: 1px solid rgba(255, 255, 255, 0.14); + padding: 0.6rem 1.1rem; + font-family: var(--mono); + font-size: 0.84rem; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; + border-radius: 0; + transition: opacity 0.15s; + white-space: nowrap; +} + +.search-row button:hover { opacity: 0.72; } + +#locate-btn { + background: rgba(0, 0, 0, 0.22) !important; + color: var(--card-ink) !important; + border-left: none !important; + font-size: 1.2rem !important; + letter-spacing: 0 !important; + padding: 0.6rem 0.9rem !important; +} + +#units-btn { + background: rgba(0, 0, 0, 0.22) !important; + color: var(--card-ink) !important; + border-left: none !important; + min-width: 76px; +} + +.saved-wrap { margin-top: 0.6rem; } +.saved-head { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.62rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-light); + margin-bottom: 0.35rem; +} + +.saved-locations { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + font-size: 0.72rem; + color: var(--ink-mid); +} + +/* ── Toggle Switch ── */ +.switch-toggle { + display: inline-flex; + align-items: center; + position: relative; + width: 50px; + height: 28px; + cursor: pointer; + user-select: none; +} + +.switch-toggle input { + display: none; +} + +.toggle-slider { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(255, 255, 255, 0.1); + border: 1px solid var(--surface-border); + border-radius: 14px; + transition: all 0.3s ease; + display: flex; + align-items: center; +} + +.toggle-slider::before { + content: ''; + position: absolute; + width: 22px; + height: 22px; + border-radius: 50%; + background-color: var(--ink-light); + left: 2px; + transition: all 0.3s ease; +} + +.switch-toggle input:checked + .toggle-slider { + background-color: var(--accent); + border-color: var(--accent); +} + +.switch-toggle input:checked + .toggle-slider::before { + left: 26px; + background-color: #fff; +} + +.switch-toggle input:disabled + .toggle-slider { + opacity: 0.5; + cursor: not-allowed; +} + +.auto-detect-toggle { + display: inline-flex; + align-items: center; + gap: 0.45rem; + margin-bottom: 0.45rem; + font-size: 0.68rem; + color: var(--ink-mid); + cursor: pointer; + user-select: none; +} + +.auto-detect-toggle input { + accent-color: var(--accent); +} + +.toggle-label { + display: inline-flex; + align-items: center; + gap: 0.45rem; + margin-bottom: 0.45rem; + font-size: 0.68rem; + color: var(--ink-mid); + cursor: pointer; + user-select: none; +} + +.toggle-label input { + accent-color: var(--accent); +} + +.toggle-label span { + min-width: 24px; +} + +.saved-item { + display: inline-flex; + border: 1px solid var(--border); + background: var(--paper-dark); +} + +.saved-chip, +.saved-remove { + border: none; + background: transparent; + color: var(--ink); + padding: 0.35rem 0.45rem; + font-family: var(--mono); + cursor: pointer; +} + +.saved-remove { + border-left: 1px solid var(--border); + color: var(--ink-light); +} + +/* Suggestions */ +.suggestions { + position: absolute; + top: 100%; + left: 0; right: 0; + background: var(--paper-darker); + border: 1px solid var(--border); + border-top: none; + z-index: 200; + list-style: none; +} + +.suggestions li { + padding: 0.6rem 1rem; + cursor: pointer; + font-size: 0.82rem; + border-bottom: 0.5px solid var(--border); + transition: background 0.1s; +} + +.suggestions li:last-child { border-bottom: none; } +.suggestions li:hover { background: var(--paper-dark); } +.sug-country { color: var(--ink-light); margin-left: 0.5rem; font-size: 0.75rem; } + +/* ── Error / Loading ── */ +.error { font-size: 0.78rem; color: var(--accent); margin-top: 0.5rem; } + +.loading { + display: flex; + align-items: center; + justify-content: center; + min-height: calc(100vh - 200px); + padding: 3rem; +} + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 2rem; + text-align: center; +} + +.loading-icon { + font-size: 6rem; + animation: float 2s ease-in-out infinite; +} + +.loading-text { + font-size: 1.5rem; + font-weight: 500; + color: var(--ink); + letter-spacing: 0.05em; +} + +.loading-dots { + display: flex; + gap: 0.5rem; + justify-content: center; +} + +.loading-dots span { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--accent); + animation: bounce 1.4s ease-in-out infinite; +} + +.loading-dots span:nth-child(1) { animation-delay: 0s; } +.loading-dots span:nth-child(2) { animation-delay: 0.2s; } +.loading-dots span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-20px); } } +@keyframes bounce { 0%, 100% { transform: translateY(0); opacity: 0.4; } 50% { transform: translateY(-15px); opacity: 1; } } + +.hidden { display: none !important; } + +/* ── Tab panels ── */ +.tab-panel { width: 100%; } + +/* ── Location header ── */ +.loc-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-bottom: 1.5rem; + border: var(--surface-border); + border-radius: var(--surface-radius-lg); + background: var(--card-bg-soft); + padding: 0.9rem 1rem; +} + +.loc-name { font-family: var(--serif); font-size: 1.6rem; font-weight: 400; line-height: 1.1; } +.loc-name { color: var(--card-ink); } +.loc-sub { font-size: 0.8rem; color: var(--card-sub); letter-spacing: 0.06em; margin-top: 0.2rem; text-transform: uppercase; } +.loc-time { font-size: 0.8rem; color: var(--card-sub); text-align: right; letter-spacing: 0.04em; line-height: 1.5; } +.loc-actions { display: flex; flex-direction: column; align-items: flex-end; gap: 0.35rem; } + +/* ── HERO ── */ +.hero { + display: grid; + grid-template-columns: 1fr 1.5fr 1fr; + align-items: center; + gap: 1rem; + border: var(--surface-border); + border-radius: var(--surface-radius-lg); + background: var(--card-bg); + color: var(--card-ink); + padding: 1.4rem 1rem; + margin-bottom: 1.8rem; +} + +.hero-left { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.4rem; +} + +.hero-icon { font-size: 3.5rem; line-height: 1; } +.hero-desc { font-size: 0.9rem; font-weight: 500; } +.hero-sub { font-size: 0.82rem; color: var(--card-sub); margin-top: 0.15rem; } + +.hero-center { text-align: center; } + +.hero-temp { + font-family: var(--serif); + font-size: 6.5rem; + font-weight: 400; + line-height: 1; + letter-spacing: -4px; +} + +.deg { font-family: var(--mono); font-size: 2rem; color: var(--card-sub); vertical-align: super; letter-spacing: 0; display: inline; } +.feels { font-size: 0.86rem; color: var(--card-sub); margin-top: 0.5rem; } + +.hero-right { display: flex; justify-content: flex-end; } + +.hero-hilo { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; + background: rgba(0, 0, 0, 0.2); + border: 1px solid rgba(255, 255, 255, 0.12); + padding: 1rem 1.4rem; + min-width: 80px; +} + +.hilo-item { + display: flex; + flex-direction: column; + align-items: center; + line-height: 1.2; + font-size: 1.3rem; + font-weight: 500; +} + +.hilo-arrow { font-size: 0.75rem; color: var(--ink-light); } +.hilo-label { font-size: 0.55rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--card-sub); margin-top: 0.15rem; } +.hilo-divider { width: 100%; height: 1px; background: rgba(255, 255, 255, 0.12); } + +/* ── Sun & Moon Timeline ── */ +.sun-section { + border: var(--surface-border); + background: var(--card-bg); + color: var(--card-ink); + border-radius: var(--surface-radius-md); + padding: 1rem 1.1rem; + margin-bottom: 2rem; +} + +.sun-timeline-wrap { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.65rem; +} + +.sun-endpoint { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.2rem; + flex-shrink: 0; +} + +.sun-ep-icon { font-size: 1.6rem; line-height: 1; } +.sun-ep-time { font-size: 0.8rem; font-weight: 600; color: var(--card-sub); white-space: nowrap; } + +.sun-track-wrap { flex: 1; position: relative; } + +.sun-track { + height: 6px; + background: rgba(255, 255, 255, 0.14); + border-radius: 3px; + position: relative; + overflow: visible; +} + +.sun-progress { + height: 100%; + background: linear-gradient(90deg, #fb923c, #fbbf24); + border-radius: 3px; + transition: width 0.6s ease; +} + +.sun-dot { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + font-size: 1.2rem; + pointer-events: none; + transition: left 0.6s ease; +} + +.moon-row { + display: flex; + align-items: center; + gap: 0.6rem; + font-size: 0.85rem; + font-weight: 500; + color: var(--card-sub); + border-top: 1px solid rgba(255, 255, 255, 0.12); + padding-top: 0.6rem; +} + +.moon-emoji { font-size: 1.2rem; } +.daylight-info { margin-left: auto; font-size: 0.78rem; font-weight: 500; color: var(--card-sub); } + +/* ── Card Group ── */ +.card-group { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 1px; + background: rgba(255, 255, 255, 0.08); + border: var(--surface-border); + border-radius: var(--surface-radius-md); + overflow: hidden; + margin-bottom: 2rem; +} + +.stat-card { + background: var(--card-bg); + padding: 1rem 0.9rem; + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.stat-card .stat-label { + font-size: 0.68rem; + letter-spacing: 0.12em; + text-transform: uppercase; + font-weight: 700; + color: var(--card-sub); +} + +.stat-card .stat-val { + font-size: 1.65rem; + font-weight: 600; + line-height: 1.2; + color: var(--card-ink); +} + +.stat-unit { font-size: 0.85rem; font-weight: 600; color: var(--card-sub); margin-left: 2px; } +.stat-sub { font-size: 0.8rem; font-weight: 500; color: var(--card-sub); } + +/* ── Details Drawer ── */ +.details-drawer { + border: var(--surface-border); + margin-bottom: 2rem; + background: var(--card-bg); + border-radius: var(--surface-radius-md); + overflow: hidden; +} + +.drawer-summary { + list-style: none; + font-size: 0.68rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--card-sub); + padding: 0.75rem 1rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + user-select: none; +} + +.drawer-summary::-webkit-details-marker { display: none; } +.drawer-summary:focus { outline: none; } + +.drawer-chevron { + font-size: 1.1rem; + transition: transform 0.2s; + display: inline-block; +} + +details[open] .drawer-chevron { transform: rotate(90deg); } + +.drawer-body { border-top: 1px solid rgba(255, 255, 255, 0.1); } +.drawer-card-group { margin-bottom: 0; border: none; } + +/* ── What to Bring ── */ +.recs-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); + gap: 0.5rem; + margin-bottom: 2rem; +} + +.rec-item { + background: var(--card-bg); + border: var(--surface-border); + border-radius: var(--surface-radius-md); + padding: 0.85rem 0.6rem; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; +} + +.rec-icon { font-size: 2rem; line-height: 1; } +.rec-label { font-size: 0.72rem; font-weight: 600; color: var(--card-sub); letter-spacing: 0.04em; line-height: 1.3; } + +.alerts-wrap { + border: var(--surface-border); + border-radius: var(--surface-radius-md); + padding: 0.75rem; + margin-bottom: 1.2rem; + background: var(--card-bg); +} + +.alerts-list { + display: grid; + gap: 0.5rem; +} + +.alert-item { + display: grid; + gap: 0.2rem; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 0.55rem; + background: var(--card-bg-soft); + font-size: 0.72rem; +} + +.panel-note { + font-size: 0.72rem; + color: var(--ink-light); + border: var(--surface-border); + border-radius: var(--surface-radius-md); + padding: 0.75rem; + background: var(--card-bg); +} + +.settings-wrap { + border: var(--surface-border); + border-radius: var(--surface-radius-md); + background: var(--card-bg); + padding: 0.9rem; + display: grid; + gap: 1rem; +} + +.settings-row { + display: flex; + align-items: center; + justify-content: space-between; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + background: var(--card-bg-soft); + padding: 0.65rem 0.75rem; +} + +.settings-label { + font-size: 0.72rem; + color: var(--ink-mid); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.settings-value { + font-size: 0.8rem; + color: var(--ink); + text-align: right; +} + +.settings-btn { + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 10px; + background: var(--card-bg-soft); + color: var(--ink); + padding: 0.45rem 0.7rem; + font-family: var(--mono); + font-size: 0.72rem; + cursor: pointer; +} + +.settings-btn:hover { background: var(--card-bg); } + +.maps-wrap { + border: var(--surface-border); + border-radius: var(--surface-radius-md); + background: var(--card-bg); + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.maps-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.5rem; +} + +.maps-header h3 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: var(--card-ink); +} + +.maps-quick-toggles { + display: flex; + gap: 0.5rem; +} + +.overlay-toggle { + padding: 0.5rem 1rem; + border: 1px solid var(--surface-border); + border-radius: var(--surface-radius-sm); + background: var(--card-bg-soft); + color: var(--card-sub); + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.overlay-toggle:hover { + background: var(--card-bg); + border-color: var(--accent); +} + +.overlay-toggle.active { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +.map-overview { + height: 360px; + border: var(--surface-border); + border-radius: 10px; + box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.maps-legend { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + padding: 0.75rem; + background: rgba(255, 255, 255, 0.04); + border: var(--surface-border); + border-radius: 8px; +} + +.legend-section { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.legend-title { + font-size: 0.8rem; + font-weight: 600; + color: var(--card-ink); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.legend-item { + display: flex; + align-items: center; + gap: 0.6rem; + font-size: 0.75rem; + color: var(--card-sub); +} + +.legend-item svg { + flex-shrink: 0; +} + +.maps-data { + display: grid; + grid-template-columns: 1fr 1.2fr; + gap: 1rem; +} + +.maps-card { + background: var(--card-bg-soft); + border: var(--surface-border); + border-radius: var(--surface-radius-sm); + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.card-title { + font-size: 0.8rem; + font-weight: 600; + color: var(--card-sub); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.maps-value { + font-size: 1rem; + font-weight: 500; + color: var(--card-ink); +} + +.wind-stats { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 0.75rem; +} + +.wind-stat { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.wind-stat .stat-label { + font-size: 0.7rem; + color: var(--card-sub); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.wind-stat .stat-value { + font-size: 1.1rem; + font-weight: 600; + color: var(--accent); +} + +.stat-label { font-size: 0.58rem; letter-spacing: 0.1em; color: var(--ink-light); text-transform: uppercase; margin-bottom: 0.3rem; } + +/* ── Section heads ── */ +.section-head { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 0.6rem; +} + +.section-label { font-size: 0.74rem; font-weight: 800; letter-spacing: 0.14em; color: var(--ink); text-transform: uppercase; } +.section-note { font-size: 0.72rem; color: var(--card-sub); font-style: italic; } + +.link-btn { + background: none; + border: none; + font-family: var(--mono); + font-size: 0.82rem; + font-weight: 600; + color: var(--cool); + cursor: pointer; + padding: 0.35rem 0.65rem; + font-style: italic; +} + +.link-btn:hover { text-decoration: underline; } + +/* ── Radar maps ── */ +.radar-embed-wrap { + border: var(--surface-border); + border-radius: var(--surface-radius-md); + overflow: hidden; + margin-bottom: 2rem; + background: #1a1f2e; +} + +.radar-map { + width: 100%; + height: 320px; +} + +.radar-map-full { + height: calc(100vh - 280px); + min-height: 420px; +} + +.radar-full-wrap { + border: var(--surface-border); + border-radius: var(--surface-radius-md); + background: #1a1f2e; + margin-bottom: 0.5rem; + overflow: hidden; +} + +/* ── Radar controls ── */ +.radar-controls { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0.75rem; + background: var(--paper-darker); + border-top: 1px solid var(--border); +} + +.radar-controls-full { padding: 0.75rem 1rem; } + +.radar-btn { + background: var(--ink); + color: var(--paper); + border: none; + width: 28px; height: 28px; + display: flex; align-items: center; justify-content: center; + font-size: 0.7rem; + cursor: pointer; + flex-shrink: 0; + transition: opacity 0.15s; +} + +.radar-btn:hover { opacity: 0.72; } +.layer-btn { background: var(--accent); } + +.radar-timeline { + flex: 1; + display: flex; + gap: 2px; + align-items: center; + height: 28px; +} + +.tl-tick { + flex: 1; + height: 100%; + background: var(--paper-dark); + border: 1px solid var(--border); + cursor: pointer; + transition: background 0.1s; + position: relative; +} + +.tl-tick:hover { background: var(--paper); } +.tl-tick.active { background: var(--cool); border-color: var(--cool); } +.tl-tick.forecast { opacity: 0.55; } + +.radar-ts { + font-size: 0.74rem; + color: var(--ink); + font-weight: 600; + letter-spacing: 0.04em; + white-space: nowrap; + min-width: 80px; + text-align: right; +} + +/* Radar legend */ +.radar-legend { + display: flex; + align-items: center; + gap: 0.4rem; + margin-left: 0.5rem; +} + +.legend-label { font-size: 0.66rem; color: var(--ink-mid); letter-spacing: 0.06em; } + +.legend-bar { + width: 80px; + height: 8px; + background: linear-gradient(to right, + #00d8ff, #00a0e0, #0070d0, + #00c800, #80e000, #ffff00, + #ffc000, #ff6000, #ff0000, #c00000 + ); + border: 0.5px solid var(--border); +} + +.radar-attribution { + font-size: 0.62rem; + color: var(--ink-light); + padding-top: 0.5rem; +} + +/* ── Hourly scroll ── */ +.hourly-scroll { + overflow-x: auto; + margin-bottom: 2rem; + border: var(--surface-border); + border-radius: var(--surface-radius-md); + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.hourly-track { display: flex; min-width: max-content; } + +.hour-cell { + min-width: 72px; + padding: 0.75rem 0.5rem; + text-align: center; + border-right: 0.5px solid var(--border); + background: var(--card-bg); + transition: background 0.12s; +} + +.hour-cell:last-child { border-right: none; } +.hour-cell:hover { background: var(--paper-dark); } +.hour-cell.now { background: var(--paper-darker); } + +.hour-time { font-size: 0.6rem; color: var(--card-sub); letter-spacing: 0.06em; margin-bottom: 0.35rem; text-transform: uppercase; } +.hour-icon { font-size: 1.4rem; margin-bottom: 0.3rem; } +.hour-temp { font-size: 0.88rem; font-weight: 500; color: var(--card-ink); } +.hour-precip { font-size: 0.64rem; color: #1f5f7e; margin-top: 0.2rem; font-weight: 600; } + +/* ── Daily forecast ── */ +.daily-list { + border: none; + display: grid; + gap: 0.55rem; + margin-bottom: 2rem; +} + +.day-row { + display: grid; + grid-template-columns: 120px 44px 1fr 80px 60px; + align-items: center; + gap: 1rem; + padding: 0.85rem 1.1rem; + border: var(--surface-border); + border-radius: var(--surface-radius-md); + background: var(--card-bg); + transition: background 0.12s; +} + +.day-row:last-child { border-bottom: var(--surface-border); } +.day-row:hover { background: var(--paper-dark); } +.day-row.today { background: var(--paper-dark); } + +.day-name { font-size: 0.8rem; font-weight: 500; color: var(--card-ink); } +.day-icon { font-size: 1.5rem; text-align: center; } +.day-desc { font-size: 0.75rem; color: var(--ink-mid); } +.day-temps { text-align: right; font-size: 0.82rem; font-weight: 500; white-space: nowrap; color: var(--card-ink); } +.day-lo { color: var(--ink-mid); } +.day-meta { text-align: right; font-size: 0.62rem; color: var(--ink-mid); line-height: 1.6; } + +/* ── Footer ── */ +.footer-note { + font-size: 0.72rem; + color: var(--ink-light); + letter-spacing: 0.04em; + border-top: 0.5px solid var(--border); + padding-top: 0.75rem; +} + +/* ── PWA: safe area insets (iPhone notch / home bar) ── */ +@supports (padding: env(safe-area-inset-bottom)) { + .app { + padding-top: max(2rem, calc(1rem + env(safe-area-inset-top))); + padding-left: max(1.5rem, calc(1rem + env(safe-area-inset-left))); + padding-right: max(1.5rem, calc(1rem + env(safe-area-inset-right))); + padding-bottom: max(4rem, calc(2rem + env(safe-area-inset-bottom))); + } + .radar-map-full { + height: calc(100vh - 280px - env(safe-area-inset-top) - env(safe-area-inset-bottom)); + } +} + +/* ── PWA: status bar area when running standalone ── */ +@media (display-mode: standalone) { + .masthead { padding-top: max(1rem, calc(0.75rem + env(safe-area-inset-top))); } +} + +/* ── Touch-friendly tap targets (min 44 × 44 px) ── */ +.tab { + min-height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; +} +.search-row input, +.search-row button { min-height: 44px; } +.radar-btn { width: 44px; height: 44px; font-size: 0.8rem; } +.tl-tick { min-height: 36px; } + +/* iOS momentum scrolling */ +.hourly-scroll { -webkit-overflow-scrolling: touch; } + +/* ── Leaflet overrides ── */ +.leaflet-container { background: #1a1f2e; } +.leaflet-tile { transition: opacity 0.24s linear; } +.leaflet-control-zoom a { + background: var(--paper-dark) !important; + color: var(--ink) !important; + border-color: var(--border) !important; +} + +/* ── Mobile Bottom Navigation ── */ +.bottom-nav { + display: none; + position: fixed; + left: 0; + right: 0; + bottom: 0; + width: 100vw; + z-index: 300; + border-top: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(25, 21, 16, 0.96); + backdrop-filter: blur(6px); + padding-bottom: env(safe-area-inset-bottom, 0px); +} + +.bottom-tab { + flex: 1; + min-height: 56px; + border: none; + border-right: 1px solid rgba(255, 255, 255, 0.08); + background: transparent; + color: #efe8dc; + font-family: var(--mono); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.15rem; + cursor: pointer; +} + +.bottom-tab:last-child { border-right: none; } +.bottom-icon { font-size: 1rem; line-height: 1; } +.bottom-label { font-size: 0.58rem; letter-spacing: 0.05em; text-transform: uppercase; } + +.bottom-tab.active { + background: var(--accent); + color: #fff; +} + +/* ── Responsive ── */ +@media (max-width: 640px) { + html, body { font-size: 16px; } + .app { padding: max(1.2rem, calc(0.8rem + env(safe-area-inset-top, 0px))) 0.9rem max(5.25rem, calc(4.5rem + env(safe-area-inset-bottom, 0px))); } + .masthead { + grid-template-columns: 1fr; + grid-template-rows: auto; + gap: 0.35rem; + padding: 0.75rem 0.9rem; + } + .masthead h1 { font-size: 2.2rem; line-height: 1.08; } + .tagline { display: none; } + .tabs { display: none; } + .bottom-nav { display: flex; } + + #tab-radar-full { + padding-bottom: max(6.4rem, calc(5.7rem + env(safe-area-inset-bottom, 0px))); + } + + .radar-controls-full { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + grid-template-areas: + 'play layer ts' + 'timeline timeline timeline'; + gap: 0.45rem 0.5rem; + padding: 0.55rem 0.65rem; + align-items: center; + } + + .radar-controls-full .play-btn { grid-area: play; } + .radar-controls-full .layer-btn { grid-area: layer; } + .radar-controls-full .radar-ts { + grid-area: ts; + min-width: 0; + text-align: right; + font-size: 0.66rem; + } + + .radar-controls-full .radar-timeline { + grid-area: timeline; + width: 100%; + height: 24px; + } + + .radar-controls-full .radar-legend { display: none; } + .radar-attribution { + overflow-wrap: anywhere; + padding-bottom: 0.3rem; + } + + .tab { min-width: 88px; } + .search-wrap { padding: 0.45rem; } + .search-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto auto; + gap: 0; + } + .search-row input { + font-size: 0.84rem; + padding: 0.58rem 0.68rem; + } + .search-row button { + font-size: 0.64rem; + padding: 0.58rem 0.62rem; + letter-spacing: 0.04em; + } + #locate-btn { font-size: 1rem !important; padding: 0.58rem 0.55rem !important; } + #units-btn { min-width: 56px; } + + .loc-header { + flex-direction: column; + align-items: center; + text-align: center; + gap: 0.45rem; + } + .loc-name { font-size: 2rem; } + .loc-sub { font-size: 0.88rem; } + .loc-actions { align-items: center; margin-top: 0; } + .loc-time { text-align: center; font-size: 0.88rem; } + + .hero { grid-template-columns: 1fr; text-align: center; } + .hero-left { align-items: center; } + .hero-right { justify-content: center; } + .hero-temp { font-size: 5.4rem; } + .hero-desc { font-size: 1.55rem; } + .hero-sub { font-size: 0.92rem; } + .feels { font-size: 0.95rem; } + .hilo-item { font-size: 1.35rem; } + .hilo-label { font-size: 0.78rem; } + + .section-label { font-size: 0.76rem; font-weight: 800; } + .section-note { font-size: 0.78rem; color: var(--ink-mid); } + .stat-card .stat-label { font-size: 0.75rem; font-weight: 700; } + .stat-card .stat-val { font-size: 1.8rem; font-weight: 600; } + .stat-unit { font-size: 0.95rem; font-weight: 600; } + .stat-sub { font-size: 0.95rem; font-weight: 600; } + + /* Sun & moon section */ + .sun-ep-icon { font-size: 1.8rem; } + .sun-ep-time { font-size: 0.9rem; font-weight: 600; } + .moon-row { font-size: 0.95rem; font-weight: 500; } + .moon-emoji { font-size: 1.4rem; } + .daylight-info { font-size: 0.88rem; font-weight: 500; } + + /* What to bring */ + .rec-item { padding: 0.95rem 0.68rem; } + .rec-icon { font-size: 2.2rem; } + .rec-label { font-size: 0.82rem; font-weight: 600; } + + /* Hourly readability */ + .hour-cell { min-width: 88px; padding: 0.8rem 0.55rem; } + .hour-time { font-size: 0.74rem; } + .hour-icon { font-size: 1.6rem; } + .hour-temp { font-size: 1.08rem; } + .hour-precip { font-size: 0.78rem; } + + /* 7-day readability */ + .day-row { padding: 0.95rem 0.95rem; } + .day-name { font-size: 0.98rem; } + .day-icon { font-size: 1.35rem; } + .day-desc { font-size: 0.9rem; } + .day-temps { font-size: 0.95rem; } + + /* Bottom nav readability */ + .bottom-tab { min-height: 62px; gap: 0.2rem; } + .bottom-icon { font-size: 1.2rem; } + .bottom-label { font-size: 0.68rem; letter-spacing: 0.04em; } + + .hero-hilo { flex-direction: row; gap: 1.5rem; padding: 0.75rem 1.2rem; } + .hilo-divider { width: 1px; height: 2rem; } + .card-group { grid-template-columns: repeat(2, 1fr); } + .recs-grid { grid-template-columns: repeat(3, 1fr); } + .day-row { grid-template-columns: 80px 36px 1fr 70px; } + .day-meta { display: none; } + .radar-map { height: 240px; } + .legend-bar { width: 50px; } + .settings-row { flex-direction: column; align-items: flex-start; gap: 0.5rem; } + .settings-value { text-align: left; } + .map-overview { height: 280px; } + .maps-data { grid-template-columns: 1fr; } + .maps-legend { grid-template-columns: 1fr; } + .wind-stats { grid-template-columns: 1fr 1fr; } +} diff --git a/.tmp/imgdiff/new/sw.js b/.tmp/imgdiff/new/sw.js new file mode 100644 index 0000000..664dffd --- /dev/null +++ b/.tmp/imgdiff/new/sw.js @@ -0,0 +1,19 @@ +'use strict'; + +// Disable offline caching so UI updates are always immediate. +self.addEventListener('install', event => { + event.waitUntil(self.skipWaiting()); +}); + +self.addEventListener('activate', event => { + event.waitUntil((async () => { + const keys = await caches.keys(); + await Promise.all(keys.map(k => caches.delete(k))); + await self.clients.claim(); + await self.registration.unregister(); + })()); +}); + +self.addEventListener('fetch', () => { + // Intentionally no fetch interception. +}); \ No newline at end of file diff --git a/.tmp/imgdiff/old/app.js b/.tmp/imgdiff/old/app.js new file mode 100644 index 0000000..ab383c1 --- /dev/null +++ b/.tmp/imgdiff/old/app.js @@ -0,0 +1,1569 @@ +'use strict'; + +// ── WMO codes ──────────────────────────────────────────────────────────────── +const WMO = { + 0:{desc:'Clear sky',icon:'☀️'},1:{desc:'Mainly clear',icon:'🌤️'}, + 2:{desc:'Partly cloudy',icon:'⛅'},3:{desc:'Overcast',icon:'☁️'}, + 45:{desc:'Foggy',icon:'🌫️'},48:{desc:'Icy fog',icon:'🌫️'}, + 51:{desc:'Light drizzle',icon:'🌦️'},53:{desc:'Drizzle',icon:'🌦️'}, + 55:{desc:'Heavy drizzle',icon:'🌧️'},56:{desc:'Freezing drizzle',icon:'🌨️'}, + 57:{desc:'Heavy freezing drizzle',icon:'🌨️'}, + 61:{desc:'Light rain',icon:'🌧️'},63:{desc:'Rain',icon:'🌧️'}, + 65:{desc:'Heavy rain',icon:'🌧️'},66:{desc:'Freezing rain',icon:'🌨️'}, + 67:{desc:'Heavy freezing rain',icon:'🌨️'}, + 71:{desc:'Light snow',icon:'🌨️'},73:{desc:'Snow',icon:'❄️'}, + 75:{desc:'Heavy snow',icon:'❄️'},77:{desc:'Snow grains',icon:'🌨️'}, + 80:{desc:'Light showers',icon:'🌦️'},81:{desc:'Showers',icon:'🌧️'}, + 82:{desc:'Heavy showers',icon:'⛈️'},85:{desc:'Snow showers',icon:'🌨️'}, + 86:{desc:'Heavy snow showers',icon:'❄️'}, + 95:{desc:'Thunderstorm',icon:'⛈️'},96:{desc:'Thunderstorm w/ hail',icon:'⛈️'}, + 99:{desc:'Severe thunderstorm',icon:'⛈️'}, +}; + +function wmo(code) { return WMO[code] || { desc: 'Unknown', icon: '🌡️' }; } + +const DAYS = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; +const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; +const STORAGE_KEYS = { + lastCity: 'weather.lastCity', + savedCities: 'weather.savedCities', + units: 'weather.units', + autoDetect: 'weather.autoDetect', + showLightning: 'weather.showLightning', + showWind: 'weather.showWind', + defaultCity: 'weather.defaultCity', + highContrast: 'weather.highContrast', + radarOpacity: 'weather.radarOpacity', + lightningAgeMin: 'weather.lightningAgeMin', +}; + +function parseSavedCities(raw) { + try { + const parsed = JSON.parse(raw || '[]'); + if (!Array.isArray(parsed)) return []; + return parsed.map((entry) => { + if (entry && entry.city && typeof entry.city === 'object') { + return { + city: entry.city, + addedAt: entry.addedAt || Date.now(), + lastViewedAt: entry.lastViewedAt || null, + isDefault: Boolean(entry.isDefault), + }; + } + return { + city: entry, + addedAt: Date.now(), + lastViewedAt: null, + isDefault: false, + }; + }).filter(e => e.city && e.city.latitude != null && e.city.longitude != null); + } catch { + return []; + } +} + +const state = { + units: localStorage.getItem(STORAGE_KEYS.units) || 'us', + autoDetect: localStorage.getItem(STORAGE_KEYS.autoDetect) !== 'false', + showLightning: localStorage.getItem(STORAGE_KEYS.showLightning) !== 'false', + showWind: localStorage.getItem(STORAGE_KEYS.showWind) !== 'false', + highContrast: localStorage.getItem(STORAGE_KEYS.highContrast) === 'true', + radarOpacity: Number(localStorage.getItem(STORAGE_KEYS.radarOpacity) || 65), + lightningAgeMin: Number(localStorage.getItem(STORAGE_KEYS.lightningAgeMin) || 15), + currentCity: null, + currentWeather: null, + currentAir: null, + currentSource: 'startup', + savedCities: parseSavedCities(localStorage.getItem(STORAGE_KEYS.savedCities)), + alertIdsSeen: new Set(), + autoRefreshTimer: null, + lastRefreshAt: 0, + lightningLayer: null, + windArrows: [], + dailyExpanded: false, + pushSupported: false, + pushEnabled: false, +}; + +const persistedDefaultKey = localStorage.getItem(STORAGE_KEYS.defaultCity); +if (persistedDefaultKey && state.savedCities.length && !state.savedCities.some(e => e.isDefault)) { + state.savedCities = state.savedCities.map(e => ({ ...e, isDefault: cityKey(e.city) === persistedDefaultKey })); +} + +// ── DOM ────────────────────────────────────────────────────────────────────── +const searchInput = document.getElementById('search-input'); +const searchBtn = document.getElementById('search-btn'); +const locateBtn = document.getElementById('locate-btn'); +const suggestEl = document.getElementById('suggestions'); +const errorMsg = document.getElementById('error-msg'); +const loadingEl = document.getElementById('loading'); +const weatherDiv = document.getElementById('weather'); +const unitsBtn = document.getElementById('units-btn'); +const unitsBtnSettings = document.getElementById('units-btn-settings'); +const shareBtn = document.getElementById('share-btn'); +const saveCurrentBtn = document.getElementById('save-current-btn'); +const savedLocationsEl = document.getElementById('saved-locations'); +const notifyBtn = document.getElementById('notify-btn'); +const autoDetectToggle = document.getElementById('auto-detect-toggle'); +const alertsEmptyEl = document.getElementById('alerts-empty'); +const settingsLocationEl = document.getElementById('settings-location'); +const mapsLocationEl = document.getElementById('maps-location'); +const mapsWindEl = document.getElementById('maps-wind'); +const lightningToggle = document.getElementById('lightning-toggle'); +const windToggle = document.getElementById('wind-toggle'); +const lightningStatus = document.getElementById('lightning-status'); +const windStatus = document.getElementById('wind-status'); +const dailyForecastLabel = document.getElementById('daily-forecast-label'); +const dailyForecastToggle = document.getElementById('daily-forecast-toggle'); +const pushTestBtn = document.getElementById('push-test-btn'); +const hourlySummaryEl = document.getElementById('hourly-summary'); +const defaultCityEl = document.getElementById('default-city'); +const contrastToggle = document.getElementById('contrast-toggle'); +const contrastStatus = document.getElementById('contrast-status'); +const radarOpacityEl = document.getElementById('radar-opacity'); +const lightningAgeEl = document.getElementById('lightning-age'); +const mapsFullscreenBtn = document.getElementById('maps-fullscreen-btn'); + +// ── Helpers ────────────────────────────────────────────────────────────────── +const show = el => el.classList.remove('hidden'); +const hide = el => el.classList.add('hidden'); +const showErr = msg => { errorMsg.textContent = msg; show(errorMsg); }; +const clearErr = () => { errorMsg.textContent = ''; hide(errorMsg); }; + +function compassDir(deg) { + return ['N','NE','E','SE','S','SW','W','NW'][Math.round(deg / 45) % 8]; +} + +function formatHour(iso) { + const h = new Date(iso).getHours(); + return h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h-12} PM`; +} + +function formatDayShort(iso) { + const d = new Date(iso + 'T00:00:00'); + const today = new Date(); + const tomorrow = new Date(today); + tomorrow.setDate(today.getDate() + 1); + if (d.toDateString() === today.toDateString()) return 'Today'; + if (d.toDateString() === tomorrow.toDateString()) return 'Tomorrow'; + return DAYS[d.getDay()]; +} + +function formatClock(iso) { + return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +function tempDisplay(v) { + if (v == null || Number.isNaN(v)) return '--'; + return Math.round(v); +} + +function speedDisplay(v) { + if (v == null || Number.isNaN(v)) return '--'; + return Math.round(v); +} + +function precipDisplay(v) { + if (v == null || Number.isNaN(v)) return '--'; + return Number(v).toFixed(1); +} + +function visDisplay(vMeters) { + if (vMeters == null || Number.isNaN(vMeters)) return '--'; + const miles = vMeters / 1609; + if (state.units === 'us') return miles.toFixed(1); + return (miles * 1.60934).toFixed(1); +} + +function tempUnit() { return state.units === 'us' ? 'F' : 'C'; } +function speedUnit() { return state.units === 'us' ? 'mph' : 'km/h'; } +function visUnit() { return state.units === 'us' ? 'mi' : 'km'; } + +function weatherThemeFromCode(code) { + const n = Number(code); + if (Number.isNaN(n)) return 'cloud'; + if (n === 0 || n === 1) return 'clear'; + if (n === 2 || n === 3) return 'cloud'; + if (n === 45 || n === 48) return 'fog'; + if ((n >= 51 && n <= 67) || (n >= 80 && n <= 82)) return 'rain'; + if (n >= 71 && n <= 77) return 'snow'; + if (n >= 85 && n <= 86) return 'snow'; + if (n >= 95) return 'storm'; + return 'cloud'; +} + +function applyWeatherBackground(code, isDay) { + const theme = weatherThemeFromCode(code); + const body = document.body; + const themes = ['clear', 'cloud', 'rain', 'snow', 'storm', 'fog']; + themes.forEach(t => body.classList.remove(`weather-${t}`)); + body.classList.add(`weather-${theme}`); + body.classList.toggle('weather-night', Number(isDay) === 0); +} + +function updateUnitsButton() { + const label = state.units === 'us' ? 'US' : 'Metric'; + unitsBtn.textContent = label; + if (unitsBtnSettings) unitsBtnSettings.textContent = label; +} + +function syncAutoDetectToggle() { + autoDetectToggle.checked = state.autoDetect; +} + +function updateNotifyButton() { + if (!('Notification' in window)) { + notifyBtn.textContent = 'Notifications unsupported'; + notifyBtn.disabled = true; + if (pushTestBtn) pushTestBtn.disabled = true; + return; + } + if (!window.isSecureContext) { + notifyBtn.textContent = 'Needs HTTPS'; + notifyBtn.disabled = true; + if (pushTestBtn) pushTestBtn.disabled = true; + return; + } + + notifyBtn.disabled = false; + if (Notification.permission === 'granted') { + notifyBtn.textContent = state.pushEnabled ? 'Push enabled' : 'Enable push'; + } else if (Notification.permission === 'denied') { + notifyBtn.textContent = 'Notifications blocked'; + } else { + notifyBtn.textContent = 'Enable notifications'; + } + + if (pushTestBtn) { + pushTestBtn.disabled = !(Notification.permission === 'granted' && state.pushEnabled); + } +} + +function applyHighContrast() { + document.body.classList.toggle('high-contrast', state.highContrast); + if (contrastStatus) contrastStatus.textContent = state.highContrast ? 'On' : 'Off'; +} + +function urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = atob(base64); + const outputArray = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; ++i) outputArray[i] = rawData.charCodeAt(i); + return outputArray; +} + +async function ensurePushSubscription() { + if (!('serviceWorker' in navigator) || !('PushManager' in window)) return false; + + const keyResp = await fetch('/api/push/public-key').then(r => r.json()).catch(() => null); + const publicKey = keyResp?.publicKey; + if (!publicKey) return false; + + const reg = await navigator.serviceWorker.ready; + let sub = await reg.pushManager.getSubscription(); + if (!sub) { + sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }); + } + + await fetch('/api/push/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + subscription: sub, + location: state.currentCity ? { + latitude: state.currentCity.latitude, + longitude: state.currentCity.longitude, + } : null, + }), + }); + + state.pushEnabled = true; + updateNotifyButton(); + return true; +} + +async function sendTestPush() { + const resp = await fetch('/api/push/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: 'Weather push test', + body: state.currentCity ? `Alerts enabled for ${cityLabel(state.currentCity) || 'your location'}` : 'Push notifications are active.', + url: window.location.pathname, + }), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.error || 'Push test failed'); + } +} + +function moonPhase(dateLike) { + const date = new Date(dateLike); + const synodic = 29.53058867; + const knownNewMoon = new Date('2000-01-06T18:14:00Z'); + const days = (date - knownNewMoon) / 86400000; + const phase = ((days % synodic) + synodic) % synodic; + const index = Math.floor((phase / synodic) * 8) % 8; + return ['New', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous', 'Full', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent'][index]; +} + +function precipType(code, tempF, rain, snowfall) { + if ((snowfall ?? 0) > 0) return 'Snow'; + if ([56,57,66,67].includes(code) || ((rain ?? 0) > 0 && tempF <= 32)) return 'Sleet'; + if ((rain ?? 0) > 0 || [51,53,55,61,63,65,80,81,82].includes(code)) return 'Rain'; + return 'Dry'; +} + +function apparentLabel(tempF, apparentF) { + if (tempF <= 50 && apparentF <= tempF - 2) return 'Wind chill'; + if (tempF >= 80 && apparentF >= tempF + 2) return 'Heat index'; + return 'Feels like'; +} + +function cityKey(city) { + return `${Number(city.latitude).toFixed(3)},${Number(city.longitude).toFixed(3)}`; +} + +function persistSavedCities() { + localStorage.setItem(STORAGE_KEYS.savedCities, JSON.stringify(state.savedCities)); + const defaultEntry = state.savedCities.find(e => e.isDefault); + localStorage.setItem(STORAGE_KEYS.defaultCity, defaultEntry ? cityKey(defaultEntry.city) : ''); +} + +function persistLastCity(city) { + localStorage.setItem(STORAGE_KEYS.lastCity, JSON.stringify(city)); +} + +function getLastCity() { + try { return JSON.parse(localStorage.getItem(STORAGE_KEYS.lastCity) || 'null'); } + catch { return null; } +} + +function cityLabel(city) { + return [city?.name, city?.admin1, city?.country].filter(Boolean).join(', '); +} + +function formatRelativeTime(ts) { + if (!ts) return 'Never opened'; + const mins = Math.max(1, Math.round((Date.now() - ts) / 60000)); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.round(mins / 60); + if (hrs < 48) return `${hrs}h ago`; + const days = Math.round(hrs / 24); + return `${days}d ago`; +} + +function getDefaultSavedEntry() { + return state.savedCities.find(e => e.isDefault) || null; +} + +function updateDefaultCityLabel() { + if (!defaultCityEl) return; + const def = getDefaultSavedEntry(); + defaultCityEl.textContent = def?.city?.name || 'None'; +} + +function setDefaultCityByKey(key) { + state.savedCities = state.savedCities.map(e => ({ + ...e, + isDefault: cityKey(e.city) === key, + })); + persistSavedCities(); + updateDefaultCityLabel(); + renderSavedCities(); +} + +function moveSavedCity(index, direction) { + const next = index + direction; + if (next < 0 || next >= state.savedCities.length) return; + const arr = [...state.savedCities]; + [arr[index], arr[next]] = [arr[next], arr[index]]; + state.savedCities = arr; + persistSavedCities(); + renderSavedCities(); +} + +function updateShareUrl(city) { + if (!city) return; + const params = new URLSearchParams(window.location.search); + params.set('lat', city.latitude); + params.set('lon', city.longitude); + params.set('name', city.name || 'Location'); + history.replaceState(null, '', `${window.location.pathname}?${params.toString()}`); +} + +function updateSourceLocation(city) { + const label = cityLabel(city) || (city ? `${Number(city.latitude).toFixed(2)}, ${Number(city.longitude).toFixed(2)}` : '--'); + if (settingsLocationEl) settingsLocationEl.textContent = label; + if (mapsLocationEl) mapsLocationEl.textContent = label; +} + +function renderSavedCities() { + savedLocationsEl.innerHTML = ''; + if (!state.savedCities.length) { + savedLocationsEl.textContent = 'None yet'; + updateDefaultCityLabel(); + return; + } + + state.savedCities.forEach((entry, index) => { + const city = entry.city; + const wrap = document.createElement('div'); + wrap.className = 'saved-item'; + + const chip = document.createElement('button'); + chip.className = 'saved-chip'; + chip.type = 'button'; + chip.textContent = city.name; + chip.title = `${cityLabel(city)} • ${formatRelativeTime(entry.lastViewedAt)}`; + chip.addEventListener('click', () => { + entry.lastViewedAt = Date.now(); + persistSavedCities(); + loadAll(city, 'saved-location'); + }); + + const defaultBtn = document.createElement('button'); + defaultBtn.className = 'saved-default'; + defaultBtn.type = 'button'; + defaultBtn.title = entry.isDefault ? 'Default city' : 'Set as default city'; + defaultBtn.textContent = entry.isDefault ? '★' : '☆'; + defaultBtn.addEventListener('click', () => setDefaultCityByKey(cityKey(city))); + + const upBtn = document.createElement('button'); + upBtn.className = 'saved-move'; + upBtn.type = 'button'; + upBtn.title = 'Move up'; + upBtn.textContent = '↑'; + upBtn.disabled = index === 0; + upBtn.addEventListener('click', () => moveSavedCity(index, -1)); + + const downBtn = document.createElement('button'); + downBtn.className = 'saved-move'; + downBtn.type = 'button'; + downBtn.title = 'Move down'; + downBtn.textContent = '↓'; + downBtn.disabled = index === state.savedCities.length - 1; + downBtn.addEventListener('click', () => moveSavedCity(index, 1)); + + const remove = document.createElement('button'); + remove.className = 'saved-remove'; + remove.type = 'button'; + remove.textContent = 'x'; + remove.addEventListener('click', (e) => { + e.stopPropagation(); + state.savedCities = state.savedCities.filter(c => cityKey(c.city) !== cityKey(city)); + persistSavedCities(); + renderSavedCities(); + }); + + const meta = document.createElement('span'); + meta.className = 'saved-meta'; + meta.textContent = formatRelativeTime(entry.lastViewedAt); + + wrap.appendChild(defaultBtn); + wrap.appendChild(chip); + wrap.appendChild(upBtn); + wrap.appendChild(downBtn); + wrap.appendChild(remove); + wrap.appendChild(meta); + savedLocationsEl.appendChild(wrap); + }); + + updateDefaultCityLabel(); +} + +// ── Tab switching ───────────────────────────────────────────────────────────── +document.querySelectorAll('.tab').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.tab-panel').forEach(p => hide(p)); + btn.classList.add('active'); + show(document.getElementById('tab-' + btn.dataset.tab)); + if (btn.dataset.tab === 'radar-full') { + setTimeout(() => { radarFull.map && radarFull.map.invalidateSize(); }, 50); + } + if (btn.dataset.tab === 'maps') { + setTimeout(refreshMapsOverview, 80); + } else { + setMapsFullscreen(false); + } + }); +}); + +document.getElementById('open-radar-tab').addEventListener('click', () => { + document.querySelector('.tab[data-tab="radar-full"]').click(); +}); + +// ── APIs ───────────────────────────────────────────────────────────────────── +async function geocode(q) { + const r = await fetch(`/api/geocode?q=${encodeURIComponent(q)}`); + if (!r.ok) throw new Error('Geocoding failed'); + return r.json(); +} + +async function reverseGeocode(lat, lon) { + const r = await fetch(`/api/reverse-geocode?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}`); + if (!r.ok) throw new Error('Reverse geocoding failed'); + return r.json(); +} + +async function fetchWeather(lat, lon, tz) { + const r = await fetch(`/api/weather?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}&units=${encodeURIComponent(state.units)}`); + if (!r.ok) throw new Error('Weather fetch failed'); + return r.json(); +} + +async function fetchAirQuality(lat, lon, tz) { + const r = await fetch(`/api/air-quality?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}`); + if (!r.ok) throw new Error('Air quality fetch failed'); + return r.json(); +} + +async function fetchAlerts(lat, lon) { + const r = await fetch(`/api/alerts?lat=${lat}&lon=${lon}`); + if (!r.ok) throw new Error('Alerts fetch failed'); + return r.json(); +} + +async function fetchLightning(lat, lon) { + const ageMin = Number(state.lightningAgeMin || 15); + const r = await fetch(`/api/lightning?lat=${lat}&lon=${lon}&age=${ageMin}`); + if (!r.ok) throw new Error('Lightning fetch failed'); + return r.json(); +} + +function detectedCityFallback(lat, lon) { + return { + name: `Auto-detected (${lat.toFixed(2)}, ${lon.toFixed(2)})`, + latitude: lat, + longitude: lon, + country: '', + admin1: '', + timezone: 'auto', + }; +} + +function resolveDetectedName(place, fallback) { + const raw = (place?.name || '').trim(); + const genericName = !raw || /current\s*location|detected\s*location/i.test(raw); + if (!genericName) return raw; + + const displayRoot = (place?.display_name || '').split(',')[0]?.trim(); + if (displayRoot) return displayRoot; + + return fallback.name; +} + +async function buildDetectedCity(lat, lon) { + const fallback = detectedCityFallback(lat, lon); + + try { + const place = await reverseGeocode(lat, lon); + return { + ...fallback, + ...place, + name: resolveDetectedName(place, fallback), + latitude: lat, + longitude: lon, + timezone: place.timezone || 'auto', + }; + } catch { + return fallback; + } +} + +// ── Radar engine ────────────────────────────────────────────────────────────── +function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) { + const map = L.map(mapEl, { + center: [39.5, -98.35], + zoom: 4, + zoomControl: true, + attributionControl: false, + }); + + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + maxZoom: 19, + }).addTo(map); + + let radarFrames = []; + let satelliteFrames = []; + let mode = 'radar'; + let currentIdx = 0; + let layers = { radar: {}, satellite: {} }; + let playing = false; + let animTimer = null; + let locationMarker = null; + + function activeFrames() { + return mode === 'radar' ? radarFrames : satelliteFrames; + } + + function tsLabel(unixSec) { + const d = new Date(unixSec * 1000); + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + + ' ' + MONTHS[d.getMonth()] + ' ' + d.getDate(); + } + + function frameTileUrl(frame) { + if (mode === 'satellite') { + return `https://tilecache.rainviewer.com${frame.path}/256/{z}/{x}/{y}/0/0_0.png`; + } + return `https://tilecache.rainviewer.com${frame.path}/256/{z}/{x}/{y}/2/1_1.png`; + } + + function showFrame(idx) { + const frames = activeFrames(); + currentIdx = idx; + const frame = frames[idx]; + if (!frame) return; + + if (!layers[mode][frame.time]) { + layers[mode][frame.time] = L.tileLayer(frameTileUrl(frame), { opacity: 0.75, maxZoom: 15 }); + } + + Object.values(layers.radar).forEach(l => { if (map.hasLayer(l)) map.removeLayer(l); }); + Object.values(layers.satellite).forEach(l => { if (map.hasLayer(l)) map.removeLayer(l); }); + layers[mode][frame.time].addTo(map); + + tsEl.textContent = tsLabel(frame.time); + document.querySelectorAll(`#${timelineEl.id} .tl-tick`).forEach((el, i) => { + el.classList.toggle('active', i === idx); + }); + } + + function buildTimeline() { + timelineEl.innerHTML = ''; + activeFrames().forEach((f, i) => { + const tick = document.createElement('div'); + tick.className = 'tl-tick' + (f.isForecast ? ' forecast' : ''); + tick.title = tsLabel(f.time); + tick.addEventListener('click', () => { stopPlay(); showFrame(i); }); + timelineEl.appendChild(tick); + }); + } + + function step() { + const frames = activeFrames(); + if (!frames.length) return; + const next = (currentIdx + 1) % frames.length; + showFrame(next); + if (next === 0) { + stopPlay(); + setTimeout(startPlay, 800); + } + } + + function startPlay() { + playing = true; + playBtn.textContent = '⏸'; + animTimer = setInterval(step, 500); + } + + function stopPlay() { + playing = false; + playBtn.textContent = '▶'; + clearInterval(animTimer); + } + + playBtn.addEventListener('click', () => playing ? stopPlay() : startPlay()); + + if (layerBtn) { + layerBtn.addEventListener('click', () => { + if (!(radarFrames.length && satelliteFrames.length)) return; + mode = mode === 'radar' ? 'satellite' : 'radar'; + layerBtn.textContent = mode === 'radar' ? 'R' : 'S'; + buildTimeline(); + const idx = Math.min(currentIdx, activeFrames().length - 1); + showFrame(Math.max(idx, 0)); + }); + } + + async function loadFrames() { + try { + const r = await fetch('/api/radar/times'); + const data = await r.json(); + + radarFrames = []; + satelliteFrames = []; + (data.radar?.past || []).forEach(f => radarFrames.push({ time: f.time, path: f.path, isForecast: false })); + (data.radar?.nowcast || []).forEach(f => radarFrames.push({ time: f.time, path: f.path, isForecast: true })); + (data.satellite?.infrared || []).forEach(f => satelliteFrames.push({ time: f.time, path: f.path, isForecast: false })); + + if (!radarFrames.length && satelliteFrames.length) mode = 'satellite'; + if (!radarFrames.length && !satelliteFrames.length) return; + + if (layerBtn) { + layerBtn.disabled = !(radarFrames.length && satelliteFrames.length); + layerBtn.textContent = mode === 'radar' ? 'R' : 'S'; + } + + buildTimeline(); + const frames = activeFrames(); + showFrame(frames.length - 1); + } catch (e) { + console.error('Radar load failed', e); + } + } + + function panTo(lat, lon, zoom) { + map.setView([lat, lon], zoom || map.getZoom()); + if (locationMarker) map.removeLayer(locationMarker); + locationMarker = L.circleMarker([lat, lon], { + radius: 6, + fillColor: '#e07848', + fillOpacity: 1, + color: '#fff', + weight: 2, + }).addTo(map); + } + + loadFrames(); + return { map, panTo, startPlay, stopPlay }; +} + +const radarEmbed = createRadar( + document.getElementById('radar-embed'), + document.getElementById('embed-timeline'), + document.getElementById('embed-play'), + document.getElementById('embed-timestamp'), + document.getElementById('embed-layer') +); + +const radarFull = createRadar( + document.getElementById('radar-full'), + document.getElementById('full-timeline'), + document.getElementById('full-play'), + document.getElementById('full-timestamp'), + document.getElementById('full-layer') +); + +function createOverviewMap(mapEl) { + const map = L.map(mapEl, { + center: [39.5, -98.35], + zoom: 4, + zoomControl: true, + attributionControl: false, + }); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 18, + }).addTo(map); + + const radarLayer = L.tileLayer('https://tilecache.rainviewer.com/v2/radar/nowcast_0/256/{z}/{x}/{y}/2/1_1.png', { + maxZoom: 15, + opacity: Math.max(0.25, Math.min(0.9, state.radarOpacity / 100)), + }).addTo(map); + + let lastRadarFramePath = null; + + async function refreshRadarOverlay() { + try { + const r = await fetch('/api/radar/times'); + if (!r.ok) return; + const data = await r.json(); + const frames = [ + ...(data?.radar?.past || []), + ...(data?.radar?.nowcast || []), + ]; + const latest = frames[frames.length - 1]; + const path = latest?.path; + if (!path || path === lastRadarFramePath) return; + lastRadarFramePath = path; + radarLayer.setUrl(`https://tilecache.rainviewer.com${path}/256/{z}/{x}/{y}/2/1_1.png`); + } catch (e) { + console.warn('Maps radar overlay refresh failed', e); + } + } + + refreshRadarOverlay(); + setInterval(refreshRadarOverlay, 5 * 60 * 1000); + + let marker = null; + + function panTo(lat, lon, zoom) { + map.setView([lat, lon], zoom || map.getZoom()); + if (marker) map.removeLayer(marker); + marker = L.circleMarker([lat, lon], { + radius: 7, + fillColor: '#e07848', + fillOpacity: 1, + color: '#fff', + weight: 2, + }).addTo(map); + } + + function setRadarOpacity(opacityPct) { + const normalized = Math.max(0.25, Math.min(0.9, Number(opacityPct) / 100)); + radarLayer.setOpacity(normalized); + } + + return { map, panTo, setRadarOpacity, refreshRadarOverlay }; +} + +function clearLightningLayer() { + if (!mapsOverview.map) return; + if (state.lightningLayer) { + mapsOverview.map.removeLayer(state.lightningLayer); + state.lightningLayer = null; + } +} + +function clearWindArrows() { + if (!mapsOverview.map) return; + state.windArrows.forEach(arrow => mapsOverview.map.removeLayer(arrow)); + state.windArrows = []; +} + +function refreshMapsOverview() { + if (!mapsOverview.map) return; + + mapsOverview.map.invalidateSize(); + mapsOverview.refreshRadarOverlay(); + + if (!state.currentCity) return; + + mapsOverview.panTo(state.currentCity.latitude, state.currentCity.longitude, 6); + + clearLightningLayer(); + clearWindArrows(); + + if (state.showLightning) renderLightningStrikes(state.currentCity); + if (state.showWind && state.currentWeather) renderWindArrows(state.currentCity); +} + +function circleCenter(coords) { + let sumLat = 0; + let sumLon = 0; + let count = 0; + coords.forEach(([lon, lat]) => { + sumLat += lat; + sumLon += lon; + count += 1; + }); + if (!count) return null; + return [sumLat / count, sumLon / count]; +} + +async function renderLightningStrikes(city) { + if (!mapsOverview.map || !state.showLightning) return; + clearLightningLayer(); + + try { + const data = await fetchLightning(city.latitude, city.longitude); + const features = data?.features || []; + if (!features.length) return; + + const layers = []; + features.forEach((feature) => { + const geom = feature?.geometry; + const event = feature?.properties?.event || 'Thunderstorm activity'; + const severity = feature?.properties?.severity || 'Unknown severity'; + if (!geom) return; + + const polygonSets = []; + if (geom.type === 'Polygon' && Array.isArray(geom.coordinates?.[0])) { + polygonSets.push(geom.coordinates[0]); + } + if (geom.type === 'MultiPolygon' && Array.isArray(geom.coordinates)) { + geom.coordinates.forEach(poly => { + if (Array.isArray(poly?.[0])) polygonSets.push(poly[0]); + }); + } + + polygonSets.forEach((polyCoords, idx) => { + const ring = polyCoords.map(([lon, lat]) => [lat, lon]); + const poly = L.polygon(ring, { + color: '#ff8b1a', + weight: 1.5, + opacity: 0.8, + fillColor: '#ffd34d', + fillOpacity: 0.18, + }).bindPopup(`${event} • ${severity}`); + layers.push(poly); + + const center = circleCenter(polyCoords); + if (center) { + const strike = L.circleMarker(center, { + radius: 5, + fillColor: '#ffe066', + fillOpacity: 0.95, + color: '#ff6f00', + weight: 2, + }).bindPopup(`${event} area ${idx + 1}`); + layers.push(strike); + } + }); + }); + + if (!layers.length) return; + state.lightningLayer = L.layerGroup(layers).addTo(mapsOverview.map); + } catch (e) { + console.warn('Lightning overlay unavailable', e); + } +} + +function renderWindArrows(city) { + if (!mapsOverview.map || !state.showWind || !state.currentWeather) return; + clearWindArrows(); + + // Get current wind data + const current = state.currentWeather.current; + if (!current || !current.wind_speed_10m) return; + + const windSpeed = current.wind_speed_10m; + const windDir = current.wind_direction_10m || 0; + + // Draw a wind barb pointing in the direction of wind movement + const lat = parseFloat(city.latitude); + const lon = parseFloat(city.longitude); + const radius = 0.3; + + // Calculate arrow end point based on wind direction + const radians = (windDir * Math.PI) / 180; + const endLat = lat + radius * Math.cos(radians); + const endLon = lon + radius * Math.sin(radians); + + // Draw arrow line + const arrow = L.polyline( + [[lat, lon], [endLat, endLon]], + { + color: '#4a90e2', + weight: 2, + opacity: 0.7, + } + ).bindPopup(`Wind: ${speedDisplay(windSpeed)} ${speedUnit()}`); + + arrow.addTo(mapsOverview.map); + state.windArrows.push(arrow); + + // Draw arrow head as a small circle at the end + const arrowHead = L.circleMarker([endLat, endLon], { + radius: 3, + fillColor: '#4a90e2', + fillOpacity: 0.7, + color: '#fff', + weight: 1, + }); + + arrowHead.addTo(mapsOverview.map); + state.windArrows.push(arrowHead); +} + +const mapsOverview = createOverviewMap(document.getElementById('map-overview')); + +function setMapsFullscreen(active) { + const wrap = document.querySelector('#tab-maps .maps-wrap'); + if (!wrap) return; + wrap.classList.toggle('is-fullscreen', active); + document.body.classList.toggle('map-fullscreen-lock', active); + if (mapsFullscreenBtn) { + mapsFullscreenBtn.textContent = active ? 'Exit fullscreen' : 'Fullscreen map'; + } + setTimeout(() => { + if (mapsOverview?.map) mapsOverview.map.invalidateSize(); + }, 100); +} + +if (mapsFullscreenBtn) { + mapsFullscreenBtn.addEventListener('click', async () => { + const wrap = document.querySelector('#tab-maps .maps-wrap'); + if (!wrap) return; + + if (document.fullscreenElement === wrap) { + await document.exitFullscreen().catch(() => {}); + setMapsFullscreen(false); + return; + } + + if (wrap.requestFullscreen) { + try { + await wrap.requestFullscreen(); + setMapsFullscreen(true); + return; + } catch { + // Some mobile browsers reject Fullscreen API; fallback below. + } + } + + setMapsFullscreen(!wrap.classList.contains('is-fullscreen')); + }); +} + +document.addEventListener('fullscreenchange', () => { + const wrap = document.querySelector('#tab-maps .maps-wrap'); + if (!wrap) return; + setMapsFullscreen(document.fullscreenElement === wrap); +}); + +// ── Rendering ──────────────────────────────────────────────────────────────── +function renderDailyForecast(daily) { + const list = document.getElementById('daily-list'); + const totalDays = daily?.time?.length || 0; + const defaultDays = Math.min(7, totalDays); + const visibleDays = state.dailyExpanded ? totalDays : defaultDays; + + list.innerHTML = ''; + for (let i = 0; i < visibleDays; i++) { + const dw = wmo(daily.weather_code[i]); + const row = document.createElement('div'); + row.className = 'day-row' + (i === 0 ? ' today' : ''); + row.innerHTML = ` +
${formatDayShort(daily.time[i])}
+
${dw.icon}
+
${dw.desc}
+
${tempDisplay(daily.temperature_2m_max[i])}° / ${tempDisplay(daily.temperature_2m_min[i])}°
+
${daily.precipitation_probability_max[i] > 0 ? daily.precipitation_probability_max[i] + '% rain
' : ''}${speedDisplay(daily.wind_speed_10m_max[i])} ${speedUnit()}
+ `; + list.appendChild(row); + } + + if (dailyForecastLabel) { + dailyForecastLabel.textContent = `${visibleDays}-Day Forecast`; + } + + if (dailyForecastToggle) { + if (totalDays > defaultDays) { + show(dailyForecastToggle); + dailyForecastToggle.textContent = state.dailyExpanded + ? 'Show 7-day view' + : `Show full ${totalDays}-day forecast`; + } else { + hide(dailyForecastToggle); + } + } +} + +function summarizeHourly(hourly, startIdx) { + if (!hourly?.time?.length || !hourly?.precipitation_probability?.length) { + return 'Hourly details unavailable.'; + } + + let rainIdx = -1; + for (let i = startIdx; i < Math.min(startIdx + 24, hourly.time.length); i++) { + const pp = Number(hourly.precipitation_probability[i] || 0); + const rain = Number(hourly.rain?.[i] || 0); + if (pp >= 45 || rain > 0.1) { + rainIdx = i; + break; + } + } + + if (rainIdx === -1) { + return 'No notable rain expected in the next 24 hours.'; + } + return `Rain likely around ${formatHour(hourly.time[rainIdx])}.`; +} + +function renderWeather(data, city) { + const c = data.current; + const h = data.hourly; + const d = data.daily; + + applyWeatherBackground(c.weather_code, c.is_day); + + document.getElementById('loc-name').textContent = city.name; + document.getElementById('loc-sub').textContent = [city.admin1, city.country].filter(Boolean).join(', '); + + const now = new Date(); + document.getElementById('loc-time').innerHTML = + `${DAYS[now.getDay()]}, ${MONTHS[now.getMonth()]} ${now.getDate()}
` + + now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + + const w = wmo(c.weather_code); + document.getElementById('cur-icon').textContent = w.icon; + document.getElementById('cur-desc').textContent = w.desc; + document.getElementById('cur-temp').textContent = tempDisplay(c.temperature_2m); + document.querySelector('.deg').textContent = `°${tempUnit()}`; + document.querySelector('.feels').innerHTML = `${apparentLabel(c.temperature_2m, c.apparent_temperature)} ${tempDisplay(c.apparent_temperature)}°${tempUnit()}`; + document.getElementById('cur-hi').textContent = tempDisplay(d.temperature_2m_max[0]); + document.getElementById('cur-lo').textContent = tempDisplay(d.temperature_2m_min[0]); + document.getElementById('cur-precip').textContent = c.precipitation > 0 ? `${precipDisplay(c.precipitation)} ${state.units === 'us' ? 'in' : 'mm'} precip` : 'No precipitation'; + document.getElementById('cur-hum').textContent = c.relative_humidity_2m; + document.getElementById('cur-wind').textContent = `${speedDisplay(c.wind_speed_10m)} ${compassDir(c.wind_direction_10m)}`; + document.getElementById('cur-pres').textContent = Math.round(c.surface_pressure); + document.getElementById('cur-vis').textContent = visDisplay(c.visibility); + document.getElementById('cur-uv').textContent = Math.round(d.uv_index_max[0] ?? 0); + document.getElementById('cur-cloud').textContent = c.cloud_cover; + document.querySelector('.stat:nth-child(2) .unit').textContent = speedUnit(); + document.querySelector('.stat:nth-child(4) .unit').textContent = visUnit(); + + document.getElementById('sunrise-val').textContent = formatClock(d.sunrise[0]); + document.getElementById('sunset-val').textContent = formatClock(d.sunset[0]); + document.getElementById('moon-phase-val').textContent = moonPhase(d.time[0]); + + const nowTs = Date.now(); + let startIdx = h.time.findIndex(t => new Date(t).getTime() >= nowTs); + if (startIdx < 0) startIdx = 0; + + const track = document.getElementById('hourly-track'); + track.innerHTML = ''; + for (let i = startIdx; i < Math.min(startIdx + 24, h.time.length); i++) { + const hw = wmo(h.weather_code[i]); + const pp = h.precipitation_probability[i]; + const ptype = precipType(h.weather_code[i], h.temperature_2m[i], h.rain?.[i], h.snowfall?.[i]); + const cell = document.createElement('div'); + cell.className = 'hour-cell' + (i === startIdx ? ' now' : ''); + cell.innerHTML = ` +
${i === startIdx ? 'Now' : formatHour(h.time[i])}
+
${hw.icon}
+
${tempDisplay(h.temperature_2m[i])}°
+
Feels ${tempDisplay(h.apparent_temperature?.[i])}°
+
${pp > 0 ? `${pp}% ${ptype}` : ptype}
+ `; + track.appendChild(cell); + } + + if (hourlySummaryEl) { + hourlySummaryEl.textContent = summarizeHourly(h, startIdx); + } + + renderDailyForecast(d); + + document.getElementById('updated-at').textContent = + new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + + radarEmbed.panTo(city.latitude, city.longitude, 6); + radarFull.panTo(city.latitude, city.longitude, 6); + mapsOverview.panTo(city.latitude, city.longitude, 6); + if (mapsWindEl) mapsWindEl.textContent = `${speedDisplay(c.wind_speed_10m)} ${speedUnit()} ${compassDir(c.wind_direction_10m)}`; + updateSourceLocation(city); + updateShareUrl(city); + persistLastCity(city); + refreshMapsOverview(); + + show(weatherDiv); +} + +function renderAirQuality(data) { + const cur = data?.current || {}; + const pollen = ['alder_pollen', 'birch_pollen', 'grass_pollen', 'mugwort_pollen', 'olive_pollen', 'ragweed_pollen'] + .map(k => cur[k] || 0) + .reduce((max, v) => Math.max(max, v), 0); + + document.getElementById('aqi-us').textContent = cur.us_aqi ?? '--'; + document.getElementById('pm25').textContent = cur.pm2_5 != null ? `${Number(cur.pm2_5).toFixed(1)} ug/m3` : '--'; + document.getElementById('pollen').textContent = pollen > 0 ? pollen.toFixed(1) : '--'; +} + +function renderAlerts(data) { + const wrap = document.getElementById('alerts-wrap'); + const list = document.getElementById('alerts-list'); + const features = data?.features || []; + list.innerHTML = ''; + + if (!features.length) { + hide(wrap); + if (alertsEmptyEl) show(alertsEmptyEl); + return; + } + + if (alertsEmptyEl) hide(alertsEmptyEl); + + features.slice(0, 5).forEach(f => { + const p = f.properties || {}; + const item = document.createElement('div'); + item.className = 'alert-item'; + item.innerHTML = `${p.event || 'Alert'}${p.severity || 'Unknown severity'}${p.headline || p.description || ''}`; + list.appendChild(item); + }); + show(wrap); + + if (typeof Notification !== 'undefined' && Notification.permission === 'granted') { + features.forEach(f => { + const id = f.id || f.properties?.id; + if (!id || state.alertIdsSeen.has(id)) return; + state.alertIdsSeen.add(id); + const p = f.properties || {}; + new Notification(`Weather alert: ${p.event || 'Severe weather'}`, { + body: p.headline || p.description || 'An alert was issued for your selected location.', + }); + }); + } +} + +async function loadAll(city, source) { + state.currentCity = city; + if (source) state.currentSource = source; + + const saved = state.savedCities.find(e => cityKey(e.city) === cityKey(city)); + if (saved) { + saved.lastViewedAt = Date.now(); + persistSavedCities(); + renderSavedCities(); + } + + clearErr(); + hide(suggestEl); + hide(weatherDiv); + show(loadingEl); + + try { + const [weather, air, alerts] = await Promise.all([ + fetchWeather(city.latitude, city.longitude, city.timezone), + fetchAirQuality(city.latitude, city.longitude, city.timezone), + fetchAlerts(city.latitude, city.longitude).catch(() => ({ features: [] })), + ]); + + state.currentWeather = weather; + state.currentAir = air; + state.lastRefreshAt = Date.now(); + renderWeather(weather, city); + renderAirQuality(air); + renderAlerts(alerts); + + // Render map overlays + if (state.showLightning) renderLightningStrikes(city); + if (state.showWind) renderWindArrows(city); + } catch (e) { + showErr('Failed to load weather data. Please try again.'); + console.error(e); + } finally { + hide(loadingEl); + } +} + +function getPosition() { + return new Promise((resolve, reject) => { + if (!navigator.geolocation) { + reject(new Error('Geolocation not supported')); + return; + } + navigator.geolocation.getCurrentPosition(resolve, reject, { + enableHighAccuracy: false, + timeout: 8000, + maximumAge: 10 * 60 * 1000, + }); + }); +} + +async function autoLocateAndLoad() { + try { + const pos = await getPosition(); + const { latitude: lat, longitude: lon } = pos.coords; + const city = await buildDetectedCity(lat, lon); + searchInput.value = city.name; + await loadAll(city, 'auto-detect'); + return true; + } catch { + return false; + } +} + +// ── Search autocomplete ─────────────────────────────────────────────────────── +let debounceTimer = null; + +searchInput.addEventListener('input', () => { + clearTimeout(debounceTimer); + const q = searchInput.value.trim(); + if (q.length < 2) { hide(suggestEl); return; } + + debounceTimer = setTimeout(async () => { + try { + const data = await geocode(q); + const results = data.results || []; + suggestEl.innerHTML = ''; + if (!results.length) { hide(suggestEl); return; } + + results.forEach(r => { + const li = document.createElement('li'); + li.innerHTML = `${r.name}${r.admin1 ? r.admin1 + ', ' : ''}${r.country || ''}`; + li.addEventListener('click', () => { + searchInput.value = r.name; + hide(suggestEl); + loadAll(r, 'search-result'); + }); + suggestEl.appendChild(li); + }); + show(suggestEl); + } catch { + hide(suggestEl); + } + }, 300); +}); + +document.addEventListener('click', e => { + if (!e.target.closest('.search-wrap')) hide(suggestEl); +}); + +function doSearch() { + const q = searchInput.value.trim(); + if (!q) { showErr('Please enter a city name.'); return; } + clearErr(); + hide(suggestEl); + + geocode(q).then(data => { + const results = data.results || []; + if (!results.length) { showErr(`No results found for "${q}".`); return; } + loadAll(results[0], 'search-result'); + }).catch(() => showErr('Search failed. Please try again.')); +} + +searchBtn.addEventListener('click', doSearch); +searchInput.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); }); + +// ── Geolocation ─────────────────────────────────────────────────────────────── +locateBtn.addEventListener('click', () => { + if (!navigator.geolocation) { showErr('Geolocation not supported.'); return; } + clearErr(); + hide(weatherDiv); + show(loadingEl); + + navigator.geolocation.getCurrentPosition(async pos => { + const { latitude: lat, longitude: lon } = pos.coords; + try { + const city = await buildDetectedCity(lat, lon); + await loadAll(city, 'gps-button'); + } catch { + showErr('Could not fetch weather for your location.'); + } finally { + hide(loadingEl); + } + }, () => { + hide(loadingEl); + showErr('Location access denied.'); + }); +}); + +unitsBtn.addEventListener('click', () => { + state.units = state.units === 'us' ? 'metric' : 'us'; + localStorage.setItem(STORAGE_KEYS.units, state.units); + updateUnitsButton(); + if (state.currentWeather && state.currentCity) { + renderWeather(state.currentWeather, state.currentCity); + renderAirQuality(state.currentAir); + } +}); + +if (unitsBtnSettings) { + unitsBtnSettings.addEventListener('click', () => unitsBtn.click()); +} + +shareBtn.addEventListener('click', async () => { + if (!state.currentCity) return; + try { + await navigator.clipboard.writeText(window.location.href); + shareBtn.textContent = 'Copied link'; + setTimeout(() => { shareBtn.textContent = 'Share location'; }, 1500); + } catch { + showErr('Could not copy URL to clipboard.'); + } +}); + +saveCurrentBtn.addEventListener('click', () => { + if (!state.currentCity) return; + const key = cityKey(state.currentCity); + if (state.savedCities.some(c => cityKey(c.city) === key)) return; + state.savedCities.push({ + city: state.currentCity, + addedAt: Date.now(), + lastViewedAt: Date.now(), + isDefault: state.savedCities.length === 0, + }); + persistSavedCities(); + renderSavedCities(); +}); + +notifyBtn.addEventListener('click', async () => { + if (!('Notification' in window)) { + showErr('Notifications are not supported in this browser.'); + return; + } + if (!window.isSecureContext) { + showErr('Notifications require HTTPS (or localhost).'); + updateNotifyButton(); + return; + } + + try { + const permission = await Notification.requestPermission(); + if (permission === 'granted') { + const subscribed = await ensurePushSubscription(); + if (!subscribed) { + showErr('Push needs VAPID keys configured on server.'); + } + if (state.currentCity) { + const alerts = await fetchAlerts(state.currentCity.latitude, state.currentCity.longitude).catch(() => ({ features: [] })); + renderAlerts(alerts); + } + } else if (permission === 'denied') { + showErr('Notifications are blocked in browser settings for this site.'); + } + } catch { + showErr('Could not request notification permission.'); + } finally { + updateNotifyButton(); + } +}); + +autoDetectToggle.addEventListener('change', () => { + state.autoDetect = autoDetectToggle.checked; + localStorage.setItem(STORAGE_KEYS.autoDetect, String(state.autoDetect)); +}); + +lightningToggle.addEventListener('change', () => { + state.showLightning = lightningToggle.checked; + localStorage.setItem(STORAGE_KEYS.showLightning, String(state.showLightning)); + lightningStatus.textContent = state.showLightning ? 'On' : 'Off'; + if (state.currentCity && mapsOverview.map) { + clearLightningLayer(); + if (state.showLightning) renderLightningStrikes(state.currentCity); + } +}); + +windToggle.addEventListener('change', () => { + state.showWind = windToggle.checked; + localStorage.setItem(STORAGE_KEYS.showWind, String(state.showWind)); + windStatus.textContent = state.showWind ? 'On' : 'Off'; + if (state.currentCity && mapsOverview.map) { + clearWindArrows(); + if (state.showWind && state.currentWeather) renderWindArrows(state.currentCity); + } +}); + +if (dailyForecastToggle) { + dailyForecastToggle.addEventListener('click', () => { + if (!state.currentWeather?.daily) return; + state.dailyExpanded = !state.dailyExpanded; + renderDailyForecast(state.currentWeather.daily); + }); +} + +if (contrastToggle) { + contrastToggle.addEventListener('change', () => { + state.highContrast = contrastToggle.checked; + localStorage.setItem(STORAGE_KEYS.highContrast, String(state.highContrast)); + applyHighContrast(); + }); +} + +if (radarOpacityEl) { + radarOpacityEl.addEventListener('input', () => { + state.radarOpacity = Number(radarOpacityEl.value || 65); + localStorage.setItem(STORAGE_KEYS.radarOpacity, String(state.radarOpacity)); + mapsOverview.setRadarOpacity(state.radarOpacity); + }); +} + +if (lightningAgeEl) { + lightningAgeEl.addEventListener('change', () => { + state.lightningAgeMin = Number(lightningAgeEl.value || 15); + localStorage.setItem(STORAGE_KEYS.lightningAgeMin, String(state.lightningAgeMin)); + if (state.showLightning && state.currentCity) { + renderLightningStrikes(state.currentCity); + } + }); +} + +if (pushTestBtn) { + pushTestBtn.addEventListener('click', async () => { + try { + await sendTestPush(); + pushTestBtn.textContent = 'Push sent'; + setTimeout(() => { pushTestBtn.textContent = 'Send test push'; }, 1500); + } catch (e) { + showErr(e.message || 'Push test failed.'); + } + }); +} + +function startAutoRefresh() { + clearInterval(state.autoRefreshTimer); + state.autoRefreshTimer = setInterval(() => { + if (state.currentCity) loadAll(state.currentCity, state.currentSource || 'auto-refresh'); + }, 10 * 60 * 1000); + + // Refresh shortly after users return to the tab if data is stale. + document.addEventListener('visibilitychange', () => { + if (document.visibilityState !== 'visible') return; + if (!state.currentCity) return; + if (Date.now() - state.lastRefreshAt > 10 * 60 * 1000) { + loadAll(state.currentCity, state.currentSource || 'tab-resume-refresh'); + } + }); +} + +// ── Default city / query link load ─────────────────────────────────────────── +(async () => { + updateUnitsButton(); + updateNotifyButton(); + syncAutoDetectToggle(); + + // Sync lightning/wind and UI polish toggles. + lightningToggle.checked = state.showLightning; + lightningStatus.textContent = state.showLightning ? 'On' : 'Off'; + windToggle.checked = state.showWind; + windStatus.textContent = state.showWind ? 'On' : 'Off'; + if (contrastToggle) contrastToggle.checked = state.highContrast; + if (lightningAgeEl) lightningAgeEl.value = String(state.lightningAgeMin || 15); + if (radarOpacityEl) radarOpacityEl.value = String(state.radarOpacity || 65); + applyHighContrast(); + mapsOverview.setRadarOpacity(state.radarOpacity); + + renderSavedCities(); + startAutoRefresh(); + + // Restore push state if already subscribed. + if ('serviceWorker' in navigator && 'PushManager' in window) { + try { + const reg = await navigator.serviceWorker.ready; + const existingSub = await reg.pushManager.getSubscription(); + state.pushEnabled = Boolean(existingSub); + } catch { + state.pushEnabled = false; + } + updateNotifyButton(); + } + + const qs = new URLSearchParams(window.location.search); + const qLat = Number(qs.get('lat')); + const qLon = Number(qs.get('lon')); + const qName = qs.get('name'); + if (!Number.isNaN(qLat) && !Number.isNaN(qLon) && qs.get('lat') && qs.get('lon')) { + const city = { + name: qName || `${qLat.toFixed(2)}, ${qLon.toFixed(2)}`, + latitude: qLat, + longitude: qLon, + timezone: 'auto', + country: '', + admin1: '' + }; + searchInput.value = city.name; + await loadAll(city, 'shared-link'); + return; + } + + if (state.autoDetect) { + const autoLocated = await autoLocateAndLoad(); + if (autoLocated) return; + } + + const defaultSaved = getDefaultSavedEntry(); + if (defaultSaved?.city) { + searchInput.value = defaultSaved.city.name; + await loadAll(defaultSaved.city, 'default-saved-city'); + return; + } + + const lastCity = getLastCity(); + if (lastCity) { + searchInput.value = lastCity.name; + await loadAll(lastCity, 'last-city'); + return; + } + + try { + const data = await geocode('New York'); + const city = (data.results || [])[0]; + if (city) { + searchInput.value = city.name; + await loadAll(city, 'default-city'); + } + } catch { + // silent startup fallback + } +})(); diff --git a/.tmp/imgdiff/old/index.html b/.tmp/imgdiff/old/index.html new file mode 100644 index 0000000..9d9070d --- /dev/null +++ b/.tmp/imgdiff/old/index.html @@ -0,0 +1,322 @@ + + + + + +NeCloud Weather + + + + + + + + + + + + + + + +
+ +
+

NeCloud Weather

+ Open-Meteo · RainViewer +
+ + +
+
+ + + + +
+ + +
+ + + + + +
+ +
+ + + + + + + + + + + + + + + +
+ + + + + + diff --git a/.tmp/imgdiff/old/server.js b/.tmp/imgdiff/old/server.js new file mode 100644 index 0000000..177207e --- /dev/null +++ b/.tmp/imgdiff/old/server.js @@ -0,0 +1,474 @@ +const express = require('express'); +const path = require('path'); +const fs = require('fs'); +const webpush = require('web-push'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +app.use(express.json({ limit: '256kb' })); + +// Lightweight request logging for observability. +app.use((req, res, next) => { + const started = Date.now(); + res.on('finish', () => { + const ms = Date.now() - started; + console.log(`${new Date().toISOString()} ${req.method} ${req.originalUrl} ${res.statusCode} ${ms}ms`); + }); + next(); +}); + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function fetchJsonWithRetry(url, options = {}, attempts = 3, initialDelayMs = 300) { + let lastErr = null; + for (let i = 0; i < attempts; i++) { + try { + const r = await fetch(url, options); + if (!r.ok) { + const text = await r.text().catch(() => ''); + throw new Error(`Upstream status ${r.status}${text ? `: ${text.slice(0, 200)}` : ''}`); + } + return await r.json(); + } catch (e) { + lastErr = e; + if (i < attempts - 1) { + await sleep(initialDelayMs * Math.pow(2, i)); + } + } + } + throw lastErr || new Error('Unknown upstream fetch error'); +} + +// --------------- Persistent data storage --------------- +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); +const SUBS_FILE = path.join(DATA_DIR, 'subscriptions.json'); +const SENT_IDS_FILE = path.join(DATA_DIR, 'sent-alert-ids.json'); + +try { fs.mkdirSync(DATA_DIR, { recursive: true }); } catch (_) {} + +function loadSubscriptions() { + try { + const arr = JSON.parse(fs.readFileSync(SUBS_FILE, 'utf8')); + const map = new Map(); + for (const entry of arr) { + if (entry?.subscription?.endpoint) map.set(entry.subscription.endpoint, entry); + } + return map; + } catch (_) { return new Map(); } +} + +function saveSubscriptions() { + try { + fs.writeFileSync(SUBS_FILE, JSON.stringify(Array.from(subscriptions.values()), null, 2)); + } catch (e) { console.error('Failed to save subscriptions:', e.message); } +} + +const subscriptions = loadSubscriptions(); +if (subscriptions.size) console.log(`Loaded ${subscriptions.size} push subscription(s) from disk.`); + +function loadSentAlertIds() { + try { + return new Map(JSON.parse(fs.readFileSync(SENT_IDS_FILE, 'utf8'))); + } catch (_) { return new Map(); } +} + +function saveSentAlertIds() { + try { + fs.writeFileSync(SENT_IDS_FILE, JSON.stringify(Array.from(sentAlertIds.entries()), null, 2)); + } catch (e) { console.error('Failed to save sent alert IDs:', e.message); } +} + +const sentAlertIds = loadSentAlertIds(); +// -------------------------------------------------------- + +function getVapidConfig() { + const publicKey = process.env.VAPID_PUBLIC_KEY || ''; + const privateKey = process.env.VAPID_PRIVATE_KEY || ''; + const subject = process.env.VAPID_SUBJECT || 'mailto:nate@necloud.us'; + return { publicKey, privateKey, subject }; +} + +function pushConfigured() { + const cfg = getVapidConfig(); + return Boolean(cfg.publicKey && cfg.privateKey); +} + +function ensurePushConfigured() { + const cfg = getVapidConfig(); + if (!cfg.publicKey || !cfg.privateKey) { + return false; + } + webpush.setVapidDetails(cfg.subject, cfg.publicKey, cfg.privateKey); + return true; +} + +// HTML: never cache (always revalidate so SW/app updates are picked up immediately) +app.use('/', (req, res, next) => { + if (req.path === '/' || req.path.endsWith('.html')) { + res.setHeader('Cache-Control', 'no-store'); + } else if (req.path.endsWith('.css') || req.path.endsWith('.js') || req.path === '/sw.js' || req.path === '/manifest.json') { + // Short-lived cache — browser revalidates every 60 s but still gets a 304 fast + res.setHeader('Cache-Control', 'no-cache'); + } + next(); +}); +app.use(express.static(path.join(__dirname, 'public'))); + +app.get('/healthz', (_req, res) => { + res.json({ + ok: true, + uptimeSec: Math.round(process.uptime()), + pushConfigured: pushConfigured(), + subscriptionCount: subscriptions.size, + now: new Date().toISOString(), + }); +}); + +// Geocoding proxy +app.get('/api/geocode', async (req, res) => { + const { q } = req.query; + if (!q) return res.status(400).json({ error: 'Missing query' }); + try { + const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(q)}&count=5&language=en&format=json`; + const data = await fetchJsonWithRetry(url); + res.json(data); + } catch (e) { + res.status(500).json({ error: 'Geocoding failed', detail: e.message }); + } +}); + +// Reverse geocoding proxy +app.get('/api/reverse-geocode', async (req, res) => { + const { lat, lon } = req.query; + if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' }); + + try { + // Prefer Open-Meteo reverse geocoding for stable city/region labels. + const omParams = new URLSearchParams({ + latitude: String(lat), + longitude: String(lon), + count: '1', + language: 'en', + format: 'json', + }); + + const omData = await fetchJsonWithRetry(`https://geocoding-api.open-meteo.com/v1/reverse?${omParams}`, {}, 2, 200).catch(() => null); + if (omData) { + const top = omData?.results?.[0]; + if (top) { + return res.json({ + name: top.name || top.admin2 || top.admin1 || top.country || 'Detected location', + admin1: top.admin1 || top.admin2 || '', + country: top.country || '', + latitude: Number(lat), + longitude: Number(lon), + timezone: top.timezone || 'auto', + display_name: [top.name, top.admin1, top.country].filter(Boolean).join(', '), + }); + } + } + + const params = new URLSearchParams({ + format: 'jsonv2', + lat: String(lat), + lon: String(lon), + zoom: '10', + addressdetails: '1', + }); + + const data = await fetchJsonWithRetry(`https://nominatim.openstreetmap.org/reverse?${params}`, { + headers: { + 'User-Agent': 'weather-app-v2 (nate@necloud.us)', + Accept: 'application/json', + }, + }); + + const address = data.address || {}; + const name = address.city || address.town || address.village || address.hamlet || address.municipality || address.county || address.state || data.name || 'Detected location'; + + res.json({ + name, + admin1: address.state || address.region || address.county || '', + country: address.country || '', + latitude: Number(lat), + longitude: Number(lon), + timezone: 'auto', + display_name: data.display_name || '', + }); + } catch (e) { + res.status(500).json({ error: 'Reverse geocoding failed', detail: e.message }); + } +}); + +// Weather proxy +app.get('/api/weather', async (req, res) => { + const { lat, lon, tz, units } = req.query; + if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' }); + const timezone = tz || 'auto'; + const useMetric = units === 'metric'; + try { + const params = new URLSearchParams({ + latitude: lat, longitude: lon, timezone, + current: [ + 'temperature_2m','relative_humidity_2m','apparent_temperature', + 'is_day','precipitation','weather_code','cloud_cover', + 'wind_speed_10m','wind_direction_10m','surface_pressure','visibility' + ].join(','), + hourly: [ + 'temperature_2m','precipitation_probability','weather_code', + 'wind_speed_10m','apparent_temperature','rain','snowfall' + ].join(','), + daily: [ + 'weather_code','temperature_2m_max','temperature_2m_min', + 'precipitation_sum','precipitation_probability_max', + 'wind_speed_10m_max','sunrise','sunset','uv_index_max' + ].join(','), + forecast_days: 16, + wind_speed_unit: useMetric ? 'kmh' : 'mph', + temperature_unit: useMetric ? 'celsius' : 'fahrenheit', + precipitation_unit: useMetric ? 'mm' : 'inch' + }); + const data = await fetchJsonWithRetry(`https://api.open-meteo.com/v1/forecast?${params}`); + res.json(data); + } catch (e) { + res.status(500).json({ error: 'Weather fetch failed', detail: e.message }); + } +}); + +// Open-Meteo Air Quality proxy +app.get('/api/air-quality', async (req, res) => { + const { lat, lon, tz } = req.query; + if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' }); + try { + const params = new URLSearchParams({ + latitude: lat, + longitude: lon, + timezone: tz || 'auto', + current: ['us_aqi', 'pm2_5', 'alder_pollen', 'birch_pollen', 'grass_pollen', 'mugwort_pollen', 'olive_pollen', 'ragweed_pollen'].join(','), + forecast_days: 1, + }); + const data = await fetchJsonWithRetry(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`); + res.json(data); + } catch (e) { + res.status(500).json({ error: 'Air quality fetch failed', detail: e.message }); + } +}); + +// NWS alerts proxy (US only) +app.get('/api/alerts', async (req, res) => { + const { lat, lon } = req.query; + if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' }); + try { + const data = await fetchJsonWithRetry(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, { + headers: { + 'User-Agent': 'weather-app-v2 (nate@necloud.us)', + Accept: 'application/geo+json', + }, + }); + res.json(data); + } catch (e) { + res.status(500).json({ error: 'Alerts fetch failed', detail: e.message }); + } +}); + +// Lightning activity from active weather.gov thunderstorm alerts (real alert geometries). +app.get('/api/lightning', async (req, res) => { + const { lat, lon, age } = req.query; + if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' }); + const ageMinutes = Number(age || 15); + + try { + const data = await fetchJsonWithRetry(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, { + headers: { + 'User-Agent': 'weather-app-v2 (nate@necloud.us)', + Accept: 'application/geo+json', + }, + }); + + const cutoff = Date.now() - (Number.isFinite(ageMinutes) ? ageMinutes : 15) * 60 * 1000; + const features = (data?.features || []).filter(f => { + const p = f?.properties || {}; + const event = String(p.event || '').toLowerCase(); + const relevant = event.includes('thunderstorm') || event.includes('lightning'); + if (!relevant) return false; + const ts = Date.parse(p.sent || p.onset || p.effective || ''); + if (Number.isNaN(ts)) return true; + return ts >= cutoff; + }); + + res.json({ + source: 'weather.gov alerts', + count: features.length, + features, + ageMinutes: Number.isFinite(ageMinutes) ? ageMinutes : 15, + }); + } catch (e) { + res.status(500).json({ error: 'Lightning fetch failed', detail: e.message }); + } +}); + +app.get('/api/push/public-key', (_req, res) => { + const { publicKey } = getVapidConfig(); + res.json({ + enabled: pushConfigured(), + publicKey: publicKey || null, + }); +}); + +app.post('/api/push/subscribe', (req, res) => { + const { subscription, location } = req.body || {}; + if (!subscription?.endpoint) { + return res.status(400).json({ error: 'Missing subscription endpoint' }); + } + subscriptions.set(subscription.endpoint, { + subscription, + location: location || null, + createdAt: Date.now(), + }); + saveSubscriptions(); + res.json({ ok: true, count: subscriptions.size }); +}); + +app.post('/api/push/unsubscribe', (req, res) => { + const { endpoint } = req.body || {}; + if (!endpoint) return res.status(400).json({ error: 'Missing endpoint' }); + subscriptions.delete(endpoint); + saveSubscriptions(); + res.json({ ok: true, count: subscriptions.size }); +}); + +app.post('/api/push/test', async (req, res) => { + if (!ensurePushConfigured()) { + return res.status(400).json({ + error: 'Push is not configured. Set VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY.', + }); + } + + const payload = JSON.stringify({ + title: req.body?.title || 'Weather test notification', + body: req.body?.body || 'Push notifications are working.', + url: req.body?.url || '/', + ts: Date.now(), + }); + + let sent = 0; + let removed = 0; + const errors = []; + + const entries = Array.from(subscriptions.values()); + for (const entry of entries) { + try { + await webpush.sendNotification(entry.subscription, payload); + sent++; + } catch (e) { + const statusCode = e?.statusCode; + if (statusCode === 404 || statusCode === 410) { + subscriptions.delete(entry.subscription.endpoint); + removed++; + saveSubscriptions(); + } else { + errors.push(String(e.message || e)); + } + } + } + + res.json({ ok: true, sent, removed, errors, count: subscriptions.size }); +}); + +// --------------- Background NWS alert poller --------------- +// Runs every ALERT_POLL_INTERVAL_MS (default 5 min). +// Sends push notifications to all subscribers for any new active NWS alerts +// at their saved location. Deduplicates by alert ID (persisted to disk). + +function cleanSentAlertIds() { + const cutoff = Date.now() - 48 * 60 * 60 * 1000; + for (const [id, ts] of sentAlertIds) { + if (ts < cutoff) sentAlertIds.delete(id); + } +} + +async function alertPoller() { + if (!pushConfigured() || !ensurePushConfigured()) return; + if (subscriptions.size === 0) return; + + cleanSentAlertIds(); + + // Group subscribers by rounded lat/lon so we don't spam the NWS API. + const byLocation = new Map(); + for (const entry of subscriptions.values()) { + const loc = entry.location; + if (!loc?.latitude || !loc?.longitude) continue; + const key = `${Number(loc.latitude).toFixed(2)},${Number(loc.longitude).toFixed(2)}`; + if (!byLocation.has(key)) byLocation.set(key, { loc, entries: [] }); + byLocation.get(key).entries.push(entry); + } + + let savedIds = false; + for (const { loc, entries } of byLocation.values()) { + try { + const data = await fetchJsonWithRetry( + `https://api.weather.gov/alerts/active?point=${loc.latitude},${loc.longitude}`, + { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/geo+json' } } + ).catch(() => null); + if (!data?.features?.length) continue; + + for (const feature of data.features) { + const id = feature?.properties?.id; + if (!id || sentAlertIds.has(id)) continue; + + const props = feature.properties || {}; + const title = props.event || 'Weather Alert'; + const area = props.areaDesc ? props.areaDesc.split(';')[0].trim() : (loc.name || 'Your area'); + const headline = props.headline || props.description?.slice(0, 120) || title; + const payload = JSON.stringify({ + title, + body: `${area}: ${headline}`, + url: '/', + ts: Date.now(), + }); + + let anySuccess = false; + for (const entry of entries) { + try { + await webpush.sendNotification(entry.subscription, payload); + anySuccess = true; + } catch (e) { + if (e?.statusCode === 404 || e?.statusCode === 410) { + subscriptions.delete(entry.subscription.endpoint); + saveSubscriptions(); + } + } + } + + if (anySuccess) { + console.log(`Alert push sent: [${id}] ${title} → ${area}`); + sentAlertIds.set(id, Date.now()); + savedIds = true; + } + } + } catch (e) { + console.error('Alert poller error:', e.message); + } + } + + if (savedIds) saveSentAlertIds(); +} + +const POLL_INTERVAL_MS = Number(process.env.ALERT_POLL_INTERVAL_MS) || 5 * 60 * 1000; +setInterval(alertPoller, POLL_INTERVAL_MS); +// ----------------------------------------------------------- + +// RainViewer timestamps proxy +app.get('/api/radar/times', async (req, res) => { + try { + const data = await fetchJsonWithRetry('https://api.rainviewer.com/public/weather-maps.json'); + res.json(data); + } catch (e) { + res.status(500).json({ error: 'Radar times failed', detail: e.message }); + } +}); + +app.listen(PORT, () => console.log(`Weather app running on port ${PORT}`)); diff --git a/.tmp/imgdiff/old/style.css b/.tmp/imgdiff/old/style.css new file mode 100644 index 0000000..71c96f7 --- /dev/null +++ b/.tmp/imgdiff/old/style.css @@ -0,0 +1,1031 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --ink: #102539; + --ink-soft: #476a82; + --ink-muted: #6f90a4; + --paper: #e7f2f8; + --paper-deep: #d6e7f1; + --surface: rgba(255, 255, 255, 0.9); + --surface-soft: rgba(255, 255, 255, 0.74); + --line: rgba(96, 138, 166, 0.35); + --brand: #f0722f; + --brand-strong: #d75f1f; + --info: #1d87bf; + --ok: #1f9c79; + --danger: #c84b2f; + --radius-xl: 22px; + --radius-lg: 16px; + --radius-md: 12px; + --shadow-lg: 0 14px 36px rgba(11, 35, 52, 0.16); + --shadow-md: 0 8px 22px rgba(11, 35, 52, 0.12); + --ui: 'Manrope', 'Avenir Next', 'Segoe UI', sans-serif; + --display: 'Playfair Display', Georgia, serif; +} + +@media (prefers-color-scheme: dark) { + :root { + --ink: #ecf6fd; + --ink-soft: #bed1dd; + --ink-muted: #92aebf; + --paper: #0b1721; + --paper-deep: #122433; + --surface: rgba(26, 46, 61, 0.88); + --surface-soft: rgba(28, 49, 65, 0.7); + --line: rgba(118, 152, 176, 0.34); + --brand: #ff8d4d; + --brand-strong: #ef7330; + --info: #63c2ef; + --ok: #38c89f; + --danger: #f77257; + --shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.45); + --shadow-md: 0 9px 22px rgba(0, 0, 0, 0.35); + } +} + +html, body { + min-height: 100%; + font-family: var(--ui); + font-size: 18px; + line-height: 1.45; + color: var(--ink); + background: var(--paper); + overflow-x: hidden; +} + +body { + background: + radial-gradient(circle at 8% -6%, rgba(255, 255, 255, 0.86), transparent 46%), + radial-gradient(circle at 100% 0%, rgba(111, 184, 222, 0.25), transparent 35%), + linear-gradient(180deg, var(--paper) 0%, var(--paper-deep) 100%); + transition: background 0.6s ease; +} + +body.weather-clear { + background: + radial-gradient(circle at 15% 0%, rgba(255, 255, 255, 0.95), transparent 44%), + radial-gradient(circle at 95% 5%, rgba(255, 214, 131, 0.4), transparent 30%), + linear-gradient(180deg, #dff2ff 0%, #cbe6f7 45%, #b6d9ee 100%); +} + +body.weather-cloud { + background: + radial-gradient(circle at 10% -5%, rgba(255, 255, 255, 0.75), transparent 46%), + radial-gradient(circle at 85% 0%, rgba(175, 203, 220, 0.35), transparent 35%), + linear-gradient(180deg, #d7e8f1 0%, #c6dbe7 55%, #b5cfde 100%); +} + +body.weather-rain { + background: + radial-gradient(circle at 20% -8%, rgba(196, 220, 235, 0.45), transparent 45%), + radial-gradient(circle at 100% 0%, rgba(109, 162, 191, 0.32), transparent 35%), + linear-gradient(180deg, #a8c1d2 0%, #8eaabd 55%, #748fa5 100%); +} + +body.weather-snow { + background: + radial-gradient(circle at 12% 0%, rgba(255, 255, 255, 0.9), transparent 45%), + radial-gradient(circle at 92% 0%, rgba(225, 241, 252, 0.8), transparent 36%), + linear-gradient(180deg, #ebf4fa 0%, #dfeef8 55%, #d2e4f1 100%); +} + +body.weather-storm { + background: + radial-gradient(circle at 30% -10%, rgba(140, 160, 181, 0.36), transparent 44%), + radial-gradient(circle at 100% 5%, rgba(80, 111, 142, 0.45), transparent 34%), + linear-gradient(180deg, #6f8598 0%, #576d83 55%, #42566c 100%); +} + +body.weather-fog { + background: + radial-gradient(circle at 15% 0%, rgba(241, 246, 249, 0.9), transparent 44%), + radial-gradient(circle at 92% 0%, rgba(214, 224, 231, 0.6), transparent 36%), + linear-gradient(180deg, #d9e3e9 0%, #cdd9e0 55%, #c0ced7 100%); +} + +body.weather-night { + background: + radial-gradient(circle at 15% -10%, rgba(123, 145, 173, 0.2), transparent 44%), + radial-gradient(circle at 100% 0%, rgba(74, 104, 131, 0.28), transparent 34%), + linear-gradient(180deg, #0b1722 0%, #102131 55%, #13283b 100%); +} + +a { color: var(--info); text-decoration: none; } +a:hover { text-decoration: underline; } +.hidden { display: none !important; } + +.app { + width: 100%; + max-width: 100%; + padding: calc(0.7rem + env(safe-area-inset-top, 0px)) 0.75rem calc(6.9rem + env(safe-area-inset-bottom, 0px)); +} + +/* Header */ +.masthead { + position: sticky; + top: env(safe-area-inset-top, 0px); + z-index: 220; + padding: 0.55rem 0.55rem 0.45rem; + border: 1px solid var(--line); + border-radius: var(--radius-xl); + background: linear-gradient(180deg, var(--surface) 0%, var(--surface-soft) 100%); + backdrop-filter: blur(9px); + box-shadow: var(--shadow-md); + margin-bottom: 0.6rem; +} + +.masthead h1 { + font-family: var(--display); + font-size: 1.7rem; + letter-spacing: -0.3px; + margin-bottom: 0.35rem; + color: #111; + text-shadow: + 0 1px 2px rgba(255,255,255,0.7), + 0 0 8px rgba(255,255,255,0.5); +} + +.tagline { + display: block; + margin-top: 0.35rem; + font-size: 0.76rem; + letter-spacing: 0.04em; + color: rgba(0,0,0,0.65); + text-shadow: 0 1px 2px rgba(255,255,255,0.6); +} + +/* Bottom nav */ +.tabs { + position: fixed; + left: 0; + right: 0; + bottom: 0; + width: 100vw; + max-width: 100vw; + z-index: 300; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 0.22rem; + padding: 0.45rem 0.28rem calc(0.45rem + env(safe-area-inset-bottom, 0px)); + background: rgba(9, 28, 40, 0.92); + backdrop-filter: blur(12px); + border-top: 1px solid rgba(121, 165, 191, 0.36); +} + +@supports (bottom: env(safe-area-inset-bottom)) { + .tabs { + bottom: 0; + padding-bottom: calc(0.45rem + env(safe-area-inset-bottom)); + } +} + +.tab { + border: none; + border-radius: 12px; + min-width: 0; + min-height: 58px; + padding: 0.32rem 0.15rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.24rem; + background: rgba(255, 255, 255, 0.04); + color: #d8eaf4; + cursor: pointer; + font-family: var(--ui); +} + +.tab-icon { + font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif; + font-size: 1.18rem; + line-height: 1; +} + +.tab-text { + font-size: 0.67rem; + line-height: 1; + letter-spacing: 0.02em; + font-weight: 600; +} + +.tab.active { + color: #fff; + background: linear-gradient(180deg, var(--brand) 0%, var(--brand-strong) 100%); +} + +/* Search */ +.search-wrap { + margin-bottom: 0.4rem; +} + +.search-row { + display: flex; + align-items: center; + flex-wrap: nowrap; + gap: 0; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--surface); + box-shadow: var(--shadow-md); +} + +button, input, select { font-family: var(--ui); } + +.search-row input, +.search-row button, +.maps-control select, +.settings-btn { + font-size: 16px; /* prevent iOS focus zoom */ +} + +.search-row input { + flex: 1; + min-width: 0; + min-height: 46px; + border: none; + background: transparent; + color: var(--ink); + padding: 0 0.75rem; +} + +.search-row button { + flex: 0 0 auto; + min-height: 46px; + border: none; + border-left: 1px solid var(--line); + cursor: pointer; + padding: 0 0.65rem; +} + +#search-btn { + background: linear-gradient(180deg, #15445f 0%, #11344c 100%); + color: #f2f8fc; + min-width: 52px; + width: 52px; + font-size: 0; + letter-spacing: 0; + text-transform: none; + padding: 0; +} + +#search-btn::before { + content: '🔎'; + font-size: 1rem; + line-height: 1; +} + +#locate-btn, +#units-btn { + background: var(--surface) !important; + color: var(--ink) !important; + min-width: 48px; +} + +#locate-btn { + font-size: 1.05rem !important; +} + +@media (max-width: 390px) { + #search-btn, + #locate-btn, + #units-btn { + min-width: 46px; + width: 46px; + padding: 0 0.5rem; + } +} + +.suggestions { + list-style: none; + margin-top: 0.3rem; + border-radius: var(--radius-lg); + overflow: hidden; + border: 1px solid var(--line); + background: var(--surface); + box-shadow: var(--shadow-lg); +} + +.suggestions li { + padding: 0.72rem 0.8rem; + border-bottom: 1px solid var(--line); + font-size: 0.88rem; + cursor: pointer; +} + +.suggestions li:last-child { border-bottom: none; } +.suggestions li:hover { background: rgba(29, 135, 191, 0.09); } +.sug-country { color: var(--ink-muted); font-size: 0.8rem; margin-left: 0.35rem; } + +.error { + margin-top: 0.35rem; + color: var(--danger); + font-size: 0.84rem; +} + +/* Generic cards */ +.tab-panel { + width: 100%; + min-height: calc(100dvh - 11.6rem); +} + +.loc-header, +.radar-embed-wrap, +.radar-full-wrap, +.maps-wrap, +.alerts-wrap, +.panel-note, +.settings-wrap, +.daily-list, +.hourly-scroll, +.stats-bar, +.astro-grid, +.aq-grid { + border: 1px solid var(--line); + border-radius: var(--radius-xl); + background: var(--surface-soft); + box-shadow: var(--shadow-md); +} + +.loading { + display: flex; + align-items: center; + gap: 0.65rem; + color: var(--ink-soft); + font-size: 0.88rem; + padding: 1rem 0.35rem; +} + +.loading-dot { + width: 11px; + height: 11px; + border-radius: 50%; + background: var(--brand); + animation: pulse 1.1s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 0.4; transform: scale(0.9); } + 50% { opacity: 1; transform: scale(1.08); } +} + +.loc-header { + padding: 0.9rem; + margin-bottom: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.loc-name { + font-family: var(--display); + font-size: 1.5rem; + line-height: 1.1; +} + +.loc-sub, +.loc-time { + font-size: 0.8rem; + color: var(--ink-muted); +} + +.loc-actions { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.32rem; +} + +.link-btn { + border: none; + background: none; + color: var(--info); + cursor: pointer; + font-size: 0.84rem; +} + +.section-head { + display: flex; + flex-direction: column; + gap: 0.22rem; + margin: 0.35rem 0.45rem 0.5rem; +} + +.section-label { + font-size: 0.74rem; + color: var(--ink-soft); + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 700; +} + +.section-note { + color: var(--ink-muted); + font-size: 0.79rem; +} + +.section-actions { + display: inline-flex; + flex-wrap: wrap; + gap: 0.62rem; +} + +/* Current */ +.current-grid { + display: grid; + gap: 0.6rem; + margin-bottom: 0.75rem; +} + +.current-grid > * { + border-radius: var(--radius-lg); + border: 1px solid var(--line); + background: var(--surface); + padding: 1rem 0.85rem; + box-shadow: var(--shadow-md); +} + +.temp-big { + font-family: var(--display); + font-size: 3.75rem; + line-height: 0.92; + letter-spacing: -1.5px; +} + +.deg { + font-size: 1.35rem; + color: var(--ink-soft); + vertical-align: super; +} + +.feels, +.hi-lo, +.cond-sub { + font-size: 0.84rem; + color: var(--ink-soft); +} + +.current-cond { display: flex; flex-direction: column; justify-content: center; } +.cond-icon { font-size: 2.2rem; margin-bottom: 0.15rem; } +.cond-desc { font-size: 1.08rem; font-weight: 700; } + +/* Stat blocks */ +.stats-bar, +.astro-grid, +.aq-grid { + display: grid; + gap: 0.52rem; + border: none; + background: transparent; + box-shadow: none; + margin-bottom: 0.78rem; +} + +.stats-bar { grid-template-columns: repeat(2, minmax(0, 1fr)); } + +.stat, +.astro-card, +.aq-card, +.maps-card, +.settings-row, +.alert-item { + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--surface); + box-shadow: var(--shadow-md); +} + +.stat { padding: 0.78rem 0.58rem; text-align: center; } + +.stat-label { + font-size: 0.64rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ink-muted); + margin-bottom: 0.2rem; + font-weight: 700; +} + +.stat-val { font-size: 1.02rem; font-weight: 700; } +.unit { font-size: 0.72rem; color: var(--ink-soft); } + +.astro-card, +.aq-card, +.maps-card { padding: 0.74rem 0.7rem; } +.astro-val, +.aq-val, +.maps-value { font-size: 0.98rem; font-weight: 700; } + +/* Radar & maps */ +.radar-embed-wrap, +.radar-full-wrap, +.maps-wrap, +.alerts-wrap, +.settings-wrap { + padding: 0.7rem; + margin-bottom: 0.85rem; +} + +.radar-map, +.map-overview { + width: 100%; + height: 235px; + border-radius: var(--radius-md); + border: 1px solid var(--line); + overflow: hidden; +} + +.radar-map-full { + height: calc(100dvh - 20rem); + min-height: 310px; +} + +.radar-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.45rem; + margin-top: 0.62rem; +} + +.radar-btn { + width: 46px; + height: 46px; + border: none; + border-radius: 12px; + background: #0f3248; + color: #f5fbfe; + cursor: pointer; + font-size: 0.86rem; +} + +.layer-btn { background: var(--brand); } + +.radar-timeline { + order: 3; + flex-basis: 100%; + height: 32px; + display: flex; + gap: 2px; +} + +.tl-tick { + flex: 1; + border-radius: 7px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.52); + cursor: pointer; +} + +.tl-tick.active { background: var(--info); border-color: var(--info); } +.tl-tick.forecast { opacity: 0.6; } + +.radar-ts { font-size: 0.75rem; color: var(--ink-soft); } +.radar-legend { display: inline-flex; align-items: center; gap: 0.35rem; } +.legend-label { font-size: 0.64rem; color: var(--ink-muted); } +.legend-bar { + width: 84px; + height: 10px; + border-radius: 999px; + border: 1px solid var(--line); + background: linear-gradient(to right, #00d8ff, #00a0e0, #0070d0, #00c800, #ffff00, #ff6000, #ff0000); +} + +.radar-attribution, +.footer-note { + font-size: 0.74rem; + color: var(--ink-muted); + margin-bottom: 0.8rem; +} + +/* Hourly */ +.hourly-summary { + margin: 0.22rem 0.38rem 0.45rem; + font-size: 0.8rem; + color: var(--ink-soft); +} + +.hourly-scroll { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + margin-bottom: 0.85rem; + padding: 0.42rem; +} + +.hourly-track { display: flex; min-width: max-content; } + +.hour-cell { + min-width: 90px; + padding: 0.72rem 0.45rem; + text-align: center; + border-radius: 12px; + border: 1px solid var(--line); + background: var(--surface); + margin-right: 0.3rem; +} + +.hour-cell.now { + background: linear-gradient(180deg, rgba(29, 135, 191, 0.18) 0%, var(--surface) 100%); +} + +.hour-time { font-size: 0.7rem; color: var(--ink-muted); margin-bottom: 0.28rem; } +.hour-icon { font-size: 1.35rem; margin-bottom: 0.18rem; } +.hour-temp { font-size: 1rem; font-weight: 700; } +.hour-feels, +.hour-precip { font-size: 0.68rem; color: var(--ink-soft); } + +/* Daily */ +.daily-list { + padding: 0.4rem; + margin-bottom: 0.9rem; +} + +.day-row { + display: grid; + grid-template-columns: 1fr auto; + grid-template-areas: + 'name temps' + 'desc desc'; + gap: 0.28rem 0.55rem; + border-radius: 12px; + border: 1px solid var(--line); + background: var(--surface); + padding: 0.72rem; + margin-bottom: 0.34rem; +} + +.day-row:last-child { margin-bottom: 0; } +.day-row.today { outline: 1px solid rgba(29, 135, 191, 0.45); } +.day-name { grid-area: name; font-size: 0.9rem; font-weight: 700; } +.day-icon { display: none; } +.day-desc { + grid-area: desc; + font-size: 0.78rem; + color: var(--ink-soft); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.day-temps { grid-area: temps; font-size: 0.88rem; font-weight: 700; } +.day-lo { color: var(--ink-muted); } +.day-meta { display: none; } + +/* Lists and settings */ +.panel-note { + padding: 0.75rem; + font-size: 0.82rem; + color: var(--ink-soft); +} + +.alerts-list, +.saved-locations, +.maps-controls, +.maps-meta { + display: grid; + gap: 0.5rem; +} + +.alert-item { + padding: 0.72rem; + display: grid; + gap: 0.22rem; + font-size: 0.82rem; +} + +.maps-control { + display: grid; + gap: 0.3rem; + font-size: 0.68rem; + color: var(--ink-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.maps-control input, +.maps-control select, +.settings-btn { + min-height: 44px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface); + color: var(--ink); + padding: 0.35rem 0.55rem; +} + +#maps-fullscreen-btn { + font-size: 0.78rem; + font-weight: 700; +} + +body.map-fullscreen-lock { + overflow: hidden; +} + +.maps-wrap.is-fullscreen { + position: fixed; + inset: 0; + z-index: 520; + margin: 0; + border-radius: 0; + border: none; + background: var(--paper); + overflow-y: auto; + padding: calc(0.65rem + env(safe-area-inset-top, 0px)) 0.65rem calc(0.65rem + env(safe-area-inset-bottom, 0px)); +} + +.maps-wrap.is-fullscreen .map-overview { + height: calc(100dvh - 210px); + min-height: 320px; +} + +.wind-legend { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.42rem; + margin-top: 0.52rem; + font-size: 0.71rem; + color: var(--ink-muted); +} + +.wind-legend-scale { + width: 126px; + height: 10px; + border-radius: 999px; + border: 1px solid var(--line); + background: linear-gradient(to right, #d2f0ff, #84ccff, #3f9fff, #1879df); +} + +.saved-wrap { margin-top: 0.1rem; } + +.saved-head { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.3rem; + margin-bottom: 0.32rem; + color: var(--ink-muted); + text-transform: uppercase; + letter-spacing: 0.07em; + font-size: 0.67rem; +} + +.saved-help { + margin-bottom: 0.38rem; + font-size: 0.74rem; + color: var(--ink-muted); +} + +.saved-item { + display: grid; + grid-template-columns: 36px 1fr 36px 36px 36px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface); + overflow: hidden; +} + +.saved-chip, +.saved-remove, +.saved-default, +.saved-move { + min-height: 44px; + border: none; + background: transparent; + color: var(--ink); + cursor: pointer; +} + +.saved-chip { + padding: 0 0.58rem; + text-align: left; + border-left: 1px solid var(--line); + border-right: 1px solid var(--line); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.saved-default { color: var(--brand); } +.saved-move { border-right: 1px solid var(--line); } +.saved-remove { border-right: 1px solid var(--line); color: var(--ink-muted); } +.saved-move:disabled { opacity: 0.35; cursor: not-allowed; } +.saved-meta { display: none; } + +.settings-wrap { display: grid; gap: 0.62rem; } + +.settings-row { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.45rem; + padding: 0.74rem; +} + +.settings-label { + font-size: 0.72rem; + color: var(--ink-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 700; +} + +.settings-value { font-size: 0.88rem; color: var(--ink); } + +.auto-detect-toggle, +.toggle-label { + display: inline-flex; + align-items: center; + gap: 0.48rem; + color: var(--ink-soft); + font-size: 0.9rem; +} + +.auto-detect-toggle input, +.toggle-label input { accent-color: var(--brand); } + +button:focus-visible, +input:focus-visible, +select:focus-visible { + outline: 2px solid var(--brand); + outline-offset: 2px; +} + +/* Leaflet */ +.leaflet-container { background: #1a2430; } +.leaflet-control-zoom a { + background: var(--surface) !important; + color: var(--ink) !important; + border-color: var(--line) !important; +} + +/* High contrast */ +body.high-contrast { + --ink: #0a0a0a; + --ink-soft: #1f1f1f; + --ink-muted: #3b3b3b; + --paper: #fff; + --paper-deep: #f2f2f2; + --surface: #fff; + --surface-soft: #fff; + --line: #111; + --brand: #a93a00; + --brand-strong: #8d2f00; + --info: #005a82; +} + +/* Desktop second */ +@media (min-width: 940px) { + html, body { font-size: 16px; } + + .app { + max-width: 1120px; + margin: 0 auto; + padding: 1.35rem 1.3rem calc(6.9rem + env(safe-area-inset-bottom, 0px)); + } + + .masthead { + position: static; + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + padding: 0; + border: none; + box-shadow: none; + background: transparent; + backdrop-filter: none; + } + + .masthead h1 { margin: 0; font-size: 2rem; } + + .tabs { + position: fixed; + left: 0; + right: 0; + bottom: 0; + width: 100vw; + max-width: 100vw; + z-index: 300; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 0.22rem; + padding: 0.45rem 0.28rem calc(0.45rem + env(safe-area-inset-bottom, 0px)); + background: rgba(9, 28, 40, 0.92); + backdrop-filter: blur(12px); + border-top: 1px solid rgba(121, 165, 191, 0.36); + border-radius: 0; + } + + .tab { + min-height: 58px; + flex-direction: column; + border-radius: 12px; + border-right: none; + padding: 0.32rem 0.15rem; + background: rgba(255, 255, 255, 0.04); + color: #d8eaf4; + gap: 0.24rem; + } + + .tab-icon { + font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif; + font-size: 1.18rem; + line-height: 1; + } + + .tab-text { + font-size: 0.67rem; + line-height: 1; + letter-spacing: 0.02em; + font-weight: 600; + } + + .tab.active { + color: #fff; + background: linear-gradient(180deg, var(--brand) 0%, var(--brand-strong) 100%); + } + + .tagline { margin: 0; } + + .search-row input { min-height: 48px; } + #search-btn { min-width: 140px; } + + .loc-header { + flex-direction: row; + justify-content: space-between; + align-items: flex-end; + } + + .loc-actions { align-items: flex-end; } + + .current-grid { grid-template-columns: 1fr 1fr; } + .stats-bar { grid-template-columns: repeat(6, minmax(0, 1fr)); } + + .astro-grid, + .aq-grid, + .maps-meta, + .maps-controls { grid-template-columns: repeat(3, minmax(0, 1fr)); } + + .maps-controls { grid-template-columns: 1fr 1fr; } + .alerts-list { grid-template-columns: 1fr 1fr; } + + .day-row { + grid-template-columns: 120px 44px 1fr 84px 64px; + grid-template-areas: none; + align-items: center; + gap: 0.8rem; + } + + .day-name { grid-area: auto; } + .day-icon { display: block; font-size: 1.36rem; text-align: center; } + .day-desc { grid-area: auto; white-space: normal; } + .day-temps { grid-area: auto; text-align: right; } + .day-meta { + display: block; + text-align: right; + font-size: 0.67rem; + color: var(--ink-muted); + line-height: 1.35; + } + + .saved-item { grid-template-columns: 36px 1fr 36px 36px 36px auto; } + .saved-meta { + display: block; + padding: 0 0.58rem; + font-size: 0.68rem; + color: var(--ink-muted); + } + + .settings-row { + flex-direction: row; + justify-content: space-between; + align-items: center; + } + + .section-head { + flex-direction: row; + justify-content: space-between; + align-items: center; + margin-left: 0; + margin-right: 0; + } + + .radar-timeline { + order: 0; + flex-basis: auto; + flex: 1; + min-width: 180px; + } + + .radar-ts { margin-left: auto; } + + .radar-map-full { + height: calc(100vh - 300px); + min-height: 440px; + } + + .tab-panel { min-height: auto; } +} diff --git a/.tmp/imgdiff/old/sw.js b/.tmp/imgdiff/old/sw.js new file mode 100644 index 0000000..22c715d --- /dev/null +++ b/.tmp/imgdiff/old/sw.js @@ -0,0 +1,123 @@ +'use strict'; + +const CACHE = 'weather-v4'; + +// App shell — cached on install for offline use +const PRECACHE = [ + '/', + '/app.js?v=4', + '/style.css?v=4', + '/manifest.json', + '/icons/icon.svg' +]; + +self.addEventListener('install', event => { + event.waitUntil( + caches.open(CACHE) + .then(cache => cache.addAll(PRECACHE)) + .then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', event => { + event.waitUntil( + caches.keys() + .then(keys => Promise.all( + keys.filter(k => k !== CACHE).map(k => caches.delete(k)) + )) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', event => { + const { request } = event; + const url = new URL(request.url); + + // Skip non-GET, API calls, and map/radar tile requests + if ( + request.method !== 'GET' || + url.pathname.startsWith('/api/') || + url.hostname.includes('rainviewer') || + url.hostname.includes('openstreetmap') || + url.hostname.includes('cartodb') || + url.hostname.includes('basemaps') + ) { + return; + } + + const isAppShellRequest = + request.mode === 'navigate' || + request.destination === 'document' || + request.destination === 'script' || + request.destination === 'style'; + + // Prefer fresh app shell assets so UI updates are visible immediately. + if (isAppShellRequest) { + event.respondWith( + fetch(request) + .then(response => { + if (response.ok) { + const clone = response.clone(); + caches.open(CACHE).then(cache => cache.put(request, clone)); + } + return response; + }) + .catch(() => caches.match(request).then(cached => cached || caches.match('/'))) + ); + return; + } + + event.respondWith( + caches.match(request).then(cached => { + if (cached) return cached; + return fetch(request).then(response => { + // Only cache successful same-origin and trusted CDN responses + if (response.ok && ( + url.origin === self.location.origin || + url.hostname.includes('googleapis') || + url.hostname.includes('gstatic') || + url.hostname.includes('unpkg.com') + )) { + const clone = response.clone(); + caches.open(CACHE).then(cache => cache.put(request, clone)); + } + return response; + }); + }) + ); +}); + +self.addEventListener('push', event => { + let payload = { title: 'Weather alert', body: 'New weather update available.', url: '/' }; + try { + const data = event.data ? event.data.json() : null; + if (data) payload = { ...payload, ...data }; + } catch { + // Ignore malformed payload and use defaults. + } + + event.waitUntil( + self.registration.showNotification(payload.title, { + body: payload.body, + icon: '/icons/icon.svg', + badge: '/icons/icon.svg', + data: { url: payload.url || '/' }, + }) + ); +}); + +self.addEventListener('notificationclick', event => { + event.notification.close(); + const target = event.notification?.data?.url || '/'; + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then(windowClients => { + const existing = windowClients.find(c => c.url.includes(self.location.origin)); + if (existing) { + existing.focus(); + existing.navigate(target); + return; + } + return clients.openWindow(target); + }) + ); +}); diff --git a/.tmp/imgdiff/server.diff b/.tmp/imgdiff/server.diff new file mode 100644 index 0000000000000000000000000000000000000000..02d1571b9a407cb80f4ee4d99615ef3bd13f25e4 GIT binary patch literal 45004 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 literal 0 HcmV?d00001 diff --git a/.tmp/imgdiff/style.diff b/.tmp/imgdiff/style.diff new file mode 100644 index 0000000000000000000000000000000000000000..1236296ba3d75402240073eb1ff2c59d9c366169 GIT binary patch literal 111354 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 pSgk&ej0yk% literal 0 HcmV?d00001 diff --git a/.tmp/imgdiff/sw.diff b/.tmp/imgdiff/sw.diff new file mode 100644 index 0000000000000000000000000000000000000000..b64e17a01464387e5a1abeb92cd41dec7c788390 GIT binary patch literal 8488 zcmcJU-EJFI5XXo}StD+I7=Zt7O+_&zUoy|CuxU-@oH@oDOtVX-C(gex2)in0C`ZPp4_Br(@j@ zlh52uXX!-0c2k`$HS1U1tF*Uv#U$-(Zd~zBdRufSqO+fV*ZP~fM(LAuBYmW?fv%5r z-O@AMjWy#&`al#Mewu+)or2LrF>u5of0wC(>;0AaevQEP5cxhI%`e-Y+yV z5xws+SI{NKW~nbJ;GkF*9aMVfEAXr@H1aIirMw+ven5dInf|f%?T49I(IrLO14%i} zyZ3`HY_(Rf3OvCxC!$vp zhw{WT`7#kh1RKV*{m3c#>VZ5Qf5V&cs+Rj1uX!O`_pBjnwu_zOX?8a}r>on^uYRxUblZqEOe!rR*fO6;B=_sRH^ikJV zjBH0cvidXqfjhqmj|?d*al@)e#XOYEN~D+EG}l^k3bQSrgKm$6K>Zfgf#@^0$XvX~ zu=MdF7NNurJLwx?+7B72?keKJOza2()Oi$yCwG~>cSWCw0Y@uo1Fr#3o6^<6Ysn6k zI4ztYUvoa*gl><-6MMPG^%mUqH1?;iCJmEyATP8$J<%OHWEHaOO#f@44w!Q!4#~YSHAk}J`Z~~P=6V$@X=Gn7}&7O9^=O)4he36P_rqS!5?AQ(u=nCza1+IraHby__ zd+(Zf-V;qC%J{4^)nYNU663?Y;5_^61bm$JQ`QdK5BV_bW>(&i5t9d%6qd>Ir=8pbi zCh|9>|7aN)P9_;kPa1q+QO7=CO9ZXbDqV3qXBM5KJsWolZE2rthG8C6!Lrnr5AG|% zIg8_!cFLyT$ZzXNE_3DR?>%l#AP3S5Im5ak#Y;UR%1G239nke1$KuKGNB*%Bso@^A zBByO|`BItZO#7o(`nzRDzol33z`YbLw!H!CM89lWW$@x%>|A?{dS}^v(0|v;zW!Xg z&RrBZ#f~NE{vsyIy3J|0N5BQWpAr{sS?rBhb5^tAM{UlUm8WCRX1&tBB&rp6^e@Qf zr8YIq8x=GdnJugfedla;=P<2R`&}H zqq_MnG*(9dxjMeJE^3oEEQpSL6f2kKtK>Eh5zVE|* zc^y5t*u(3~vUEDlC!3xdDG|Ni59r#>^XRHIpkmX{yaPi2*?s9{+;pRS{Bp4!?6XO2 zS-xi7Ej*q9OaT%O3Vs0V@9k&N1_; literal 0 HcmV?d00001 diff --git a/app/public/app.js b/app/public/app.js index 873c03d..74e7b7c 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -31,6 +31,16 @@ const STORAGE_KEYS = { autoDetect: 'weather.autoDetect', showLightning: 'weather.showLightning', showWind: 'weather.showWind', + showRadarLayer: 'weather.map.radar', + showSatelliteLayer: 'weather.map.satellite', + showLightningLayer: 'weather.map.lightning', + showWindLayer: 'weather.map.wind', + showAqiLayer: 'weather.map.aqi', + showAlertsLayer: 'weather.map.alerts', + showTempMarker: 'weather.map.temp', + showSunMoonMarkers: 'weather.map.sunMoon', + unifiedMapBaseLayer: 'weather.map.baseLayer', + unifiedMapRadarMode: 'weather.map.radarMode', }; const state = { @@ -48,7 +58,23 @@ const state = { lastRefreshAt: 0, lightningMarkers: [], windArrows: [], + aqiMarkers: [], + alertMarkers: [], + tempMarker: null, + sunMoonMarkers: [], showExtendedForecast: false, + unifiedMapLayers: { + radar: localStorage.getItem(STORAGE_KEYS.showRadarLayer) !== 'false', + satellite: localStorage.getItem(STORAGE_KEYS.showSatelliteLayer) !== 'false', + lightning: localStorage.getItem(STORAGE_KEYS.showLightningLayer) !== 'false', + wind: localStorage.getItem(STORAGE_KEYS.showWindLayer) !== 'false', + aqi: localStorage.getItem(STORAGE_KEYS.showAqiLayer) !== 'false', + alerts: localStorage.getItem(STORAGE_KEYS.showAlertsLayer) !== 'false', + temp: localStorage.getItem(STORAGE_KEYS.showTempMarker) !== 'false', + sunMoon: localStorage.getItem(STORAGE_KEYS.showSunMoonMarkers) !== 'false', + }, + unifiedMapBaseLayer: localStorage.getItem(STORAGE_KEYS.unifiedMapBaseLayer) || 'dark', + unifiedMapRadarMode: localStorage.getItem(STORAGE_KEYS.unifiedMapRadarMode) || 'radar', }; // ── DOM ────────────────────────────────────────────────────────────────────── @@ -315,6 +341,7 @@ function renderSavedCities() { } // ── Tab switching ───────────────────────────────────────────────────────────── + function activateTab(tabName) { document.querySelectorAll('.tab, .bottom-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-panel').forEach(p => hide(p)); @@ -322,24 +349,23 @@ function activateTab(tabName) { show(document.getElementById('tab-' + tabName)); if (tabName === 'conditions') { - setTimeout(() => { radarEmbed.refresh && radarEmbed.refresh(); }, 60); + setTimeout(() => { radarEmbed && radarEmbed.refresh && radarEmbed.refresh(); }, 60); } - if (tabName === 'radar-full') { - setTimeout(() => { radarFull.refresh && radarFull.refresh(); }, 50); - } - if (tabName === 'maps') { - setTimeout(refreshMapsOverview, 80); + if (tabName === 'unified-map') { + setTimeout(() => { unifiedMap && unifiedMap.refresh && unifiedMap.refresh(); }, 50); } } - document.querySelectorAll('.tab, .bottom-tab').forEach(btn => { btn.addEventListener('click', () => activateTab(btn.dataset.tab)); }); -document.getElementById('open-radar-tab').addEventListener('click', () => { - activateTab('radar-full'); -}); +const openMapTabBtn = document.getElementById('open-map-tab'); +if (openMapTabBtn) { + openMapTabBtn.addEventListener('click', () => { + activateTab('unified-map'); + }); +} // ── APIs ───────────────────────────────────────────────────────────────────── async function geocode(q) { const r = await fetch(`/api/geocode?q=${encodeURIComponent(q)}`); diff --git a/app/public/index.html b/app/public/index.html index d45ef07..e40a5b4 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -3,17 +3,15 @@ -Weather +NeCloud Weather - + - - @@ -21,11 +19,10 @@
-

Weather

+

NeCloud Weather

@@ -46,7 +43,13 @@ @@ -64,47 +67,132 @@
- -
-
-
--°F
+ +
+
+
+
+
+
+
+
--°F
Feels like --°F
-
↑--° · ↓--°
-
-
-
-
+
+
+
+ ↑ + --° +
High
+
+
+
+ ↓ + --° +
Low
+
+
- -
-
Humidity
--%
-
Wind
--mph
-
Pressure
--hPa
-
Visibility
--mi
-
UV Index
--
-
Cloud Cover
--%
+ +
+
+
+ 🌅 + -- +
+
+
+
+
☀️
+
+
+
+ 🌇 + -- +
+
+
+ 🌙 + -- + -- +
-
-
Sunrise
--
-
Sunset
--
-
Moon Phase
--
+ +
+ +
+
+
+
Wind
+
-- mph
+
--
+
+
+
Gusts
+
-- mph
+
+
+
Humidity
+
--%
+
+
+
+
Dewpoint
+
--°
+
+
+
Pressure
+
-- hPa
+
+
+
Visibility
+
-- mi
+
-
-
US AQI
--
-
PM2.5
--
-
Pollen
--
+ +
+ More Details › +
+
+
+
UV Index
+
--
+
+
+
Cloud Cover
+
--%
+
+
+
US AQI
+
--
+
+
+
PM2.5
+
--
+
+
+
Pollen
+
--
+
+
+
+
+ + +
+
+
- +
@@ -128,7 +216,8 @@
- + +
@@ -140,40 +229,97 @@
- -