From e9547343794ff50afc0f97833c7338513ee62297 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 8 Nov 2026 19:45:01 -0800 Subject: [PATCH] fix(cockpit): a place pack that will not load ends the loading batch failed; flags keep their neighbours out from under the border and out of a scaled flag's edge; a new ride asks again for a sheet that failed --- scripts/build-owner-flags.mjs | 18 ++++-- scripts/format-scope.json | 4 +- scripts/package-boundaries.json | 2 + src/data/local_data/owner_flags/README.md | 3 +- .../local_data/owner_flags/owner_flags.png | Bin 15348 -> 15621 bytes src/data/placeBelow.js | 42 +++++++++---- src/data/placePacksLoading.test.mjs | 22 +++++-- src/ui/applicationShell.js | 20 +----- src/ui/cockpitOrbital.test.mjs | 10 ++- src/ui/ownerFlag.js | 14 +++-- src/ui/ownerFlag.test.mjs | 14 +++-- src/ui/ownerFlagSheet.js | 1 + src/ui/placePacksLoading.js | 30 +++++++++ src/ui/placePacksLoading.test.mjs | 59 ++++++++++++++++++ src/ui/styles/cockpitOrbital.css | 3 + src/ui/views/panels/CockpitOrbital.vue | 11 +++- 16 files changed, 198 insertions(+), 55 deletions(-) create mode 100644 src/ui/placePacksLoading.js create mode 100644 src/ui/placePacksLoading.test.mjs diff --git a/scripts/build-owner-flags.mjs b/scripts/build-owner-flags.mjs index 99b7a7545..9ca7ed083 100644 --- a/scripts/build-owner-flags.mjs +++ b/scripts/build-owner-flags.mjs @@ -9,7 +9,7 @@ * src/ui/ownerFlagSheet.js its layout (generated) * * Flag i (in the sorted code list) sits at column i % COLUMNS, row - * floor(i / COLUMNS). A rerun with the same flag-icons and sharp versions + * floor(i / COLUMNS), each cell followed by a GUTTER of transparent pixels. A rerun with the same flag-icons and sharp versions * reproduces both files. * * Usage: node scripts/build-owner-flags.mjs @@ -33,6 +33,11 @@ const FLAG_ICONS_VERSION = require('flag-icons/package.json').version; /** One flag's cell, CSS pixels × 2. */ export const CELL = Object.freeze({ width: 40, height: 30 }); +/** + * Transparent sheet pixels after each flag, so a browser smoothing a scaled + * flag (a 125% display) never reaches into its neighbour. + */ +export const GUTTER = 2; export const COLUMNS = 16; const SHEET = path.join( @@ -51,11 +56,13 @@ async function flagCell(code) { const codes = [...OWNER_COUNTRY_CODES]; const rows = Math.ceil(codes.length / COLUMNS); +const pitchX = CELL.width + GUTTER; +const pitchY = CELL.height + GUTTER; const cells = await Promise.all(codes.map(flagCell)); const sheet = await sharp({ create: { - width: COLUMNS * CELL.width, - height: rows * CELL.height, + width: COLUMNS * pitchX, + height: rows * pitchY, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 }, }, @@ -63,8 +70,8 @@ const sheet = await sharp({ .composite( cells.map((input, i) => ({ input, - left: (i % COLUMNS) * CELL.width, - top: Math.floor(i / COLUMNS) * CELL.height, + left: (i % COLUMNS) * pitchX, + top: Math.floor(i / COLUMNS) * pitchY, })), ) .png({ compressionLevel: 9, palette: true, quality: 100, effort: 10 }) @@ -76,6 +83,7 @@ const layout = `// Generated by scripts/build-owner-flags.mjs from flag-icons ${ export const OWNER_FLAG_SHEET = Object.freeze({ cellWidthPx: ${CELL.width}, cellHeightPx: ${CELL.height}, + gutterPx: ${GUTTER}, columns: ${COLUMNS}, rows: ${rows}, codes: Object.freeze(${JSON.stringify(codes)}), diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 887c72eb7..7b09e8077 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1366,5 +1366,7 @@ "server/providers/inaturalist/proxy.fixture.mjs", "scripts/build-owner-flags.mjs", "src/ui/ownerFlagSheet.js", - "src/data/placePacksLoading.test.mjs" + "src/data/placePacksLoading.test.mjs", + "src/ui/placePacksLoading.js", + "src/ui/placePacksLoading.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 997191dbb..f7fd906a7 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1069,6 +1069,7 @@ "src/cockpitOrbitalSatellites.js", "src/layers/installations/classLabel.js", "src/data/placeBelow.js", + "src/ui/placePacksLoading.js", "src/ui/ownerFlag.js", "src/ui/ownerFlagSheet.js", "src/layers/satellites/sensors.js", @@ -2916,6 +2917,7 @@ "src/ui/cockpitOrbitalSatellites.js", "src/cockpitOrbitalSatellites.js", "src/data/placeBelow.js", + "src/ui/placePacksLoading.js", "src/ui/ownerFlag.js", "src/ui/ownerFlagSheet.js", "src/ui/cockpitContext.js", diff --git a/src/data/local_data/owner_flags/README.md b/src/data/local_data/owner_flags/README.md index 60a02a919..ed0dc071a 100644 --- a/src/data/local_data/owner_flags/README.md +++ b/src/data/local_data/owner_flags/README.md @@ -3,7 +3,8 @@ `owner_flags.png` is one sheet of every owner country a satellite can be marked with (`OWNER_COUNTRY_CODES` in `src/layers/satellites/classes.js`), drawn at 40 × 30 px (twice the orbital readout's 20 × 15) in sorted code -order, 16 to a row. `src/ui/ownerFlag.js` loads it once at startup and draws +order, 16 to a row, each followed by 2 transparent pixels so a scaled flag +never smooths into its neighbour. `src/ui/ownerFlag.js` loads it once at startup and draws each flag from its cell; the layout is `src/ui/ownerFlagSheet.js`. **Source:** the 4:3 SVGs of [flag-icons](https://github.com/lipis/flag-icons) diff --git a/src/data/local_data/owner_flags/owner_flags.png b/src/data/local_data/owner_flags/owner_flags.png index fd1826a0e44129fa907d5c0a2eec8ca97253e485..99e3d07b08ef5482bb25d180ef940a8578676d11 100644 GIT binary patch literal 15621 zcmYjYcQjmIxV`YF+ERCLcDJX zA*um@1i%fK>1S}7ZjvbS}EieD6Y+)}J(t`l^B*YL4 zuYl%-Ev9E$ix&!xRcFUkU?U2hczJK3= z^G2r2AYe@H3c#FrY0T)9eW_-hMdc%W^Nk!Z2>cXgxXixJ%9_Ct0|$WQ1U_U5yYzc5 zB#`03ntTZ~E&(#2=u2{i1ORZm38ZKP&UFCLzK&G9CZ|?D;$WYJ=kbElHQ8zz2*j}sr2_!zEx@(QUjBorX@EKoP-wtU zZ67aFjJ#LcZfaRx`+Hdr4BPU2mor$7i21j z!#|!rk~+l|lU)_9gyl%ujf!^@KYkYE=UD{z0KyLeE3}?z%x_)2#f-ee)TH)z@YAJ_ z6)y$*ITd{Rp2d5}&zRc|yt+PBPvL({Vc=Jvk=}iW`oJn%;kZ1uvs-qr+TJ(?)KS*Zdw=Y;(RUd$Hg)(HhM6@uRt+YDmE|t;W}Wa)>;0yM*+y zh^Y5_vaIpXLz8lw^!0~Vf8?f)JZ|e=prRmkb9Y~wrgeJt_{X~jlL#w$FO7h}j(hjv zmtiK&LD8QzI4k?E^iMFbv$LV(NnbRv+SDjMN`y8~*%-q}GpeT_xe&y}ri6puz5o55 z)3}MHpie2!DS(7TKRPKJMhd%0=_lo_K^slRPb$J2&N0Gq!LeOM93NIcGh=h0>&usc zFY6cQUpqddkH38RdN|vW`DMA=fO>&ggu+YrmM?MH5dQl?>Ykdp0RSJMr-`-*{I!`& zbvxBLZ>vT*Q{ic^>eNh4K5MC&ma@PPOQm1NPkT2#Ze$(cwU*8;ju|FYzYPJt+c9m z?yf+Gl5X2GBKS{E&m8g$n57M`ohnu_$1TU0A@9?dVE!?iQue55iZZ`j|F@fd)!JUF zr<)Dlfes3N`A8_5IqotR`C{=N@%cx?Q03TyE%Ar?1)u4)E{prKwn^@ZGLt>I7_Vx- zIK`Z|1fBAGZ6BPBp2BEFnU4hY3y$H$dK)9PrUl1UF=ij`+V1Z;2evsef>f6Mv~(cr z%Vx_7cYwLeE3|?Q)Z%c=f#c;vD=6coP;=9n4CG+VXSA}RwD4Q;c$n-)h%qf~5Dh1| zv1UGxy`R-op-YcXJdHT8nRG*dRq40hHC&ge24+%{lXqwW@aeZ!SwB@giJ-?$T(a}Fmu z=O57FZ}_wvkeMYjSIaYZ6yeu znNJD~wI!8UrIsJ4IRpWlefKKAY;o-e#H85t)Zcq8aLp7^9v3r$;J`nqG?V2){SK|= zS9>;iWOW0K4E-yLbjDqoU6eQVK$*WJ}gqbv^vCZw3t1x5$M`{e#=*j7EfkgJk7wg7O?EB0iv}wh$ZLY0@--Vki;t_MD{<|t6z%q~XgN-=JYQ+gK z^PtA>miQ;tY3wtv7fUrDHGPvqnQyMz{3p!aLS8Qt%3y;p=$FsDpYJqvwwhpvLdkfM-f&Mqfw>r#G%Pe|g$`b?){zQi5Dl&FC0f zVKuz^JpWCUypndn8zLHWf}R;JUt5gg9|$o(2?7q5r8hU97SZ{yC;u(2tu6h_@C3lV zCjatq&=8!z`So z?P%-IM>|(RPRhrvB_%rU>6{^ul1(O;iQKR}|4m*oNl9=Ec>MGV_=Pi80rxWlXcle6 zHD)C*lrMHsBY!i@w(8s0Z)GnAAKwL<%nX;+u}%Xt$UEK9Fg%Rs5lybs*))%JSvnlw z&^b`=5ajsJ=N=aveBgol!k@#cs~LUR41@GY%O#rky%vq=aMpzUx+_Q_XVN18h#ADN z{;napx(buE;eQrNUKyx{`o_EAlebYnKB`i-Kr(vRJVkdt+)Ij-J2jTi#<7{yX5T&| zRaYT|$>%lLJg=1HBN^H@LqL@3$`-n6K7MpQniwtw#B`DoWG+@GHE$GM@Nva~f7kpw zaEn3QwN1U*4n-uHN^SHGiqBRL105I0It14GUnKfuJ!NzZadKk!wVCC_GbOOKz4$N` zFV+;Cq=|Y^^{bcTeQ%qan~o3W)-OX3#u^x!#xh4V%~FLIY)$lzH(0HnxM76wvfg== zULtSz$nmvq&Kg&r@LEy!ou_O}IX1L$5016&|Cxc=|BPoB5;8<}O*{Ftqn!&L8z)Tu zDo07u;#3lXE4ahZ9AJm*XsT9-jmE#vd*|ml!6K(;BG)KFLh;8moQtF!2fPiO2XI_? zaiXnBQXd?zeH`NbW*dXWahRt|0SEP_?fPHcf6(^?rk3`W&L*iot-JU3Uffv#nX?)) z`4MeK(u(sP0zK4^sSwJ(zO)tLp`FI1W!Cw*yL2@_?AZ@tD%D#{t1*k5>nfPpWW`?9 z(V#^I@da<1QnQhS66*Af5is?yjom%{O0 zDT$>*w(YyR4Y?`39 zM=P@HMzrYOCc}_5wyq5v>ZqFjuSc~nEs;-_k}}{h94}KwkPoIDOJ>&N3!GBkdzEK! zcr;q_Idb{x8>Wnoe@)s<;ZCu(PoyIZk||tVeDQC6q&`uUMyAyO+}&wJa1wjc7kyjN zh0jtYDMAec4|Hsr!@lR68^# z9^n8l`Nvqet{9>V>iXmmSS}pDJmR7Cexl8cfuI&@o5%sbP|e!x2H?P^WSqa_s!1}U z$-AqU8m0x&G9z%MJ-rh$Q5pDo?y85B!@Ya@Pb(0pnFUo!!XVTlPoR&_HyP1LCq_od zcu9Q%Nf4{R6~^^DjB&pFRblZsE{l!I^76Tx&7~wUF>=Eru+t8jN|wQ=UtkkQG>){0 zM$TiU(LNf&n$bm=PHI4~IgGb7MzZzf2Cer^lYQ&8Fk;bTNFj5aTnrS;LH)C$Fj#{b zgGQyM4m^Z2n_Fo@Sm3gjRrR)&X3lKjqelf~vh01DLJ-Q8YT_eO3kT~mY38wS%&4Og zY#yZ?s4FbSfI%?PP=I=$z9YOGYkG7zK+A&zj&!v_MFQV-(4c(7+`yQWmhd#D5YUn` zEyosSxokrcJYBY6uJse2pFGtF(S+DOl(pH||2Yr+r60qK5fTH>{Qok=?wSEw`QtPM zacybSHEdjt3m#&$^)NQpWdb6%ejwB!47Bpk~B-NVimx%l*P=y zw>k1PrGmvqu_-ZeSvX#7+-qWWRW&Dr`gC=s$C7n&;zo16Duq6nO0?yca}GuOID!P! zS$O(hIc^>-lm9c_C?gs{yUR~ntB`G${RbPD<$}UWY^;@!S7@pKJ18%CdMIw>>FH^s z`zjoFkQpT=zI8aGRasj8)qaZP+s}rf;VMnoQc{Q#Iiwd@R>Z zF98<$5dgUJ(?tCGg>{iFfd8oaz&s-=;IR`FPghp@nWlqGmfQ5hkVV?-*Y^*~hKdE+ zq{(Orkhf4}ER0~j!}&YEaG=;6LQ;Wdrf~Vl`ThDNUL z_j|pW=8lAQ=Howq&-`-~i?P2VA$7KLqLvrTdaiOi2^8jVO2T}&NFuEO_m0?Ki{(bX1reXJ08+F|NBA;kpwnm;rAesd(#}F3g8I=hXXEFIkKgep=Xq zjXj0nYzH;Z$!c%%-|M(usB1>`;lMul?Aa_EJsKBtUf>~y-ZqbsTC8L=(^irNsE#>>`RW&ht^I^X*C*+ z(xL2n44G@8?9ZnHAN&+B4kBry4r$Y&Yao+svlpT!j4PMA?dlAOiHS9-UG9xnLnzf| zi%g3=x=$cR1|qHn`Vdt7K3V!KpIorCyfP{Eu7F*PeR!H?-VKElW z2gQ3Y%VDgtrlLJuOn4p)4}|2rKDbg14a0Bd*IQpr?;nX9c|H8Cet^v7K?gYoEhq^- zq5<&gA2?YZvZ4j`tP;?UuO^(#I9mgLS-;@Gn->IN`qkq}WlZ~MZK1SAo!Kq`R)X&o z{22*zV;QEy&Fn6|_s>kl7_Gi%z2=_{aElViaynREZY#S4{PJ7-xOcGnqj{N>x$S~{N{AZQuwbQoy`-h;3#ePT_eP8P<^FxukZ3K;L|_SS_gW}w~g&9u-g4W z_UPY(2OG|%{Gg|_LkSk>kyFQ7ap;!~wW~U8iOUuDaV|<|SjIUgTh@Ai-@u zP0QPjdo%N>SpkaCrJ((K;c)p>n`GHkuG*~0cV&Nk_ZHK>m``V3DBxm38L?zVy7!^3 zvu#eMnMLb-hu?gx2eu|B^`H&+KB~U4Zm@4@TU60soZpE3$=@%N6Wb*4hiN{$^AjEbmOp(%0W9vL zGWIcDI}Tx?ml86B>tkuNVVC0$UwAVpFe#Wf?R2hJ0jzwTVLg^)p7N2)o-=9~rlY{PmujbrCS!AzXG1n;Xw&Uy zNZm}!LiDpXeFD<_AB*3lyV1;y(Khx*H&d$C`Z>(vJONTW(HuBum`nb9+VClE4!iry z8HIh`C(s@3eOYNQ)qUB|<*67jalan>W5L#-W!bs_%IsK8t~m3zB_F%UKu%DO+4Yo9 zol+~vD0{`)9WZe2kAa*~Q)9x{{Ou)QGa3^V&7tJ#iTi|#+pTv9dqxDniPEXZFyjB z^4T}lsX2byOdUpWsZi0`3$py~VHXuH%`&$qZ!wpAC(>6U`mf&j?!dVZhT)8w2Bi9Y zlAG?HJMxi-unhi7-|bOeI7{6(dPOAu{Zp%V zP0rS+SPTXbA|kIKC5{c;$1@c8P3Vj879%&78ro`qOS z68(gh)Uh+CU5*U>_&zo|-;rgrDMRETJ89aq%Ab(`;8#&t!dssb)FtN0{)hNtVqpg= zg)Q1@KTZ2rKb<;f@7l7PRzDYg1&J_juYN3dP00UMjHRnPcjym#kK7w3l8$;Cw(7p0 zi{7!MsqdH=2Ay9!u@BJGv(3{^Q`N6RJ+NVVJVPf29w#(}n?@mrIbW27gR-Jb+{M9&s&6u~5Ci*7_B6-{ z44*vA$D><3DarBC&yEO1?N5F7UEl!XSxB=%}DB z+=c%7gD=lzQW_i+hq_#y_zq8s?TC%Q08QyU+9^w51juo^|D#CflKed#vL~8zMC4Y)s*pIEN3 zG<@jMSjN&)nv0#U`Z~C1sWPQ{m6qO=Cr1W(sPcrn>0gLfICkdQBCPYJN4wd!h>;~n zk*T?33LVBJH=wEMZ%E7L@5lV!n0=FQXYUC-{%&G|T*OsV)c6HW{RCEwd(7tdSMtB^ z{xLuWv|0FX?}r&_p)9dT+9pd^;soe5b&2gSV#ycl$#ahO@bmJr80V+Ok*Gun(yyKC zB$7ibu5(B)7AI5TpFhS9zn``D@^-dEBe_OBQA5ssjaAN5JrFJNl(beQYF_q7ewIgK zg47r#2n)B~ee3Oq`?%u=FOdG+ClRa?)GP5Ml*jJxqlo0z3y560jdUwRO-9xdw3f<3 z=gxc~NHwZ=hhY?LOc?PbQr;MPV-jo2yPIr#KF#H9v7;;>KAiP97 zsq2vYpJJS$up{Y~AdV-ruVo`)1WXAZ(o&JG#= z91cic?j=vDw=-Gj3NJFC&UcSNY zFRBH|)o~b={|?qnt9-8Tn>gdvE)){%~G}4CH8F?rXiRS8zNRQy7o%)?;5E+|39dM$^1iW{zW}XNluV zBmCr5=s!^oOF)Xo$9CAa2R+%>${i1|zo7^9yJ0|5=QjS6_Rpay{g=h@T(TqF!Cn8{ z%!u^`aTV7_vq;G6CwepxONV|+2JpgiM?F>RIRrQIu-5jS@Ppj3hjKsJ7s6R)HMz#@ z%1J6k`A{d!D)4Jj?WAlg9l^QIO}UAI@Aqh3_fiEJ@NcUm*b_3_`ui#Ci2H$C(;X>5 zYi{pm3dB*_g_C7AUeGj1!m$V;0Ylp>p^jN2~a$x_a}k# zs}voO*dQRf&(G6`zDm!6Kv_#YP&pc>_Q60gCV{Kdr%}d`HX=|R1Q;>SZM}>NEFS8r zP+7=*x+hf~b6u0$;%{rV6E$e6#bRk;dQz#ydvb1CaNV6WHx?Q3hYc(_0|9G0d8RFwF98*6{~TD`KrY+&!B7;CNNO zBD*2VPT#Hpcr1GG4P7JHiB2V$lS5c^M#E5Zt1A0Uc;Bt@LCaG^(Hy(I3#pBfEdfZd z@K@UY7b0mEsF{rHYuj=;+!kD>5-2&_ciLQdjvE1K zAO}~04Ck;j&JV9zSGdjstoY-C{g$VFS66<%d3X5i^LmY(@3+RN^glZ7@k=1le#lc{ zPsYY6xwrszS-*e(FlZj6!G0qfa56>N*u9k*^CiKZT(;i6c{!t#)$(z8wcXGSq zWM{Xcl%LZG*~}!td>119CxgKL+60CIYlOJja5J-_fD1pYrnbDC`ME8Ul3>7g$jAtW z!~~O6Tt;N>cWwY~R`0&MTEDB<8L}#04UxRfMS(G&xk~P`C&fK)skQHN?PDd9VJW&& z@!~0Y7S1!~W)N=c)SFMfpbh(pflAcxu?zN-hHm&TgDDbU{D&hn-PAP%s2Rx$l`VM` zJR&=J41*hTE|cxUqH<9%Sz+Na&4(xDD|F(ITArDG`_72~JwJGsL5yC= z=_yo^DLo-@&23ESh$hCKctmlaDDe}}J|RX$w;Dehuhiz^4c(B|d^H^9qSE#JI`(`)fs(Qr%rwJx8#Nfg9!&`!66p72i4rAS*9fJKN~2 zA3OsneetP=Btj=D!}=PNw7*tPgsAE6e1W?>dbDb<@P0H;d%`6KvbmOdqnow&PhOaq zpi{u$kLUbt<>i2aGx{FC_t+?c3^_ex1qIt;f?EyYlUN>R=zjc!WhHf#W##?$d9t+P zvsta~E6il~P<{6=XoNv6*A8kMHoQi3ix`3uvo)9o_2YhG-jYvyYd{HZAXjEwQod(FY4m;92#P4_%3K^q>o+?uB=JSyyMBi1>Z3t^MbqW3?NRJ(gb1i`{~o7MCiv#2 zcTY3mUMaa;R!?7COgo-eE4b`l{YHMEv)6QIdh0Zv+) zy^`&0&GDT+%>7o_W%UCtexCDyU~1XMWAEob&2@{@$AqK<1~_%D{8yh?n2 z!HCQ`{X9~zF@AO&hp>uqxOQbxd1{KC39Vs=;2ya1N!(f<71lcF!QK{Bv(axeK9utq zz!Zq_e1-mFLajT3Tv{n#G+Y136viRC3Y9hluDT|}G?ev3j`gk1=#WLaN?6y3drMD0s^vy-?d-W~g8SVJx*M z_RaDPjw~ATTS2^OGkn=|bZ{Oy`MK?prR1`!t$>E9qjc~okc zs-raB9X5%E>mdt7Q^W=58ysxf@5cnIeWc`Q6fXtp5OkEElevHi!6XKLMR}pkWu{g9 zhm)f&^qdDxb+Y+MY9FmcCU7_7r*SufqJU3HZ23p)GC}Gpjz#tf!NM)mw3JzYMAV5OH}zUJ|KU_L(Skb)A7FU?!R zY)30ej}#2M9$}m>Y?ZpJhD4&3=2qF)Zwq4btD}_`PLopcW+ps}ebOo2e94NVjmB^3 zDB(&|6sztg8~X|bX`%&iQ`>?Z2)%fbVB+aQ;P7zCnsxg`4z>Mdp>)RvgV7x|cqaRNO^seBs;g#?!lo99IYPUqhPss&{+u?QK+VCpf*Zm+Jwy8p&1kJvVefmQD?6qUJ9Ad;ikht>#9Cet&Si zO;9$6qBpQGO>!yAV>N;QxS?DrFqcFPt51)4rVW6*oiyF#B&xE0r86htjWY8Uv%e4(#F~&9$`^%{$n7UZC>*b^XOD8cM zF2p;_*47`S3*{IT;&s#fqTAWL^dJgKneL%+C$;u&hXBzAVE3Mx=!fH^@}{x7=X!Jk zVp;Jknty)$25-hf#n|y!)*(m1{+d5Fux~MtH0Mcgnx~UH9BHO2+Y-u&&^T(GLLLJN zvUKSgres_ln-)ac&AmmxDPx*_=jl`sA>bJ*2*GPf zs+=tSfim~0iK4n1gC@jb%t3on6j<&5j7P2Is%IS*oAObvt$$OhkH_5JchzS>em{MS zJyweL_tq^qb}ns^Png|$lBvF9rx|pvCHD5%BuuQ=LM3cuV@@YjmBw|kW+&>ih|1c6 zMar+e@`Ia8uhc(giB_#C3fTvUG8g4vkn81rp7|H^0jf7~EbJmi6u0!ddF-4RB<)>;@qA{zQU_^HtC*O84ONOQ=Ox!3UBix1oSbDn{_} z0es_Q^%DfR(f`)!_T&{Q(A7}U`4cpb>Xyqk5SpdN4{3S;C&B#96rINY{q@4BurmJJ z{}Zm*JlR>tLV8SO@1MY(guqoePy8{xr{0aD{XoEI0fPXV+*smbJ57eAnak(PUanj6 zwREYZ@#0~N-83%G;rQNzgn6eC3qPl*z$XVnJM{S?${?M&vo~sJO<9*U^s&y;3b^zm z(0E#N&6yNZlB82fa^c`tjQ>=wpH3J#=G;SOQy|gz1X^|>X`oCv*=Vb_=kN`g?ydOB zeIIf%~kKOBrfYhl5kRHVXsxNI9#|I zLK=T3@tI(Jai_`-{Ut}?v?8HP7u%anf!bX2_FnT3bXmqM5j!H;>m!2P#c7JZQWr`t zjPOy!iKsfZ2YoR(0WuQkXC1G^<`$pKonRJmu~mk-z&bxyKX&td_I_Bi(y45m41(xw%BK6Tjx3TB*D1kJsjMgUtwY{oHFJaltlK^li-U z%sTo2!K8og`!a|nGGX@TL|O3r#lYM+{mQ+3X_!abs+IWd4X zdGpyjPzfD!BbXV2r+6ugzb&$o)`bp8d_2io`RbN`FE13PxS!O<|U> zZI1!T{yit!inYJh`$x)+Q--Q*E8{V;Y3mU_lCP=AK>ImH;!c7f-|42sR4^;8&C84! z=GxMM2%VHTosSix!4q~I*KZfv8D`5p&XfOfzm03Oc?ib6-yQo~GFQR7F!_`}al}L( zGCdZF%#tlmENt^!Shk`g%&?2Y zdnEqxGo3$hml6|qD|FgE2JUCJWtHEAu=vIgqTd}@YWJEit|^3Xd$8Sq+0(VfpRrWp zS)|6FJcW*h&RvD~I6!Tan&)ih{%fC&lpvVen)H=^vFZ-zKwpQ)^k*L*feE*6%-`rlQ@Vax|{rf}NLKG8L*MoIcGu zl^EwLtB5$r9-K7%Y|8(-FZ=Ortnl}jJ}(grq-r$C?Qcx<7(xN8DB+8D{)ovHFqKVS z!Z~NHKYhUN6SdV#T93(;E3DB_+y<}LKc+Hc1^-yNb*j`w?2DCfeKjLC%zjeUgJp1N z=skn3OC1vh9={BxKuF)8gi4-3Pc<~rs6J-dWM_nEIQya^WXxuA?_?6I6a+c9)(j$f zJ))$bq%`q5oRaXE>*f+A7ruXgf4}S@+5SC~7&tbs?Z<|f95EpMU0p>FH(^soWaRk+ zhQ;zeXWUJb?5;eZxJeJ&XL$9`sGporM;A8J#2Ixw^Ny22qviKkk)bh2R3HHi>hQ({ zi_cDm6g3Bg$>=24MrjmK?WDvZKxUYF2|&4XMw%26{M|fHpj<5dffjSKZfAufZ2!g8 zOe%T%x9{5;ya$E@YXxFI%YGK10AAM@Tz~hSgkZ}KIIe(M)sxG9KR>%B(hwvxU$hU^ zUe&6)F&57xHi>54TLS;?r_~G!p z^oVhaG{MAhUp@)`7v!UA4S6@e*mI4uvB?naHmZU8@jd1H^UMfJ>L-PldIYSnpgso} zEO25TAB>aAcKhE8stDsesrYY$+yHxsr#T--7(xL#Y{wRJokkYEyae<$jv#tztxam}T3y zhsk>IuOv?rr*)Pf`yH(B?=0ktFVca!{B5r${ywK5*wUgdDNPOGEIHzgFS(dIttA2l zh8ekQ6GNnmY_3VVfEsX`w|xRF38y`OZyn5fmi3F(K8Bt8DH9je8;@015UG_S3ErkX zmC@A8_Hc&rN=I)yrjfZcet42`#OB`Gwm5?$m9$4W@w( zso8L(ZBDt5#((Qqk^<9BF@PRG%TUlNNMZB(6MijnX9~IOW0GB&2ve%*`o_{>Hs7#k;ybNUr4vrBz zwE8SNd0M`|1V(ZTh{%eoR1%5~gu!X7XvO~IFU>p~#?5J&G@P}G)AlRRB(_D{_=mKN zIN#pmWLk`X5dnzEw{f{=R|J|AF!O;Sk^M{HOXb)E&OkeZns!~viU>8XJg&6*kfV0G zIDOncmjxIXunJn1yX4#)?xvTV`gx;+d`G6#^jpbz`&sC%C?&0cD-1?z&Qf4u!s&kz zstVjcmQ%8Cgax+Hfptfsg!dUyo_k-6%!sn5h$p$IHp$1Jz$P3iTJ>GV@O(9w`dn^9 zrXVbW$f5Bg4F-HO!owe2JB$u2!3^r8sVBz}^N-#HzxTRlfdCetlKI)c7=_I7K@`2q+w$lgla=^q(!poGLRt_VS0 zJGZ}BzYRn^(1g|oySNZ)B@Fr7eD4N+zF+2xfqPSOgSXnh^=~zJ-J|DtgaM2wU1?7z z``Ts&nG3%Ip<6#C@7YAJ+SNZh$&&)f@7Z&JL3b-=;^0Jzt*l$ZGv=)9?^{Wk&f$9@H)YE!5A$wGd!R=IsW9t$9m5Bnbl6c z$MRgu6N3+NWjBM}^{r4wY=_6c#Z(Qo1ZNUBNfawusMQLe@ z;Q^meNGM(85Tz`tr?W*C)$!Bts#PtcR*JOAC>jD{zim1!EC8dk56G~upT^LK!5Et^ zV-!Gxm%**xvD8?53@u~W!n{k6)Rdtf+7~|{pOX}^VywU|!3x#{71q~JO8@nGseGr^ zI8lvYBqZGJe2oN?EG0RKhG1hyZ^7_l!0M14fDsqR8vFZy^7A)tBC`8_c0IsQX|HW% zMhswU{XC+ss#NG6(PBzU^DCVT(g!1f%15Q?vV`p-aLLBi700(- zvJ(6HX6=EshX=)7Bg&>T1JM1_GUYIXFA2_ERCtlgqFVrio@EQ!kGD1jH{R>M?Pr4e zDUZf;FAZ~Sxv#Q_fSFa^&|o&xzsU#;e_gSe<-biq9nCY)z-us#{CP_su4h{aihcgZ z)~GQ1F|`Sc$&@p3qMZM!NzFg`l#O*>_Zw>E8&viKOrjqYj*tEM>L|ay*Rg)9!t^Gk zIhTaWZ+CH2e#m!zCXjC4hN4rKF?KGK1ruQgIrm}`0hQ^Aj3FEItsX(Sbpxp%|4y{! zY-A~ig`c6q!zwlip4DCBa3jjn2%jXx@}V(+6uFb=bmvJ;(fGnw?f}_lpH?pBuIcbQ z(Ux9|CkvHoShXO{J~;AJ-Id&-kiz;F1LVBofuCRP;sC1<^mI^W7jWHLTl)wm^+@bHpgx-=yc9+qC4>Bbk+71V0#y^jx1CY9@ z%`aq$lbc_$90hLAEh~GKQW>Ov5&ch!?*qEJH9JDFPYQVAO?<5N3SfTsTPD|_d24UD z0>YzGeHrg6{WVAISpMZvQG_g0?4gOO%hR#LO-5iQC_!ynRu~SpCj&Shnc@7ZpM=ao zlSn}-Z8a36%T^eClBNCl<(o-E0@i~#Ws06LK5EKk&Fg?Nw?=&AHf`g-xE|zZ6h-fu zd}UD&$_mAnqhodO^YvHzM& zkpO+9Wpgb$Jof=%a2Bh0dKBAAEHh;hgiG~&_@&|(lO&Z^ZLU3^3?Y!1}tqT zwoMfzu1+{Tw`|EX?QwQ~YQ4h`mEBfz(FK;ff50)>90vt}-=CAKqgq((CqvuMIu$m- zc*vc?4t>;%QCC<(u#rF*FLT;X7K)dsMd)W=9Jz7p6$G!xfC}1vNOI~b-u|3~a2~S= zq;YL70ks^a&y)%O+-XWBMm7vav$VZ%Oo@nO9^3KPY{ID2WA%_%kfA5C&7^AQeQR%) z;AyNxvo+uWF&MLJRG%|^)jc^SLY=N;*TlSV`ys<|(9?BB^YjWiX=!)b*WIc)6x{d# z2BCSdwe>xP8j5s#AG$vtmPi8!o9Wx0Qz1=zObpo8BlX|PaBMJYl}$P3SSY-g^BHz} zEzPgSYWi#$TviXf4hfZ$mzP)P_Oteas5(7bZ<6IYb^O8)Nqf1imrRKx&!a|d^+r)( zm@lctK!1aQ&n4joT{TRY3pwIY;*fbBD6P)3K_ARor4j@jtr)&V?$R9<2vd24a!5!> z$W^5kW5JJxzn1VduxG}Dlq@Wzn;MWjp>?Kl^E=Y)0S1Q2F-vVdBAgGAU zbYcSFr}zV|68@@)ig~@@qz|79d6bS#C4o(*2i}=9_~-gm$K6lh#}%u z#z8lFGG@2Dr#C2Fib-s5-#+u-nd)xLQqbUDIUe*om08cVIWPqFm)l8&E8a1|n9X^# zrALrL3p6{_bT2L@f0DgFQTyH8$hpC>CQPeC=XKQ#5HhrQ#0+EZa=rMc%Z!g9^CO=R z;+Utvi<7PXDyZ);imr1v^%Sv}GQ&tM`aS>6*}{9B(H_-)UN0$Qly-xuh08T8Q(m~|^SdT*+~QAsFw?g&cszclKzS@5X+D!*609@2pY!qedX@TF(1gOpO6R`b~I`(&U9{{OAoTG zG`SL`H6O8`&_}R9#?|^*Upr|N+6q|6>&Gjl&t2Q&5p`tN127jyMiKC%Z~LZW;oPFM z=?ze!bmLLde-)vZTtxBed~O{*o+!x4y<L)T zuRu1A6La;LS0<#cHpS$uFfHK39i4lK8P3>wjIPBC0<sEV@V86G-LdkjJTWS_`wi* zL7`GhlZdF}e(xL~qMUnYhSZ33#;JrT+G}ZM;AQXN6lGclWP@a7d}WC+Uq(?95p?s# zQh_AMCA{8@dM>{BLw?T0sYj0Y9WhvlhzhJ=(hoqt;5aaTTiNJVY_R6cTibLi;)eVp zAKfxpt+=9j_M5kZh7v?J+NUg2-V5377-1rqY4>&#d+J>%K*SQ%FR;$W0L%=2pO_)s-_K8|JPPRt@fiqT_rR2 z6iTV4{P*JQ@!HypBgrZOyFwKiogzx#7)fFEkdMmUR7O`uNab%8H)BVf?yFDupGNQ@Pkx_zT(U3X(KxIPNx-l9_Y`D%SmP}m zhG+=5#N?;K8`>}8i)9(V9k;-tBo=qb$#~p@PesQxAqi4D?t5Jfi&yD|0D|t3Zb0m( zV9l}x%ALNJIWNFg7y9}F3xJFKJRwpT=jO?2j}mQFIvtIWVu~5out?-H)YwemjmTEp zwJE1vJ>^!9`6u|?e^>!#J+GQiLz^@!{?&<|iC6TJ%YrrgAV@N5i+3Zqx+Yg%RO;TX zA)zYb0T!cQjlQDpvYWq2nBNT*KLa)4&MKB#Q-%x`H|T|^z;$i8_s|>@(Xnv(^$sbs z(;sve5{_frDVzr!uDXV#y}$dkLz(ekQy`|**G@ir_nHXv^~3S{)xqDUJ}^JQ?Rn1L z?>m$9Y2)Qyf3u*7GsM0&^Z{OLM<3Rz*Y*kA@}z(&h18`unCiFbVKZ<`z^F{1U)6+P zbNgzwi6^S)$EK48Z7^VJ<}`XngZrF9hdA4Z$|~Jcv_-t2#~{ug0vvaC8AdA9M+YH9 z!dDfm%{nxc!R1#P8HhOZBe9M6Af=|S$5x_l;pKmYLKu90s0XP~3Axc!^VPi%O;pe0 z)!Gk!+C9fA71_;cLTKnfue}dj>Fq+)E`EC-{?CPSs=BzjYP~c1!Hw%S@t`K4r)8}9 JUj6R#{{g!L#$x~g literal 15348 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 diff --git a/src/data/placeBelow.js b/src/data/placeBelow.js index 67e120fa9..b5bdff4f2 100644 --- a/src/data/placeBelow.js +++ b/src/data/placeBelow.js @@ -11,7 +11,7 @@ * PURE data module: no Cesium, node-testable. Each pack loads lazily on the * first lookup that needs it (divisions on the first land hit, places on the * first radius) and is indexed once, and the loading status names them while - * one is on its way (`placePacksLoading`); a country's division rings are decoded + * one is on its way (`placePacksStatus`); a country's division rings are decoded * the first time a point falls inside it. A failed load is retried later * (see `createRetryableLoader`). No network. */ @@ -47,8 +47,9 @@ const PACKS = { ), }; -/** The packs on their way now, for the loading status. */ +/** The packs on their way now, and those whose last load failed. */ const loadingPacks = new Set(); +const failedPacks = new Set(); const loadingListeners = new Set(); function notifyLoading() { @@ -61,13 +62,25 @@ function notifyLoading() { } } -/** A pack load that the loading status counts while it runs. */ -function reportedLoad(name, load) { +/** + * A pack load that the loading status follows: on its way while it runs, + * failed until a later load of it succeeds. + * @template T + * @param {string} name + * @param {() => Promise} load + * @returns {() => Promise} + */ +export function reportedPlacePackLoad(name, load) { return async () => { loadingPacks.add(name); notifyLoading(); try { - return await load(); + const result = await load(); + failedPacks.delete(name); + return result; + } catch (error) { + failedPacks.add(name); + throw error; } finally { loadingPacks.delete(name); notifyLoading(); @@ -76,12 +89,13 @@ function reportedLoad(name, load) { } /** - * Whether a place pack is loading now. The packs load lazily, on the first - * lookup that needs them, so the loading status says so meanwhile. - * @returns {boolean} + * The place packs' state for the loading status. The packs load lazily, on + * the first lookup that needs them, so the status says so meanwhile, and + * says when one would not load. + * @returns {{loading: boolean, failed: boolean}} */ -export function placePacksLoading() { - return loadingPacks.size > 0; +export function placePacksStatus() { + return { loading: loadingPacks.size > 0, failed: failedPacks.size > 0 }; } /** @@ -191,7 +205,9 @@ async function readPlaces() { } return { land, water }; } -const loadPlaces = createRetryableLoader(reportedLoad('places', readPlaces)); +const loadPlaces = createRetryableLoader( + reportedPlacePackLoad('places', readPlaces), +); /** Divisions grouped by country code; rings decoded per country on demand. */ async function readDivisions() { @@ -208,7 +224,7 @@ async function readDivisions() { return { byCountry, decoded: new Map() }; } const loadDivisions = createRetryableLoader( - reportedLoad('divisions', readDivisions), + reportedPlacePackLoad('divisions', readDivisions), ); /** The first-level division holding a point inside country `code`, or null. */ @@ -243,7 +259,7 @@ async function readPopulatedPlaces() { ); } const loadPopulatedPlaces = createRetryableLoader( - reportedLoad('populated-places', readPopulatedPlaces), + reportedPlacePackLoad('populated-places', readPopulatedPlaces), ); /** diff --git a/src/data/placePacksLoading.test.mjs b/src/data/placePacksLoading.test.mjs index ff1837a3d..2d95afd49 100644 --- a/src/data/placePacksLoading.test.mjs +++ b/src/data/placePacksLoading.test.mjs @@ -8,16 +8,30 @@ test('a place pack load is reported from its start to its end', async () => { const place = await import('./placeBelow.js?loading-status'); const heard = []; const stop = place.onPlacePacksLoadingChange(() => - heard.push(place.placePacksLoading()), + heard.push(place.placePacksStatus().loading), ); - assert.equal(place.placePacksLoading(), false); + assert.equal(place.placePacksStatus().loading, false); const lookup = place.nearPlace(48.86, 2.35, 50); - assert.equal(place.placePacksLoading(), true, 'on its way'); + assert.equal(place.placePacksStatus().loading, true, 'on its way'); assert.equal(await lookup, 'Paris'); - assert.equal(place.placePacksLoading(), false, 'loaded'); + assert.equal(place.placePacksStatus().loading, false, 'loaded'); assert.deepEqual(heard, [true, false]); // Loaded once: a later lookup is not reported again. await place.nearPlace(48.86, 2.35, 50); assert.deepEqual(heard, [true, false]); stop(); }); + +test('a pack that would not load stays failed until it loads', async () => { + const place = await import('./placeBelow.js?failed-status'); + let fail = true; + const load = place.reportedPlacePackLoad('test', async () => { + if (fail) throw new Error('offline'); + return 1; + }); + await assert.rejects(load(), /offline/); + assert.deepEqual(place.placePacksStatus(), { loading: false, failed: true }); + fail = false; + assert.equal(await load(), 1); + assert.deepEqual(place.placePacksStatus(), { loading: false, failed: false }); +}); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 4cf9643d7..7bffa4d4d 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -64,26 +64,10 @@ import { useLocationStore } from './stores/location.js'; import { useCockpitStore } from './stores/cockpit.js'; import { useGlobeActionsStore } from './stores/globeActions.js'; import { useRecordingStore } from './stores/recording.js'; -import { - onPlacePacksLoadingChange, - placePacksLoading, -} from '../data/placeBelow.js'; -import { LOADING_NAME_KEYS } from './layerNames.js'; +import { onPlacePacksLoadingChange } from '../data/placeBelow.js'; +import { withPlacePacks } from './placePacksLoading.js'; import { preloadOwnerFlags } from './ownerFlag.js'; -/** The place packs, as one more loading entry while they are on their way. */ -const PLACE_PACKS_LOADING = Object.freeze({ - id: 'place-names', - name: LOADING_NAME_KEYS['place-names'], - enabled: true, - stats: Object.freeze({ loading: true }), -}); - -/** The data layers, with the place packs while they load. */ -function withPlacePacks(layers) { - return placePacksLoading() ? [...layers, PLACE_PACKS_LOADING] : layers; -} - /** * Central UI orchestrator for the God's Eye View application. * diff --git a/src/ui/cockpitOrbital.test.mjs b/src/ui/cockpitOrbital.test.mjs index 5de131693..987527c28 100644 --- a/src/ui/cockpitOrbital.test.mjs +++ b/src/ui/cockpitOrbital.test.mjs @@ -222,14 +222,20 @@ test('an owner country shows its flag from the sheet, or its code if the sheet w await settle(); // No sheet: the code, so the owner never disappears. assert.equal(f.text('.cockpit-orbital-owner-badge'), 'US'); + // Another satellite of the same country asks again. + f.store.publishOrbital({ ...f.store.orbital, noradId: 43013 }); + await nextTick(); + assert.equal(images.length, 2, 'a new ride asks again'); + images[1].onerror(); + await settle(); // The next flag asks again; this time the sheet comes. f.store.publishOrbital({ ...f.store.orbital, owner: { countryCode: 'FR', label: 'France' }, }); await nextTick(); - assert.equal(images.length, 2); - images[1].onload(); + assert.equal(images.length, 3); + images[2].onload(); await settle(); const flag = f.section().querySelector('.cockpit-orbital-flag'); assert.equal(flag.getAttribute('role'), 'img'); diff --git a/src/ui/ownerFlag.js b/src/ui/ownerFlag.js index 750b36e43..5020143c6 100644 --- a/src/ui/ownerFlag.js +++ b/src/ui/ownerFlag.js @@ -62,12 +62,18 @@ export function preloadOwnerFlags({ export function ownerFlagStyle(code, { widthPx = 20, heightPx = 15 } = {}) { const index = SHEET_INDEX.get(code); if (index === undefined) return null; - const column = index % OWNER_FLAG_SHEET.columns; - const row = Math.floor(index / OWNER_FLAG_SHEET.columns); + const sheet = OWNER_FLAG_SHEET; + const column = index % sheet.columns; + const row = Math.floor(index / sheet.columns); + // A cell and its gutter, in CSS pixels at this size. + const pitchX = + (widthPx * (sheet.cellWidthPx + sheet.gutterPx)) / sheet.cellWidthPx; + const pitchY = + (heightPx * (sheet.cellHeightPx + sheet.gutterPx)) / sheet.cellHeightPx; return { backgroundImage: `url("${OWNER_FLAG_SHEET_URL}")`, - backgroundSize: `${OWNER_FLAG_SHEET.columns * widthPx}px ${OWNER_FLAG_SHEET.rows * heightPx}px`, - backgroundPosition: `${-column * widthPx}px ${-row * heightPx}px`, + backgroundSize: `${sheet.columns * pitchX}px ${sheet.rows * pitchY}px`, + backgroundPosition: `${-column * pitchX}px ${-row * pitchY}px`, }; } diff --git a/src/ui/ownerFlag.test.mjs b/src/ui/ownerFlag.test.mjs index 737ab018d..d1d6bf455 100644 --- a/src/ui/ownerFlag.test.mjs +++ b/src/ui/ownerFlag.test.mjs @@ -69,28 +69,34 @@ test('the sheet holds every owner country a satellite can be marked with', () => ); // The PNG is as large as its layout says (width and height at bytes 16-23). const png = readFileSync(new URL(OWNER_FLAG_SHEET_URL)); + const { cellWidthPx, cellHeightPx, gutterPx } = OWNER_FLAG_SHEET; assert.equal( png.readUInt32BE(16), - OWNER_FLAG_SHEET.columns * OWNER_FLAG_SHEET.cellWidthPx, + OWNER_FLAG_SHEET.columns * (cellWidthPx + gutterPx), ); assert.equal( png.readUInt32BE(20), - OWNER_FLAG_SHEET.rows * OWNER_FLAG_SHEET.cellHeightPx, + OWNER_FLAG_SHEET.rows * (cellHeightPx + gutterPx), ); }); test('a flag is drawn from its cell of the sheet, at the size asked', () => { + // 40 × 30 sheet cells with a 2 px gutter, drawn at 20 × 15: each cell and + // its gutter is 21 × 16 CSS pixels. + assert.equal(OWNER_FLAG_SHEET.cellWidthPx, 40); + assert.equal(OWNER_FLAG_SHEET.cellHeightPx, 30); + assert.equal(OWNER_FLAG_SHEET.gutterPx, 2); const columns = OWNER_FLAG_SHEET.columns; const index = OWNER_FLAG_SHEET.codes.indexOf('US'); const style = ownerFlagStyle('US'); assert.equal(style.backgroundImage, `url("${OWNER_FLAG_SHEET_URL}")`); assert.equal( style.backgroundSize, - `${columns * 20}px ${OWNER_FLAG_SHEET.rows * 15}px`, + `${columns * 21}px ${OWNER_FLAG_SHEET.rows * 16}px`, ); assert.equal( style.backgroundPosition, - `${-(index % columns) * 20}px ${-Math.floor(index / columns) * 15}px`, + `${-(index % columns) * 21}px ${-Math.floor(index / columns) * 16}px`, ); // The first flag sits at the corner. assert.equal( diff --git a/src/ui/ownerFlagSheet.js b/src/ui/ownerFlagSheet.js index dec6ef8e6..723bf00de 100644 --- a/src/ui/ownerFlagSheet.js +++ b/src/ui/ownerFlagSheet.js @@ -3,6 +3,7 @@ export const OWNER_FLAG_SHEET = Object.freeze({ cellWidthPx: 40, cellHeightPx: 30, + gutterPx: 2, columns: 16, rows: 7, codes: Object.freeze([ diff --git a/src/ui/placePacksLoading.js b/src/ui/placePacksLoading.js new file mode 100644 index 000000000..f18d79769 --- /dev/null +++ b/src/ui/placePacksLoading.js @@ -0,0 +1,30 @@ +/** + * The place packs in the top-centre loading status: they load lazily, on the + * Cockpit's first place lookup (`src/data/placeBelow.js`), so while one is on + * its way the status names them beside the layers, and a pack that would not + * load makes the batch end LOAD FAILED. + */ + +import { placePacksStatus } from '../data/placeBelow.js'; +import { LOADING_NAME_KEYS } from './layerNames.js'; + +/** + * The data layers, with the place packs as one more entry while they load + * or after one would not load, so a batch they join ends LOAD FAILED rather + * than LOAD COMPLETE. + */ +export function withPlacePacks( + layers, + { loading, failed } = placePacksStatus(), +) { + if (!loading && !failed) return layers; + return [ + ...layers, + { + id: 'place-names', + name: LOADING_NAME_KEYS['place-names'], + enabled: true, + stats: { loading, error: failed ? 'Place names did not load' : null }, + }, + ]; +} diff --git a/src/ui/placePacksLoading.test.mjs b/src/ui/placePacksLoading.test.mjs new file mode 100644 index 000000000..09db21fc3 --- /dev/null +++ b/src/ui/placePacksLoading.test.mjs @@ -0,0 +1,59 @@ +// src/ui/placePacksLoading.test.mjs — the loading status names the place +// packs while they load, and a failed pack ends the batch as failed. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + aggregateLayerLoading, + createLoadingFeedbackState, + reduceLoadingFeedback, +} from '../loadingFeedback.js'; +import { withPlacePacks } from './placePacksLoading.js'; + +const SATELLITES = Object.freeze({ + id: 'satellites', + name: 'Satellites', + enabled: true, + stats: { count: 9000, lastUpdate: 1 }, +}); + +function batch(states) { + let state = createLoadingFeedbackState(); + let now = 0; + for (const status of states) { + now += 1000; + state = reduceLoadingFeedback( + state, + aggregateLayerLoading(withPlacePacks([SATELLITES], status)), + now, + ); + } + return state; +} + +test('nothing extra while the packs are idle', () => { + const layers = [SATELLITES]; + assert.equal( + withPlacePacks(layers, { loading: false, failed: false }), + layers, + ); +}); + +test('the packs are named while they load', () => { + const summary = aggregateLayerLoading( + withPlacePacks([SATELLITES], { loading: true, failed: false }), + ); + assert.deepEqual(summary.activeIds, ['place-names']); + assert.equal(summary.active[0].label, 'Place names'); +}); + +test('a load that lands ends complete; one that fails ends failed', () => { + const loading = { loading: true, failed: false }; + assert.equal( + batch([loading, { loading: false, failed: false }]).terminal, + 'complete', + ); + assert.equal( + batch([loading, { loading: false, failed: true }]).terminal, + 'error', + ); +}); diff --git a/src/ui/styles/cockpitOrbital.css b/src/ui/styles/cockpitOrbital.css index 6f0bd76f9..9e799f2be 100644 --- a/src/ui/styles/cockpitOrbital.css +++ b/src/ui/styles/cockpitOrbital.css @@ -200,6 +200,9 @@ border: 1px solid var(--orbital-faint); border-radius: 1px; background-repeat: no-repeat; + /* Only the flag's own cell: the sheet's neighbours stay out from under + the border. */ + background-clip: content-box; } .cockpit-orbital-owner-badge { flex: none; diff --git a/src/ui/views/panels/CockpitOrbital.vue b/src/ui/views/panels/CockpitOrbital.vue index 3e8083755..e43848d7e 100644 --- a/src/ui/views/panels/CockpitOrbital.vue +++ b/src/ui/views/panels/CockpitOrbital.vue @@ -195,9 +195,14 @@ const mark = computed(() => const flagsReady = ref(false); const flagsFailed = ref(false); watch( - () => (mark.value?.kind === 'flag' ? mark.value.code : null), - async (code) => { - if (!code || flagsReady.value) return; + // Keyed on the satellite too: after a failed load, the next ride asks + // again even when its owner is the same country. + () => + mark.value?.kind === 'flag' + ? `${mark.value.code}:${orbital.value?.noradId ?? ''}` + : null, + async (key) => { + if (!key || flagsReady.value) return; const ready = await preloadOwnerFlags(); flagsReady.value = ready; flagsFailed.value = !ready;