From 1ff98bacf655891df8e79c67e66b3589a640b9a4 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:53:21 -0500 Subject: [PATCH] Galaxy web presentation Add Galaxy web assets and client settings interfaces with their bundled vendor notices. --- .../tests/test_speed_source_preference.mjs | 14 + .../galaxy/web/assets/galaxy-icon-180.png | Bin 0 -> 2104 bytes .../galaxy/web/assets/galaxy-icon-192.png | Bin 0 -> 2289 bytes .../galaxy/web/assets/galaxy-icon-512.png | Bin 0 -> 6923 bytes .../galaxy/web/assets/galaxy-icon.svg | 6 + .../galaxy/web/assets/starpilot-logo.svg | 1 + openpilot/starpilot/galaxy/web/css/galaxy.css | 1229 ++ .../starpilot/galaxy/web/css/settings.css | 459 + .../starpilot/galaxy/web/data/catalog.json | 96 + .../starpilot/galaxy/web/data/runtime.json | 1 + .../web/data/system-monitor.sample.json | 21 + openpilot/starpilot/galaxy/web/index.html | 27 + .../starpilot/galaxy/web/js/ambient-stars.js | 13 + .../starpilot/galaxy/web/js/android-auto.js | 457 + openpilot/starpilot/galaxy/web/js/app.js | 226 + .../starpilot/galaxy/web/js/auth-client.js | 97 + .../galaxy/web/js/bluetooth-devices.js | 21 + .../starpilot/galaxy/web/js/bluetooth.js | 220 + openpilot/starpilot/galaxy/web/js/boot.js | 14 + openpilot/starpilot/galaxy/web/js/cameras.js | 100 + .../starpilot/galaxy/web/js/cloud-provider.js | 65 + .../starpilot/galaxy/web/js/controllers.js | 202 + .../starpilot/galaxy/web/js/crash-reports.js | 102 + .../starpilot/galaxy/web/js/device-picker.js | 92 + .../galaxy/web/js/device-preferences.js | 8 + .../starpilot/galaxy/web/js/device-state.js | 86 + .../galaxy/web/js/diagnostic-tools.js | 114 + .../starpilot/galaxy/web/js/drive-state.js | 67 + .../starpilot/galaxy/web/js/drive-stats.js | 178 + openpilot/starpilot/galaxy/web/js/driving.js | 57 + .../starpilot/galaxy/web/js/favorites.js | 138 + openpilot/starpilot/galaxy/web/js/flm.js | 283 + .../starpilot/galaxy/web/js/galaxy-select.js | 146 + .../galaxy/web/js/galaxy-setting-row.js | 184 + openpilot/starpilot/galaxy/web/js/galaxy.js | 81 + openpilot/starpilot/galaxy/web/js/home.js | 270 + .../starpilot/galaxy/web/js/install-app.js | 41 + .../starpilot/galaxy/web/js/layout-backup.js | 96 + .../starpilot/galaxy/web/js/layout-preview.js | 105 + .../starpilot/galaxy/web/js/local-access.js | 86 + openpilot/starpilot/galaxy/web/js/logs.js | 71 + .../galaxy/web/js/longitudinal-curves.js | 105 + .../starpilot/galaxy/web/js/map-operations.js | 330 + .../galaxy/web/js/model-laboratory.js | 185 + openpilot/starpilot/galaxy/web/js/models.js | 584 + .../starpilot/galaxy/web/js/monitor-feed.js | 88 + .../starpilot/galaxy/web/js/navigation-map.js | 262 + .../galaxy/web/js/navigation-style.js | 47 + .../starpilot/galaxy/web/js/navigation.js | 292 + .../starpilot/galaxy/web/js/onroad-layout.js | 862 + .../starpilot/galaxy/web/js/pip-geometry.js | 30 + openpilot/starpilot/galaxy/web/js/pip.js | 243 + openpilot/starpilot/galaxy/web/js/plots.js | 249 + .../galaxy/web/js/projection-layout.js | 25 + .../starpilot/galaxy/web/js/record-history.js | 357 + openpilot/starpilot/galaxy/web/js/router.js | 22 + .../galaxy/web/js/sentry-notifications.js | 139 + openpilot/starpilot/galaxy/web/js/sentry.js | 72 + openpilot/starpilot/galaxy/web/js/settings.js | 347 + .../galaxy/web/js/software-status.js | 456 + .../starpilot/galaxy/web/js/sound-packs.js | 172 + openpilot/starpilot/galaxy/web/js/startup.js | 36 + .../galaxy/web/js/system-monitor-data.js | 76 + .../starpilot/galaxy/web/js/system-monitor.js | 60 + .../starpilot/galaxy/web/js/toggle-search.js | 159 + openpilot/starpilot/galaxy/web/js/tools.js | 20 + .../starpilot/galaxy/web/js/vasm-geometry.js | 50 + openpilot/starpilot/galaxy/web/js/vasm.js | 274 + .../galaxy/web/js/vehicle-controls.js | 202 + .../starpilot/galaxy/web/manifest.webmanifest | 30 + .../galaxy/web/sentry-push-worker.js | 20 + .../galaxy/web/vendor/.gitattributes | 2 + .../bootstrap-icons/bootstrap-icons.min.css | 5 + .../fonts/bootstrap-icons.woff | Bin 0 -> 176032 bytes .../fonts/bootstrap-icons.woff2 | Bin 0 -> 130396 bytes .../vendor/notices/Bootstrap-Icons-MIT.txt | 21 + .../galaxy/web/vendor/notices/Vue-MIT.txt | 21 + .../galaxy/web/vendor/vue/vue.esm-browser.js | 16774 ++++++++++++++++ 78 files changed, 28091 insertions(+) create mode 100644 openpilot/starpilot/galaxy/tests/test_speed_source_preference.mjs create mode 100644 openpilot/starpilot/galaxy/web/assets/galaxy-icon-180.png create mode 100644 openpilot/starpilot/galaxy/web/assets/galaxy-icon-192.png create mode 100644 openpilot/starpilot/galaxy/web/assets/galaxy-icon-512.png create mode 100644 openpilot/starpilot/galaxy/web/assets/galaxy-icon.svg create mode 100644 openpilot/starpilot/galaxy/web/assets/starpilot-logo.svg create mode 100644 openpilot/starpilot/galaxy/web/css/galaxy.css create mode 100644 openpilot/starpilot/galaxy/web/css/settings.css create mode 100644 openpilot/starpilot/galaxy/web/data/catalog.json create mode 100644 openpilot/starpilot/galaxy/web/data/runtime.json create mode 100644 openpilot/starpilot/galaxy/web/data/system-monitor.sample.json create mode 100644 openpilot/starpilot/galaxy/web/index.html create mode 100644 openpilot/starpilot/galaxy/web/js/ambient-stars.js create mode 100644 openpilot/starpilot/galaxy/web/js/android-auto.js create mode 100644 openpilot/starpilot/galaxy/web/js/app.js create mode 100644 openpilot/starpilot/galaxy/web/js/auth-client.js create mode 100644 openpilot/starpilot/galaxy/web/js/bluetooth-devices.js create mode 100644 openpilot/starpilot/galaxy/web/js/bluetooth.js create mode 100644 openpilot/starpilot/galaxy/web/js/boot.js create mode 100644 openpilot/starpilot/galaxy/web/js/cameras.js create mode 100644 openpilot/starpilot/galaxy/web/js/cloud-provider.js create mode 100644 openpilot/starpilot/galaxy/web/js/controllers.js create mode 100644 openpilot/starpilot/galaxy/web/js/crash-reports.js create mode 100644 openpilot/starpilot/galaxy/web/js/device-picker.js create mode 100644 openpilot/starpilot/galaxy/web/js/device-preferences.js create mode 100644 openpilot/starpilot/galaxy/web/js/device-state.js create mode 100644 openpilot/starpilot/galaxy/web/js/diagnostic-tools.js create mode 100644 openpilot/starpilot/galaxy/web/js/drive-state.js create mode 100644 openpilot/starpilot/galaxy/web/js/drive-stats.js create mode 100644 openpilot/starpilot/galaxy/web/js/driving.js create mode 100644 openpilot/starpilot/galaxy/web/js/favorites.js create mode 100644 openpilot/starpilot/galaxy/web/js/flm.js create mode 100644 openpilot/starpilot/galaxy/web/js/galaxy-select.js create mode 100644 openpilot/starpilot/galaxy/web/js/galaxy-setting-row.js create mode 100644 openpilot/starpilot/galaxy/web/js/galaxy.js create mode 100644 openpilot/starpilot/galaxy/web/js/home.js create mode 100644 openpilot/starpilot/galaxy/web/js/install-app.js create mode 100644 openpilot/starpilot/galaxy/web/js/layout-backup.js create mode 100644 openpilot/starpilot/galaxy/web/js/layout-preview.js create mode 100644 openpilot/starpilot/galaxy/web/js/local-access.js create mode 100644 openpilot/starpilot/galaxy/web/js/logs.js create mode 100644 openpilot/starpilot/galaxy/web/js/longitudinal-curves.js create mode 100644 openpilot/starpilot/galaxy/web/js/map-operations.js create mode 100644 openpilot/starpilot/galaxy/web/js/model-laboratory.js create mode 100644 openpilot/starpilot/galaxy/web/js/models.js create mode 100644 openpilot/starpilot/galaxy/web/js/monitor-feed.js create mode 100644 openpilot/starpilot/galaxy/web/js/navigation-map.js create mode 100644 openpilot/starpilot/galaxy/web/js/navigation-style.js create mode 100644 openpilot/starpilot/galaxy/web/js/navigation.js create mode 100644 openpilot/starpilot/galaxy/web/js/onroad-layout.js create mode 100644 openpilot/starpilot/galaxy/web/js/pip-geometry.js create mode 100644 openpilot/starpilot/galaxy/web/js/pip.js create mode 100644 openpilot/starpilot/galaxy/web/js/plots.js create mode 100644 openpilot/starpilot/galaxy/web/js/projection-layout.js create mode 100644 openpilot/starpilot/galaxy/web/js/record-history.js create mode 100644 openpilot/starpilot/galaxy/web/js/router.js create mode 100644 openpilot/starpilot/galaxy/web/js/sentry-notifications.js create mode 100644 openpilot/starpilot/galaxy/web/js/sentry.js create mode 100644 openpilot/starpilot/galaxy/web/js/settings.js create mode 100644 openpilot/starpilot/galaxy/web/js/software-status.js create mode 100644 openpilot/starpilot/galaxy/web/js/sound-packs.js create mode 100644 openpilot/starpilot/galaxy/web/js/startup.js create mode 100644 openpilot/starpilot/galaxy/web/js/system-monitor-data.js create mode 100644 openpilot/starpilot/galaxy/web/js/system-monitor.js create mode 100644 openpilot/starpilot/galaxy/web/js/toggle-search.js create mode 100644 openpilot/starpilot/galaxy/web/js/tools.js create mode 100644 openpilot/starpilot/galaxy/web/js/vasm-geometry.js create mode 100644 openpilot/starpilot/galaxy/web/js/vasm.js create mode 100644 openpilot/starpilot/galaxy/web/js/vehicle-controls.js create mode 100644 openpilot/starpilot/galaxy/web/manifest.webmanifest create mode 100644 openpilot/starpilot/galaxy/web/sentry-push-worker.js create mode 100644 openpilot/starpilot/galaxy/web/vendor/.gitattributes create mode 100644 openpilot/starpilot/galaxy/web/vendor/bootstrap-icons/bootstrap-icons.min.css create mode 100644 openpilot/starpilot/galaxy/web/vendor/bootstrap-icons/fonts/bootstrap-icons.woff create mode 100644 openpilot/starpilot/galaxy/web/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 create mode 100644 openpilot/starpilot/galaxy/web/vendor/notices/Bootstrap-Icons-MIT.txt create mode 100644 openpilot/starpilot/galaxy/web/vendor/notices/Vue-MIT.txt create mode 100644 openpilot/starpilot/galaxy/web/vendor/vue/vue.esm-browser.js diff --git a/openpilot/starpilot/galaxy/tests/test_speed_source_preference.mjs b/openpilot/starpilot/galaxy/tests/test_speed_source_preference.mjs new file mode 100644 index 0000000000..88d5ba9576 --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_speed_source_preference.mjs @@ -0,0 +1,14 @@ +import assert from "node:assert/strict" +import { validDocument } from "../web/js/onroad-layout.js" +const metadata = { profiles: Object.fromEntries(["large", "compact"].map(name => [name, { + bounds: {x:0,y:0,width:100,height:100}, widgets:{}, reservedZones:[] +}])) } +const document = { version:4, palette:{cardFill:"#000000A6",cardBorder:"#C4CDD0B4",text:"#FFFFFFFF"}, + layouts:{large:{},compact:{}},widgetColors:{large:{},compact:{}},roadColors:{large:{},compact:{}} } +assert.equal(validDocument(document, metadata), true) +for (const flag of [true, false]) assert.equal(validDocument({...document,speedSources:flag},metadata),true) +for (const flag of [1, 0, null, "true", {}, []]) assert.equal(validDocument({...document,speedSources:flag},metadata),false) +for (const malformed of [null,undefined,[],1,"layout",{}]) assert.equal(validDocument(malformed,metadata),false) +assert.equal(validDocument({...document,unexpected:true},metadata),false) +assert.equal(validDocument({...document,layouts:null},metadata),false) +console.log("PASS speed source preference: absent/booleans/invalid flags/null/undefined/malformed documents") diff --git a/openpilot/starpilot/galaxy/web/assets/galaxy-icon-180.png b/openpilot/starpilot/galaxy/web/assets/galaxy-icon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..62972b9778dd019751feded8b092f848333e9948 GIT binary patch literal 2104 zcmc(g`8(T*7RJ*uM5mTiYRgyEF118yRTQN`B3gU3qiAbX7~8d$s^wP6RA#K5Sc;A$ zY8y*Y)KWuiu{IN`sWxIuqp{qOA|trwubAg~&v~EoIlsT}Q&(rS^a+&{AP`8}5o70m zm#mhaj{xkUsb9|eR=MZ2*gmsuZ}J(kcc;7F)7p~E z@vi`@ey9B%X;|%S`e%>yJ}c4*jr&?Zc~G1tSGzmEykREsn)7E9O!tzSNX^j$Ypk}7 z@vel+i98yW0W$7KANv!8{8Rkj6GatieYnX0sXbFz`q|s^V4c8D+jT6a$GGiz$S)M| zInx8rdk()Zll$S$!M00PQMT-Qit1NNr^C+rAcDO%(8)fT`)!EP(C0sY{nJ{tNnL;Y z{yqYK{%-u+3uJj+Jg(lI6PIp0&cu3zrC;E@oZHVcDV<`2cEeFAG7r8|4kEjPe`Nj;D`bHIs z-d6*@YnZ`pgdd*6^`GBt_lG0Q2g!gtNZQHN8>@{FWszpo4Cj5`+S@K8>ojx%vWWrF&o~`>EZGK- z$o7xo!gCtwz7`Ee6yhfqcGiqLz}Ah2hRBbRf5l23jD49N-%FwRn=42UEp8iosqhY_7iy*fG-@p(K;eE=J`xAaJJr~=?MLIS-^ zHsodR==`rxPC{a%OopV0~=X>Zxg^qa)!=+%onzXJJ9<9(jYuYl|%0<0WKxg`>7zRR+ z3&*jkZW6d=dlRRk;Ygu=y`XWVpMfKmj;fQ_S2dI#NsrtOEGknaPBPqZA1CBfO6DZE zIYBgm;$Je$7)h3|FoJotKtFXQ4~y;4xfC6v=M^AP zd(GmkYVbjRou=5yS6{12MWSm~x0MYN9LLUv>ZMhZmRmFg+PA{yP#+p!o#NNSU9H

w`tFdxjU~uaLax0(kp3n_v+OLos4iLMcAUpMKxO(W7)te z<)Jn6&AInF7_krF;Iz8XF8HOS>%k6H++3Ar|3#y%sM(KD0uWsNOezeUqUZHB&gP7T zPuZi`gO~THYg3=e0F0*1oSM0E`+h!l z3><#BH^5Xjp;YJ=H+q6#C_T}t8+q_MxJP>M6lvMBzKjEv&z#Xs#2f06n0h0JX&OE| zIe~nXze5ENF8 z68T#lS%C=bLxK!Tn@q`>j7VuC5l{*)XUa0L z+=2m1+`5XSvU04QjJjX+=iLiZ>N1P?v)RtX8U7;eV`$Z{#Y=Kc(8Y1tm3gj%y%TAi z{!6C2Bldc*wr0cCUwVOiw~P2~Go_M$1&FUG4MC6p+(r zwco?uJqE76#C*6tbwdEAuZ) ps0IQZ9x@DBgrokK_^0^AKphvI(u3T^GLHzz(camv{_5Sw{|Bs=*|Got literal 0 HcmV?d00001 diff --git a/openpilot/starpilot/galaxy/web/assets/galaxy-icon-192.png b/openpilot/starpilot/galaxy/web/assets/galaxy-icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..8547af3969a7f8b3a8e8ee70df44d8bf59670da4 GIT binary patch literal 2289 zcmd6p`9BkmAII0s$Jz&t4a|QF7nr zN>aIRa(|2&Dn34c#rONe>-~B?-tQmYKfazMjD-mYgdYL`060uhhSsOv@}I!K(>c}j z69WK%3r!96Y(igcWLNnKm_T*Ys;(6Ua(zgdv(~dYTUw#NxyCsx_Ll?E-R1#0RefyAld)PVd@Xd@Shs&$!8#YKhkm|m8Xw)T@b~_ zQQQ|xVG)(bzje|V*XYQmM#4Q+DB&}o+t(bW$M@;`#Klig5P8Pr7wKLbRqL#&XGG)4 z0|ft+#&iV#Bsa^@Sl@!;Hy1_c$KdcpOwj(ky1CS)0+;rVZG(`k7F_s0Op)#1;cU$( zcTxx$Z!hh(aR$F&Su87c<8Y6KiXxUt} zO1ddDoGz8B?Fx-GFe&;eEJROkd#P0!N3`&`9KXW06_}9)hDU|rCRA!X0U2eY#v=!m z=N2Mexd&}l{tPZcCcbSq>R^6z-Ln0iNo=lMc)e1i8V>$c5S*-n6?8evFpwOGL1wxI ziGD(Da1}J*s@*-DPT%(T^(%{Vb|!DOiH!g|xsttZh1NNFqdF7gvQ$0-d(lRSpt%Mz z+;5Xj>@{$y&S}BaS)&A@A{_k;l3dvkFb<G7+rdR(AtkmR3O}(!jHX?ydMjX?tlJ7nNYYNy+OkbD@43ToYN19I?o&8 zygP1Jx@t8bUi(7S5HubcI?xN0-3Q|}IlGGGF=XUn|J)OS?qG?(??>5{7wR7RYa%DJ z8e4KM^{0Nm!HU$ewTa!OM++`+sxbD9w`TyJtJ#W`bD)5Zid(7*w+<+rbzjhh~hllIbo8 z*e&>0iX6)u0=ZJ(N?PR4n@vYaw7gIW4+{JH9q&9~8rAAmc~S6pA;kR;6v|W`8a9i`kKy zXZ~)20wtZNs^YEET{Qt384Q|uSqJ=RFQSI%%z9w@7|l zAJ{uwo8jxb);yhj13o=V3hmDq_+Iv?8DzO=QL~3t4Bg4GrI;a&q&403gRCY5*3P=0N&eAG&YCtGPSWf z=oUju)yw2;pUEiKIZ+4ay=|Ky1h2m)=*XfhJP=sH$Se9+LuAM0&dna{FzPU zdH0wxk%3qF?tU)mt!{vIm5GM|?>%?nk_KEobi0C4J8HM}NF0UK5qiUvDzZ}f%ns}1 z&Eh0*UF%OGh<5H=?1Fkul>HqpRm(|ld$n@Lk(PE&q^yhi%|&H=6*F6V{K z(|l1AM;R+LKR0J0j2j-w`SQ2y_nA^}Y@QNBLXns%jHwHwHQLo8wO;!s{Fdve)}>q| zrV0h>9b+091y92v&a`Xmz?egl2OX)gIKOOm<$&=^Cr-ma@hTf92-2w`eEsPnLTuqo zi3X~sJHRA-^Q<#Sw$L)Om$I5U45%M+oPd46HJqr1&h|0B6%P-u8XT(OaVM<_A~TOU ziKOkYQ5}4pv^w`i^Jda=3H|9KrpTmOgpaeO)D^S*17bpsCcB&<`H;EM8E`bZ+J0N> z?1hfd>*`+&CCWI}tZ-`>SL=XoVOaH!dOf-Kw7OcwI{Bc#99xZG8Ra$yzS+BcglLZr z=_D%07cW6HY8*Xx86B~+)}ATetL@AR*&U?V8WwH0Qw??+vl}(FkyB|d3t7+B)c&nFv>}XL+rSXE5 z2_jE7GAwSB&0r?cH2YcuvEsRsEdgZb>&sT~kCr*@szieHBAneZ&#=f;Y{KY5-?DcT zqceVb&Ur=L&&yeuglw|~p;c!ddox!C3$8&`);sju$CgKu5^}KDevR* z-`v!^W`&rOxjfOPx3J!l6Mygn1$y^?RM!OZ{Zn2!Iq4gfC7y;q{v#F^fXs9au3=|2 Q>vRJ!HL@_Q)V~$^FVAHW6#xJL literal 0 HcmV?d00001 diff --git a/openpilot/starpilot/galaxy/web/assets/galaxy-icon-512.png b/openpilot/starpilot/galaxy/web/assets/galaxy-icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..3b9dea092f7a3d6cdc5507862ce1140f4caf7c5d GIT binary patch literal 6923 zcmeHs`9D-`{P#7Bu{4$xQiR+gWX+OdMhg*yuQDj<@$WCbI$eI&inJe?pt3l6%^Pn000m)J9owg z03`Pk3Hbh;qV}II2S5avoiVlxfBthES1CPM$2;{)P}**<=F#1S`L(AiejpuvZ4gg> z^Q4Gg{L59qT;NQLMD5-SR%Zn^mBbt~MUUK!-etgQ*l^y^n23T_vJJKUCgxMUj&~&P z`IH+$@N4wz$&@H&;W1!zvBOA z%|g`rxY^+5NaniNYwH&QQs+Q3R?X$^a!6plzD$4v4`ZA@9}w$KQMp)Q{${RY)on|i z8r>YpSdmf5Qex)gSflwhlMyOnd1E>5mbk{P;~XEt>NLlyDg5$y!@T^I!RG%7q=!$< z35FYXVH>hBQdYS}TN`wlZcLUr(0JO{d}%x1tT_pxup3})-5#VABNv5@6inV?C`3C4 zBNjb>AS8BUI}B#cH>+$YTXxwjTAfE0@2d?{FBi#r{f(+7YcPm{D~)0*($oK&HTu?2 zg7}M6@Py&f(P4!%ya-C&*zbs)7kaih zx%V;30_j>i1C{3=taKHc{l(Xz{k#Ac^ge9eL2&S@fPM zkaCML4|-Y?Ro_`MqD6UvS3*urea(SBMBOsMoKXNmDyvNY+o9Bvrj$-18BNig{o%IM zn47U=RoP!auF`w=1fj`CMBO+DWt2czgD9iCCkO-|G5;c`&czQCerAe-ro4O&b%$gl zicMZcP>lJBKWg7Vojx)I+m^j0L-W=7^vVSu=?tKMZ)hp7Ly=}?C?hEnmoNJ`uZ-2; zS~ub~zt-8$TkO~!Eb9&0Y&mtQ)rlzO-^5l;l+q-kK{poqbXN(gJ^K~iXe%F?YdszKYuN`5d z^F~k|n$mO~vVK)zM#K&uN08NXaC=K*M0;-=R?8sWO^*?j9O+Mc&E>C8qA6FniUw4S zgemxWXHK|~J|cQUlza*4E^&;YkGXWJ>k#-r3!by$IjhO9lmvz+qJ&p#V#n7=qS@%Z zXMlhAh&r#QsI>@!TvhR?&o&V@ar&8PhChO)JT;)j7Gq)h{LxE^u4JP$BG<@Ad&XGK znNWpKkh_Wk;;aaGmaV5YogX@1UZ|27hZ(6*S8ZFHyJlS@K^aL zAa)eNhi^JUtY144hVbVS1}-KAql8s#FaZU8oJb?s5rp3O z5rW%_(3B_Z-@n?H-DbrOl05r)Midd`0$b*4!1plbA1$iQnvpZgsk>{4(BUj($RSlX zn{VDHyeS=9+b_<#5~9o`uB+Te94!ilsrR7dAzWf=qL#X2o7!ESBPg|w9dB?+6O~m~ zvFaEYJj9-MUoR`|FHbE(`m~K7K;AwJx@~Bi$4zryLsC{T_N2!jZekOPjFeg}`H9X; z9$f>=uuhMR(2?dk1NitjPZBo6={d+MBup5T!k$}H;}^NU1f3?yZZMC$c?$SmB;x9mI8 zG+prYJ)mpf_B+S4X`Jz~1IG5N-AOl(R?OR0uh*BB*KDMj3nR!_)Si9E^cj-T`zb$=U7~-V5={CdXoPLH-<=XU??$b_GT5g#w z%xhw_r`HOjdJ3re1|{b}!gi;*&kLRU()stU-JtsNl@3E%AtZe5cP646h|2--x%z)J zBemRTEN8AfvjW{(C51n>)nC_1Z{R_uLspA zTFDf+Woq_nr<5@69UU- zd*57dob9;3{Cc;MCkN(Yr8OuPEKE z2|4l6!w5kpsdxP^! z`?K_EA{CxpW0u(n)e?gARy6Ry-6F+K%?|a2^k=^y?FN*IN0Jcf;QP9$fIRIBD+fPy;>`7qaLY

X z6+l3nfd+Q}N7JQgf)%yHWn~aeDdz+G2AYwV)!`zs_d^Y4Op+NG`?7ekawW|9GiG1< zgD9#4=@0r|)=mSp8Jb-{%`KiT$zS*_hBaC-9#RS)lEQ?YRbY6m(*`rGQ#F#U|tA79XN|3Dor#6Lgy6SQ_dc|15IX^W1fNj)WH`=#myu zw&J?)g?eat>mv^$=n^OMrnzD1B$#9eT*?p9c_$+u^)VyhmbTX!wgt&rk=0#4; zofO0?6>IK{OBxwUYOKGlS)Lz4Q}(kO@M2P3>ZakhuTfsp@HaSGCjZ> zQH0fR_}5e|rUX(E0e+t=@2|2hxyn8+c5b3ptZD8L>L*a$Z_yO@+{Y^(ZY zGwLydgFJBPBdDcdr@~zn5uS4mrSOgas}|8hJNDaK?iC4aC=a^UCqx$EKs3)8hCu|-)!n#_|lsBXTbs!YsZ&XX+*Aj zue2D=?9!oH(vx(IZ8e`&_g>BK0OS@O!r#AzRQ&hM_v~ zIQyHyFT!c0J1PSA+6+Xc(PMS~wq0NROf42(FLPHTOikeeYO7zm8AfgY~JQS-@4J+{LAXo(GhQDe$QJ6-CW(xMZt)XkL__P<+V1V6+EbRAmLeU5F3 zEx3D)r9(jd`(2mN0sSO_;fKq3f3?Ad)YTvv-@fFx8x36CDwzg!)0IP&ISGo|R#4TW z7*cD8(E%v1=g`_yf8MJx^1@zzD@-Ze;qvC27Msmi{gCeGKcK4KhFoM~yXn%|uJXwc zf&G3p)|3%CQLaT(efZs_a z3%DJ2gWbJK=w6iCNJ!fT&MdF0PKJxrP6&Q#f?oRdzFWT$ba2Qy+URaq96Xp0X&Y+O zpxLXfuxpiIhy-irB;B9ia&y`D3cS{|M8NtC;$q(TE{sRIw}w_+zu>YnegHw1FqF?1 z%iVRp?T|g8)5kFoU7}ur%+j4{f5b|>*{@t-)tB9Iqb|1_;oEu{sj>#FQ#A9Kv!yMK5TFQnQ_<0`Q*X$FkF<5eOtmJq=xE@y&kap@8G8ubt%;@Oc}yo&v*IHqbV20SH6+Gi)&w9JK@%D1v?y6 z9^(dyt=Os5)Zd`7kBcJ6eyB%z_mdDk z6b(If$HqHh2}`(u?&*(IZeQX`4@BGV3A+>djjcJO{Lzdl^PR2vQRxmCZsFp|fmZm&*&cb%sMpY}D7YgT-PZ9r5%q>Y(<^Nn&bs3^*nAzCcQU~z z_Qk|xzY}4mn`1qk~dwF!_KLo1R@Glta|4rPa8~RuRB+U~HcY)0^^NV0Lkcd|5MZlhA39 zely7P`e1FDy6H{bgr`^UR6nzWiD>+6(&3i|GQJD-lWk0UQR2B(aJ-KKsZVNLiKsSfrKwz_lI4WU0c$#2A>LKB zjQ*14V3zM`JGgZZ;7wm@6O+u_{mv&MAZ&j%wTqZ~zG-kg)wUv$EA2?vk`;c>@BFwF z6+3(Gw%hN@NFm=|#{3gWN{gkdxKT_o&$M(Gr;#mLSQQP0vi{ptub&Xl9_3O)mm~B4 zTUbA87<-W1*nK_d9%RfeqOR`hy1-?EQE@#L=Tsp1&&5F}%p&9r`cX29@ZGw>RaP2fC zX;y}PrEa?X5uC{sezfnn82K-GMc)xQ2Cn;CUFmfO{m3+Svy z^ih@3psV5PDQjsJ{tEhUyCoyKC>ZM%+Y_&vXl#6*hk33!=L)0DF%$>CjDODu0#@qz zjgMA}+nwl2xI8-<6;4>%AD&iE{eTAa>}>RRhX{?}hm!>>)q9Xc!I?e9^ABH82QorZ zpG0m1zMpC?fQ%Xrfng`ZqAR6eSIl!O^~rHDk@@kKIyD?sBW)))HHN{-y~cnnA4+JB z6vGUzDDo;BaSO>$J8kS+15s}^dFr-fDV$PoknzB3`VGRmG*W)f4-URx_pjH>%7Mto zpRq)n#xd;3#mt5$c|s5C-?~8-e5XK=g=^vE`-vk2d+^U+|GT;H|NW-QYnT@R7~a>< h?N + + + + + diff --git a/openpilot/starpilot/galaxy/web/assets/starpilot-logo.svg b/openpilot/starpilot/galaxy/web/assets/starpilot-logo.svg new file mode 100644 index 0000000000..b326657a86 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/assets/starpilot-logo.svg @@ -0,0 +1 @@ + diff --git a/openpilot/starpilot/galaxy/web/css/galaxy.css b/openpilot/starpilot/galaxy/web/css/galaxy.css new file mode 100644 index 0000000000..e5b2688cf4 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/css/galaxy.css @@ -0,0 +1,1229 @@ +/* Focused extract of frozen StarPilot Galaxy material.css, commit 678af78347d9656bc2f5dacd4204b012db861fa7. See ../LICENSE and README.md. */ +.gx-settings { display: grid; gap: 18px; max-width: 1120px; margin: 0 auto; } +.gx-settings__header, .gx-settings__subhead { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.gx-card.gx-settings__header, .gx-card.gx-settings__row, .gx-card.gx-settings__dialog { padding: 24px; } +.gx-settings__header h2, .gx-settings__subhead h3, .gx-settings__row h4 { margin: 0 0 8px; } +.gx-settings__body { display: grid; gap: 18px; } +.gx-settings__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 330px), 1fr)); gap: 16px; } +.gx-settings__row { display: flex; flex-direction: column; justify-content: space-between; gap: 16px; } +.gx-settings__row p { margin: 4px 0; } +.gx-settings__value { font-size: 1.12rem; color: var(--on-surface); } +.gx-settings__controls { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; } +.gx-settings__controls button { min-height: var(--touch-target); } +.gx-settings__modal { position: fixed; inset: 0; z-index: var(--z-modal); display: grid; place-items: center; padding: 20px; background: rgba(0, 0, 0, .72); } +.gx-card.gx-settings__dialog { width: min(100%, 560px); max-height: calc(100dvh - 40px); overflow-y: auto; display: grid; gap: 18px; } +.gx-card.gx-vasm__editor { padding: 24px; display: grid; gap: 16px; min-width: 0; } +.gx-vasm__editor h3, .gx-vasm__editor p { margin: 0; } +.gx-vasm__editor label { display: grid; gap: 8px; } +.gx-vasm__editor input[type=file] { max-width: 100%; } +.gx-vasm__sides { display: grid; gap: 10px; } +.gx-vasm__side { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; } +.gx-vasm__side span { margin-right: auto; color: var(--on-surface-variant); } +.gx-vasm__canvas { display: block; width: 100%; height: auto; min-width: 0; border: 1px solid var(--glass-border); border-radius: var(--radius-sm); touch-action: none; } +.gx-map-manager { padding: 24px; } +.gx-map-manager__dialog { padding: 24px; } +.gx-map-manager__body { display: grid; gap: 14px; } +.gx-map-manager__status { display: grid; grid-template-columns: minmax(120px, 1fr) 2fr; gap: 8px 16px; margin: 0; } +.gx-map-manager__status dt { color: var(--on-surface-variant); } +.gx-map-manager__status dd { margin: 0; overflow-wrap: anywhere; } +.gx-map-manager__picker { display: grid; gap: 8px; } +.gx-map-manager__regions { max-height: 420px; overflow-y: auto; border: 1px solid var(--glass-border); border-radius: var(--radius-sm); } +.gx-map-manager__region { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px 16px; border-bottom: 1px solid var(--glass-border); } +.gx-map-manager__region:last-child { border-bottom: 0; } +.gx-map-manager__region small { display: block; margin-top: 4px; color: var(--on-surface-variant); } +.gx-map-manager__region .gx-btn { flex-shrink: 0; } +@media (max-width: 520px) { + .gx-card.gx-settings__header, .gx-card.gx-settings__row, .gx-card.gx-settings__dialog { padding: 16px; } + .gx-card.gx-vasm__editor { padding: 16px; } + .gx-map-manager, .gx-map-manager__dialog { padding: 16px; } + .gx-map-manager__region { align-items: flex-start; flex-direction: column; } +} +@media (max-width: 680px) { .gx-settings__header, .gx-settings__subhead { align-items: flex-start; flex-direction: column; } } +:root { + color-scheme: dark; + --primary: #9d72ff; + --primary-hover: #b38fff; + --primary-glow: rgba(157, 114, 255, 0.35); + --on-primary: #ffffff; + --primary-container: rgba(157, 114, 255, 0.18); + --on-primary-container: #ede4ff; + + --secondary: #4de8e8; + --secondary-glow: rgba(77, 232, 232, 0.3); + --on-secondary: #002b2b; + + --accent-rose: #ff60a8; + --accent-amber: #ffb865; + --error: #ff5277; + --on-error: #ffffff; + --warning: #ffb865; + --success: #4de8e8; + + --background: #070712; + --surface: rgba(18, 15, 32, 0.65); + --surface-container: rgba(26, 21, 46, 0.55); + --surface-container-high: rgba(36, 30, 62, 0.7); + --surface-container-low: rgba(14, 11, 26, 0.45); + + --on-surface: #f3f2f8; + --on-surface-variant: #9d9bb8; + --text-muted: #8b88a8; + --text-color: #f3f2f8; + + --glass-bg: rgba(22, 18, 38, 0.7); + --glass-bg-hover: rgba(35, 29, 60, 0.8); + --glass-active-bg: rgba(157, 114, 255, 0.22); + --glass-border: rgba(255, 255, 255, 0.12); + --glass-border-light: rgba(255, 255, 255, 0.2); + --glass-shadow: 0 8px 24px 0 rgba(0, 0, 0, 0.35); + --glass-blur: blur(4px); + --card-scroll-bg: rgba(22, 18, 38, 0.95); + --glass-text: #f3f2f8; + --glass-accent: #9d72ff; + + --outline: rgba(255, 255, 255, 0.08); + --outline-variant: rgba(255, 255, 255, 0.05); + + --radius-xs: 8px; + --radius-sm: 14px; + --radius-md: 18px; + --radius-lg: 24px; + --radius-xl: 32px; + --radius-full: 9999px; + + --elev-1: 0 4px 12px rgba(0, 0, 0, 0.2); + --elev-2: 0 6px 18px rgba(0, 0, 0, 0.28); + --elev-3: 0 12px 32px rgba(0, 0, 0, 0.4); + + --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; + --sp-5: 24px; --sp-6: 32px; --sp-7: 40px; --sp-8: 48px; + + --font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --fs-xs: 0.75rem; --fs-sm: 0.875rem; --fs-base: 1rem; + --fs-lg: 1.2rem; --fs-xl: 1.45rem; --fs-xxl: 1.85rem; + --fw-normal: 400; --fw-medium: 500; --fw-bold: 600; + + --motion-fast: 150ms cubic-bezier(0.2, 0.9, 0.3, 1); + --motion-base: 240ms cubic-bezier(0.2, 0.9, 0.3, 1); + --motion-slow: 350ms cubic-bezier(0.16, 1, 0.3, 1); + + --appbar-height: 64px; + --bottomnav-height: 72px; + --bottomnav-clearance: calc(var(--bottomnav-height) + env(safe-area-inset-bottom, 0px) + var(--sp-5)); + --touch-target: 44px; + --z-appbar: 100; + --z-drawer: 200; + --z-modal: 1200; + --z-snackbar: 1300; + + --scrollbar-track: rgba(255, 255, 255, 0.03); + --scrollbar-thumb: rgba(157, 114, 255, 0.4); + --scrollbar-thumb-hover: rgba(157, 114, 255, 0.7); + --scrollbar-border: rgba(157, 114, 255, 0.3); + + --gx-para-x: 0px; +} + +[data-theme="light"] { + color-scheme: light; + --primary: #7849e8; + --primary-hover: #6737d9; + --primary-glow: rgba(120, 73, 232, 0.25); + --on-primary: #ffffff; + --primary-container: rgba(120, 73, 232, 0.12); + --on-primary-container: #471fa8; + + --secondary: #0c9b9b; + --secondary-glow: rgba(12, 155, 155, 0.25); + --on-secondary: #ffffff; + --accent-rose: #e04b8f; + --accent-amber: #c9821f; + --error: #d63a62; + --on-error: #ffffff; + --warning: #c9821f; + --success: #0c9b9b; + + --background: #f2f4fc; + --surface: rgba(255, 255, 255, 0.85); + --surface-container: rgba(255, 255, 255, 0.75); + --surface-container-high: rgba(255, 255, 255, 0.95); + --surface-container-low: rgba(240, 243, 252, 0.75); + --card-scroll-bg: rgba(245, 246, 252, 0.96); + + --on-surface: #171526; + --on-surface-variant: #5f5b7d; + --text-muted: #6f6a8f; + --text-color: #171526; + + --glass-bg: rgba(255, 255, 255, 0.82); + --glass-bg-hover: rgba(255, 255, 255, 0.95); + --glass-active-bg: rgba(120, 73, 232, 0.15); + --glass-border: rgba(120, 73, 232, 0.28); + --glass-border-light: rgba(120, 73, 232, 0.4); + --glass-shadow: 0 8px 24px 0 rgba(80, 60, 140, 0.08); + --glass-text: #171526; + --glass-accent: #7849e8; + + --outline: rgba(120, 73, 232, 0.14); + --outline-variant: rgba(120, 73, 232, 0.08); + + --scrollbar-track: rgba(0, 0, 0, 0.05); + --scrollbar-thumb: #7849e8; + --scrollbar-thumb-hover: #5d31ca; + --scrollbar-border: #471fa8; +} + +* { + box-sizing: border-box; + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); +} + +*::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +*::-webkit-scrollbar-track { + background: var(--scrollbar-track); + border-radius: var(--radius-full); +} + +*::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border: 1px solid var(--scrollbar-border); + border-radius: var(--radius-full); +} + +*::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); +} + +button::-moz-focus-inner { + border: 0; + padding: 0; +} + +html, body { + background: var(--background); + color: var(--on-surface); + font-family: var(--font-body); + margin: 0; + min-height: 100dvh; + padding: 0; + -webkit-tap-highlight-color: transparent; + text-rendering: optimizeSpeed; + touch-action: pan-x pan-y; + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; +} + +html { cursor: default; } + +body { + overscroll-behavior-y: contain; + padding-bottom: env(safe-area-inset-bottom); +} + +#galaxy-bg { + position: fixed; + inset: 0; + overflow: hidden; + z-index: 0; + background-color: var(--background); + background-image: + radial-gradient(ellipse at 15% 20%, rgba(157, 114, 255, 0.22) 0%, transparent 55%), + radial-gradient(ellipse at 85% 75%, rgba(85, 45, 180, 0.18) 0%, transparent 60%), + radial-gradient(circle at 75% 15%, rgba(77, 232, 232, 0.12) 0%, transparent 48%); + pointer-events: none; + transform: translateZ(0); + will-change: transform; + isolation: isolate; + contain: strict; +} + +#galaxy-bg::before { + content: ""; + position: absolute; + top: 0; left: 0; width: 100%; height: 200%; + background-image: + radial-gradient(1px 1px at 30px 40px, #ffffff, rgba(255,255,255,0)), + radial-gradient(1px 1px at 110px 220px, rgba(220, 200, 255, 0.8), rgba(255,255,255,0)), + radial-gradient(1.5px 1.5px at 200px 90px, #ffffff, rgba(255,255,255,0)), + radial-gradient(1.5px 1.5px at 310px 340px, rgba(160, 240, 255, 0.9), rgba(255,255,255,0)), + radial-gradient(1px 1px at 410px 150px, #ffffff, rgba(255,255,255,0)), + radial-gradient(1.5px 1.5px at 640px 110px, #ffffff, rgba(255,255,255,0)), + radial-gradient(1px 1px at 890px 180px, #ffffff, rgba(255,255,255,0)); + background-size: 800px 400px; + opacity: 0.5; + will-change: transform; + transform: translate3d(var(--gx-para-x), 0, 0); +} + +#galaxy-bg::after { + content: ""; + position: absolute; + top: 0; left: 0; width: 100%; height: 200%; + background-image: + radial-gradient(1.5px 1.5px at 80px 140px, #ffffff, rgba(255,255,255,0)), + radial-gradient(2px 2px at 240px 390px, rgba(190, 235, 255, 0.9), rgba(255,255,255,0)), + radial-gradient(1.5px 1.5px at 380px 60px, rgba(230, 195, 255, 0.85), rgba(255,255,255,0)), + radial-gradient(2px 2px at 560px 480px, #ffffff, rgba(255,255,255,0)), + radial-gradient(1.5px 1.5px at 730px 220px, rgba(255, 255, 255, 0.9), rgba(255,255,255,0)); + background-size: 900px 450px; + opacity: 0.6; + will-change: transform; + transform: translate3d(var(--gx-para-x), 0, 0); +} + +#galaxy-bg .galaxy-hero { + position: absolute; + width: 3px; + height: 3px; + border-radius: 50%; + background: #ffffff; + box-shadow: 0 0 6px rgba(255, 255, 255, 0.9); + animation: galaxy-twinkle 6s ease-in-out infinite alternate; +} + +@keyframes galaxy-twinkle { + 0%, 100% { opacity: 0.3; transform: scale(0.9); } + 50% { opacity: 1; transform: scale(1.2); } +} + +@media (prefers-reduced-motion: reduce) { + #galaxy-bg, #galaxy-bg::before, #galaxy-bg::after, #galaxy-bg .galaxy-hero { + animation: none !important; + } +} + +/* Keep decorative animation out of modal backdrops and drawer content. */ +body:has(.gx-scrim, .gx-underlay, .gx-drawer.open) #galaxy-bg .galaxy-hero { + display: none; +} + +[data-theme="light"] #galaxy-bg { + background-color: #eef1fb; + background-image: + radial-gradient(ellipse at 20% 20%, rgba(180, 145, 255, 0.28) 0%, transparent 55%), + radial-gradient(ellipse at 80% 80%, rgba(120, 220, 235, 0.22) 0%, transparent 60%), + radial-gradient(circle at 70% 15%, rgba(255, 170, 210, 0.18) 0%, transparent 48%); +} + +[data-theme="light"] #galaxy-bg::before, +[data-theme="light"] #galaxy-bg::after, +[data-theme="light"] #galaxy-bg .galaxy-hero { + opacity: 0.12; +} + +[v-cloak] { display: none !important; } +a { color: inherit; text-decoration: none; } +.gx-link { color: var(--primary); text-decoration: underline; text-decoration-color: var(--primary-glow); text-underline-offset: 0.16em; } +button { font-family: var(--font-body); } +ul { list-style: none; margin: 0; padding: 0; } + +:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 3px; +} + +.gx-app { + display: flex; + flex-direction: column; + min-height: 100dvh; + position: relative; + z-index: 1; +} + +.gx-appbar { + align-items: center; + display: flex; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-3); + padding-top: calc(env(safe-area-inset-top) + var(--sp-2)); + position: sticky; + top: 0; + z-index: var(--z-appbar); + transform: translateZ(0); + isolation: isolate; +} + +.gx-appbar__pill { + align-items: center; + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); + border-radius: var(--radius-full); + box-shadow: var(--glass-shadow); + color: var(--glass-text); + display: flex; + flex: 1; + gap: var(--sp-2); + min-width: 0; + padding: 6px 12px; + transition: background-color var(--motion-fast), border-color var(--motion-fast); +} + +.gx-appbar .gx-appbar__back, +.gx-appbar .gx-theme-toggle, +.gx-appbar .gx-appbar__menu { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + margin: 0; + line-height: 1; + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); + border-radius: 50%; + box-shadow: var(--glass-shadow); + color: var(--glass-text); + flex: none; + height: var(--touch-target); + width: var(--touch-target); + transition: transform var(--motion-fast), background-color var(--motion-fast); +} + +.gx-appbar .gx-appbar__back:active, +.gx-appbar .gx-theme-toggle:active, +.gx-appbar .gx-appbar__menu:active { transform: scale(0.92); } + +.gx-appbar__title { + background: linear-gradient(135deg, #8b6cc5 0%, #5ec8c8 55%, #d4789c 100%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + font-size: var(--fs-lg); + font-weight: var(--fw-bold); + white-space: nowrap; + letter-spacing: -0.02em; +} + +.gx-appbar__title .gx-logo { + height: 28px; + margin-right: var(--sp-2); + vertical-align: middle; +} + +.gx-appbar__home { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + padding: 2px 8px; + border-radius: var(--radius-full); + transition: background-color var(--motion-fast), transform var(--motion-fast); +} + +.gx-brand { display: block; width: 28px; height: 28px; flex: none; background: currentColor; mask: url("../assets/starpilot-logo.svg") center / contain no-repeat; } +[data-theme="light"] .gx-brand { color: #6f42a6; } +.gx-drawer__header .gx-brand { width: 44px; height: 44px; } + +.gx-appbar__home:hover { background-color: var(--glass-bg-hover); } + +.gx-appbar__home:active { transform: scale(0.96); } + +.gx-appbar__home:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +.gx-appbar__right { + align-items: center; + display: flex; + gap: var(--sp-2); + margin-left: auto; +} + +.gx-search { + background: var(--surface-container); + border: 1px solid var(--glass-border); + border-radius: var(--radius-full); + color: var(--on-surface); + font-size: var(--fs-sm); + height: 40px; + max-width: 420px; + outline: none; + padding: 0 var(--sp-4); + transition: border-color var(--motion-fast), box-shadow var(--motion-fast); +} + +.gx-search:focus { + background: var(--surface-container-high); + border-color: var(--primary); + box-shadow: 0 0 0 2px var(--primary-glow); +} + +[data-theme="light"] .gx-search { + border-color: rgba(120, 73, 232, 0.3); +} + +.gx-icon-btn { + align-items: center; + background: transparent; + border: none; + border-radius: var(--radius-full); + color: inherit; + cursor: pointer; + display: inline-flex; + height: var(--touch-target); + justify-content: center; + min-height: var(--touch-target); + min-width: var(--touch-target); + padding: 0; + margin: 0; + line-height: 1; + position: relative; + transition: background-color var(--motion-fast), transform var(--motion-fast); + width: var(--touch-target); +} + +.gx-icon-btn:hover { background: var(--glass-bg-hover); } +.gx-icon-btn:active { transform: scale(0.9); } +.gx-icon-btn i, +.gx-appbar .bi, +.gx-appbar i { + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + height: 1em; + width: 1em; + font-size: 1.3rem; + vertical-align: middle; +} +.gx-back-btn { display: none; } +.gx-appbar__menu { display: inline-flex; } + +.gx-searchwrap { + align-items: center; + display: flex; + flex: 1; + min-width: 0; + position: relative; +} + +.gx-searchwrap .gx-search { + padding-right: var(--sp-5); + width: 100%; +} + +.gx-search-clear { + align-items: center; + background: transparent; + border: none; + border-radius: 50%; + color: var(--glass-text); + cursor: pointer; + display: inline-flex; + height: 28px; + justify-content: center; + position: absolute; + right: 4px; + transition: background-color var(--motion-fast); + width: 28px; +} + +.gx-search-clear:hover { background: var(--glass-active-bg); } +.gx-search-clear i { font-size: 1rem; } + +.gx-search-results { + background: var(--surface-container-high); + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + box-shadow: 0 12px 32px rgba(0, 0, 0, .28); + left: 0; + max-height: min(60vh, 420px); + min-width: min(420px, 90vw); + overflow-y: auto; + position: absolute; + right: 0; + top: calc(100% + 8px); + z-index: 30; +} + +.gx-search-results__item { + background: transparent; + border: 0; + color: var(--on-surface); + cursor: pointer; + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 14px; + text-align: left; + width: 100%; +} +.gx-search-results__item:hover, .gx-search-results__item.active { background: var(--glass-bg-hover); } +.gx-search-results__item span, .gx-search-results__status { color: var(--text-muted); font-size: var(--fs-xs); } +.gx-search-results__status { margin: 0; padding: 12px 14px; } + +.gx-status-pill { + align-items: center; + background: var(--surface-container); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); + border-radius: var(--radius-full); + display: inline-flex; + font-size: var(--fs-xs); + font-weight: var(--fw-medium); + gap: var(--sp-2); + padding: 6px 12px; +} + +[data-theme="light"] .gx-status-pill { + border: 1px solid rgba(120, 73, 232, 0.3); +} + +.gx-status-dot { + border-radius: 50%; + height: 8px; + width: 8px; +} + +.gx-status-dot.online { + background: var(--success); + box-shadow: 0 0 8px var(--success); +} + +.gx-status-dot.offline { + background: var(--error); + box-shadow: 0 0 8px var(--error); +} + +.gx-content { + contain: layout style; + display: flex; + flex: 1; + flex-direction: column; + margin: 0 auto; + max-width: none; + padding: var(--sp-4) var(--sp-4) calc(var(--bottomnav-height) + var(--sp-5)); + width: 100%; +} + +.blur-nav { + position: fixed; + bottom: calc(18px + env(safe-area-inset-bottom, 0px)); + left: 50%; + transform: translateX(-50%) translateZ(0); + will-change: transform; + isolation: isolate; + contain: layout style; + z-index: 1000; + + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + + border-radius: var(--radius-full); + border: 1px solid var(--glass-border); + box-shadow: var(--glass-shadow); + + transition: transform var(--motion-base), background-color var(--motion-fast); + max-width: calc(100vw - 32px); +} + +[data-theme="light"] .blur-nav { + border: 1.5px solid var(--primary); +} + +.blur-nav .nav-item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 8px 16px; + border-radius: var(--radius-full); + color: var(--on-surface-variant); + text-decoration: none; + font-size: 0.75rem; + font-weight: var(--fw-medium); + transition: color var(--motion-fast), background-color var(--motion-fast); + background: transparent; + border: none; + cursor: pointer; +} + +.blur-nav .nav-item i { + font-size: 1.3rem; + margin-bottom: 2px; + transition: transform var(--motion-fast); +} + +.blur-nav .nav-item:hover { + color: var(--on-surface); + background: rgba(255, 255, 255, 0.08); +} + +.blur-nav .nav-item:active i { + transform: scale(0.88); +} + +.blur-nav .nav-item.active { + background: var(--glass-active-bg); + color: var(--primary); + font-weight: var(--fw-bold); +} + +.blur-nav .nav-item.active i { + transform: translateY(-2px); +} + +.gx-card { + contain: layout style; + background: var(--surface-container); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + box-shadow: var(--elev-1); + margin-bottom: var(--sp-4); + overflow: hidden; + transition: transform var(--motion-fast), box-shadow var(--motion-fast), background-color 0.15s ease-out; +} + +.gx-btn { + align-items: center; + background: var(--primary); + border: none; + border-radius: var(--radius-full); + box-shadow: 0 4px 12px var(--primary-glow); + color: var(--on-primary); + cursor: pointer; + display: inline-flex; + font-weight: var(--fw-bold); + gap: var(--sp-2); + justify-content: center; + min-height: 44px; + padding: 0 var(--sp-5); + position: relative; + transition: transform var(--motion-fast), background-color var(--motion-fast); +} + +.gx-btn:hover { background: var(--primary-hover); } +.gx-btn:active { transform: scale(0.96); } +.gx-btn:disabled { opacity: 0.4; box-shadow: none; cursor: not-allowed; } + +.gx-grid { + display: grid; + gap: var(--sp-3); + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); +} + +.gx-card-grid { + display: grid; + gap: var(--sp-3); + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); +} + +.gx-card-grid .gx-card { margin-bottom: 0; } + +.gx-plots__toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: .75rem; margin: 1rem 0; } +.gx-plots__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 28rem), 1fr)); gap: 1rem; } +.gx-plots__panel { min-width: 0; padding: clamp(.85rem, 2vw, 1.25rem); } +.gx-plots__panel svg { display: block; width: 100%; height: auto; min-height: 8rem; background: rgba(127,127,127,.06); border-radius: .6rem; } +.gx-plots__panel polyline { fill: none; stroke-width: 2.5; vector-effect: non-scaling-stroke; stroke-linejoin: round; stroke-linecap: round; } +.gx-plots__panel .gx-plot-axis { stroke: var(--on-surface-variant); opacity: .5; stroke-width: 1; } +.gx-plots__panel .gx-plot-zero { stroke: var(--on-surface-variant); opacity: .35; stroke-width: 1; stroke-dasharray: 4 4; } +.gx-plots__panel .gx-plot-label { fill: var(--on-surface-variant); font-size: 11px; } +.gx-flm__panel { padding: clamp(.85rem, 2vw, 1.25rem); margin-top: 1rem; overflow-wrap: anywhere; } +.gx-flm__choice { padding: .45rem 0; border-bottom: 1px solid var(--outline-variant); } +.gx-flm__choice label { display: flex; align-items: flex-start; gap: .7rem; cursor: pointer; } +.gx-flm__choice input { margin-top: .25rem; } +.gx-flm__panel .gx-btn { margin-right: .65rem; margin-top: .45rem; } +.gx-flm__legend { display: flex; gap: 1rem; flex-wrap: wrap; margin: .35rem 0; } +.gx-flm__legend span::before { content: ''; display: inline-block; width: 1.2rem; height: .2rem; margin: 0 .4rem .2rem 0; vertical-align: middle; } +.gx-flm__legend-desired::before { background: #45a9ff; } +.gx-flm__legend-actual::before { background: #ffa352; } +.gx-plot-desired, .gx-plot-term-0 { stroke: #45a9ff; } +.gx-plot-actual, .gx-plot-term-1 { stroke: #ffa352; } +.gx-plot-term-2 { stroke: #ad8cff; } +.gx-plot-term-3 { stroke: #58c9a3; } + +.gx-tight-grid { + display: grid; + gap: 8px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.gx-tight-grid .gx-row { border-radius: var(--radius-md); } + +.gx-tile { + align-items: center; + background: var(--surface-container); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + color: var(--on-surface); + cursor: pointer; + display: flex; + flex-direction: column; + gap: var(--sp-2); + min-height: 100px; + padding: var(--sp-4); + text-align: center; + transition: transform var(--motion-fast), background-color var(--motion-fast); +} + +[data-theme="light"] .gx-tile { + border: 1px solid rgba(120, 73, 232, 0.25); +} + +.gx-tile:hover { + background: var(--glass-bg-hover); + transform: translateY(-2px); +} + +.gx-tile:active { transform: scale(0.96); } +.gx-tile i { color: var(--primary); font-size: 1.75rem; } +.gx-tile span { font-size: var(--fs-sm); font-weight: var(--fw-medium); } + +.gx-underlay { + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + inset: 0; + position: fixed; + z-index: var(--z-drawer); +} + +.gx-drawer { + background: var(--surface-container-high); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border-right: 1px solid var(--glass-border); + box-shadow: var(--elev-3); + height: 100dvh; + left: 0; + max-width: 320px; + overflow-y: auto; + padding: env(safe-area-inset-top) var(--sp-3) var(--sp-5); + position: fixed; + top: 0; + transform: translateX(-100%); + transition: transform var(--motion-slow); + width: 84vw; + z-index: calc(var(--z-drawer) + 1); + display: flex; + flex-direction: column; +} + +[data-theme="light"] .gx-drawer { + border-right: 1.5px solid var(--primary); +} + +.gx-drawer.open { transform: translateX(0); } + +.gx-drawer.open ~ .blur-nav { + opacity: 0; + pointer-events: none; + transform: translateX(-50%) translateY(12px) translateZ(0); +} + +.gx-drawer__header { + align-items: center; + display: flex; + gap: var(--sp-3); + margin-bottom: var(--sp-3); + padding: var(--sp-3) var(--sp-2); +} + +.gx-drawer__header img { height: 44px; width: 44px; } + +.gx-drawer__header .gx-drawer-title { + font-size: var(--fs-lg); + font-weight: var(--fw-bold); + letter-spacing: -0.02em; +} + +.gx-drawer__pin { + color: var(--text-muted); + margin-left: auto; +} + +.gx-drawer__pin:hover, +.gx-drawer__pin[aria-pressed="true"] { + color: var(--primary); +} + +@media (min-width: 768px) { + .gx-app.gx-nav-pinned .gx-content { + margin-left: 320px; + width: calc(100% - 320px); + } + + .gx-app.gx-nav-pinned .gx-appbar { + margin-left: 320px; + width: calc(100% - 320px); + } +} + +.gx-nav-section { margin-bottom: var(--sp-3); } + +.gx-nav-section__title { + color: var(--text-muted); + font-size: var(--fs-xs); + font-weight: var(--fw-bold); + letter-spacing: 0.08em; + padding: var(--sp-2); + text-transform: uppercase; +} + +.gx-nav-item { + align-items: center; + border-radius: var(--radius-full); + color: var(--on-surface); + cursor: pointer; + display: flex; + font-weight: var(--fw-medium); + gap: var(--sp-3); + min-height: 46px; + padding: 0 var(--sp-4); + position: relative; + transition: background-color var(--motion-fast); +} + +.gx-nav-item:hover { background: rgba(255, 255, 255, 0.08); } + +.gx-nav-item.active { + background: var(--glass-active-bg); + color: var(--primary); + font-weight: var(--fw-bold); +} + +[data-theme="light"] .gx-nav-item.active { + border: 1px solid rgba(120, 73, 232, 0.35); +} + +.gx-nav-item i { font-size: 1.3rem; width: 24px; } + +@media (max-width: 767px) { + .gx-appbar { display: grid; grid-template-columns: 44px minmax(0, 1fr) 44px 44px; } + .gx-appbar__pill { display: contents; } + .gx-appbar__back { grid-column: 1; grid-row: 1; } + .gx-appbar__home { grid-column: 2; grid-row: 1; justify-self: start; } + .gx-appbar__right { grid-column: 2; grid-row: 1; justify-self: end; margin-left: 0; } + .gx-theme-toggle { grid-column: 3; grid-row: 1; } + .gx-appbar__menu { grid-column: 4; grid-row: 1; } + .gx-searchwrap { grid-column: 1 / -1; grid-row: 2; width: 100%; } + .gx-search-results { left: 0; right: 0; min-width: 0; } + .blur-nav { display: flex; } + .gx-drawer { padding-bottom: calc(var(--sp-5) + env(safe-area-inset-bottom, 0px)); } + .gx-back-btn { display: inline-flex; } + .gx-appbar__search { flex: 1; min-width: 0; } + .gx-status-pill { display: none; } + .gx-status-pill.gx-device-state { display: inline-flex; max-width: 104px; padding: 5px 8px; gap: 5px; line-height: 1.2; } + .gx-device-state .gx-status-dot { flex-shrink: 0; } + .gx-content { padding-left: var(--sp-3); padding-right: var(--sp-3); } +} + +@media (max-width: 420px) { + .gx-appbar__title { display: none; } +} + +@media (min-width: 768px) { + .blur-nav { display: none; } + .gx-back-btn { display: none; } + .gx-content { padding-bottom: var(--sp-6); } +} + +/* Preview-specific affordances and button resets. */ +.gx-appbar__home { border: 0; background: transparent; color: inherit; font: inherit; } +.gx-search:disabled { cursor: not-allowed; opacity: .58; } +.gx-nav-item { width: 100%; border: 0; background: transparent; text-align: left; font: inherit; } +.gx-tile small { color: var(--text-muted); } +.gx-tile .gx-availability { color: var(--warning); font-size: var(--fs-xs); } +.gx-message { padding: var(--sp-5); max-width: 680px; } +.gx-message h2 { margin-top: 0; } +.gx-message p { color: var(--text-muted); line-height: 1.5; } +.gx-auth { max-width: 460px; margin: var(--sp-6) auto; padding: var(--sp-6); } +.gx-auth h2 { margin-top: 0; } +.gx-auth form { display: grid; gap: var(--sp-3); } +.gx-auth form .gx-btn { justify-self: start; } + +/* Focused System Monitor adaptation from frozen Galaxy material.css:2424-2443. */ +.gx-view, .gx-monitor { min-width: 0; max-width: 100%; } +.gx-view h2, .gx-monitor h2 { margin-top: 0; } +.gx-note { color: var(--text-muted); font-size: var(--fs-sm); } +.gx-note--danger { color: var(--error); } +.gx-loading, .gx-empty { padding: var(--sp-4); color: var(--text-muted); } +.gx-chip { display: inline-block; padding: 4px 9px; border-radius: var(--radius-full); background: var(--primary-container); color: var(--on-primary-container); font-size: var(--fs-xs); } +.gx-field { min-height: 44px; padding: 9px 12px; border: 1px solid var(--glass-border); border-radius: var(--radius-sm); background: var(--surface-container-high); color: var(--on-surface); font: inherit; } +.gx-btn--tonal { background: var(--primary-container); color: var(--on-primary-container); box-shadow: none; } +.gx-logs-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: var(--sp-3); } +.gx-logs-entry { display: flex; flex-direction: column; align-items: flex-start; gap: var(--sp-2); padding: var(--sp-4); color: var(--on-surface); text-align: left; font: inherit; } +button.gx-logs-entry { cursor: pointer; } +.gx-logs-entry i { color: var(--primary); font-size: 25px; } +.gx-logs-entry span { color: var(--text-muted); font-size: var(--fs-sm); } +.gx-logs-entry--unavailable { opacity: .65; } +.gx-logs-back { margin-bottom: var(--sp-3); } +.gx-crash-controls { display: flex; gap: var(--sp-2); margin-bottom: var(--sp-3); } +.gx-crash-controls .gx-field { flex: 1; min-width: 0; } +.gx-crash-list { display: grid; gap: var(--sp-2); } +.gx-crash-row { width: 100%; display: flex; justify-content: space-between; align-items: center; gap: var(--sp-2); text-align: left; color: var(--on-surface); font: inherit; cursor: pointer; } +.gx-crash-row strong { overflow-wrap: anywhere; } +.gx-crash-row span { flex: 0 0 auto; color: var(--text-muted); font-size: var(--fs-sm); } +.gx-crash-preview { margin-top: var(--sp-3); } +.gx-crash-preview__head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); overflow-wrap: anywhere; } +.gx-crash-preview pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 60vh; overflow-y: auto; font-size: var(--fs-sm); } +.gx-software-card { margin-top: var(--sp-3); padding: var(--sp-4); } +.gx-software-card h3 { margin: 0 0 var(--sp-3); } +.gx-software-card p { margin: 0 0 var(--sp-3); } +.gx-software-card dl { display: grid; grid-template-columns: minmax(130px, 1fr) minmax(0, 2fr); gap: var(--sp-2) var(--sp-3); margin: 0; } +.gx-software-card dt { color: var(--text-muted); } +.gx-software-card dd { margin: 0; overflow-wrap: anywhere; } +.gx-software-actions, .gx-software-branch { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; margin: var(--sp-3) 0; } +.gx-software-branch .gx-field { min-width: min(100%, 260px); flex: 1; } +@media (max-width: 600px) { .gx-software-card dl { grid-template-columns: 1fr; gap: 2px; } .gx-software-card dd { margin-bottom: var(--sp-2); } } +@media (max-width: 600px) { .gx-crash-row, .gx-crash-preview__head { align-items: flex-start; flex-direction: column; } } +.gx-monitor__toolbar, .gx-monitor__filters { display: flex; gap: 12px; align-items: center; justify-content: space-between; margin-bottom: 12px; flex-wrap: wrap; } +.gx-monitor__summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; } +.gx-monitor__metric { padding: 16px; margin: 0; display: flex; flex-direction: column; gap: 6px; position: relative; overflow: hidden; } +.gx-monitor__metric > span, .gx-monitor__metric small { color: var(--text-muted); } +.gx-monitor__metric strong { font-size: 26px; overflow-wrap: anywhere; } +.gx-monitor progress { width: 100%; height: 6px; accent-color: var(--primary); } +.gx-monitor__cores { padding: 14px; margin: 12px 0; } +.gx-monitor__cores summary { cursor: pointer; } +.gx-monitor__cores > div { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 16px; padding-top: 12px; } +.gx-monitor__cores span { font-size: 12px; display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px; } +.gx-monitor__filters input { flex: 1; min-width: 180px; } +.gx-monitor__table { overflow: auto; max-height: 65vh; } +.gx-monitor table { width: 100%; border-collapse: collapse; font-size: 13px; font-variant-numeric: tabular-nums; } +.gx-monitor th { position: sticky; top: 0; background: var(--surface-container-high); z-index: 1; text-align: left; } +.gx-monitor th button { background: transparent; color: inherit; border: 0; font: inherit; font-weight: 600; cursor: pointer; padding: 12px; white-space: nowrap; } +.gx-monitor td { padding: 10px 12px; border-top: 1px solid var(--outline); white-space: nowrap; } +.gx-monitor td:first-child { min-width: 180px; max-width: 350px; white-space: normal; overflow-wrap: anywhere; } +.gx-monitor tbody tr:hover { background: var(--surface); } +.gx-monitor__feature { color: var(--primary); font-weight: 500; } +@media(max-width:600px) { .gx-monitor__summary { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .gx-monitor__metric { min-width: 0; padding: 12px; } .gx-monitor__metric strong { font-size: 22px; } } + +/* Dashboard */ +.gx-home { max-width: 1120px; margin: 0 auto; } +.gx-home__hero { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-4); margin-bottom: var(--sp-5); } +.gx-home__hero h1 { margin: 0 0 var(--sp-1); font-size: clamp(1.7rem, 3vw, 2.4rem); } +.gx-home__hero .gx-note { margin: 0; } +.gx-home__grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--sp-4); } +.gx-home__card { min-width: 0; display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-5); } +.gx-home__card h2, .gx-home__pending h2 { margin: 0 0 var(--sp-2); font-size: var(--fs-lg); } +.gx-home__card h2 i { color: var(--primary); margin-right: var(--sp-2); } +.gx-home__card p { margin: 0; line-height: 1.5; overflow-wrap: anywhere; } +.gx-home__card small { color: var(--text-muted); overflow-wrap: anywhere; } +.gx-recordings { min-width: 0; } +.gx-recordings__toolbar, .gx-recordings__actions { display: flex; flex-wrap: wrap; gap: var(--sp-2); } +.gx-recordings__route { padding: var(--sp-5); min-width: 0; } +.gx-recordings__route header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--sp-3); } +.gx-recordings__route header h3 { margin: 0; font-size: 1rem; line-height: 1.5; overflow-wrap: anywhere; } +.gx-recordings__route header p { margin: var(--sp-1) 0 var(--sp-4); color: var(--text-muted); } +.gx-recordings__route header .gx-recordings__date { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-2); margin: 0 0 var(--sp-1); color: var(--on-surface); font-size: var(--fs-lg); font-weight: 600; } +.gx-recordings__date small { color: var(--text-muted); font-size: var(--fs-sm); font-weight: 400; } +.gx-recordings__route header h3 { color: var(--text-muted); font-size: var(--fs-sm); font-weight: 400; } +.gx-recordings__count { color: var(--text-muted); font-size: var(--fs-sm); white-space: nowrap; } +.gx-recordings__route-actions { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin: var(--sp-4) 0; } +.gx-recordings__expand { border-top: 1px solid var(--outline-variant); } +.gx-recordings__expand > summary { cursor: pointer; padding-block: var(--sp-4); color: var(--on-surface); } +.gx-recordings__expand > summary > .gx-note { display: inline-block; margin: 0 0 0 var(--sp-2); font-size: var(--fs-sm); } +.gx-recordings__expand > summary:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; border-radius: var(--radius-sm); } +.gx-recordings__segments, .gx-recordings__files { list-style: none; margin: 0; padding: 0; } +.gx-recordings__segments > li { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3); padding: var(--sp-4) 0; border-top: 1px solid var(--outline-variant); } +.gx-recordings__segment-info { flex: 1 1 17rem; min-width: 0; } +.gx-recordings__files { display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-2); margin-top: var(--sp-2); } +.gx-recordings__files li { padding: .2rem .5rem; border-radius: var(--radius-sm); background: var(--surface-container-high); color: var(--text-muted); font-size: var(--fs-sm); } +.gx-recordings__actions .gx-btn { white-space: normal; } +.gx-recordings__player { display: grid; gap: var(--sp-4); padding: var(--sp-5); min-width: 0; } +.gx-recordings__player-head, .gx-recordings__player-controls { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--sp-3); } +.gx-recordings__player-head h3, .gx-recordings__player-head p { margin: 0; overflow-wrap: anywhere; } +.gx-recordings__player video { display: block; width: 100%; max-height: 68vh; background: #000; } +.gx-recordings__player-controls span { color: var(--text-muted); } +.gx-recordings__details { display: grid; gap: var(--sp-3); padding: var(--sp-5); min-width: 0; overflow-wrap: anywhere; } +.gx-recordings__details h3, .gx-recordings__details p { margin: 0; } +.gx-recordings__metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); gap: var(--sp-3); } +.gx-recordings__metrics > div { display: grid; gap: var(--sp-1); } +.gx-recordings__metrics strong { font-size: 1.25rem; } +.gx-recordings__metrics span { color: var(--text-muted); } +.gx-home__metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-3); } +.gx-home__metrics div { display: grid; gap: var(--sp-1); } +.gx-home__metrics strong { font-size: var(--fs-xl); } +.gx-home__metrics span { color: var(--text-muted); font-size: var(--fs-sm); } +.gx-home__link { align-self: flex-start; margin-top: auto; padding: var(--sp-2) 0 0; border: 0; background: none; color: var(--primary); font: inherit; cursor: pointer; text-align: left; } +.gx-home__link:hover { text-decoration: underline; } +.gx-home__pending { margin-top: var(--sp-4); padding: var(--sp-5); } +.gx-home__pending p { margin: var(--sp-2) 0 0; color: var(--text-muted); } +.gx-home__last { padding: var(--sp-5); display: grid; gap: var(--sp-4); min-height: 180px; } +.gx-home__last h2, .gx-home__week h2, .gx-home__records h2, .gx-home__recent h2, .gx-home__models h2 { margin: 0; font-size: var(--fs-lg); } +.gx-home__last p { margin: 0; } +.gx-home__kicker, .gx-home__section-title { display: flex; align-items: center; gap: var(--sp-2); font-weight: 700; color: var(--primary); } +.gx-home__kicker { width: fit-content; font-size: var(--fs-sm); } +.gx-home__kicker span, .gx-home__section-title span { width: 8px; height: 8px; border-radius: 50%; background: var(--primary); } +.gx-home__section-title { margin: var(--sp-6) 0 var(--sp-3); } +.gx-home__subtle { color: var(--text-muted); } +.gx-home__four, .gx-home__totals { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--sp-3); } +.gx-home__four > div, .gx-home__totals > section { min-width: 0; display: grid; gap: var(--sp-1); } +.gx-home__four strong, .gx-home__totals strong { font-size: clamp(1.2rem, 2.2vw, 1.9rem); color: var(--on-surface); overflow-wrap: anywhere; } +.gx-home__four span, .gx-home__totals span { color: var(--text-muted); font-size: var(--fs-sm); } +.gx-home__attention { display: flex; flex-wrap: wrap; gap: var(--sp-4); color: var(--text-muted); font-size: var(--fs-sm); } +.gx-home__attention i { margin-right: var(--sp-1); } +.gx-home__analysis { width: fit-content; margin: var(--sp-3) 0 0; padding: var(--sp-2) var(--sp-3); border-radius: var(--radius-sm); background: var(--surface-container-high); color: var(--text-muted); } +.gx-home__totals { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.gx-home__totals > section, .gx-home__week, .gx-home__records, .gx-home__recent, .gx-home__models { padding: var(--sp-5); } +.gx-home__totals h2 { margin: 0; font-size: var(--fs-sm); color: var(--text-muted); } +.gx-home__pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-4); margin-top: var(--sp-4); } +.gx-home__week, .gx-home__records { min-width: 0; } +.gx-home__week-top { display: flex; align-items: center; gap: var(--sp-5); margin: var(--sp-4) 0; } +.gx-home__week-top > div:last-child { display: grid; gap: var(--sp-1); } +.gx-home__week-top > div:last-child strong { font-size: var(--fs-xl); } +.gx-home__week-top span, .gx-home__week h3 { color: var(--text-muted); font-size: var(--fs-sm); } +.gx-home__week h3 { margin: var(--sp-4) 0 var(--sp-2); } +.gx-home__donut, .gx-home__model-donut { width: 112px; height: 112px; flex: 0 0 auto; border-radius: 50%; display: grid; place-content: center; text-align: center; position: relative; } +.gx-home__donut::before { content: ""; position: absolute; inset: 0; border-radius: 50%; + background: conic-gradient(var(--primary) var(--gx-home-progress, 0%), rgba(157, 114, 255, .18) 0); + -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 18px), #000 calc(100% - 17px)); + mask: radial-gradient(farthest-side, transparent calc(100% - 18px), #000 calc(100% - 17px)); } +.gx-home__donut strong, .gx-home__donut span { position: relative; z-index: 1; } +.gx-home__bars { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); align-items: end; gap: var(--sp-2); } +.gx-home__bars > div { min-width: 0; display: grid; justify-items: center; gap: var(--sp-1); } +.gx-home__bar-track { width: 100%; height: 82px; display: flex; align-items: end; justify-content: center; border-bottom: 1px solid var(--outline-variant); overflow: hidden; } +.gx-home__bar-track span { width: 100%; background: linear-gradient(180deg, var(--primary), var(--secondary)); border-radius: var(--radius-sm) var(--radius-sm) 0 0; } +.gx-home__bars small { max-width: 100%; color: var(--text-muted); text-align: center; font-size: .7rem; overflow-wrap: anywhere; } +.gx-home__bars small:last-child { min-height: 2.8em; } +.gx-home__record { display: grid; grid-template-columns: 42px minmax(0, 1fr) auto; align-items: center; justify-content: space-between; gap: var(--sp-3); padding: var(--sp-3) 0; border-bottom: 1px solid var(--outline); } +.gx-home__record:last-child { border-bottom: 0; } +.gx-home__record span { color: var(--text-muted); } +.gx-home__record strong { text-align: right; overflow-wrap: anywhere; } +.gx-home__recent, .gx-home__models { margin-top: var(--sp-4); } +.gx-home__drive { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr) minmax(0, 1fr); gap: var(--sp-3); padding: var(--sp-3) 0; border-bottom: 1px solid var(--outline); } +.gx-home__drive:last-child { border-bottom: 0; } +.gx-home__drive > div { min-width: 0; display: grid; align-content: start; gap: var(--sp-1); } +.gx-home__drive strong, .gx-home__drive small { overflow-wrap: anywhere; } +.gx-home__drive small { color: var(--text-muted); } +.gx-home__drive.is-ignored { opacity: .75; } +.gx-home__drive .gx-home__link { margin: 0; padding: 0; } +.gx-home__drive .gx-home__link:disabled { opacity: .5; cursor: wait; } +.gx-home__model-layout { display: flex; align-items: center; gap: var(--sp-5); margin-top: var(--sp-4); } +.gx-home__model-donut { -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 22px), #000 calc(100% - 21px)); + mask: radial-gradient(farthest-side, transparent calc(100% - 22px), #000 calc(100% - 21px)); } +.gx-home__model-layout > div:last-child { display: grid; gap: var(--sp-3); } +.gx-home__model-row { display: flex; align-items: start; gap: var(--sp-2); } +.gx-home__model-row > div { display: grid; gap: var(--sp-1); } +.gx-home__model-row small { color: var(--text-muted); } +.gx-home__swatch { width: 12px; height: 12px; margin-top: 4px; flex: 0 0 auto; border-radius: 50%; } +@media (max-width: 850px) { .gx-home__grid { grid-template-columns: 1fr; } } +@media (max-width: 700px) { .gx-home__pair, .gx-home__totals { grid-template-columns: 1fr; } .gx-home__drive { grid-template-columns: 1fr 1fr; } .gx-home__drive > div:last-child { grid-column: 1 / -1; } } +@media (max-width: 600px) { .gx-home__hero { align-items: flex-start; flex-direction: column; } .gx-home__four { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +.gx-driving { max-width: 1120px; margin: 0 auto; min-width: 0; } +.gx-driving__intro { padding: var(--sp-5); } +.gx-driving__intro h2 { margin: 0 0 var(--sp-2); } +.gx-driving__intro p:last-child { margin-bottom: 0; color: var(--text-muted); } +.gx-driving__actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); margin-block: var(--sp-3); } +.gx-driving__group { margin-top: var(--sp-5); } +.gx-driving__group h3 { margin: 0 0 var(--sp-3); } +.gx-driving__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-3); } +.gx-driving__link { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); min-width: 0; width: 100%; padding: var(--sp-4); text-align: left; color: var(--on-surface); font: inherit; cursor: pointer; } +.gx-driving__link span { min-width: 0; display: grid; gap: var(--sp-1); } +.gx-driving__link strong, .gx-driving__link small { overflow-wrap: anywhere; } +.gx-driving__link small { color: var(--text-muted); } +.gx-driving__link i { flex: 0 0 auto; color: var(--primary); } +.gx-driving__extras { display: flex; flex-wrap: wrap; gap: var(--sp-3); margin-top: var(--sp-5); } +.gx-card.gx-long-curves__header, .gx-card.gx-long-curves__body { padding: var(--sp-5); } +.gx-long-curves__header, .gx-long-curves__heading { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--sp-4); } +.gx-long-curves__header h2, .gx-long-curves__heading h3 { margin: 0 0 var(--sp-2); } +.gx-long-curves__tabs { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin: var(--sp-3) 0; text-transform: capitalize; } +.gx-long-curves__tabs [aria-pressed="true"] { background: var(--primary); color: #fff; } +.gx-long-curves__body { display: grid; gap: var(--sp-4); } +.gx-long-curves__preset, .gx-long-curves__point-row { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); } +.gx-long-curves__preset > div { display: grid; gap: var(--sp-1); text-transform: capitalize; } +.gx-long-curves__graph { width: 100%; max-width: 680px; height: auto; border: 1px solid var(--glass-border); border-radius: var(--radius-sm); background: rgba(0, 0, 0, .15); } +.gx-long-curves__graph text { fill: var(--on-surface-variant); font-size: 11px; } +.gx-long-curves__axis { stroke: var(--on-surface-variant); stroke-width: 1; } +.gx-long-curves__line { fill: none; stroke: var(--primary); stroke-width: 2.5; } +.gx-long-curves__point { fill: var(--primary); } +.gx-long-curves__points { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-2) var(--sp-4); } +.gx-long-curves__point-row { padding: var(--sp-2); border: 1px solid var(--glass-border); border-radius: var(--radius-sm); } +.gx-long-curves__point-row strong { font-variant-numeric: tabular-nums; } +.gx-card.gx-vehicle__header, .gx-card.gx-vehicle__body { padding: var(--sp-5); } +.gx-vehicle__header h2 { margin: 0 0 var(--sp-2); } +.gx-vehicle__body { display: grid; gap: var(--sp-4); } +.gx-vehicle__summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-4); } +.gx-vehicle__summary > div { display: grid; align-content: start; gap: var(--sp-1); min-width: 0; } +.gx-vehicle__summary small, .gx-vehicle__summary p { color: var(--on-surface-variant); } +.gx-vehicle__summary strong { overflow-wrap: anywhere; } +.gx-vehicle__summary p { margin: 0; } +.gx-vehicle__actions, .gx-vehicle__filters { display: flex; gap: var(--sp-2); flex-wrap: wrap; } +.gx-vehicle__filters .gx-field { flex: 1 1 220px; min-width: 0; } +.gx-vehicle__models { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr)); gap: var(--sp-2); max-height: 440px; overflow-y: auto; } +.gx-vehicle__models button { display: grid; gap: var(--sp-1); text-align: left; overflow-wrap: anywhere; } +.gx-vehicle__models small { color: var(--on-surface-variant); } +@media (max-width: 700px) { .gx-long-curves__points { grid-template-columns: 1fr; } .gx-long-curves__header, .gx-long-curves__heading { flex-direction: column; } } +@media (max-width: 520px) { .gx-vehicle__summary { grid-template-columns: 1fr; } .gx-card.gx-vehicle__header, .gx-card.gx-vehicle__body { padding: 16px; } } +@media (max-width: 480px) { .gx-long-curves__point-row { display: grid; grid-template-columns: 1fr auto; } .gx-long-curves__point-row .gx-settings__controls { grid-column: 1 / -1; } } +@media (max-width: 700px) { .gx-driving__grid { grid-template-columns: 1fr; } } + +/* Navigation shares the same route and preferences as the onroad display. */ +.gx-navigation__tabs,.gx-navigation__actions,.gx-navigation__search{display:flex;gap:12px;align-items:center;flex-wrap:wrap} +.gx-navigation__tabs{margin:0 0 24px} +.gx-navigation__section{padding:24px;margin:0 0 20px} +.gx-navigation__search{margin:20px 0} +.gx-navigation__search .gx-field{flex:1;min-width:180px} +.gx-navigation__places{list-style:none;padding:0;display:grid;gap:12px} +.gx-navigation__places>li{padding:18px;display:flex;align-items:center;gap:16px;justify-content:space-between} +.gx-navigation__places>li>span{min-width:0;overflow-wrap:anywhere} +.gx-navigation__actions{flex-shrink:0} +.gx-navigation__instruction{font-size:clamp(20px,3vw,30px);font-weight:650} +.gx-navigation__route{display:block;width:100%;height:220px;border-radius:16px;background:rgba(161,112,244,.06);margin:16px 0} +.gx-toggle-row{display:flex;align-items:center;gap:12px;font-weight:650} +.gx-toggle-row input{appearance:none;flex:none;width:44px;height:26px;border-radius:20px;border:1px solid var(--glass-border);background:#7775;position:relative;cursor:pointer} +.gx-toggle-row input::after{content:"";position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:#fff;transition:transform .15s} +.gx-toggle-row input:checked{background:#9867df;border-color:#b28ce7} +.gx-toggle-row input:checked::after{transform:translateX(18px)} +.gx-toggle-row input:focus-visible{outline:2px solid #c9a8ff;outline-offset:3px} +.gx-toggle-row input:disabled{opacity:.45;cursor:default} +.gx-navigation--stale .gx-navigation__route{opacity:.45} +@media(max-width:540px){.gx-navigation__places>li{align-items:flex-start;flex-direction:column}.gx-navigation__section{padding:18px}} + +.gx-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} + +.gx-release-notes{white-space:pre-wrap;font:inherit;line-height:1.6;overflow-wrap:anywhere} +.gx-build-history{padding-left:20px;display:grid;gap:16px}.gx-build-history li{padding-left:4px}.gx-build-history small{display:block;margin-top:4px;opacity:.65} +.gx-software-card details{padding:12px 0}.gx-software-card summary{cursor:pointer;font-weight:600} + +.gx-navigation-map { position:relative; border-radius:12px; overflow:hidden; background:#20303b; margin-bottom:16px; } +.gx-navigation-map canvas { display:block; width:100%; height:360px; touch-action:none; cursor:grab; } +.gx-navigation-map__controls { position:absolute; top:12px; right:12px; display:flex; gap:6px; } +.gx-navigation-map__credits { position:absolute; bottom:0; right:0; background:#fff; color:#222; padding:3px 7px; font-size:11px; } +.gx-navigation-map__credits a { color:#222; } +.gx-navigation-map > p { padding:8px; } + +.gx-navigation-map__logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd' viewBox='0 0 88 23'%3E%3Cdefs%3E%3Cpath id='logo' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='text' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='clip'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='white'/%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/mask%3E%3Cg id='outline' opacity='0.3' stroke='%23000' stroke-width='3'%3E%3Ccircle mask='url(%23clip)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23text' mask='url(%23clip)'/%3E%3C/g%3E%3Cg id='fill' opacity='0.9' fill='%23fff'%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/g%3E%3C/svg%3E");background-repeat:no-repeat;cursor:pointer;display:block;height:23px;margin:0 0 -4px -4px;overflow:hidden;width:88px} +.gx-navigation-map__logo { position:absolute; bottom:20px; left:8px; } + +/* Original record motifs, adapted to Galaxy's purple accent without image assets. */ +.gx-home__record .gx-home__record-art { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 12px; color: var(--primary); background: color-mix(in srgb, var(--primary) 14%, transparent); } +.gx-home__record-art svg { width: 25px; height: 25px; } +.gx-home__record-label { min-width: 0; overflow-wrap: anywhere; } +.gx-home__record strong { min-width: 0; } +.gx-force-drive { padding: 24px; } +.gx-force-drive > :first-child { margin-top: 0; } +.gx-force-drive > :last-child { margin-bottom: 0; } +@media (max-width: 600px) { + .gx-force-drive { padding: 20px; } + .gx-home__record { grid-template-columns: 36px minmax(0, 1fr); } + .gx-home__record .gx-home__record-art { width: 36px; height: 36px; grid-row: span 2; } + .gx-home__record strong { grid-column: 2; text-align: left; } +} + +.gx-card.gx-sentry-channel { padding: var(--sp-4); } +.gx-sentry-channel h3 { margin-top: 0; } +.gx-sentry-channel__fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: var(--sp-3); margin: var(--sp-3) 0; } +.gx-sentry-channel__fields label { display: grid; gap: var(--sp-2); min-width: 0; } +.gx-sentry-channel__fields .gx-field { width: 100%; min-width: 0; box-sizing: border-box; } +.gx-sentry-channel__actions { display: flex; flex-wrap: wrap; gap: var(--sp-2); } diff --git a/openpilot/starpilot/galaxy/web/css/settings.css b/openpilot/starpilot/galaxy/web/css/settings.css new file mode 100644 index 0000000000..8c9669e907 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/css/settings.css @@ -0,0 +1,459 @@ +/* Settings controls adapted from the current StarPilot Vue Galaxy. */ +.gx-settings { gap: var(--sp-4); max-width: none; } +.gx-settings__header h2 { margin: 0; } +.gx-settings__header p { color: var(--text-muted); font-size: var(--fs-sm); line-height: 1.45; } +.gx-settings__body { gap: var(--sp-4); } +.gx-settings__section { padding: 0; } +.gx-settings__section .gx-section__header { cursor: default; } +.gx-settings__save-status { position: fixed; bottom: calc(var(--bottomnav-clearance) + 8px); left: 50%; transform: translateX(-50%); z-index: var(--z-snackbar); padding: var(--sp-2) var(--sp-4); border-radius: var(--radius-full); background: var(--surface-container-high); box-shadow: var(--elev-2); } +.gx-slider-header { display: flex; align-items: baseline; gap: var(--sp-2); min-height: 24px; } +.gx-slider-hint { color: var(--text-muted); font-size: var(--fs-xs); } +.gx-row__readout { max-width: 55%; text-align: right; white-space: normal !important; overflow-wrap: anywhere; } +.gx-switch input:focus-visible + .gx-switch__track { outline: 2px solid var(--secondary); outline-offset: 4px; } +input[type="range"].gx-slider:focus-visible { outline: 2px solid var(--secondary); outline-offset: 6px; } +input[type="range"].gx-slider:disabled { cursor: default; opacity: .4; } +.gx-row .gx-btn { flex-shrink: 0; } +.gx-settings-tabs { display: flex; flex-wrap: wrap; gap: var(--sp-2); } +.gx-settings-tabs button.gx-chip[aria-pressed="true"] { background: var(--primary); color: var(--on-primary); } +@media (max-width: 520px) { .gx-row__readout { max-width: 45%; } } +.gx-section__header { + align-items: center; + cursor: pointer; + display: flex; + gap: var(--sp-3); + min-height: 56px; + padding: var(--sp-3) var(--sp-4); + background: rgba(255, 255, 255, 0.02); +} + +.gx-section__header i { + color: var(--primary); + font-size: 1.35rem; +} + +.gx-section__title { + flex: 1; + font-weight: var(--fw-bold); + letter-spacing: -0.01em; +} + +.gx-section__count { + color: var(--text-muted); + font-size: var(--fs-xs); + background: rgba(255, 255, 255, 0.08); + padding: 2px 8px; + border-radius: var(--radius-full); +} + +[data-theme="light"] .gx-section__count { + background: rgba(120, 73, 232, 0.12); + color: var(--primary); + font-weight: var(--fw-bold); +} + +.gx-chevron { + color: var(--text-muted); + transition: transform var(--motion-base); +} + +.gx-chevron.open { transform: rotate(180deg); } + +.gx-row { + align-items: center; + border-top: 1px solid var(--outline); + display: flex; + gap: var(--sp-3); + min-height: 64px; + padding: var(--sp-3) var(--sp-4); + transition: background-color var(--motion-fast); +} + +.gx-row:hover { + background-color: rgba(255, 255, 255, 0.03); +} + +.gx-row__info { flex: 1; min-width: 0; } + +.gx-row__label { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--sp-2); + font-weight: var(--fw-medium); + font-size: var(--fs-base); + line-height: 1.3; +} + +.gx-row__desc { + display: block; + color: var(--text-muted); + font-size: var(--fs-sm); + margin-top: 6px; + line-height: 1.45; +} + +.gx-row__value { + color: var(--primary); + font-weight: var(--fw-bold); + white-space: nowrap; +} + +.gx-row__actions { align-self: flex-end; display: flex; flex-wrap: wrap; gap: 6px; } + +.gx-row.disabled .gx-row__label, .gx-row.disabled .gx-row__desc { opacity: 0.45; } +.gx-row--favorites { align-items: stretch; flex-direction: column; } +.gx-row--favorites .gx-row__info { flex: none; } +.gx-row--stack { align-items: stretch; flex-direction: column; } +.gx-row--stack .gx-row__info { flex: none; } +.gx-row--stack .gx-field, +.gx-row--stack .gx-slider-row { width: 100%; } + +.gx-switch { + display: inline-flex; + position: relative; + cursor: pointer; +} + +.gx-switch input { height: 1px; opacity: 0; position: absolute; width: 1px; } + +.gx-switch__track { + background: rgba(255, 255, 255, 0.12); + border: 1px solid var(--glass-border); + border-radius: var(--radius-full); + height: 30px; + width: 52px; + transition: background-color var(--motion-base), border-color var(--motion-base); +} + +[data-theme="light"] .gx-switch__track { + background: rgba(0, 0, 0, 0.08); + border: 1.5px solid var(--primary); +} + +.gx-switch__thumb { + background: #ffffff; + border-radius: 50%; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.35); + height: 22px; + left: 4px; + position: absolute; + top: 4px; + transition: transform var(--motion-base), width var(--motion-fast); + width: 22px; +} + +[data-theme="light"] .gx-switch__thumb { + border: 1px solid rgba(120, 73, 232, 0.3); +} + +.gx-switch:active .gx-switch__thumb { + width: 26px; +} + +.gx-switch input:checked + .gx-switch__track { + background: var(--primary); + border-color: var(--primary); +} + +.gx-switch input:checked ~ .gx-switch__thumb { + transform: translateX(22px); +} + +.gx-switch:active input:checked ~ .gx-switch__thumb { + transform: translateX(18px); +} + +.gx-switch input:disabled + .gx-switch__track { opacity: 0.4; } + +.gx-slider-row { + align-items: stretch; + display: flex; + flex-direction: column; + gap: var(--sp-2); + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; + width: 100%; +} + +.gx-slider-row .gx-row__value { text-align: left; min-width: 0; transition: color var(--motion-fast), text-shadow var(--motion-fast); } + +.gx-slider-meta { + color: var(--text-muted); + display: flex; + font-size: var(--fs-xs); + justify-content: space-between; +} + +input[type="range"].gx-slider { + -webkit-appearance: none; + appearance: none; + background: rgba(255, 255, 255, 0.12); + border-radius: var(--radius-full); + cursor: pointer; + margin: 0; + padding: 0; + border: none; + height: 8px; + outline: none; + -webkit-tap-highlight-color: transparent; + touch-action: none; + width: 100%; +} + +[data-theme="light"] input[type="range"].gx-slider { + background: rgba(120, 73, 232, 0.18); + border: 1px solid rgba(120, 73, 232, 0.3); +} + +input[type="range"].gx-slider::-webkit-slider-thumb { + -webkit-appearance: none; + background: #ffffff; + border: 2px solid var(--primary); + border-radius: 50%; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); + height: 22px; + width: 22px; + transition: transform var(--motion-fast); +} + +input[type="range"].gx-slider:active::-webkit-slider-thumb { + transform: scale(1.15); +} + +input[type="range"].gx-slider::-moz-range-track { + background: transparent; + border: none; + height: 8px; + border-radius: var(--radius-full); +} + +input[type="range"].gx-slider::-moz-range-thumb { + background: #ffffff; + border: 2px solid var(--primary); + border-radius: 50%; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); + height: 22px; + width: 22px; + box-sizing: border-box; + transition: transform var(--motion-fast); +} + +input[type="range"].gx-slider:active::-moz-range-thumb { + transform: scale(1.15); +} + +.gx-slider-reset { + background: var(--surface-container-high); + border: 1px solid var(--glass-border); + border-radius: var(--radius-full); + color: var(--on-surface); + cursor: pointer; + font-size: var(--fs-xs); + font-weight: var(--fw-medium); + min-height: 32px; + padding: 0 var(--sp-3); + transition: background-color var(--motion-fast); +} + +.gx-slider-reset:hover { background: var(--glass-bg-hover); } + +[data-theme="light"] .gx-slider-reset { + border: 1.5px solid var(--primary); +} + + +.gx-chip { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-full); + font-size: var(--fs-xs); + font-weight: var(--fw-bold); + padding: 2px 8px; + line-height: 1.2; + vertical-align: middle; +} + +button.gx-chip { + -webkit-appearance: none; + appearance: none; + background: var(--surface-container-high); + border: 1px solid var(--glass-border); + color: var(--on-surface-variant); + cursor: pointer; + font-family: var(--font-body); +} + +button.gx-chip:hover { + background: var(--glass-bg-hover); +} + + +.gx-select > select { display: none !important; } +.gx-select { display: inline-flex; position: relative; vertical-align: middle; min-width: 0; max-width: 100%; } +.gx-select.gx-field { padding: 0; background: var(--surface-container-high); } +.gx-select.gx-field--full { display: flex; width: 100%; } +.gx-select__button { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; min-width: 0; min-height: 44px; padding: 10px var(--sp-3); border: 0; border-radius: inherit; color: inherit; background: transparent; font: inherit; text-align: left; cursor: pointer; touch-action: manipulation; } +.gx-select__button > span { min-width: 0; overflow-wrap: anywhere; white-space: normal; } +.gx-select__button > i { flex-shrink: 0; } +.gx-select:focus-within { outline: 2px solid var(--primary); outline-offset: 2px; } +.gx-select__button:disabled { opacity: .5; cursor: default; } +.gx-select-menu { position: fixed; inset: auto; margin: 0; padding: 6px; box-sizing: border-box; max-width: none; overflow-y: auto; overscroll-behavior: contain; border: 1px solid var(--outline-variant, var(--glass-border)); border-radius: var(--radius-md); color: var(--on-surface); background: var(--surface-container-high, #252033); box-shadow: 0 12px 36px #0006; font: var(--fs-base) var(--font-body); } +.gx-select-menu::backdrop { background: transparent; } +.gx-select-menu [role="option"] { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; min-height: 44px; padding: 10px 12px; border: 0; border-radius: 10px; background: transparent; color: inherit; text-align: left; font: inherit; white-space: normal; overflow-wrap: anywhere; cursor: pointer; touch-action: manipulation; } +.gx-select-menu [role="option"][aria-selected="true"] { background: var(--primary-container); color: var(--on-primary-container); } +.gx-select-menu [role="option"]:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; background: var(--primary-container); color: var(--on-primary-container); } +@media (hover: hover) { + .gx-select-menu [role="option"]:hover:not(:disabled) { outline: 2px solid var(--primary); outline-offset: -2px; background: var(--primary-container); color: var(--on-primary-container); } +} +.gx-select-menu [role="option"]:disabled { opacity: .45; cursor: default; } +.gx-select-menu__group { padding: 10px 12px 4px; color: var(--on-surface-variant); font-size: .85em; } + +.gx-select-menu { background: linear-gradient(var(--surface-container-high), var(--surface-container-high)), #141020; } +[data-theme="light"] .gx-select-menu { background: linear-gradient(var(--surface-container-high), var(--surface-container-high)), #fff; } +.gx-select-menu:not([open]) { display: none; } + +.gx-slider-row.is-fine-scrubbing input[type="range"].gx-slider { + background: rgba(157, 114, 255, 0.28); + box-shadow: 0 0 8px var(--primary-glow); +} + +.gx-slider-row.is-fine-scrubbing input[type="range"].gx-slider::-webkit-slider-thumb { + transform: scale(1.35); + background: var(--secondary); + border-color: #ffffff; + box-shadow: 0 0 10px var(--secondary-glow); +} + +.gx-slider-row.is-fine-scrubbing input[type="range"].gx-slider::-moz-range-thumb { + transform: scale(1.35); + background: var(--secondary); + border-color: #ffffff; + box-shadow: 0 0 10px var(--secondary-glow); +} + +.gx-slider-row.is-fine-scrubbing .gx-row__value { + color: var(--secondary); + text-shadow: 0 0 10px var(--secondary-glow); +} + +.gx-model-control { flex-wrap: wrap; } +.gx-model-control > .gx-row__label { flex: 0 1 160px; min-width: 0; } +.gx-model-control > .gx-field { flex: 1 1 240px; min-width: 0; } +@media (max-width: 520px) { + .gx-model-control { align-items: stretch; flex-direction: column; } + .gx-model-control > .gx-row__label { flex: none; } + .gx-model-control > .gx-field { flex: none; width: 100%; } +} + +.gx-model-manager .gx-card-grid { grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); } +.gx-model-manager .gx-card { overflow: visible; } +.gx-model-manager .gx-card > div { min-width: 0; } +.gx-model-manager .gx-card strong, .gx-model-manager .gx-chip { overflow-wrap: anywhere; } +.gx-model-manager .gx-alert { display: flex; align-items: center; flex-wrap: wrap; gap: var(--sp-3); border-radius: var(--radius-md); padding: var(--sp-3) var(--sp-4); } +.gx-model-manager .gx-alert--info { background: rgba(157, 114, 255, .12); } +.gx-model-manager .gx-alert--warn { background: rgba(255, 184, 101, .12); } +.gx-model-manager .gx-alert__icon { font-size: 1.4rem; color: var(--primary); } +.gx-model-manager .gx-alert__body { flex: 1 1 220px; } +.gx-model-manager .gx-alert__body strong { display: block; font-weight: var(--fw-bold); } +.gx-model-manager .gx-alert__body span { color: var(--text-muted); font-size: var(--fs-sm); } +.gx-model-manager .gx-btn--danger { background: var(--error); color: var(--on-error); } +.gx-model-reason { margin: 0; padding: 0 var(--sp-3) var(--sp-3); color: var(--text-muted); font-size: var(--fs-sm); } +.gx-model-runtime { margin-top: var(--sp-3); } +.gx-model-runtime__body { padding: 0 var(--sp-3) var(--sp-3); overflow-wrap: anywhere; } +.gx-model-runtime__body p { margin: 0 0 var(--sp-2); } + +.gx-layout { width: 100%; max-width: 1280px; min-width: 0; } +.gx-favorites__status, .gx-favorites__slot-header, .gx-favorites__show { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.gx-favorites__slots { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 310px), 1fr)); gap: 18px; } +.gx-favorites__slot { display: grid; gap: 18px; padding: 24px; min-width: 0; } +.gx-favorites__slot h3 { margin: 0; } +.gx-favorites__slot small { display: block; color: var(--on-surface-variant); font-size: var(--fs-sm); font-weight: normal; margin-top: 4px; } +.gx-favorites__slot > label { display: grid; gap: 8px; min-width: 0; } +.gx-favorites__slot input[type="text"] { min-width: 0; width: 100%; } +.gx-favorites__slot .gx-switch { flex-shrink: 0; } +.gx-controllers__switch-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.gx-controllers__row { flex-wrap: wrap; } +.gx-controllers__row .gx-row__info { flex: 1 1 180px; } +.gx-controllers__selector { display: grid; gap: 8px; } +.gx-controllers__selector .gx-field { width: 100%; min-width: 0; } +.gx-controllers__row .gx-btn { flex: 0 0 auto; } +@media (max-width: 440px) { + .gx-controllers__row .gx-row__info, .gx-controllers__row .gx-btn { flex-basis: 100%; } +} +.gx-layout h3, .gx-layout h4, .gx-layout p { margin: 0; } +.gx-layout .gx-settings__header p { max-width: 760px; } +.gx-layout__savebar, .gx-layout__subhead { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } +.gx-layout__savebar { padding: 14px 18px; border: 1px solid var(--glass-border); border-radius: var(--radius-md); background: var(--surface-container); } +.gx-layout__savebar > span, .gx-layout__subhead > :first-child { margin-right: auto; } +.gx-layout__subhead > span, .gx-layout__savebar > span { color: var(--on-surface-variant); } +.gx-layout__tabs { display: flex; gap: 10px; } +.gx-layout__zone-toggle { display: flex; align-items: center; gap: 8px; color: var(--on-surface-variant); font-size: var(--fs-sm); } +.gx-layout__zone-toggle input { accent-color: var(--primary); } +.gx-layout button[aria-pressed="true"] { background: var(--primary-container); color: var(--on-primary-container); outline: 2px solid var(--primary); outline-offset: -2px; } +.gx-layout__workspace { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 18px; align-items: start; min-width: 0; } +.gx-layout__preview-card, .gx-layout__inspector, .gx-layout__colors, .gx-layout__discard { padding: 20px; display: grid; gap: 16px; min-width: 0; } +.gx-layout__preview-card { padding: 20px 0; } +.gx-layout__preview-card > :not(svg) { margin-inline: 20px; } +.gx-layout__preview { display: block; width: 100%; height: auto; border-block: 1px solid var(--glass-border); touch-action: none; user-select: none; } +.gx-layout__device { display: grid; gap: 12px; min-width: 0; border-top: 1px solid var(--glass-border); padding-top: 16px; } +.gx-layout__device .gx-layout__scenes { margin-inline: 0; } +.gx-layout__device-frame { display: grid; place-items: center; min-width: 0; width: 100%; overflow: hidden; background: #090c10; color: var(--on-surface-variant); border: 1px solid var(--glass-border); } +.gx-layout__device-frame img { display: block; width: 100%; height: 100%; object-fit: contain; } +.gx-layout__device-frame span { padding: 16px; text-align: center; } +.gx-layout__scenes { display: flex; flex-wrap: wrap; gap: 8px; margin-inline: 20px; } +.gx-layout__preview-status { min-height: 24px; margin-inline: 20px; display: flex; align-items: center; gap: 10px; color: var(--text-muted); } +.gx-layout__preview-status:empty { min-height: 0; } +.gx-layout__widget { cursor: grab; outline: none; } +.gx-layout__widget:active { cursor: grabbing; } +.gx-layout-widget-art { pointer-events: none; font-family: var(--font-body); } +.gx-layout__selection { pointer-events: none; opacity: 0; } +.gx-layout__widget.is-selected .gx-layout__selection, .gx-layout__widget:focus-visible .gx-layout__selection { opacity: 1; } +.gx-layout__widget:focus-visible .gx-layout__selection { stroke: #fff; stroke-dasharray: none; } +.gx-layout__widget-list { display: grid; gap: 8px; } +.gx-layout__widget-list > button { justify-content: flex-start; text-align: left; white-space: normal; } +.gx-layout__position, .gx-layout__tray, .gx-layout__color { display: grid; gap: 10px; } +.gx-layout__position, .gx-layout__tray { padding-block: 16px; border-top: 1px solid var(--glass-border); } +.gx-layout__coordinates { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.gx-layout__coordinates label { display: grid; gap: 6px; } +.gx-layout__coordinates input { width: 100%; min-width: 0; } +.gx-layout__tray-item { display: flex; gap: 6px; align-items: center; border: 1px solid var(--glass-border); border-radius: var(--radius-sm); padding: 6px; } +.gx-layout__drag-handle { flex: 1; min-width: 0; display: flex; align-items: center; gap: 4px; min-height: 44px; color: var(--on-surface); background: transparent; border: 0; font: inherit; text-align: left; cursor: grab; touch-action: none; } +.gx-layout__drag-handle:disabled { opacity: .5; cursor: default; } +.gx-layout__drag-handle i { color: var(--on-surface-variant); } +.gx-layout__palette { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; } +.gx-layout__inspector .gx-layout__colors { padding: 0; } +.gx-layout .gx-layout__reset { justify-self: start; flex: 0 0 auto; min-height: 36px; padding: 7px 12px; font-size: var(--fs-sm); } +.gx-layout__inspector .gx-layout__palette { grid-template-columns: 1fr; gap: 18px; } +.gx-layout__inspector .gx-layout__colors .gx-layout__subhead { flex-wrap: wrap; } +.gx-layout__color > label:first-child { font-weight: var(--fw-bold); } +.gx-layout__color-values { display: flex; gap: 10px; align-items: center; } +.gx-layout__color-values input[type="color"] { width: 48px; height: 44px; flex: 0 0 48px; padding: 3px; background: var(--surface-container-high); border: 1px solid var(--glass-border); border-radius: 8px; cursor: pointer; } +.gx-layout__color-values input[type="text"] { min-width: 0; width: 100%; font-family: ui-monospace, monospace; } +.gx-layout__color input[type="range"] { width: 100%; accent-color: var(--primary); min-height: 36px; } +.gx-layout__color > label { color: var(--on-surface-variant); } +.gx-layout__discard { border: 1px solid var(--primary); } +@media (min-width: 1041px) and (max-width: 1360px) { + .gx-app.gx-nav-pinned .gx-layout__workspace { grid-template-columns: minmax(0, 1fr); } + .gx-app.gx-nav-pinned .gx-layout__inspector { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .gx-app.gx-nav-pinned .gx-layout__inspector > h3 { grid-column: 1 / -1; } + .gx-app.gx-nav-pinned .gx-layout__position { grid-column: 2; grid-row: 2 / span 2; border-top: 0; padding-top: 0; } + .gx-app.gx-nav-pinned .gx-layout__tray { grid-column: 1; } + .gx-app.gx-nav-pinned .gx-layout__inspector > button:last-child { grid-column: 1 / -1; } +} +@media (max-width: 1040px) { + .gx-layout__workspace { grid-template-columns: 1fr; } + .gx-layout__inspector { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .gx-layout__inspector > h3 { grid-column: 1 / -1; } + .gx-layout__position { grid-column: 2; grid-row: 2 / span 2; border-top: 0; padding-top: 0; } + .gx-layout__tray { grid-column: 1; } + .gx-layout__inspector > button:last-child { grid-column: 1 / -1; } +} +@media (max-width: 600px) { + .gx-layout__palette, .gx-layout__inspector { grid-template-columns: 1fr; } + .gx-layout__position { grid-column: auto; grid-row: auto; padding-top: 16px; border-top: 1px solid var(--glass-border); } + .gx-layout__savebar > span { flex-basis: 100%; } + .gx-layout__savebar > button { flex: 1; } + .gx-layout__color:not(:last-child) { padding-bottom: 18px; border-bottom: 1px solid var(--glass-border); } +} diff --git a/openpilot/starpilot/galaxy/web/data/catalog.json b/openpilot/starpilot/galaxy/web/data/catalog.json new file mode 100644 index 0000000000..6359f7d433 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/data/catalog.json @@ -0,0 +1,96 @@ +{ + "mode": "offline-preview", + "tools": [ + { + "name": "Bluetooth", + "path": "/bluetooth", + "icon": "bi-bluetooth", + "description": "Adapter power, saved devices and connections", + "availability": "local-only" + }, + { + "name": "Cameras & Monitoring", + "path": "/cameras", + "icon": "bi-camera-video", + "description": "Saved Side Camera, Sentry motion, and V-ASM choices; live camera tools pending", + "availability": "local-only" + }, + { + "name": "Force Drive State", + "path": "/device-preferences", + "icon": "bi-volume-up", + "description": "Select a temporary drive state for this device", + "availability": "local-only" + }, + { + "name": "Galaxy Tunnel", + "path": "/galaxy", + "icon": "bi-globe2", + "description": "Remote access and device pairing", + "availability": "local-only" + }, + { + "name": "Logs & Diagnostics", + "path": "/logs", + "icon": "bi-exclamation-triangle", + "description": "Live console, driving configuration, system activity and crash reports", + "availability": "partial-preview" + }, + { + "name": "Model Manager", + "path": "/manage_models", + "icon": "bi-cpu", + "description": "Bundled model identity and runtime health", + "availability": "local-only" + }, + { + "name": "Model Laboratory", + "path": "/model_laboratory", + "icon": "bi-bezier2", + "description": "Pair lateral and longitudinal eGPU variants", + "availability": "local-only" + }, + { + "name": "Onroad Appearance", + "path": "/appearance", + "icon": "bi-display", + "description": "Saved compact border and onroad visibility choices", + "availability": "local-only" + }, + { + "name": "Navigation & Maps", + "path": "/navigation", + "icon": "bi-map", + "description": "Destinations, directions, saved places and offline maps", + "availability": "local-only" + }, + { + "name": "System Tools", + "path": "/system", + "icon": "bi-arrow-repeat", + "description": "Branch updates, automatic downloads and version history", + "availability": "local-only" + }, + { + "name": "Theme Maker", + "path": "/theme_maker", + "icon": "bi-palette-fill", + "description": "Parked colors and Big or Small driving layouts", + "availability": "local-only" + }, + { + "name": "Plots & Analysis", + "path": "/tuning", + "icon": "bi-sign-turn-right", + "description": "Live steering and acceleration plots, and recorded-drive analysis", + "availability": "local-only" + }, + { + "name": "Vehicle Controls", + "path": "/vehicle", + "icon": "bi-car-front", + "description": "Parked Auto or manual next-start vehicle selection", + "availability": "local-only" + } + ] +} diff --git a/openpilot/starpilot/galaxy/web/data/runtime.json b/openpilot/starpilot/galaxy/web/data/runtime.json new file mode 100644 index 0000000000..5e56e2fcf8 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/data/runtime.json @@ -0,0 +1 @@ +{"schemaVersion": 1, "monitor": "sample"} diff --git a/openpilot/starpilot/galaxy/web/data/system-monitor.sample.json b/openpilot/starpilot/galaxy/web/data/system-monitor.sample.json new file mode 100644 index 0000000000..8027856954 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/data/system-monitor.sample.json @@ -0,0 +1,21 @@ +{ + "mode": "synthetic-preview", + "schemaVersion": 1, + "source": "Illustrative Galaxy System Monitor sample; no device was read", + "sampledAt": 1789992000, + "cpuPercent": 27.4, + "cores": [{"name":"cpu0","percent":32.1},{"name":"cpu1","percent":22.7},{"name":"cpu2","percent":null},{"name":"cpu3","percent":28.3}], + "memory": {"totalMiB":7872,"usedMiB":3182.4,"availableMiB":4689.6,"percent":40.4}, + "storage": {"usedGiB":38.7,"totalGiB":118.1}, + "uptimeSeconds": 98432, + "processCount": 6, + "processes": [ + {"pid":101,"name":"selfdrive.controls.controlsd","user":"comma","kernel":false,"state":"R","cpu":8.3,"memoryMiB":222.6}, + {"pid":102,"name":"selfdrive.modeld.modeld","user":"comma","kernel":false,"state":"S","cpu":12.7,"memoryMiB":410.2}, + {"pid":103,"name":"system.hardware.hardwared","user":"comma","kernel":false,"state":"S","cpu":null,"memoryMiB":91.1}, + {"pid":104,"name":"system.loggerd.uploader","user":"comma","kernel":false,"state":"S","cpu":1.2,"memoryMiB":73.4}, + {"pid":105,"name":"system.manager.manager","user":"root","kernel":false,"state":"R","cpu":2.4,"memoryMiB":54.5}, + {"pid":2,"name":"kthreadd","user":"root","kernel":true,"state":"S","cpu":null,"memoryMiB":0} + ], + "vitals": {"cpuTempC":51.2,"gpuTempC":48.4,"hotspotTempC":null,"gpuEdgeTempC":null,"memoryUsedBytes":null,"memoryTotalBytes":null,"onboardMaxAgeMs":2500,"maxAgeMs":0,"memoryMaxAgeMs":0} +} diff --git a/openpilot/starpilot/galaxy/web/index.html b/openpilot/starpilot/galaxy/web/index.html new file mode 100644 index 0000000000..d0ef79b14a --- /dev/null +++ b/openpilot/starpilot/galaxy/web/index.html @@ -0,0 +1,27 @@ + + + + + + + + + + Galaxy + + + + + + + + +

+
+

Galaxy

Opening Galaxy…

+ +
+
+ + + diff --git a/openpilot/starpilot/galaxy/web/js/ambient-stars.js b/openpilot/starpilot/galaxy/web/js/ambient-stars.js new file mode 100644 index 0000000000..044d4e5964 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/ambient-stars.js @@ -0,0 +1,13 @@ +export function spawnAmbientStars(background, random = Math.random) { + if (!background || background.querySelector(".galaxy-hero")) return 0 + for (let index = 0; index < 14; index++) { + const star = background.ownerDocument.createElement("i") + star.className = "galaxy-hero" + star.style.left = `${(random() * 100).toFixed(2)}%` + star.style.top = `${(random() * 100).toFixed(2)}%` + star.style.animationDelay = `${(random() * 4).toFixed(2)}s` + star.style.width = star.style.height = random() > 0.6 ? "3px" : "2px" + background.appendChild(star) + } + return 14 +} diff --git a/openpilot/starpilot/galaxy/web/js/android-auto.js b/openpilot/starpilot/galaxy/web/js/android-auto.js new file mode 100644 index 0000000000..f2ea67f9fd --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/android-auto.js @@ -0,0 +1,457 @@ +import { BluetoothDeviceList } from "./bluetooth-devices.js" + +export function uploadPackage(path, options, progress, makeRequest = () => new XMLHttpRequest()) { + return new Promise((resolve, reject) => { + const xhr = makeRequest() + let settled = false + const abort = () => xhr.abort() + const finish = (callback, value) => { + if (settled) return + settled = true + options.signal?.removeEventListener("abort", abort) + callback(value) + } + xhr.open("POST", path) + xhr.withCredentials = true + xhr.timeout = 180000 + xhr.setRequestHeader("Content-Type", "application/octet-stream") + xhr.upload.onprogress = (event) => { + if (!settled && event.lengthComputable) progress({ loaded: event.loaded, total: event.total }) + } + xhr.onload = () => finish(resolve, { status: xhr.status, ok: xhr.status >= 200 && xhr.status < 300, + json: async () => JSON.parse(xhr.responseText) }) + xhr.onerror = () => finish(reject, new Error("Upload interrupted. Check your connection and retry.")) + xhr.ontimeout = () => finish(reject, new Error("Upload timed out. Check your connection and retry.")) + xhr.onabort = () => finish(reject, new Error("Upload canceled.")) + options.signal?.addEventListener("abort", abort, { once: true }) + if (options.signal?.aborted) { finish(reject, new Error("Upload canceled.")); return } + xhr.send(options.body) + }) +} + +const ADDRESS = /^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$/ +const PROMPT = new Set(["pin", "passkey", "confirmation", "authorization", "display_pin", "display_passkey"]) + +export function validPromptInput(prompt, value) { + if (prompt?.kind === "pin") return /^[\x20-\x7E]{1,16}$/.test(value) + if (prompt?.kind === "passkey") return /^[0-9]{1,6}$/.test(value) + return ["confirmation", "authorization"].includes(prompt?.kind) && value === "" +} + +export function validPairingStatus(value) { + if (!value || typeof value.active !== "boolean" || typeof value.approved !== "boolean") return false + if (value.devices !== undefined && (!Array.isArray(value.devices) || !value.devices.every(validDiscoveredDevice))) return false + if (value.discovering !== undefined && typeof value.discovering !== "boolean") return false + if (value.state !== undefined && !["idle", "pairing", "paired", "failed", "connecting", "connected"].includes(value.state)) return false + if (value.error !== undefined && typeof value.error !== "string") return false + const receiver = value.receiver + if (receiver !== null && (!ADDRESS.test(receiver?.address) || typeof receiver.name !== "string" || receiver.name.length > 80)) return false + const prompt = value.prompt + return prompt === null || (receiver !== null && /^[0-9a-f]{32}$/.test(prompt?.id) && PROMPT.has(prompt.kind) && + typeof prompt.value === "string" && prompt.value.length <= 16 && typeof prompt.displayOnly === "boolean") +} + +export function validDiscoveredDevice(value) { + return value !== null && ADDRESS.test(value?.address) && typeof value.name === "string" && value.name.length <= 80 && + ["paired", "connected", "android_auto"].every((key) => typeof value[key] === "boolean") +} + +function validSelected(value) { + return value === null || (ADDRESS.test(value?.address) && typeof value.name === "string" && value.name.length <= 80) +} + +function validSetup(value) { + return value && typeof value.enabled === "boolean" && typeof value.bluetoothEnabled === "boolean" && + typeof value.parked === "boolean" && typeof value.installReady === "boolean" && typeof value.serviceReady === "boolean" && + value.identity && typeof value.identity.installed === "boolean" && value.import && + Number.isSafeInteger(value.maxUploadBytes) && value.maxUploadBytes > 0 +} + +export class AndroidAutoFeed { + constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args), + uploader = uploadPackage, later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) { + Object.assign(this, { publish, unauthorized, fetcher, uploader, later, cancelTimer }) + this.active = false + this.generation = 0 + this.controller = null + this.timer = null + this.pollTimer = null + this.setup = null + this.pairing = null + this.selected = null + this.runtime = null + this.receivers = [] + this.endReason = "" + this.busy = false + this.refreshing = false + this.deviceList = new BluetoothDeviceList() + this.receiverList = new BluetoothDeviceList() + this.uploadProgress = null + this.error = "" + } + + emit() { this.publish({ setup: this.setup, pairing: this.pairing, selected: this.selected, runtime: this.runtime, + receivers: this.receivers, + endReason: this.endReason, busy: this.busy, uploadProgress: this.uploadProgress, error: this.error }) } + + stop(cancelPair = false) { + if (cancelPair && this.active && this.pairing?.active) { + this.fetcher("./api/android-auto/pairing/cancel", { method: "POST", credentials: "same-origin", keepalive: true, + headers: { "Content-Type": "application/json" }, body: "{}" }).catch(() => {}) + } + this.active = false + this.generation++ + this.controller?.abort() + if (this.timer !== null) this.cancelTimer(this.timer) + if (this.pollTimer !== null) this.cancelTimer(this.pollTimer) + this.controller = this.timer = this.pollTimer = null + this.busy = false + this.refreshing = false + this.deviceList.reset() + this.receiverList.reset() + this.uploadProgress = null + this.pairing = null + this.emit() + } + + start() { + this.stop() + this.active = true + return this.refresh() + } + + async request(path, options = {}, timeout = 8000, transport = this.fetcher, background = false) { + if (!background) { + // A user action supersedes a pending poll, including a delayed JSON body. + this.generation++ + this.controller?.abort() + if (this.timer !== null) this.cancelTimer(this.timer) + if (this.pollTimer !== null) this.cancelTimer(this.pollTimer) + this.pollTimer = null + this.refreshing = false + } + const generation = this.generation + const controller = new AbortController() + this.controller = controller + if (!background) { this.busy = true; this.emit() } + let timedOut = false + this.timer = this.later(() => { timedOut = true; controller.abort() }, timeout) + try { + const response = await transport(path, { ...options, credentials: "same-origin", cache: "no-store", signal: controller.signal }) + if (!this.active || this.generation !== generation || controller.signal.aborted) return null + if (response.status === 401) { this.stop(); this.unauthorized(); return null } + const data = await response.json() + if (!this.active || this.generation !== generation || controller.signal.aborted) return null + if (!response.ok) throw new Error(data?.error || "Android Auto setup is unavailable") + this.error = "" + return data + } catch (error) { + if (this.active && this.generation === generation) { + this.error = timedOut ? "Android Auto took too long to respond. Try again." : + error instanceof Error ? error.message : "Android Auto setup is unavailable" + } + return null + } finally { + if (this.controller === controller) { + this.cancelTimer(this.timer) + this.controller = this.timer = null + if (!background) { + this.busy = false + if (this.active && this.generation === generation && this.pollTimer === null) { + this.pollTimer = this.later(() => this.refresh(), this.pairing?.active ? 1000 : 5000) + } + } + this.emit() + } + } + } + + async refresh() { + if (!this.active || this.busy || this.refreshing) return + this.refreshing = true + if (this.pollTimer !== null) { this.cancelTimer(this.pollTimer); this.pollTimer = null } + const generation = this.generation + const setup = await this.request("./api/android-auto/setup", {}, 8000, this.fetcher, true) + if (!this.active || this.generation !== generation) return + const setupValid = setup && validSetup(setup) + if (setupValid) this.setup = setup + else if (setup) this.error = "Android Auto setup response changed" + if (setupValid && !setup.enabled) { + this.pairing = this.selected = this.runtime = null + this.receivers = [] + this.deviceList.reset() + this.receiverList.reset() + this.emit() + } + if (this.active && this.generation === generation && setupValid && setup.enabled) { + const result = await this.request("./api/android-auto/pairing/status", {}, 8000, this.fetcher, true) + if (!this.active || this.generation !== generation) return + if (result && validPairingStatus(result.pairing) && validSelected(result.selectedReceiver)) { + const wasActive = this.pairing?.active + if (!result.pairing.active) this.deviceList.reset() + this.pairing = { ...result.pairing, devices: this.deviceList.update(result.pairing.devices || []) } + this.selected = result.selectedReceiver + this.runtime = result.runtime && typeof result.runtime === "object" ? result.runtime : null + if (wasActive && !this.pairing.active && !this.selected && !this.endReason) this.endReason = "ended" + } + else if (result) this.error = "Pairing status response changed" + } + this.refreshing = false + this.emit() + if (this.active && this.generation === generation) { + const delay = this.pairing?.active || this.setup?.import?.state === "running" ? 1000 : 5000 + this.pollTimer = this.later(() => this.refresh(), delay) + } + } + + async action(path, body = {}, timeout = 12000) { + if (!this.active || this.busy || !this.setup?.parked || !this.setup?.enabled) return false + const result = await this.request(path, { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) }, timeout) + if (result === null) return false + if (path.endsWith('/pairing')) this.endReason = "" + if (path.endsWith('/cancel')) this.endReason = "canceled" + await this.refresh() + return true + } + + async selectDevice(address) { + if (!ADDRESS.test(address) || !this.pairing?.active || this.pairing?.prompt || + ["pairing", "connecting"].includes(this.pairing?.state) || this.runtime?.running || + !this.pairing?.devices?.some((device) => device.address === address)) return false + return this.action("./api/android-auto/pairing/select", { address }) + } + + async loadReceivers() { + if (!this.active || this.busy || !this.setup?.enabled || this.pairing?.active) return false + const result = await this.request("./api/android-auto/receivers") + if (result === null) return false + if (!Array.isArray(result.receivers) || !result.receivers.every((car) => car !== null && validSelected(car))) { + this.error = "Paired car list changed" + this.emit() + return false + } + this.receivers = this.receiverList.update(result.receivers) + this.emit() + return true + } + + async control(action, extra = {}) { + if (!this.active || this.busy || !this.setup?.enabled || this.pairing?.active || + !["start", "stop", "select_receiver", "auto_connect"].includes(action)) return false + const result = await this.request("./api/android-auto/control", { method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action, ...extra }) }) + if (result === null) return false + await this.refresh() + return true + } + + async setEnabled(enabled) { + if (!this.active || this.busy || typeof enabled !== "boolean" || + enabled && (!this.setup?.parked || !this.setup?.installReady)) return false + const result = await this.request("./api/android-auto/enable", { method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled }) }) + if (result === null) return false + await this.refresh() + return true + } + + async upload(file) { + if (!this.active || this.busy || !this.setup?.parked || !this.setup?.enabled || + !file || !Number.isSafeInteger(file.size) || file.size <= 0 || file.size > this.setup.maxUploadBytes) { + this.error = "Choose an APK, XAPK, or APKM within the shown size limit." + this.emit() + return false + } + this.uploadProgress = { loaded: 0, total: file.size } + const generation = this.generation + 1 + const transport = (path, options) => this.uploader(path, options, (progress) => { + if (this.active && generation === this.generation) { this.uploadProgress = progress; this.emit() } + }) + const result = await this.request("./api/android-auto/upload", { method: "POST", + headers: { "Content-Type": "application/octet-stream" }, body: file }, 180000, transport) + this.uploadProgress = null + this.emit() + if (result === null) return false + await this.refresh() + return true + } +} + +export const AndroidAutoPage = { + props: { mode: { type: String, required: true }, localAccess: { type: Boolean, required: true }, + unauthorized: { type: Function, required: true } }, + data: () => ({ setup: null, pairing: null, selected: null, runtime: null, receivers: [], endReason: "", busy: false, error: "", + pairValue: "", packageFile: null, uploadProgress: null }), + mounted() { + this.feed = new AndroidAutoFeed({ publish: (value) => Object.assign(this, value), unauthorized: this.unauthorized }) + this.visibility = () => document.hidden ? this.feed.stop(true) : this.feed.start() + document.addEventListener("visibilitychange", this.visibility) + if (!document.hidden) this.feed.start() + }, + beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.feed?.stop(true) }, + watch: { + mode() { if (!document.hidden) this.feed?.start(); else this.feed?.stop(true) }, + localAccess() { if (!document.hidden) this.feed?.start(); else this.feed?.stop(true) }, + 'pairing.prompt.id'() { this.pairValue = "" }, + }, + computed: { + canRespond() { return validPromptInput(this.pairing?.prompt, this.pairValue) }, + packageError() { + if (!this.packageFile) return "" + if (!Number.isSafeInteger(this.packageFile.size) || this.packageFile.size <= 0) return "The selected file is empty or its size cannot be read." + if (this.setup && this.packageFile.size > this.setup.maxUploadBytes) return "The selected package exceeds the maximum size shown below." + return "" + }, + uploadReason() { + if (!this.packageFile) return "Choose your Android Auto package." + if (this.packageError) return this.packageError + if (!this.setup?.parked) return "Use offroad mode or Park to upload your package." + if (!this.setup.enabled && !this.setup.installReady) return "The Android Auto display and encoder must be installed before uploading." + if (this.setup.import?.state === "running") return "Wait for the current package verification to finish." + if (this.busy) return "Waiting for Android Auto setup to respond…" + return "" + }, + pairingReason() { + if (!this.setup) return "Load setup status first." + if (!this.setup.installReady) return "This build is missing the Android Auto display or encoder." + if (!this.setup.enabled) return "Enable Android Auto in Prepare." + if (!this.setup.serviceReady) return "Waiting for the Android Auto service to start." + if (!this.setup.bluetoothEnabled) return "Turn on Bluetooth on the Bluetooth page." + if (!this.setup.parked) return "Use offroad mode or Park before starting pairing." + if (!this.setup.identity.installed) return "Upload and verify your Android Auto package in Prepare." + if (this.runtime?.running) return "Stop projection before searching or pairing." + return "" + }, + waitingForCar() { return this.pairing?.active && !this.pairing?.receiver && !this.pairing?.prompt && !this.pairing?.approved && + !["pairing", "connecting", "paired", "connected", "failed"].includes(this.pairing?.state) }, + deviceSelectionBlocked() { return this.busy || !!this.pairingReason || !this.pairing?.active || + !!this.pairing?.prompt || ["pairing", "connecting"].includes(this.pairing?.state) || !!this.runtime?.running }, + }, + methods: { + refresh() { return this.feed.refresh() }, + setEnabled(enabled) { return this.feed.setEnabled(enabled) }, + startPairing() { + if (this.pairingReason || this.runtime?.running) return false + return this.feed.action("./api/android-auto/pairing") + }, + selectDevice(address) { return this.feed.selectDevice(address) }, + cancelPairing() { return this.feed.action("./api/android-auto/pairing/cancel") }, + respond(accepted) { + const prompt = this.pairing?.prompt + if (!prompt || prompt.displayOnly || accepted && !this.canRespond) return + const value = accepted && ["pin", "passkey"].includes(prompt.kind) ? this.pairValue : "" + return this.feed.action("./api/android-auto/pairing/response", { prompt_id: prompt.id, accepted, value }) + }, + choosePackage(event) { this.packageFile = event.target.files?.[0] || null }, + async upload() { + if (this.uploadReason) return false + const file = this.packageFile + if (!this.setup.enabled && !await this.feed.setEnabled(true)) return false + return this.feed.upload(file) + }, + loadReceivers() { return this.feed.loadReceivers() }, + control(action, extra = {}) { return this.feed.control(action, extra) }, + }, + template: ` +
+

Android Auto

+

Show StarPilot on your car’s wireless Android Auto display. Enable projection, upload your Android Auto package, then find your car or wireless adapter. Setup works here over local or remote Galaxy.

+
+ +
{{ error ? "Setup could not be loaded." : "Checking Android Auto setup…" }}
+ +
+
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/app.js b/openpilot/starpilot/galaxy/web/js/app.js new file mode 100644 index 0000000000..5730ed7819 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/app.js @@ -0,0 +1,226 @@ +import { createApp, reactive, defineAsyncComponent } from "../vendor/vue/vue.esm-browser.js" +import { route, navigate, startRouter } from "./router.js" +import { loadCatalog } from "./startup.js" +import { Tools } from "./tools.js" +const Logs = page(() => import("./logs.js").then((module) => module.Logs)) +const SoftwarePage = page(() => import("./software-status.js").then((module) => module.SoftwarePage)) +const NavigationPage = page(() => import("./navigation.js").then((module) => module.NavigationPage)) +const ModelsPage = page(() => import("./models.js").then((module) => module.ModelsPage)) +const LaboratoryPage = page(() => import("./model-laboratory.js").then((module) => module.LaboratoryPage)) +import { SettingsPage } from "./settings.js" +import { ToggleSearch } from "./toggle-search.js" +const PlotsPage = page(() => import("./plots.js").then((module) => module.PlotsPage)) +const FlmPage = page(() => import("./flm.js").then((module) => module.FlmPage)) +import { LocalAuth } from "./auth-client.js" +import { Home } from "./home.js" +const LocalRecordingsPage = page(() => import("./record-history.js").then((module) => module.LocalRecordingsPage)) +const CamerasPage = page(() => import("./cameras.js").then((module) => module.CamerasPage)) +const SentryEventsPage = page(() => import("./sentry.js").then((module) => module.SentryEventsPage)) +const VasmPage = page(() => import("./vasm.js").then((module) => module.VasmPage)) +const PipPage = page(() => import("./pip.js").then((module) => module.PipPage)) +import { DrivingPage, drivingPage } from "./driving.js" +const LongitudinalCurvesPage = page(() => import("./longitudinal-curves.js").then((module) => module.LongitudinalCurvesPage)) +import { DevicePreferencesPage, devicePage } from "./device-preferences.js" +const BluetoothPage = page(() => import("./bluetooth.js").then((module) => module.BluetoothPage)) +const AndroidAutoPage = page(() => import("./android-auto.js").then((module) => module.AndroidAutoPage)) +const VehicleControlsPage = page(() => import("./vehicle-controls.js").then((module) => module.VehicleControlsPage)) +const OnroadLayoutPage = page(() => import("./onroad-layout.js").then((module) => module.OnroadLayoutPage)) +const GalaxyPage = page(() => import("./galaxy.js").then((module) => module.GalaxyPage)) +import { DevicePicker } from "./device-picker.js" +import { DeviceState } from "./device-state.js" +import { InstallApp } from "./install-app.js" +import { spawnAmbientStars } from "./ambient-stars.js" + +function page(loader) { + return defineAsyncComponent({ + loader, delay: 150, timeout: 10000, + loadingComponent: { template: '
Opening page…
' }, + errorComponent: { template: '', + methods: { reload() { location.reload() } } }, + onError(_error, retry, fail, attempts) { if (attempts < 2) setTimeout(retry, 500); else fail() }, + }) +} + +const THEME_KEY = "galaxy-preview-theme" +const PIN_KEY = "galaxy-preview-nav-pinned" +const stored = (key) => { try { return localStorage.getItem(key) } catch { return null } } +const save = (key, value) => { try { localStorage.setItem(key, value) } catch {} } + +const state = reactive({ + tools: [], + loading: true, + error: "", + pageError: "", + monitorMode: "sample", + drawerOpen: false, + navPinned: stored(PIN_KEY) === "true", + theme: stored(THEME_KEY) === "light" ? "light" : "dark", + searchPage: "", +}) +const authState = reactive({ status: "checking", error: "", localAccess: false, gatewayAccess: false }) +const authForm = reactive({ password: "" }) +const auth = new LocalAuth({ publish: (update) => Object.assign(authState, update) }) + +const NAV = [ + { name: "Home", path: "/", icon: "bi-house-fill" }, + { name: "Toggles", path: "/settings", icon: "bi-toggle-on" }, + { name: "Tools", path: "/tools", icon: "bi-tools" }, + { name: "Recordings", path: "/recordings", icon: "bi-camera-reels" }, +] + +createApp({ + components: { Home, Tools, Logs, SoftwarePage, NavigationPage, ModelsPage, LaboratoryPage, SettingsPage, ToggleSearch, PlotsPage, FlmPage, LocalRecordingsPage, CamerasPage, SentryEventsPage, VasmPage, PipPage, DrivingPage, DevicePreferencesPage, BluetoothPage, AndroidAutoPage, LongitudinalCurvesPage, VehicleControlsPage, OnroadLayoutPage, GalaxyPage, DevicePicker, DeviceState, InstallApp }, + data: () => ({ state, authState, authForm, route, NAV }), + computed: { + currentTool() { return state.tools.find((tool) => route.path === tool.path || route.path.startsWith(tool.path + "/")) }, + drivingSettingsPage() { return drivingPage(route.path) }, + deviceSettingsPage() { return devicePage(route.path) }, + pageName() { + if (this.currentTool) return this.currentTool.name + return NAV.find((item) => item.path === route.path)?.name || "This page" + }, + isLight() { return state.theme === "light" }, + connectionLabel() { + if (state.monitorMode !== "local") return "Preview" + if (authState.status === "authenticated") return authState.localAccess ? "Local connection" : "Remote connection" + return authState.status === "checking" ? "Connecting…" : "Not connected" + }, + }, + errorCaptured() { state.pageError = "This page could not finish loading."; return false }, + watch: { 'route.path'() { state.pageError = "" } }, + methods: { + retryStartup() { initialize() }, + reloadPage() { location.reload() }, + go(path) { state.drawerOpen = false; state.searchPage = ""; navigate(path) }, + openSearchHit(hit) { state.drawerOpen = false; state.searchPage = hit.page; navigate("/settings") }, + returnFromSearch() { state.searchPage = "" }, + returnToCameras() { this.go("/cameras") }, + returnToDriving() { this.go("/driving") }, + returnToDevice() { this.go("/device-preferences") }, + back() { if (["/cameras/pip", "/cameras/events", "/cameras/sentry-settings", "/cameras/vasm"].includes(route.path)) this.go("/cameras"); else if (route.path === "/tools") history.back(); else this.go("/tools") }, + toggleTheme() { + state.theme = this.isLight ? "dark" : "light" + document.documentElement.dataset.theme = state.theme + save(THEME_KEY, state.theme) + }, + togglePin() { + state.navPinned = !state.navPinned + state.drawerOpen = state.navPinned + save(PIN_KEY, String(state.navPinned)) + }, + isActive(path) { return route.path === path || route.path.startsWith(path + "/") }, + async signIn() { + const password = authForm.password + authForm.password = "" + await auth.login(password) + }, + async signOut() { await auth.logout() }, + sessionExpired() { auth.expired(); auth.check() }, + }, + mounted() { document.documentElement.dataset.theme = state.theme }, + template: ` +
+
+ +
+ + +
+ {{ connectionLabel }} +
+
+ + +
+
+ +
+
Loading Galaxy…
+ +
+

Galaxy access

+

Checking Galaxy access…

+

Remote Galaxy access is not configured.

+

Galaxy access is unavailable.

+

Reconnect to Galaxy

+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Plots & Analysis

+ + +
+ + +

{{ pageName }}

This capability is unavailable in this build. No operation was attempted.

+
+ +
+ `, +}).mount("#galaxy-app") + +spawnAmbientStars(document.getElementById("galaxy-bg")) + +startRouter() + +let startupGeneration = 0 +async function initialize() { + const generation = ++startupGeneration + state.loading = true + state.error = "" + try { + const catalog = await loadCatalog() + if (generation !== startupGeneration) return + state.monitorMode = catalog.mode + state.tools = catalog.tools + if (state.monitorMode === "local") await auth.check() + else authState.status = "preview" + } catch { + if (generation === startupGeneration) state.error = "Galaxy could not connect. Check your connection and try again." + } finally { + if (generation === startupGeneration) state.loading = false + } +} +initialize() diff --git a/openpilot/starpilot/galaxy/web/js/auth-client.js b/openpilot/starpilot/galaxy/web/js/auth-client.js new file mode 100644 index 0000000000..531d70bbad --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/auth-client.js @@ -0,0 +1,97 @@ +// Browser-only Galaxy session boundary; never persists passwords or tokens. +import { requestJson } from "./startup.js" + +export class LocalAuth { + constructor({ publish, fetcher = (...args) => fetch(...args) }) { + this.publish = publish + this.fetcher = fetcher + this.generation = 0 + this.gatewayAccess = false + this.localAccess = false + this.mutationTail = Promise.resolve() + } + + update(state, localAccess = false) { + this.localAccess = localAccess + this.publish({ ...state, localAccess }) + } + + mutate(operation) { + const pending = this.mutationTail.then(operation) + this.mutationTail = pending.then(() => {}, () => {}) + return pending + } + + async check() { + const generation = ++this.generation + try { + const result = await requestJson("./api/auth/session", { fetcher: this.fetcher }) + if (generation !== this.generation) return + this.gatewayAccess = result.gatewayAccess === true + this.publish({ gatewayAccess: this.gatewayAccess }) + if (result.state === "unavailable") this.update({ status: "unavailable", error: "Galaxy access is unavailable." }) + else if (result.state === "setup_required") this.update({ status: "setup_required", error: "Remote Galaxy access is not configured." }) + else if (result.state === "configured") this.update({ status: result.authenticated === true ? "authenticated" : this.gatewayAccess ? "gateway_login" : "login", error: "", + ...(this.gatewayAccess ? { gatewayAccess: true } : {}) }, + result.authenticated === true && result.localAccess === true) + else throw new Error("Invalid session state") + } catch { + if (generation === this.generation) this.update({ status: "unavailable", error: "Galaxy access could not be checked." }) + } + } + + login(password) { + if (this.localAccess || this.gatewayAccess) return Promise.resolve(false) + const generation = ++this.generation + return this.mutate(async () => { + try { + const response = await this.fetcher("./api/auth/login", { + method: "POST", credentials: "same-origin", cache: "no-store", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }), + }) + if (generation !== this.generation) return false + if (!response.ok) { + if (response.status === 429) this.update({ status: "login", error: "Try again shortly." }) + else if (response.status === 503) this.update({ status: "unavailable", error: "Galaxy access is unavailable." }) + else this.update({ status: "login", error: "Sign in failed." }) + return false + } + this.update({ status: "authenticated", error: "" }) + return true + } catch { + if (generation === this.generation) this.update({ status: "login", error: "Sign in could not be completed." }) + return false + } + }) + } + + logout() { + if (this.localAccess || this.gatewayAccess) return Promise.resolve(false) + const generation = ++this.generation + return this.mutate(async () => { + try { + const response = await this.fetcher("./api/auth/logout", { + method: "POST", credentials: "same-origin", cache: "no-store", + headers: { "Content-Type": "application/json" }, body: "{}", + }) + if (generation !== this.generation) return false + if (response.status === 401 || response.status === 503) { + this.update({ status: response.status === 503 ? "unavailable" : "login", error: "" }) + return false + } + if (!response.ok) throw new Error("Logout failed") + this.update({ status: "login", error: "" }) + return true + } catch { + if (generation === this.generation) this.update({ status: "authenticated", error: "Sign out could not be completed." }) + return false + } + }) + } + + expired() { + this.generation++ + if (this.localAccess || this.gatewayAccess) this.update({ status: "checking", error: "" }) + else this.update({ status: "login", error: "Your Galaxy session expired. Sign in again." }) + } +} diff --git a/openpilot/starpilot/galaxy/web/js/bluetooth-devices.js b/openpilot/starpilot/galaxy/web/js/bluetooth-devices.js new file mode 100644 index 0000000000..19ee7aef1f --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/bluetooth-devices.js @@ -0,0 +1,21 @@ +export function hasBluetoothName(device) { + if (typeof device?.name !== "string") return false + const name = device.name.trim() + const address = name.replace(/^[[(]|[\])]$/g, "").replace(/^dev_/i, "").replace(/[:.\s_-]/g, "") + return name.length > 0 && !/^[0-9a-f]{12}$/i.test(address) +} + +// Keep discovery rows in their first-seen position while updating live state. +export class BluetoothDeviceList { + constructor() { this.reset() } + reset() { this.order = [] } + update(devices) { + const current = new Map() + for (const device of devices) { + if (!hasBluetoothName(device)) continue + current.set(device.address, { ...device, name: device.name.trim() }) + if (!this.order.includes(device.address)) this.order.push(device.address) + } + return this.order.filter((address) => current.has(address)).map((address) => current.get(address)) + } +} diff --git a/openpilot/starpilot/galaxy/web/js/bluetooth.js b/openpilot/starpilot/galaxy/web/js/bluetooth.js new file mode 100644 index 0000000000..a21cad33d6 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/bluetooth.js @@ -0,0 +1,220 @@ +import { ControllersPage } from "./controllers.js" +import { BluetoothDeviceList } from "./bluetooth-devices.js" + +const ADDRESS = /^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$/ +const OPERATIONS = new Set(["power", "scan", "stop_scan", "connect", "disconnect", "forget", "pair", "pairing_response", "cancel_pair"]) +const PAIR_STATES = new Set(["pairing", "paired", "failed", "canceled"]) +const BLUETOOTH_ERRORS = { + radio_unavailable: "Bluetooth radio support is unavailable on this device.", + adapter_unavailable: "No Bluetooth adapter was detected. Turn Bluetooth on, then refresh.", + service_unavailable: "The device's Bluetooth service could not be reached. Refresh to reconnect.", + radio_preference_unavailable: "The Bluetooth power setting could not be read. Try again after restarting the device.", + busy: "Bluetooth is being used by another operation. Finish pairing or stop searching, then try again.", + park_required: "Park before changing Bluetooth connections.", + session_expired: "The pairing session expired. Refresh and start pairing again.", + changed: "The Bluetooth device or pairing request changed. Refresh and try again.", +} +export const bluetoothError = (code) => BLUETOOTH_ERRORS[code] || "Bluetooth could not complete the request. Refresh to try again." +const PROMPT_KINDS = new Set(["pin", "passkey", "confirmation", "authorization", "display_pin", "display_passkey"]) + +export function validPairInput(prompt, value) { + if (prompt?.kind === "pin") return /^[\x20-\x7E]{1,16}$/.test(value) + if (prompt?.kind === "passkey") return /^[0-9]{1,6}$/.test(value) + return ["confirmation", "authorization"].includes(prompt?.kind) +} + +function validPairing(value) { + if (value === null) return true + const prompt = value?.prompt + return ADDRESS.test(value?.address) && PAIR_STATES.has(value?.state) && + (prompt === null || (typeof prompt === "object" && /^[0-9a-f]{32}$/.test(prompt.id) && + PROMPT_KINDS.has(prompt.kind) && typeof prompt.value === "string" && prompt.value.length <= 16 && + typeof prompt.displayOnly === "boolean")) +} + +export function validBluetoothStatus(value) { + return value?.version === 1 && typeof value.available === "boolean" && typeof value.parked === "boolean" && + typeof value.powered === "boolean" && typeof value.discovering === "boolean" && + (value.errorCode === null || ["radio_unavailable", "adapter_unavailable", "service_unavailable", "radio_preference_unavailable"].includes(value.errorCode)) && + validPairing(value.pairing ?? null) && Array.isArray(value.devices) && value.devices.length <= 64 && value.devices.every((device) => + ADDRESS.test(device?.address) && typeof device.name === "string" && device.name.length <= 80 && + [device.paired, device.connected, device.trusted].every((flag) => typeof flag === "boolean")) +} + +export class BluetoothFeed { + constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args), + later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) { + Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer }) + this.active = false + this.generation = 0 + this.request = null + this.timer = null + this.pollTimer = null + this.busy = false + this.status = null + this.deviceList = new BluetoothDeviceList() + this.error = "" + } + + emit() { this.publish({ status: this.status, busy: this.busy, error: this.error }) } + stop(cancelPair = false) { + if (cancelPair && this.active && this.status?.pairing?.state === "pairing") { + this.fetcher("./api/bluetooth/action", { method: "POST", credentials: "same-origin", keepalive: true, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ operation: "cancel_pair" }) }).catch(() => {}) + } + this.active = false + this.generation++ + this.request?.abort() + if (this.timer !== null) this.cancelTimer(this.timer) + if (this.pollTimer !== null) this.cancelTimer(this.pollTimer) + this.request = this.timer = this.pollTimer = null + this.busy = false + this.status = null + this.deviceList.reset() + this.error = "" + this.emit() + } + start() { this.stop(); this.active = true; return this.refresh() } + + retire(generation, request) { + if (!this.active || this.generation !== generation || this.request !== request) return false + if (this.timer !== null) this.cancelTimer(this.timer) + this.timer = null + this.request = null + this.busy = false + if (this.pollTimer === null) this.pollTimer = this.later(() => this.refresh(), 2000) + this.emit() + return true + } + + async run(url, options, timeout, background = false) { + if (!this.active || this.busy || background && this.request !== null) return null + if (!background) { + // The user action supersedes any in-flight poll, including its JSON body. + this.generation++ + this.request?.abort() + if (this.timer !== null) this.cancelTimer(this.timer) + if (this.pollTimer !== null) this.cancelTimer(this.pollTimer) + this.request = this.timer = this.pollTimer = null + } + const generation = this.generation + const request = new AbortController() + this.request = request + if (!background) { this.busy = true; this.emit() } + this.timer = this.later(() => { + if (!this.retire(generation, request)) return + request.abort() + this.generation++ + this.error = "Bluetooth took too long to respond. Refresh to check its current state." + this.emit() + }, timeout) + try { + const response = await this.fetcher(url, { ...options, signal: request.signal, credentials: "same-origin", cache: "no-store" }) + if (!this.active || this.generation !== generation || this.request !== request || request.signal.aborted) return null + if (response.status === 401) { this.stop(); this.unauthorized(); return null } + const data = await response.json().catch(() => { throw new Error("Galaxy could not reach Bluetooth. Refresh to reconnect.") }) + if (!this.active || this.generation !== generation || this.request !== request || request.signal.aborted) return null + if (response.status === 503 && ["setup_required", "access_unavailable"].includes(data?.code)) { + this.stop(); this.unauthorized(); return null + } + if (!response.ok) throw new Error(bluetoothError(data?.code)) + if (!validBluetoothStatus(data)) throw new Error("Galaxy received an invalid Bluetooth response. Refresh to reconnect.") + this.status = { ...data, devices: this.deviceList.update(data.devices) } + this.error = "" + return data + } catch (error) { + if (this.active && this.generation === generation && this.request === request) { + this.error = error instanceof Error ? error.message : "Bluetooth is unavailable." + } + return null + } finally { + this.retire(generation, request) + } + } + + async refresh() { + if (!this.active || this.busy || this.request !== null) return + if (this.pollTimer !== null) { this.cancelTimer(this.pollTimer); this.pollTimer = null } + await this.run("./api/bluetooth/status", {}, 12000, true) + } + + async action(operation, fields = {}) { + if (!OPERATIONS.has(operation) || !this.active || this.busy || (operation === 'power' && !this.status?.parked)) return + const payload = { operation, ...fields } + const result = await this.run("./api/bluetooth/action", { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), + }, operation === "power" ? 35000 : ["connect", "disconnect"].includes(operation) ? 30000 : 15000) + if (result) await this.refresh() + } +} + +export const BluetoothPage = { + components: { ControllersPage }, + props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }, + data: () => ({ status: null, busy: false, error: "", pairValue: "" }), + mounted() { + this.feed = new BluetoothFeed({ publish: (value) => Object.assign(this, value), unauthorized: this.unauthorized }) + this.visibility = () => document.hidden ? this.feed.stop(true) : this.feed.start() + document.addEventListener("visibilitychange", this.visibility) + if (this.mode === "local" && !document.hidden) this.feed.start() + }, + beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.feed?.stop(true) }, + watch: { + mode(value) { if (value === "local" && !document.hidden) this.feed?.start(); else this.feed?.stop(true) }, + 'status.pairing.prompt.id'() { this.pairValue = "" }, + }, + computed: { + saved() { return this.status?.devices?.filter((device) => device.paired || device.trusted || device.connected) || [] }, + nearby() { return this.status?.devices?.filter((device) => !device.paired && !device.trusted && !device.connected) || [] }, + canAllowPair() { return validPairInput(this.status?.pairing?.prompt, this.pairValue) }, + }, + methods: { + bluetoothError, + refresh() { return this.feed.refresh() }, + action(operation, fields) { return this.feed.action(operation, fields) }, + respond(accepted) { + const prompt = this.status?.pairing?.prompt + if (!prompt || prompt.displayOnly || accepted && !validPairInput(prompt, this.pairValue)) return + const value = accepted && ["pin", "passkey"].includes(prompt.kind) ? this.pairValue : "" + return this.action("pairing_response", { promptId: prompt.id, accepted, value }) + }, + }, + template: ` +
+

Bluetooth

Bluetooth

+

View, pair and manage nearby and saved devices.

+
Bluetooth requires the device. No radio was checked in preview.
+ + +
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/boot.js b/openpilot/starpilot/galaxy/web/js/boot.js new file mode 100644 index 0000000000..cc26d92ee0 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/boot.js @@ -0,0 +1,14 @@ +const status = document.getElementById("galaxy-boot") +const message = status.querySelector("p") +const retry = status.querySelector("button") +retry.addEventListener("click", () => location.reload()) +function failed() { + message.textContent = "Galaxy could not finish opening. Check your connection and try again." + retry.hidden = false +} +const timer = setTimeout(failed, 10000) +import("./app.js").then(() => { + clearTimeout(timer) + if (document.querySelector("#galaxy-app .gx-app")) status.remove() + else failed() +}).catch(() => { clearTimeout(timer); failed() }) diff --git a/openpilot/starpilot/galaxy/web/js/cameras.js b/openpilot/starpilot/galaxy/web/js/cameras.js new file mode 100644 index 0000000000..6e082b7d6e --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/cameras.js @@ -0,0 +1,100 @@ +export class CameraSnapshotFeed { + constructor({ publish, unauthorized, fetcher = (...args) => fetch(...args), + createURL = (blob) => URL.createObjectURL(blob), revokeURL = (url) => URL.revokeObjectURL(url), + later = setTimeout, cancelTimer = clearTimeout }) { + Object.assign(this, { publish, unauthorized, fetcher, createURL, revokeURL, later, cancelTimer }) + this.request = null + this.url = "" + this.timer = null + } + stop() { + this.request?.abort() + this.request = null + if (this.timer !== null) this.cancelTimer(this.timer) + this.timer = null + if (this.url) this.revokeURL(this.url) + this.url = "" + this.publish({ image: "", capturing: false, error: "" }) + } + async capture(camera) { + this.stop() + const request = new AbortController() + this.request = request + this.publish({ image: "", capturing: true, error: "" }) + this.timer = this.later(() => { + if (this.request !== request) return + this.stop() + this.publish({ image: "", capturing: false, error: "Snapshot timed out. Turn off the vehicle and try again." }) + }, 13500) + try { + const response = await this.fetcher("./api/cameras/snapshot", { method: "POST", credentials: "same-origin", + cache: "no-store", signal: request.signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ camera }) }) + if (this.request !== request || request.signal.aborted) return + if (response.status === 401) { this.stop(); this.unauthorized(); return } + if (!response.ok) throw new Error(response.status === 409 ? "Turn the vehicle off before taking a snapshot." : + "Turn off the vehicle and open its camera preview, then try again.") + if (response.headers.get("Content-Type")?.split(";")[0].trim().toLowerCase() !== "image/jpeg") throw new Error("Camera image is unavailable.") + const blob = await response.blob() + if (this.request !== request || request.signal.aborted) return + if (!blob.size || blob.size > 1000000) throw new Error("Camera image is unavailable.") + this.url = this.createURL(blob) + this.publish({ image: this.url, capturing: false, error: "" }) + } catch (error) { + if (this.request === request && !request.signal.aborted) + this.publish({ image: "", capturing: false, error: error.message || "Camera image is unavailable." }) + } finally { + if (this.request === request) { + this.request = null + this.cancelTimer(this.timer) + this.timer = null + } + } + } +} + +export const CamerasPage = { + name: "CamerasPage", + props: { mode: { type: String, required: true }, go: { type: Function, required: true }, + unauthorized: { type: Function, required: true } }, + data: () => ({ camera: "cabin", image: "", capturing: false, error: "" }), + created() { this.snapshots = new CameraSnapshotFeed({ publish: (state) => Object.assign(this.$data, state), unauthorized: this.unauthorized }) }, + mounted() { + this.visibility = () => { if (document.hidden) this.snapshots.stop() } + document.addEventListener("visibilitychange", this.visibility) + }, + beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.snapshots.stop() }, + watch: { mode() { this.snapshots.stop() } }, + template: ` +
+

Cameras & Monitoring

+

Camera preferences and availability

+
+

Blind Spot Camera

+

Adjust the camera crop with a live cabin preview.

+ + Saved preferences are unavailable in preview.
+

Sentry

+

View captured motion events and configure Sentry notifications.

+ + + Motion events are unavailable in preview.
+

V-ASM

+

Draw saved camera window regions and adjust visual warning choices. A live camera image and current warning status are unavailable here.

+ + Saved settings are unavailable in preview.
+
+

Camera Snapshot

+

Turn off the vehicle, choose a camera, then take a snapshot.

+ +

Camera snapshots are available on the connected device.

+ Snapshots are not saved.
+
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/cloud-provider.js b/openpilot/starpilot/galaxy/web/js/cloud-provider.js new file mode 100644 index 0000000000..976c23e027 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/cloud-provider.js @@ -0,0 +1,65 @@ +export const validProviderStatus = value => value && ["comma", "konik"].includes(value.active) && + ["comma", "konik"].includes(value.selected) && /^[a-f0-9]{64}$/.test(value.revision) && + typeof value.restartRequired === "boolean" && typeof value.canSelect === "boolean" && + Array.isArray(value.providers) && value.providers.length === 2 && + ["comma", "konik"].every(name => value.providers.filter(p => p.id === name).length === 1) && + value.providers.every(p => ["comma", "konik"].includes(p.id) && typeof p.label === "string" && + p.url === (p.id === "comma" ? "https://connect.comma.ai" : "https://stable.konik.ai")) + +export const CloudProviderPage = { + name: "CloudProviderPage", + props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }, + data: () => ({ status: null, busy: false, error: "", request: null }), + mounted() { this.load() }, + beforeUnmount() { this.request?.abort() }, + computed: { + active() { return this.status?.providers.find(p => p.id === this.status.active) }, + selected() { return this.status?.providers.find(p => p.id === this.status.selected) }, + }, + methods: { + async call(body) { + this.request?.abort() + const controller = new AbortController() + this.request = controller + const timer = setTimeout(() => controller.abort(), 8000) + try { + const response = await fetch("./api/connect/provider", { cache: "no-store", credentials: "same-origin", + signal: controller.signal, ...(body ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) } : {}) }) + if (response.status === 401) this.unauthorized() + const value = await response.json() + if (!response.ok) throw new Error(value.error || "Cloud provider request failed") + if (!validProviderStatus(value)) throw new Error("Cloud provider status is unavailable") + this.status = value + } finally { + clearTimeout(timer) + if (this.request === controller) this.request = null + } + }, + async load() { + if (this.mode !== "local") return + try { await this.call() } catch (error) { this.error = error.message } + }, + async select(name) { + if (!this.status?.canSelect || this.busy || !this.status.providers.some(p => p.id === name)) return + const label = this.status.providers.find(p => p.id === name).label + if (!window.confirm(`Use ${label} after the next device reboot? The current session stays unchanged. Returning to comma restores its saved cloud identity.`)) return + this.busy = true + this.error = "" + try { await this.call({ provider: name, revision: this.status.revision, confirmed: true }) } + catch (error) { this.error = error.message } + finally { this.busy = false } + }, + }, + template: `

Cloud Provider

+
Open this Developer page on your device to choose a cloud provider.
+
+

Active: {{ active.label }}

+

Next Boot: {{ selected.label }}

+

Restart required. No automatic reboot will occur.

+

Cloud accounts and uploads stay separate. Your older comma recordings keep their comma links.

+

Turn off the vehicle to change this Developer setting.

+ + + +
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/controllers.js b/openpilot/starpilot/galaxy/web/js/controllers.js new file mode 100644 index 0000000000..49d0aa6ced --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/controllers.js @@ -0,0 +1,202 @@ +import { reactive } from "../vendor/vue/vue.esm-browser.js" + +const HEX = /^[0-9a-f]{64}$/ +const string = (value, limit = 128) => typeof value === "string" && value.length <= limit && !/[\x00-\x1f\x7f]/.test(value) +const key = (value) => value === null || (string(value) && value.length > 0) +const integer = (value, max) => Number.isInteger(value) && value >= 0 && value <= max +const draftFrom = (status) => ({ revision: status.revision, enabled: status.enabled, + slots: status.slots.slice(3).map((slot) => slot.key) }) + +export function validControllersStatus(data) { + if (data?.version !== 1 || typeof data.available !== "boolean" || typeof data.editable !== "boolean" || + typeof data.enabled !== "boolean" || typeof data.valid !== "boolean" || !HEX.test(data.revision) || + !Array.isArray(data.devices) || data.devices.length > 32 || !Array.isArray(data.slots) || data.slots.length !== 13 || + !Array.isArray(data.options) || data.options.length > 128 || !Array.isArray(data.bindings) || data.bindings.length > 128 || + typeof data.testing !== "boolean") return false + if (!data.devices.every((device) => string(device?.id, 256) && device.id && string(device.name, 80) && + [3, 5].includes(device.bus))) return false + if (!data.slots.every((slot, index) => slot?.index === index && string(slot.label, 80) && + key(slot.key) && typeof slot.available === "boolean")) return false + if (!data.options.every((option) => key(option?.key) && option.key !== null && + string(option.label, 80) && string(option.section, 80))) return false + if (new Set(data.options.map((option) => option.key)).size !== data.options.length) return false + if (!data.bindings.every((binding) => string(binding?.deviceId, 256) && binding.deviceId && + string(binding.name, 80) && integer(binding.code, 65551) && integer(binding.slot, 12))) return false + if (data.learning !== null && (!integer(data.learning?.slot, 12) || + !Number.isFinite(data.learning.expiresIn) || data.learning.expiresIn < 0 || data.learning.expiresIn > 20)) return false + if (data.lastPress !== null && (!string(data.lastPress?.deviceId, 256) || + !integer(data.lastPress.code, 65551) || (data.lastPress.slot !== null && !integer(data.lastPress.slot, 12)) || + typeof data.lastPress.executed !== "boolean" || !string(data.lastPress.message, 200))) return false + return true +} + +export class ControllersFeed { + constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args), + later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) { + Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer }) + this.active = false + this.generation = 0 + this.request = this.timeout = this.poll = null + this.status = null + this.busy = false + this.error = "" + } + + emit() { this.publish({ status: this.status, busy: this.busy, error: this.error }) } + stop() { + if (this.active && this.status?.editable && (this.status.learning || this.status.testing)) { + this.fetcher("./api/controllers/action", { method: "POST", credentials: "same-origin", keepalive: true, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ operation: "cancel" }) }).catch(() => {}) + } + this.active = false + this.generation++ + this.request?.abort() + if (this.timeout !== null) this.cancelTimer(this.timeout) + if (this.poll !== null) this.cancelTimer(this.poll) + this.request = this.timeout = this.poll = null + this.status = null + this.busy = false + this.error = "" + this.emit() + } + start() { this.stop(); this.active = true; return this.refresh() } + + async run(payload = null) { + if (!this.active || this.request) return null + if (this.poll !== null) this.cancelTimer(this.poll) + this.poll = null + const generation = this.generation, request = new AbortController() + this.request = request + this.busy = payload !== null + this.emit() + this.timeout = this.later(() => request.abort(), 6000) + let failure = "Controller Buttons are unavailable. Reload before trying again." + try { + const response = await this.fetcher(payload === null ? "./api/controllers/status" : "./api/controllers/action", { + credentials: "same-origin", cache: "no-store", signal: request.signal, + ...(payload === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }), + }) + if (!this.active || generation !== this.generation || request.signal.aborted) return null + if (response.status === 401) { this.stop(); this.unauthorized(); return null } + if (response.status === 403) { failure = "Turn off the vehicle to change controller buttons."; throw new Error() } + if (response.status === 409) { failure = "Controller settings changed. Reload them before saving."; throw new Error() } + if (response.status === 429) { failure = "Controller is busy. Try again shortly."; throw new Error() } + if (!response.ok) throw new Error() + const data = await response.json() + if (!this.active || generation !== this.generation || request.signal.aborted) return null + if (!validControllersStatus(data)) { failure = "Controller Buttons returned an unsupported status. Try again."; throw new Error() } + this.status = data + this.error = "" + return data + } catch { + if (this.active && generation === this.generation) this.error = failure + return null + } finally { + if (this.request === request) { + if (this.timeout !== null) this.cancelTimer(this.timeout) + this.request = this.timeout = null + this.busy = false + this.emit() + if (this.active && this.poll === null) this.poll = this.later(() => { this.poll = null; this.refresh() }, 2000) + } + } + } + refresh() { return this.run() } + action(payload) { return this.run(payload) } +} + +export const ControllersPage = { + props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }, + setup(props) { + const state = reactive({ status: null, busy: false, error: "", draft: null }) + const feed = new ControllersFeed({ unauthorized: props.unauthorized, publish(update) { + const prior = state.status + Object.assign(state, update) + if (update.status === null) state.draft = null + else if (update.status && (!state.draft || (prior && state.draft.revision === prior.revision && + state.draft.enabled === prior.enabled && state.draft.slots.every((value, index) => value === prior.slots[index + 3].key)))) { + state.draft = draftFrom(update.status) + } + } }) + return { state, feed } + }, + computed: { + dirty() { return !!this.state.status && !!this.state.draft && + (this.state.draft.enabled !== this.state.status.enabled || + this.state.draft.slots.some((value, index) => value !== this.state.status.slots[index + 3].key)) }, + changed() { return !!this.state.status && !!this.state.draft && this.state.draft.revision !== this.state.status.revision }, + canEdit() { return !!this.state.status?.editable && !this.state.busy && !this.changed }, + favorites() { return this.state.status?.slots.slice(0, 3) || [] }, + actions() { return this.state.status?.slots.slice(3) || [] }, + }, + mounted() { + this.visibility = () => { if (this.mode === "local" && !document.hidden) this.feed.start(); else this.feed.stop() } + document.addEventListener("visibilitychange", this.visibility) + this.visibility() + }, + beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.feed.stop() }, + watch: { mode() { this.visibility() } }, + methods: { + async reload() { + const status = await this.feed.refresh() + if (status) this.state.draft = draftFrom(status) + }, + async save() { + if (!this.canEdit || !this.dirty) return + const status = await this.feed.action({ operation: "save", revision: this.state.draft.revision, + enabled: this.state.draft.enabled, slots: this.state.draft.slots.map((value) => value || null) }) + if (status) this.state.draft = draftFrom(status) + }, + learn(slot) { + if (this.canEdit && !this.dirty && integer(slot, 12)) return this.feed.action({ operation: "learn", revision: this.state.status.revision, slot }) + }, + cancel() { if (this.state.status?.learning) return this.feed.action({ operation: "cancel" }) }, + test() { if (this.canEdit) return this.feed.action({ operation: "test", enabled: !this.state.status.testing }) }, + remove(binding) { + if (this.canEdit && !this.dirty) return this.feed.action({ operation: "remove", revision: this.state.status.revision, + deviceId: binding.deviceId, code: binding.code }) + }, + }, + template: ` +
+

Controller Buttons

+

Assign physical USB or Bluetooth buttons to Quick Select and available driving screen actions. Turn off the vehicle to edit.

+

Connect to local Galaxy to manage controller buttons.

+ +
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/crash-reports.js b/openpilot/starpilot/galaxy/web/js/crash-reports.js new file mode 100644 index 0000000000..0ece1cc2df --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/crash-reports.js @@ -0,0 +1,102 @@ +// Read-only local crash reports. State is cleared on navigation and sign-out. +export class CrashReportsFeed { + constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args) }) { + Object.assign(this, { publish, unauthorized, fetcher }) + this.active = false + this.generation = 0 + this.request = null + this.reports = [] + } + + clear() { + this.reports = [] + this.publish({ reports: [], scanIncomplete: false, listLimited: false, status: "idle", error: "", selected: null, preview: null, previewStatus: "idle" }) + } + + stop() { + this.active = false + this.generation++ + this.request?.abort() + this.request = null + this.clear() + } + + start() { + this.stop() + this.active = true + return this.load() + } + + async requestJson(url, generation, request) { + const response = await this.fetcher(url, { signal: request.signal, cache: "no-store" }) + if (!this.active || generation !== this.generation || request.signal.aborted) return null + if (response.status === 401) { + this.stop() + this.unauthorized() + return null + } + if (response.status === 503) { + const body = await response.json().catch(() => null) + if (!this.active || generation !== this.generation || request.signal.aborted) return null + if (["access_unavailable", "setup_required"].includes(body?.code)) { + this.stop() + this.unauthorized() + return null + } + throw new Error("Crash reports are unavailable") + } + if (!response.ok) throw new Error(response.status === 409 ? "Report changed; refresh the list" : "Report unavailable") + const body = await response.json() + if (!this.active || generation !== this.generation || request.signal.aborted) return null + return body + } + + async load() { + if (!this.active) return + const generation = ++this.generation + this.request?.abort() + const request = new AbortController() + this.request = request + this.reports = [] + this.publish({ reports: [], scanIncomplete: false, listLimited: false, status: "loading", error: "", selected: null, preview: null, previewStatus: "idle" }) + try { + const data = await this.requestJson("./api/crash-reports", generation, request) + if (!data) return + if (data.schemaVersion !== 1 || !Array.isArray(data.reports) || data.reports.length > 200 || + typeof data.scanIncomplete !== "boolean" || typeof data.listLimited !== "boolean" || + data.reports.some((item) => typeof item.id !== "string" || !item.id || + typeof item.name !== "string" || !item.name || + typeof item.size !== "number" || !Number.isFinite(item.size) || item.size < 0 || + typeof item.modifiedAt !== "number" || !Number.isFinite(item.modifiedAt))) { + throw new Error("Invalid crash report list") + } + this.reports = data.reports + this.publish({ reports: data.reports, scanIncomplete: data.scanIncomplete, listLimited: data.listLimited, status: "ready", error: "", selected: null, preview: null, previewStatus: "idle" }) + } catch (error) { + if (this.active && generation === this.generation) this.publish({ reports: [], scanIncomplete: false, listLimited: false, status: "unavailable", error: error.message, selected: null, preview: null, previewStatus: "idle" }) + } finally { + if (generation === this.generation) this.request = null + } + } + + async open(report) { + if (!this.active || !this.reports.some((item) => item.id === report?.id)) return + const generation = ++this.generation + this.request?.abort() + const request = new AbortController() + this.request = request + this.publish({ selected: report.id, preview: null, previewStatus: "loading", error: "" }) + try { + const data = await this.requestJson(`./api/crash-reports/${encodeURIComponent(report.id)}`, generation, request) + if (!data) return + if (data.schemaVersion !== 1 || data.name !== report.name || typeof data.text !== "string" || typeof data.truncated !== "boolean") { + throw new Error("Invalid crash report") + } + this.publish({ selected: report.id, preview: data, previewStatus: "ready", error: "" }) + } catch (error) { + if (this.active && generation === this.generation) this.publish({ selected: null, preview: null, previewStatus: "unavailable", error: error.message }) + } finally { + if (generation === this.generation) this.request = null + } + } +} diff --git a/openpilot/starpilot/galaxy/web/js/device-picker.js b/openpilot/starpilot/galaxy/web/js/device-picker.js new file mode 100644 index 0000000000..5e5973c732 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/device-picker.js @@ -0,0 +1,92 @@ +const SLUG = /^[A-Za-z0-9]{16}$/ +const unsupported = (response) => response.status === 404 || response.status === 405 + +async function readName(device) { + try { + const response = await fetch(`/${device.slug}/api/galaxy/device-name`, { + cache: "no-store", credentials: "same-origin", signal: AbortSignal.timeout(5000), + }) + if (!response.ok) return device + const data = await response.json() + if (typeof data.name === "string" && Array.from(data.name).length <= 40) return { ...device, name: data.name || device.name } + } catch {} + return device +} + +async function namedDevices(devices) { + const result = [...devices] + let next = 0 + const worker = async () => { + while (next < devices.length) { + const index = next++ + result[index] = await readName(devices[index]) + } + } + await Promise.all(Array.from({ length: Math.min(4, devices.length) }, worker)) + return result +} + +export const DevicePicker = { + name: "DevicePicker", + data: () => ({ devices: [], activeSlug: "", loading: true, editing: "", draft: "", error: "", saving: false }), + computed: { visible() { return location.hostname === "galaxy.firestar.link" && this.devices.length > 1 } }, + mounted() { if (location.hostname === "galaxy.firestar.link") this.load() }, + methods: { + async load() { + try { + const response = await fetch("/_gateway/devices", { cache: "no-store", credentials: "same-origin" }) + if (!response.ok) return + const data = await response.json() + this.activeSlug = SLUG.test(data?.activeSlug || "") ? data.activeSlug : "" + this.devices = Array.isArray(data?.devices) ? data.devices.filter((device) => SLUG.test(device?.slug || "")).slice(0, 40) : [] + this.devices = await namedDevices(this.devices) + } catch {} finally { this.loading = false } + }, + select(device) { + if (SLUG.test(device.slug) && device.slug !== this.activeSlug) window.location.assign(`/${device.slug}/`) + }, + edit(device) { this.editing = device.slug; this.draft = device.name || ""; this.error = "" }, + async save() { + if (!SLUG.test(this.editing)) return + const slug = this.editing + const name = Array.from(this.draft.trim()).slice(0, 40).join("") + this.saving = true + this.error = "" + try { + let response = await fetch(`/${slug}/api/galaxy/device-name`, { + method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, + signal: AbortSignal.timeout(5000), body: JSON.stringify({ name }), + }) + if (unsupported(response)) { + response = await fetch(`/_gateway/devices/${slug}/name`, { + method: "PUT", credentials: "same-origin", headers: { "Content-Type": "application/json" }, + signal: AbortSignal.timeout(7000), body: JSON.stringify({ name }), + }) + } + const data = await response.json().catch(() => ({})) + if (!response.ok || typeof data.name !== "string") throw new Error(data.error || "Could not rename comma") + this.devices = this.devices.map((device) => device.slug === slug ? { ...device, name: data.name } : device) + if (this.editing === slug) this.editing = "" + } catch (error) { this.error = error.message } + finally { this.saving = false } + }, + }, + template: ` +
+
Commas
+
+ + +
+
+ + +
+

{{ error }}

+
+
+ `, +} diff --git a/openpilot/starpilot/galaxy/web/js/device-preferences.js b/openpilot/starpilot/galaxy/web/js/device-preferences.js new file mode 100644 index 0000000000..8f1e8dfcfe --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/device-preferences.js @@ -0,0 +1,8 @@ +import { DriveStatePanel } from "./drive-state.js" +export const DEVICE_PAGES = Object.freeze({}) +export function devicePage(path) { return null } +export const DevicePreferencesPage = { + components: { DriveStatePanel }, + props: { mode: { type: String, required: true }, go: { type: Function, required: true } }, + template: `
Force Drive State is available on your connected device.
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/device-state.js b/openpilot/starpilot/galaxy/web/js/device-state.js new file mode 100644 index 0000000000..eb8ea6e2c3 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/device-state.js @@ -0,0 +1,86 @@ +import { LocalHistoryFeed } from "./record-history.js" + +const STATES = Object.freeze({ parked: "Parked", driving: "Driving", standby: "Standby" }) + +export const validDeviceState = (value) => value && + (value.state === null || Object.hasOwn(STATES, value.state)) && + Number.isFinite(value.maxAgeMs) && value.maxAgeMs >= (value.state === null ? 0 : 1) && value.maxAgeMs <= 3000 + +// Reuse the authenticated request/deadline boundary; this lightweight endpoint +// does not scan processes or start any device services. +export class DeviceStateFeed extends LocalHistoryFeed { + static endpoint = "./api/device/state" + static valid = validDeviceState + static subject = "device state" + static unavailable = "Device state is unavailable." + static invalid = "Device state is unavailable." + + constructor(options) { + super(options) + this.clock = options.clock || (() => performance.now()) + this.poll = this.expiry = null + this.lastState = null + this.validUntil = this.retainUntil = 0 + this.presented = "" + } + stop() { + this.cancelTimer(this.poll) + this.cancelTimer(this.expiry) + this.poll = this.expiry = null + this.lastState = null + this.validUntil = this.retainUntil = 0 + super.stop() + } + present() { + const now = this.clock() + const state = now < this.retainUntil ? this.lastState : null + const stale = state !== null && now >= this.validUntil + const key = `${state}:${stale}` + if (key !== this.presented) { + this.presented = key + this.publish({ state, stale }) + } + this.cancelTimer(this.expiry) + this.expiry = null + const next = now < this.validUntil ? this.validUntil : this.retainUntil + if (state !== null) this.expiry = this.later(() => this.present(), Math.max(1, next - now)) + } + emit() { + if (this.status === "loading") return + const now = this.clock() + const elapsed = now - this.startedAt + if (this.status === "ready" && this.data?.state !== null && elapsed >= 0 && this.data.maxAgeMs > elapsed) { + this.lastState = this.data.state + this.validUntil = this.startedAt + this.data.maxAgeMs + // Display continuity only. A missed poll never extends driving authority or this deadline. + this.retainUntil = this.validUntil + 5000 + } + this.present() + if (this.active && this.poll === null) { + const delay = Math.max(500, Math.min(1500, (this.validUntil - now) / 2)) + this.poll = this.later(() => { this.poll = null; this.load() }, delay) + } + } + load() { + if (!this.active || this.request !== null) return + this.startedAt = this.clock() + return super.load() + } + +} + +export const DeviceState = { + props: { unauthorized: { type: Function, required: true }, connection: { type: String, default: "" } }, + data: () => ({ state: null, stale: false }), + computed: { label() { return STATES[this.state] || "State unavailable" } }, + created() { this.feed = new DeviceStateFeed({ publish: (update) => Object.assign(this.$data, update), unauthorized: this.unauthorized }) }, + mounted() { + this.visibility = () => document.hidden ? this.feed.stop() : this.feed.start() + document.addEventListener("visibilitychange", this.visibility) + if (!document.hidden) this.feed.start() + }, + beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.feed.stop() }, + template: ` + {{ label }} + `, +} diff --git a/openpilot/starpilot/galaxy/web/js/diagnostic-tools.js b/openpilot/starpilot/galaxy/web/js/diagnostic-tools.js new file mode 100644 index 0000000000..02f45408fb --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/diagnostic-tools.js @@ -0,0 +1,114 @@ +import { reactive } from "../vendor/vue/vue.esm-browser.js" +import { LocalAccess } from "./local-access.js" + +const text = (value, max = 1024) => typeof value === "string" && value.length <= max +const rows = value => Array.isArray(value) && value.length <= 128 && value.every(row => row && text(row.label, 128) && text(row.value)) +export function validTroubleshoot(value) { + return !!value && value.schemaVersion === 1 && [null, "parked", "driving", "standby"].includes(value.device?.state) && + typeof value.vehicle?.available === "boolean" && typeof value.vehicle.longitudinal === "boolean" && + ["fingerprint", "brand", "steering"].every(key => text(value.vehicle[key])) && rows(value.snapshot) && + Array.isArray(value.sections) && value.sections.length <= 32 && + value.sections.every(section => text(section.title, 128) && rows(section.rows)) && text(value.note, 2048) +} +export function validTmux(value) { + return !!value && typeof value.available === "boolean" && text(value.reason) && text(value.pane, 128) && + typeof value.truncated === "boolean" && typeof value.text === "string" && + new TextEncoder().encode(value.text).length <= 65536 && value.text.split("\n").length <= 301 +} +export function troubleshootReport(data) { + if (!data) return "" + return ["StarPilot Troubleshoot", `Device: ${data.device.state || "Unavailable"}`, + `Vehicle: ${data.vehicle.available ? data.vehicle.fingerprint : "Unavailable"}`, + `Brand: ${data.vehicle.brand}`, `Longitudinal: ${data.vehicle.longitudinal ? "Available" : "Unavailable"}`, `Steering: ${data.vehicle.steering}`, + ...data.snapshot.map(row => `${row.label}: ${row.value}`), + ...data.sections.flatMap(section => ["", section.title, ...section.rows.map(row => `${row.label}: ${row.value}`)]), + "", data.note].join("\n") +} + +export class DiagnosticFeed { + constructor({ endpoint, validate, publish, unauthorized = () => {}, interval = 0, + fetcher = (...args) => fetch(...args), later = (fn, ms) => setTimeout(fn, ms), cancel = id => clearTimeout(id) }) { + Object.assign(this, { endpoint, validate, publish, unauthorized, interval, fetcher, later, cancel }) + this.active = false; this.visible = true; this.live = true; this.data = null + this.request = this.timer = null; this.generation = 0 + } + start(mode, visible = true) { this.stop(); this.mode = mode; this.visible = visible; this.active = true + if (mode !== "local") { this.data = null; this.publish({ data: null, error: "This tool is unavailable in sample mode.", loading: false }); return } + if (visible) return this.refresh() + } + cancelPending() { this.generation++; this.request?.abort(); this.request = null + if (this.timer !== null) this.cancel(this.timer) + this.timer = null + } + stop() { this.active = false; this.cancelPending() } + setVisible(value) { this.visible = value; this.cancelPending(); if (value && this.active && this.live) return this.refresh() } + setLive(value) { this.live = value; this.cancelPending(); this.publish({ live: value, loading: false }); if (value && this.visible) return this.refresh() } + async refresh() { + if (!this.active || !this.visible || this.mode !== "local" || this.request) return + if (this.timer !== null) this.cancel(this.timer) + this.timer = null + const generation = ++this.generation, request = new AbortController() + this.request = request; this.publish({ loading: !this.data, error: "" }) + const deadline = this.later(() => request.abort(), 4000) + try { + const response = await this.fetcher(this.endpoint, { credentials: "same-origin", cache: "no-store", signal: request.signal }) + if (!this.active || generation !== this.generation) return + if (response.status === 401) { this.stop(); this.unauthorized(); return } + const data = await response.json() + if (!this.active || generation !== this.generation) return + if (!response.ok || !this.validate(data)) throw new Error("Unsupported diagnostic response") + this.data = data; this.publish({ data, error: "" }) + } catch (error) { + if (this.active && generation === this.generation) + this.publish({ error: error.name === "AbortError" ? "Reading the tool timed out. Try Refresh." : "This tool is unavailable. Try Refresh or local access." }) + } finally { + this.cancel(deadline) + if (this.active && generation === this.generation) { + this.request = null; this.publish({ loading: false }) + if (this.interval && this.live && this.visible) this.timer = this.later(() => { this.timer = null; this.refresh() }, this.interval) + } + } + } +} + +const props = { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } } +function setup(props, consoleTail) { + const state = reactive({ loading: false, live: true, data: null, error: "", notice: "" }) + const feed = new DiagnosticFeed({ endpoint: consoleTail ? "./api/tmux/live" : "./api/troubleshoot", + validate: consoleTail ? validTmux : validTroubleshoot, interval: consoleTail ? 2000 : 0, + publish: update => Object.assign(state, update), unauthorized: props.unauthorized }) + return { state, feed } +} +const lifecycle = { + mounted() { this._visibility = () => this.feed.setVisible(!document.hidden) + document.addEventListener("visibilitychange", this._visibility); this.feed.start(this.mode, !document.hidden) }, + beforeUnmount() { document.removeEventListener("visibilitychange", this._visibility); this.feed.stop() }, + watch: { mode(value) { this.feed.start(value, !document.hidden) } }, +} +const methods = { + async copyText(value) { try { await navigator.clipboard.writeText(value); this.state.notice = "Visible text copied." } + catch { this.state.notice = "Copy is unavailable in this browser. Select the visible text to copy it." } }, + saveText(value) { const url = URL.createObjectURL(new Blob([value], { type: "text/plain;charset=utf-8" })) + const link = document.createElement("a"); link.href = url; link.download = "starpilot-console.txt"; link.click(); URL.revokeObjectURL(url) }, +} +export const TroubleshootPage = { + components: { LocalAccess }, props, emits: ["navigate"], ...lifecycle, + setup: props => setup(props, false), methods: { ...methods, report: troubleshootReport }, + template: `

Troubleshoot

+

Read-only device, vehicle and selected settings. Change settings in Toggles.

+
+

Loading report…

{{ state.error }}

{{ state.notice }}

+ +
`, +} +export const TmuxPage = { + components: { LocalAccess }, props, emits: ["navigate"], ...lifecycle, + setup: props => setup(props, true), methods, + template: `

tmux Live View

+

Read-only launcher console tail. No commands or keyboard input are sent to the comma.

+
+

{{ state.live ? 'Live · updates every 2 seconds while visible' : 'Paused' }}

Loading console…

{{ state.error }}

{{ state.notice }}

+

{{ state.data.reason }}

Pane: {{ state.data.pane }}

Console tail is limited to 300 lines and 64 KiB.

{{ state.data.text }}
+
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/drive-state.js b/openpilot/starpilot/galaxy/web/js/drive-state.js new file mode 100644 index 0000000000..c9e2cf8587 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/drive-state.js @@ -0,0 +1,67 @@ +export const validDriveState = (value) => !!value && ["auto", "offroad", "onroad"].includes(value.mode) && + typeof value.available === "boolean" && typeof value.overrideAllowed === "boolean" && + (value.revision === null || typeof value.revision === "string" && /^[0-9a-f]{32}$/.test(value.revision)) && + [null, "offroad", "onroad"].includes(value.effective) + +export const DriveStatePanel = { + data: () => ({ state: null, error: "", busy: false }), + computed: { + pending() { return this.state?.mode !== "auto" && this.state?.effective !== this.state?.mode }, + }, + mounted() { + this.active = true + this.generation = 0 + this.load() + this.timer = setInterval(() => { if (!document.hidden) this.load() }, 1000) + }, + beforeUnmount() { this.active = false; this.generation++; clearInterval(this.timer); this.controller?.abort() }, + methods: { + async load() { + if (this.busy || this.loading || !this.active) return + this.loading = true + const generation = ++this.generation + this.controller = new AbortController() + const controller = this.controller + const deadline = setTimeout(() => controller.abort(), 3000) + try { + const response = await fetch("./api/drive-state/status", { credentials: "same-origin", cache: "no-store", signal: this.controller.signal }) + const value = await response.json() + if (!response.ok || !validDriveState(value)) throw new Error(value.error || "Drive state unavailable") + if (this.active && generation === this.generation) { this.state = value; this.error = "" } + } catch (error) { if (this.active && generation === this.generation) { this.state = null; this.error = "Drive state unavailable" } } + finally { clearTimeout(deadline); if (generation === this.generation) this.loading = false } + }, + async change(mode) { + if (this.busy || !this.state?.available) return + if (mode !== "auto" && !this.state.overrideAllowed) { this.error = "Park and disengage, then retry. The device can stay on-road."; return } + if (mode === "offroad" && this.state.effective === "onroad" && + !window.confirm("Switch to Offroad and stop driving services? Stay parked until you return to Auto.")) return + const revision = this.state.revision + this.busy = true + this.loading = false + const generation = ++this.generation + this.controller?.abort() + this.controller = new AbortController() + const controller = this.controller + const deadline = setTimeout(() => controller.abort(), 3000) + try { + const response = await fetch("./api/drive-state/action", { method: "POST", credentials: "same-origin", cache: "no-store", + signal: this.controller.signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode, revision }) }) + const value = await response.json() + if (!response.ok || !validDriveState(value)) throw new Error(value.error || "Drive state unavailable") + if (this.active && generation === this.generation) { this.state = value; this.error = "" } + } catch (error) { if (this.active && generation === this.generation) this.error = error.message } + finally { clearTimeout(deadline); this.busy = false; if (this.active && generation === this.generation) this.load() } + }, + }, + template: `

Force Drive State

+

Device: {{ state?.effective === "onroad" ? "Onroad" : state?.effective === "offroad" ? "Offroad" : "Checking…" }}

+

Waiting for the device to switch {{ state.mode }}…

+

Auto follows the car. Force Offroad stops driving services even when the device is on-road. Park and disengage before switching; Return to Auto restores normal operation.

+

Waiting for a fresh parked, disengaged state. You do not need to turn the vehicle off.

+

The drive-state manager is unavailable. Reconnect to check again.

+
+

{{ error }}

`, +} diff --git a/openpilot/starpilot/galaxy/web/js/drive-stats.js b/openpilot/starpilot/galaxy/web/js/drive-stats.js new file mode 100644 index 0000000000..e584aa03cb --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/drive-stats.js @@ -0,0 +1,178 @@ +const ROUTE = /^[a-zA-Z0-9_+|.-]{1,128}$/ +const DATE = /^\d{4}-\d{2}-\d{2}$/ +const number = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 +const optionalNumber = (value) => value === null || number(value) +const integer = (value) => Number.isSafeInteger(value) && value >= 0 +const text = (value, limit = 128) => typeof value === "string" && value.length <= limit && + !/[\x00-\x1f\x7f]/.test(value) +const optionalText = (value, limit = 128) => value === null || text(value, limit) + +const validDrive = (drive) => drive && ROUTE.test(drive.routeId) && + [drive.startTime, drive.endTime].every((value) => value === null || number(value)) && + [drive.distanceMeters, drive.durationSeconds, drive.engagedSeconds, drive.engagedPercent, + drive.distractedMoments, drive.unresponsiveMoments].every(optionalNumber) && + optionalText(drive.model) && optionalText(drive.reason, 256) && + typeof drive.complete === "boolean" && typeof drive.ignored === "boolean" && + integer(drive.segmentCount) && drive.segmentCount <= 10000 && + (drive.engagedPercent === null || drive.engagedPercent <= 100) + +export function validDriveStats(value) { + if (value?.schemaVersion !== 1 || typeof value.isMetric !== "boolean" || !value.analysis || !value.totals || !value.week || + !Array.isArray(value.recentDrives) || value.recentDrives.length > 100 || + !Array.isArray(value.records) || value.records.length > 24 || + !Array.isArray(value.models) || value.models.length > 100) return false + const { analysis, totals, week, lastDrive, recentDrives, records, models } = value + if (week.timezone !== undefined && !text(week.timezone)) return false + if (typeof analysis.running !== "boolean" || !integer(analysis.pending) || !integer(analysis.failed) || + typeof analysis.scanIncomplete !== "boolean") return false + if (![totals.distanceMeters, totals.durationSeconds, totals.engagedSeconds].every(optionalNumber) || + !integer(totals.drives) || + ![week.distanceMeters, week.durationSeconds, week.engagedPercent].every(optionalNumber) || + (week.engagedPercent !== null && week.engagedPercent > 100) || !integer(week.drives) || + !Array.isArray(week.days) || week.days.length !== 7 || + !week.days.every((day) => day && DATE.test(day.date) && Number.isFinite(Date.parse(`${day.date}T00:00:00Z`)) && + optionalNumber(day.distanceMeters))) return false + const routes = new Set() + if (lastDrive !== null && !validDrive(lastDrive)) return false + if (!recentDrives.every((drive) => { + if (!validDrive(drive) || routes.has(drive.routeId)) return false + routes.add(drive.routeId) + return true + })) return false + const recordIds = new Set() + if (!records.every((record) => { + if (!record || !text(record.id) || !text(record.label) || + !(record.value === null || number(record.value) || text(record.value)) || !optionalText(record.unit, 32) || + recordIds.has(record.id)) return false + recordIds.add(record.id) + return true + })) return false + const names = new Set() + return models.every((model) => { + if (!model || !text(model.name) || names.has(model.name) || + ![model.distanceMeters, model.durationSeconds].every(optionalNumber) || !integer(model.drives)) return false + names.add(model.name) + return true + }) +} + +export class DriveStatsFeed { + constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args), + later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) { + Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer }) + this.active = false + this.generation = 0 + this.controller = null + this.timeout = null + this.poll = null + this.data = null + this.status = "idle" + this.error = "" + this.busy = false + } + + emit() { this.publish({ status: this.status, data: this.data, error: this.error, busy: this.busy }) } + + stop() { + this.active = false + this.generation++ + this.controller?.abort() + if (this.timeout !== null) this.cancelTimer(this.timeout) + if (this.poll !== null) this.cancelTimer(this.poll) + this.controller = this.timeout = this.poll = null + this.data = null + this.status = "idle" + this.error = "" + this.busy = false + this.emit() + } + + start() { this.stop(); this.active = true; return this.load() } + + schedule(delay = null) { + if (!this.active || this.poll !== null || this.controller !== null) return + const generation = this.generation + const analyzing = this.data?.analysis?.running + this.poll = this.later(() => { + this.poll = null + if (this.active && generation === this.generation) this.load() + }, delay ?? (analyzing ? 5000 : 60000)) + } + + async request(body = null) { + if (!this.active || this.controller !== null) return null + const generation = this.generation + const controller = new AbortController() + this.controller = controller + if (this.poll !== null) this.cancelTimer(this.poll) + this.poll = null + this.status = this.data ? "ready" : "loading" + this.busy = body !== null + this.error = "" + this.emit() + this.timeout = this.later(() => { + if (!this.active || generation !== this.generation || this.controller !== controller) return + controller.abort() + this.generation++ + this.controller = this.timeout = null + this.busy = false + this.status = this.data ? "ready" : "unavailable" + this.error = "Drive history timed out. Refresh to try again." + this.emit() + this.schedule(5000) + }, 5000) + try { + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC" + const statsUrl = "./api/drives/stats?timezone=" + encodeURIComponent(timezone) + let response = await this.fetcher(body === null ? statsUrl : "./api/drives/ignore", { + credentials: "same-origin", cache: "no-store", signal: controller.signal, + ...(body === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }), + }) + if (!this.active || generation !== this.generation || controller.signal.aborted) return null + if (response.status === 401) { this.stop(); this.unauthorized(); return null } + if (response.status === 503) { + const detail = await response.json().catch(() => null) + if (!this.active || generation !== this.generation || controller.signal.aborted) return null + if (["setup_required", "access_unavailable"].includes(detail?.code)) { + this.stop(); this.unauthorized(); return null + } + } + if (!response.ok) throw new Error("Drive history could not be updated. Refresh to try again.") + if (body !== null) { + response = await this.fetcher(statsUrl, { credentials: "same-origin", cache: "no-store", signal: controller.signal }) + if (response.status === 401) { this.stop(); this.unauthorized(); return null } + if (!response.ok) throw new Error("Drive history could not be refreshed.") + } + const data = await response.json() + if (!this.active || generation !== this.generation || controller.signal.aborted) return null + if (!validDriveStats(data)) throw new Error("Drive history is unavailable. Refresh to try again.") + this.data = data + this.status = "ready" + this.emit() + return data + } catch (error) { + if (this.active && generation === this.generation) { + this.status = this.data ? "ready" : "unavailable" + this.error = controller.signal.aborted ? "Drive history timed out. Refresh to try again." : error.message + this.emit() + } + return null + } finally { + if (this.controller === controller) { + if (this.timeout !== null) this.cancelTimer(this.timeout) + this.controller = this.timeout = null + this.busy = false + this.emit() + this.schedule() + } + } + } + + load() { return this.request() } + + ignore(routeId, ignored) { + if (!this.data?.recentDrives?.some((drive) => drive.routeId === routeId && drive.ignored !== ignored) || + typeof ignored !== "boolean") return Promise.resolve(null) + return this.request({ routeId, ignored }) + } +} diff --git a/openpilot/starpilot/galaxy/web/js/driving.js b/openpilot/starpilot/galaxy/web/js/driving.js new file mode 100644 index 0000000000..bb18377488 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/driving.js @@ -0,0 +1,57 @@ +export const DRIVING_PAGES = Object.freeze({ + aol: "aol", + conditional: "conditional", + curve: "curve", + slc: "slc", + torque: "torque", + profiles: "profiles", + traffic: "traffic", + lane: "lane", + lane_change: "lane_change", +}) + +export const DRIVING_GROUPS = Object.freeze([ + { title: "Steering and buttons", items: [ + { page: "aol", title: "Always On Lateral", detail: "Steering assistance independent of cruise control" }, + { page: "lane", title: "Lane centering", detail: "Saved steering behavior" }, + { page: "lane_change", title: "Lane changes", detail: "Saved lane-change choices" }, + { page: "torque", title: "Torque tuning", detail: "Saved steering tune and vehicle-source selection" }, + ] }, + { title: "Speed and longitudinal", items: [ + { page: "conditional", title: "Conditional driving modes", detail: "Experimental and Chill conditions" }, + { page: "curve", title: "Curve Speed Controller", detail: "Saved curve-speed choices" }, + { page: "slc", title: "Speed Limit Controller", detail: "Dashboard and qualified vision source choices" }, + { page: "profiles", title: "Longitudinal profiles", detail: "Saved acceleration, braking, and following profiles" }, + { page: "traffic", title: "Traffic Mode", detail: "Saved follow, jerk, and profile curves" }, + ] }, +]) + +export function drivingPage(path) { + const match = /^\/driving\/([a-z_]+)$/.exec(path) + return match && DRIVING_PAGES[match[1]] || null +} + +export const DrivingPage = { + props: { mode: { type: String, required: true }, go: { type: Function, required: true } }, + data: () => ({ groups: DRIVING_GROUPS }), + template: ` +
+

Driving features

Driving Settings

+

Choose how StarPilot steers and controls speed.

+
Local driving settings are unavailable in preview.
+ +
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/favorites.js b/openpilot/starpilot/galaxy/web/js/favorites.js new file mode 100644 index 0000000000..6bf10163e2 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/favorites.js @@ -0,0 +1,138 @@ +import { reactive } from "../vendor/vue/vue.esm-browser.js" +import { GalaxySelect } from "./galaxy-select.js" + +export function validFavorites(data) { + return !!data && typeof data.revision === "string" && !!data.revision && typeof data.editable === "boolean" && + typeof data.valid === "boolean" && Array.isArray(data.slots) && data.slots.length === 3 && + data.slots.every((slot) => slot && typeof slot.enabled === "boolean" && typeof slot.show_onroad === "boolean" && + (slot.key === null || typeof slot.key === "string") && typeof slot.label === "string" && slot.label.length <= 32) && + Array.isArray(data.options) && data.options.length <= 128 && data.options.every((option) => option && + typeof option.key === "string" && typeof option.label === "string" && ["toggle", "enum", "action"].includes(option.kind)) && + new Set(data.options.map((option) => option.key)).size === data.options.length && + Array.isArray(data.states) && data.states.length === 3 && data.states.every((state, index) => state && + state.index === index && typeof state.available === "boolean" && typeof state.stateLabel === "string" && typeof state.reason === "string") +} + +export class FavoritesFeed { + constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args), + later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) { + Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer }) + this.active = false; this.generation = 0; this.data = this.request = this.timer = null; this.needsReload = false + } + stop() { + this.active = false; this.generation++; this.request?.abort() + if (this.timer !== null) this.cancelTimer(this.timer) + this.request = this.timer = null + } + start() { this.stop(); this.active = true; return this.load() } + load() { return this.run() } + update(index, patch) { + if (!this.data?.editable || this.request || this.needsReload || !Number.isInteger(index) || index < 0 || index > 2 || + !patch || Object.keys(patch).some((key) => !["key", "label", "enabled", "show_onroad"].includes(key))) return + const slots = this.data.slots.map((slot) => ({ ...slot })) + if (Object.hasOwn(patch, "key")) { + const option = this.data.options.find((entry) => entry.key === patch.key) + if (patch.key !== null && !option) return + delete slots[index].value + slots[index].label = option?.label.slice(0, 32) || "" + if (!option) Object.assign(slots[index], { enabled: false, show_onroad: false }) + } + Object.assign(slots[index], patch) + return this.run({ revision: this.data.revision, slots }) + } + async run(body = null) { + if (!this.active || this.request) return + const request = new AbortController(), generation = ++this.generation, saving = body !== null + this.request = request + this.publish({ status: saving ? "saving" : "loading", error: "", notice: "" }) + const fail = (error) => { + this.needsReload ||= saving + this.publish({ status: this.data ? "ready" : "unavailable", needsReload: this.needsReload, error }) + } + this.timer = this.later(() => { + if (!this.active || generation !== this.generation) return + request.abort(); this.generation++; this.request = this.timer = null + fail(saving ? "Saving timed out. Reload to check which Quick Select were saved." : "Reading Quick Select timed out. Reload to try again.") + }, 5000) + try { + const response = await this.fetcher("./api/favorites/slots", { credentials: "same-origin", cache: "no-store", signal: request.signal, + ...(saving ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) } : {}) }) + if (!this.active || generation !== this.generation) return + const data = await response.json().catch(() => null) + if (!this.active || generation !== this.generation) return + if (response.status === 401 || ["access_unavailable", "setup_required"].includes(data?.code)) { this.stop(); this.unauthorized(); return } + if (response.status === 409) throw new Error("Quick Select changed on another screen. Reload before editing again.") + if (!response.ok || !validFavorites(data)) throw new Error(saving ? "Quick Select could not be confirmed. Reload before editing again." : "Saved Quick Select are unavailable.") + this.data = data; this.needsReload = false + this.publish({ status: "ready", data, needsReload: false, error: "", notice: saving ? "Quick Select saved." : "" }) + } catch (error) { + if (this.active && generation === this.generation) fail(error?.message || "Quick Select are unavailable.") + } finally { + if (this.request === request) { + if (this.timer !== null) this.cancelTimer(this.timer) + this.request = this.timer = null + } + } + } +} + +export const FavoritesPage = { + components: { GalaxySelect }, + props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }, + emits: ["close"], + setup(props) { + const state = reactive({ status: "idle", data: null, error: "", notice: "", needsReload: false }) + const feed = new FavoritesFeed({ publish: (update) => Object.assign(state, update), unauthorized: props.unauthorized }) + return { state, feed } + }, + computed: { + busy() { return ["loading", "saving"].includes(this.state.status) }, + disabled() { return !this.state.data?.editable || this.busy || this.state.needsReload }, + byKey() { return new Map((this.state.data?.options || []).map((option) => [option.key, option])) }, + groups() { return [...new Set((this.state.data?.options || []).map((option) => option.section || "Controls"))] }, + }, + mounted() { if (this.mode === "local") this.feed.start() }, + beforeUnmount() { this.feed.stop() }, + methods: { + select(index, event) { return this.feed.update(index, { key: event.target.value || null }) }, + toggle(index, field, event) { + const checked = event.target.checked + event.target.checked = this.state.data.slots[index][field] + return this.feed.update(index, { [field]: checked }) + }, + label(index, event) { + const value = event.target.value.trim().slice(0, 32) + event.target.value = this.state.data.slots[index].label + if (value !== this.state.data.slots[index].label) return this.feed.update(index, { label: value }) + }, + }, + template: ` +
+

Quick Select

Choose your three driving-screen shortcuts.

+
+

Small UI: tap the invisible left, middle or right third. Big UI: tap or swipe the lower-left corner to open Quick Select.

+

Assigning a shortcut does not activate it. Each control keeps its usual availability; some settings can only change while parked.

+
Connect to local Galaxy to configure Quick Select.
+ +
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/flm.js b/openpilot/starpilot/galaxy/web/js/flm.js new file mode 100644 index 0000000000..f10f87efe7 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/flm.js @@ -0,0 +1,283 @@ +import { LocalHistoryFeed, validLocalHistory } from './record-history.js' + +const SEGMENT = /^(?:[a-f0-9]{8}--[a-f0-9]{10}|[0-9]{4}-[0-9]{2}-[0-9]{2}--[0-9]{2}-[0-9]{2}-[0-9]{2}|[a-f0-9]{16}[|_](?:[a-f0-9]{8}--[a-f0-9]{10}|[0-9]{4}-[0-9]{2}-[0-9]{2}--[0-9]{2}-[0-9]{2}-[0-9]{2}))--[0-9]{1,6}$/ +const STATES = ['idle', 'running', 'completed', 'canceled', 'failed', 'unavailable'] +const RESULTS = ['measured', 'insufficient_samples', 'missing_car_params', 'unsupported_car', 'unsupported_controller'] +const FAILURE = { + not_parked: 'Park the vehicle before analyzing recordings.', + canceled: 'Analysis was canceled.', + source_changed: 'A selected recording changed. Refresh the inventory.', + invalid_segment: 'A selected full log is unavailable. Refresh the inventory.', + busy: 'Another analysis is already running.', + invalid_request: 'Select one to five closed full logs, then try again.', + operation_changed: 'The analysis session changed. Refresh status.', + process_failed: 'The analysis process stopped unexpectedly. Its error was saved to the device log.', + deadline: 'Analysis exceeded its time limit. Try one segment at a time.', + recording_unavailable: 'A selected recording is missing, still open, or changed. Refresh recordings.', + decode_failed: 'A selected full log could not be decoded. Try another segment.', + resource_limit: 'This recording exceeds the analysis memory limit. Try a smaller segment.', +} +const labelFailure = (code) => FAILURE[code] || 'Offline analysis is unavailable.' +const finite = (value) => typeof value === 'number' && Number.isFinite(value) + +export function selectableSegments(inventory) { + if (!validLocalHistory(inventory)) return [] + return inventory.routes.flatMap((route) => route.segments.filter((segment) => segment.files.rlog && + typeof segment.segmentName === 'string' && SEGMENT.test(segment.segmentName)) + .map((segment) => ({ routeId: route.routeId, number: segment.number, name: segment.segmentName }))) +} + +export function validFlmStatus(value) { + return value?.version === 1 && STATES.includes(value.state) && + (value.operationId === null || typeof value.operationId === 'string' && /^[a-zA-Z0-9:_-]{1,100}$/.test(value.operationId)) && + Number.isSafeInteger(value.selected) && value.selected >= 0 && value.selected <= 5 && + Number.isSafeInteger(value.processed) && value.processed >= 0 && value.processed <= value.selected && + (value.errorCode === null || typeof value.errorCode === 'string' && /^[a-z_]{1,60}$/.test(value.errorCode)) && + (value.state === 'idle' ? value.operationId === null : value.operationId !== null) +} + +function validSeries(points) { + return Array.isArray(points) && points.length <= 160 && points.every((point) => + point && finite(point.mono_ns) && point.mono_ns > 0 && finite(point.desired_lat_accel) && + finite(point.actual_lat_accel) && Number.isSafeInteger(point.continuity_id) && point.continuity_id >= 0) +} + +function validAnalysis(value) { + return value && typeof value.route === 'string' && value.route.length <= 80 && + Number.isSafeInteger(value.number) && value.number >= 0 && RESULTS.includes(value.status) && + (value.car_params_sha256 === null || typeof value.car_params_sha256 === 'string' && /^[a-f0-9]{64}$/.test(value.car_params_sha256)) && + ['messages', 'torque_frames', 'eligible_samples'].every((key) => Number.isSafeInteger(value[key]) && value[key] >= 0) && + [value.mean_abs_error, value.root_mean_square_error].every((number) => number === null || finite(number) && number >= 0) && + Array.isArray(value.exclusions) && value.exclusions.length <= 32 && value.exclusions.every((item) => + Array.isArray(item) && item.length === 2 && typeof item[0] === 'string' && /^[a-z_]{1,60}$/.test(item[0]) && + Number.isSafeInteger(item[1]) && item[1] >= 0) && validSeries(value.series) && + Array.isArray(value.windows) && value.windows.length <= 128 && typeof value.windows_truncated === 'boolean' +} + +export function validFlmReport(value, operationId) { + return value?.schemaVersion === 1 && value.purpose === 'offline_tracking_diagnostics' && + value.operationId === operationId && value.tuneRecommendation === null && value.vehicleQualification === false && + Array.isArray(value.segments) && value.segments.length >= 1 && value.segments.length <= 5 && value.segments.every((entry) => + entry?.source && typeof entry.source.segmentName === 'string' && SEGMENT.test(entry.source.segmentName) && + typeof entry.source.sha256 === 'string' && /^[a-f0-9]{64}$/.test(entry.source.sha256) && + Number.isSafeInteger(entry.source.compressedBytes) && entry.source.compressedBytes > 0 && + ['zst', 'bz2'].includes(entry.source.codec) && validAnalysis(entry.analysis)) +} + +// Each continuity ID is a distinct measured interval. Never draw through an excluded gap. +export function flmChart(series) { + if (!validSeries(series) || !series.length) return { valid: false, paths: [[], []] } + const first = series[0].mono_ns, last = series.at(-1).mono_ns + const values = series.flatMap((point) => [point.desired_lat_accel, point.actual_lat_accel]) + const lo = Math.min(0, ...values), hi = Math.max(0, ...values), span = Math.max(.01, hi - lo) + const x = (stamp) => 48 + Math.max(0, Math.min(1, (stamp - first) / Math.max(1, last - first))) * 576 + const y = (value) => 132 - (value - lo) / span * 114 + const paths = [[], []] + for (const [keyIndex, key] of ['desired_lat_accel', 'actual_lat_accel'].entries()) { + let points = [], prior = null + const flush = () => { if (points.length >= 2) paths[keyIndex].push(points.join(' ')); points = [] } + for (const point of series) { + if (prior && (point.continuity_id !== prior.continuity_id || point.mono_ns <= prior.mono_ns)) flush() + points.push(`${x(point.mono_ns).toFixed(1)},${y(point[key]).toFixed(1)}`) + prior = point + } + flush() + } + return { valid: true, paths, top: hi.toFixed(2), bottom: lo.toFixed(2), zeroY: y(0), + duration: Math.max(0, (last - first) / 1e9).toFixed(1) } +} + +export class FlmFeed { + constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args), + later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) { + Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer }) + this.active = false; this.generation = 0; this.inFlight = null; this.timer = this.deadline = null; this.busy = false + this.status = null; this.report = null; this.error = '' + } + emit() { this.publish({ operation: this.status, report: this.report, operationError: this.error, + busy: this.busy, requesting: this.inFlight !== null }) } + stop() { + this.active = false; this.generation++ + this.inFlight?.abort(); this.inFlight = null + if (this.timer !== null) this.cancelTimer(this.timer) + if (this.deadline !== null) this.cancelTimer(this.deadline) + this.timer = this.deadline = null; this.busy = false; this.status = this.report = null; this.error = '' + this.emit() + } + start() { this.stop(); this.active = true; return this.refresh() } + async request(url, options = {}, timeoutMs = 10000) { + const generation = this.generation, controller = new AbortController() + this.inFlight = controller + this.emit() + let timer = null + const timeout = new Promise((_, reject) => { timer = this.later(() => { controller.abort(); reject(new Error('Offline analysis timed out. Refresh status before trying again.')) }, timeoutMs); this.deadline = timer }) + try { + const response = await Promise.race([this.fetcher(url, { credentials: 'same-origin', cache: 'no-store', ...options, signal: controller.signal }), timeout]) + if (!this.active || generation !== this.generation || controller.signal.aborted) return null + if (response.status === 401) { this.stop(); this.unauthorized(); return null } + const body = await Promise.race([response.json(), timeout]) + if (!this.active || generation !== this.generation || controller.signal.aborted) return null + if (response.status === 503 && ['setup_required', 'access_unavailable'].includes(body?.code)) { + this.stop(); this.unauthorized(); return null + } + if (!response.ok) throw new Error(labelFailure(body?.code)) + return body + } finally { + if (timer !== null) this.cancelTimer(timer) + if (this.deadline === timer) this.deadline = null + if (this.inFlight === controller) { + this.inFlight = null + if (this.active && generation === this.generation) this.emit() + } + } + } + schedule() { + if (!this.active || this.status?.state !== 'running') { + if (this.timer !== null) this.cancelTimer(this.timer) + this.timer = null + return + } + if (this.active && this.status?.state === 'running' && this.timer === null) { + this.timer = this.later(() => { this.timer = null; this.refresh() }, 1000) + } + } + async refresh() { + if (!this.active || this.inFlight || this.busy) return + const generation = this.generation + try { + const status = await this.request('./api/flm/status') + if (!this.active || generation !== this.generation || status === null) return + if (!validFlmStatus(status)) throw new Error('Offline analysis status is unavailable.') + const previousId = this.status?.operationId + this.status = status + if (previousId !== status.operationId || status.state !== 'completed') this.report = null + this.error = status.state === 'failed' || status.state === 'unavailable' ? labelFailure(status.errorCode) : '' + this.emit() + if (status.state === 'completed' && this.report === null) await this.loadReport(status.operationId, generation) + } catch (error) { + if (this.active && generation === this.generation) { this.error = error.message; this.emit() } + } finally { if (generation === this.generation) this.schedule() } + } + async loadReport(id, generation = this.generation) { + try { + const report = await this.request(`./api/flm/report?operationId=${encodeURIComponent(id)}`) + if (!this.active || generation !== this.generation || report === null || this.status?.operationId !== id) return + if (!validFlmReport(report, id)) throw new Error('Offline analysis report is unavailable.') + this.report = report; this.error = ''; this.emit() + } catch (error) { + if (this.active && generation === this.generation) { this.error = error.message; this.emit() } + } + } + async mutate(url, body) { + if (!this.active || this.inFlight || this.busy) return + const generation = this.generation + if (this.timer !== null) this.cancelTimer(this.timer) + this.timer = null + this.busy = true; this.error = ''; this.emit() + try { + const status = await this.request(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) + if (!this.active || generation !== this.generation || status === null) return + if (!validFlmStatus(status)) throw new Error('Offline analysis response is unavailable.') + this.status = status + this.report = null; this.emit() + } catch (error) { + if (this.active && generation === this.generation) { this.error = error.message; this.emit() } + } finally { + if (this.active && generation === this.generation) { this.busy = false; this.emit(); this.refresh() } + } + } + startAnalysis(segments) { return this.mutate('./api/flm/start', { segments }) } + cancelAnalysis() { + if (this.status?.state !== 'running' || !this.status.operationId) return + return this.mutate('./api/flm/cancel', { operationId: this.status.operationId }) + } +} + +export const FlmChart = { + name: 'FlmChart', + props: { series: { type: Array, required: true } }, + computed: { chart() { return flmChart(this.series) } }, + template: ` + + + `, +} + +export const FlmPage = { + name: 'FlmPage', + components: { FlmChart }, + props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, go: { type: Function, required: true } }, + data: () => ({ inventoryStatus: 'idle', inventory: null, inventoryError: '', operation: null, report: null, + operationError: '', busy: false, requesting: false, selected: [] }), + created() { + this.inventoryFeed = new LocalHistoryFeed({ publish: ({ status, data, error }) => { + this.inventoryStatus = status; this.inventory = data; this.inventoryError = error + if (data) this.selected = this.selected.filter((name) => selectableSegments(data).some((segment) => segment.name === name)) + }, unauthorized: this.unauthorized }) + this.operationFeed = new FlmFeed({ publish: (state) => Object.assign(this.$data, state), unauthorized: this.unauthorized }) + }, + mounted() { + this.visibility = () => { + if (document.hidden) { this.inventoryFeed.stop(); this.operationFeed.stop() } + else if (this.mode === 'local') { this.inventoryFeed.start(); this.operationFeed.start() } + } + document.addEventListener('visibilitychange', this.visibility) + if (this.mode === 'local' && !document.hidden) { this.inventoryFeed.start(); this.operationFeed.start() } + }, + beforeUnmount() { document.removeEventListener('visibilitychange', this.visibility); this.inventoryFeed.stop(); this.operationFeed.stop() }, + computed: { available() { return selectableSegments(this.inventory) }, canAnalyze() { return this.selected.length >= 1 && this.selected.length <= 5 && !this.busy && !this.requesting && this.operation?.state !== 'running' } }, + methods: { + toggle(name) { this.selected = this.selected.includes(name) ? this.selected.filter((item) => item !== name) : + this.selected.length < 5 ? [...this.selected, name] : this.selected }, + analyze() { if (this.canAnalyze && this.selected.every((name) => this.available.some((item) => item.name === name))) this.operationFeed.startAnalysis(this.selected) }, + resultLabel(status) { return ({ measured: 'Measured', insufficient_samples: 'Insufficient samples', missing_car_params: 'Missing vehicle data', + unsupported_car: 'Unsupported vehicle', unsupported_controller: 'Unsupported controller' })[status] || 'Unavailable' }, + metric(value) { return finite(value) ? `${value.toFixed(2)} m/s²` : 'Unavailable' }, + exclusions(items) { return items.map(([reason, count]) => `${reason.replaceAll('_', ' ')}: ${count}`).join(' · ') || 'None recorded' }, + }, + template: `
+

Offline Tracking

Local recording diagnostics for Ioniq 6 steering torque tracking. Reports do not qualify a vehicle or recommend a tune.

+

Offline analysis requires authenticated local Galaxy access.

+ +
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/galaxy-select.js b/openpilot/starpilot/galaxy/web/js/galaxy-select.js new file mode 100644 index 0000000000..8c929e1805 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/galaxy-select.js @@ -0,0 +1,146 @@ +let nextId = 0 + +// Keep the native select as the value/event adapter; presentation belongs to Galaxy. +export const GalaxySelect = { + name: "GalaxySelect", + inheritAttrs: false, + props: { value: { default: undefined }, modelValue: { default: undefined }, disabled: Boolean }, + emits: ["change", "update:modelValue"], + data() { return { uid: `gx-select-${++nextId}`, items: [], selected: "", label: "", open: false, name: "Choose an option", typeahead: "", typedAt: 0 } }, + computed: { + current() { return this.modelValue !== undefined ? this.modelValue : this.value }, + buttonId() { return this.$attrs.id || this.uid }, + nativeAttrs() { + const attrs = { ...this.$attrs } + for (const key of ["class", "style", "id", "aria-label", "aria-labelledby", "aria-describedby"]) delete attrs[key] + return attrs + }, + }, + methods: { + sync() { + const native = this.$refs.native + if (!native) return + if (this.current !== undefined) native.value = String(this.current ?? "") + const items = [...native.options].map((option, index) => ({ index, value: option.value, label: option.label, description: option.dataset?.description || "", disabled: option.disabled || (option.parentElement?.tagName === "OPTGROUP" && option.parentElement.disabled), group: option.parentElement?.tagName === "OPTGROUP" ? option.parentElement.label : "" })) + if (JSON.stringify(items) !== JSON.stringify(this.items)) this.items = items + this.selected = native.value + this.label = native.selectedOptions[0]?.dataset?.collapsedLabel || native.selectedOptions[0]?.label || "Choose an option" + const button = this.$refs.button + const labelled = this.$attrs["aria-labelledby"]?.split(/\s+/).map(id => document.getElementById(id)?.textContent || "").join(" ") + const labels = [...new Set([...(button?.labels || []), button?.closest("label")].filter(Boolean))].map(label => { + const copy = label.cloneNode(true) + copy.querySelectorAll(".gx-select, select, button").forEach(node => node.remove()) + return copy.textContent.trim() + }).filter(Boolean).join(" ") + this.name = this.$attrs["aria-label"] || labelled || labels || button?.closest(".gx-row")?.querySelector(".gx-row__label")?.textContent || this.$attrs.title || "Choose an option" + if (this.disabled && this.open) this.close() + }, + async show(event) { + if (this.disabled || this.open) return + this.sync() + this.open = true + await this.$nextTick() + const menu = this.$refs.menu + try { + menu.showModal() + } catch (error) { + this.open = false + return + } + this.position() + const options = [...menu.querySelectorAll('[role="option"]:not(:disabled)')] + const selected = options.find(option => option.dataset.value === this.selected) + const first = event?.key === "End" ? options.at(-1) : options[0] + ;(selected || first)?.focus() + }, + close() { + if (!this.open) return + this.open = false + this.$refs.menu?.close() + if (this.$refs.button?.isConnected) this.$refs.button.focus({ preventScroll: true }) + }, + position() { + if (!this.open) return + const rect = this.$refs.button.getBoundingClientRect() + const viewport = window.visualViewport + const width = viewport?.width || window.innerWidth + const height = viewport?.height || window.innerHeight + const menu = this.$refs.menu + let zoom = 1 + for (let node = menu; node; node = node.parentElement) zoom *= Number.parseFloat(getComputedStyle(node).zoom) || 1 + const menuWidth = Math.min(Math.max(rect.width, 240), width - 24) + menu.style.width = `${menuWidth / zoom}px` + menu.style.maxHeight = `${(height - 24) / zoom}px` + const menuHeight = Math.min(menu.scrollHeight * zoom + 2, height - 24) + const below = height - rect.bottom - 12 + const top = below >= Math.min(menuHeight, 220) ? Math.min(rect.bottom + 6, height - menuHeight - 12) : Math.max(12, rect.top - menuHeight - 6) + menu.style.left = `${Math.max(12, Math.min(rect.left, width - menuWidth - 12)) / zoom}px` + menu.style.top = `${Math.max(12, top) / zoom}px` + }, + select(item) { + if (this.disabled) return this.close() + const native = this.$refs.native + const option = native.options[item.index] + if (!option || option.value !== item.value || option.disabled || option.parentElement?.disabled) return this.close() + this.close() + if (native.value === option.value) return + native.value = option.value + native.dispatchEvent(new Event("change", { bubbles: true })) + this.sync() + }, + change(event) { + this.$emit("update:modelValue", event.target.value) + this.$emit("change", event) + }, + buttonKey(event) { + if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) { event.preventDefault(); this.show(event) } + }, + menuKey(event) { + if (event.key === "Tab") { event.preventDefault(); this.close(); return } + const options = [...this.$refs.menu.querySelectorAll('[role="option"]:not(:disabled)')] + const index = options.indexOf(document.activeElement) + let target + if (event.key === "ArrowDown") target = options[(index + 1) % options.length] + if (event.key === "ArrowUp") target = options[(index - 1 + options.length) % options.length] + if (event.key === "Home") target = options[0] + if (event.key === "End") target = options.at(-1) + if (event.key.length === 1 && event.key !== " " && !event.ctrlKey && !event.metaKey && !event.altKey) { + const now = performance.now() + this.typeahead = (now - this.typedAt < 700 ? this.typeahead : "") + event.key.toLocaleLowerCase() + this.typedAt = now + target = options.find(option => option.textContent.trim().toLocaleLowerCase().startsWith(this.typeahead)) + } + if (target) { event.preventDefault(); target.focus() } + }, + }, + mounted() { + this.sync() + window.addEventListener("resize", this.position) + window.visualViewport?.addEventListener("resize", this.position) + }, + updated() { this.sync() }, + beforeUnmount() { + this.$refs.menu?.close() + window.removeEventListener("resize", this.position) + window.visualViewport?.removeEventListener("resize", this.position) + }, + template: ` + + + + +
+ +
+
+
+ `, +} diff --git a/openpilot/starpilot/galaxy/web/js/galaxy-setting-row.js b/openpilot/starpilot/galaxy/web/js/galaxy-setting-row.js new file mode 100644 index 0000000000..b9b67aee31 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/galaxy-setting-row.js @@ -0,0 +1,184 @@ +import { GalaxySelect } from "./galaxy-select.js" + +export const FINE_SCRUB_HOLD_MS = 300 +export const FINE_SCRUB_FACTOR = 5 +const FINE_SCRUB_JITTER_PX = 4 + +export function numericBounds(row) { + const min = Number(row.minimum), max = Number(row.maximum), step = Number(row.step) + return Number.isFinite(min) && Number.isFinite(max) && Number.isFinite(step) && max > min && step > 0 + ? { min, max, step } : null +} + +export function snapNumeric(value, bounds) { + if (!bounds || value === "" || value === null || value === undefined || !Number.isFinite(Number(value))) return null + const { min, max, step } = bounds + let lastStep = Math.floor((max - min) / step + 1e-9) + if (Number((min + lastStep * step).toFixed(8)) > max) lastStep-- + const index = Math.max(0, Math.min(lastStep, Math.round((Number(value) - min) / step))) + return Number((min + index * step).toFixed(8)) +} + +export function settingControl(row) { + if (row.page) return "group" + if (row.confirm || row.repairValue) return "action" + if (row.choices?.length === 2 && row.choices.includes("Off") && row.choices.includes("On") && row.choices.includes(row.value)) return "switch" + if (numericBounds(row) && ((Number.isFinite(Number(row.value)) && row.value !== "") || + (row.value === "Auto" && row.choices?.includes("Auto")))) return "slider" + if (row.choices?.length && row.choices.includes(row.value)) return "select" + return "readout" +} + +export const GalaxySettingRow = { + name: "GalaxySettingRow", + components: { GalaxySelect }, + props: { + row: { type: Object, required: true }, + index: { type: Number, required: true }, + disabled: { type: Boolean, default: false }, + saveValue: { type: Function, required: true }, + }, + emits: ["open", "review"], + data() { return { preview: undefined, interacting: false, fineScrub: null, isFineScrubbing: false, updating: false } }, + computed: { + control() { return settingControl(this.row) }, + bounds() { + const bounds = numericBounds(this.row) + if (!bounds || !this.row.choices?.includes("Auto")) return bounds + const numericMax = snapNumeric(bounds.max, bounds) + return { ...bounds, numericMax, max: Number((numericMax + bounds.step).toFixed(8)), auto: true } + }, + locked() { return this.disabled || this.updating || !this.row.available || (!this.row.action && !this.row.page) }, + currentValue() { return this.preview !== undefined ? this.preview : this.row.value }, + sliderValue() { return this.currentValue === "Auto" && this.bounds?.auto ? this.bounds.max : this.currentValue }, + displayValue() { + return this.currentValue === "Auto" || (this.bounds?.auto && Number(this.currentValue) === this.bounds.max) + ? "Auto" : `${this.currentValue}${this.row.unit ? " " + this.row.unit : ""}` + }, + }, + methods: { + async commit(value) { + if (this.locked || String(value) === String(this.row.value)) { this.preview = undefined; return } + this.preview = value + this.updating = true + try { await this.saveValue(this.index, value) } + finally { this.preview = undefined; this.updating = false } + }, + onSwitch(event) { + const next = event.target.checked ? "On" : "Off" + event.target.checked = this.currentValue === "On" + return this.commit(next) + }, + onSelect(event) { return this.commit(event.target.value) }, + beginInteract() { if (!this.locked) this.interacting = true }, + flushSlider(raw) { + const numeric = snapNumeric(raw, this.bounds) + const next = numeric !== null && this.bounds?.auto && numeric === this.bounds.max ? "Auto" : numeric + this.preview = undefined + if (next !== null && String(next) !== String(this.row.value)) return this.commit(next) + }, + clearHoldTimer() { + if (this._holdTimer !== undefined) clearTimeout(this._holdTimer) + this._holdTimer = undefined + }, + startHoldTimer() { + this.clearHoldTimer() + if (!this.fineScrub || this.fineScrub.active) return + this._holdTimer = setTimeout(() => this.activateFineScrub(), FINE_SCRUB_HOLD_MS) + }, + activateFineScrub() { + if (!this.fineScrub || this.fineScrub.active || this.locked) return + this.fineScrub.active = true + this.fineScrub.baseValue = snapNumeric(this.sliderValue, this.bounds) ?? this.bounds.min + this.fineScrub.baseX = this.fineScrub.lastX + this.isFineScrubbing = true + try { globalThis.navigator?.vibrate?.(15) } catch {} + }, + onSliderInput(event) { + if (this.locked || this.fineScrub?.active) { event.target.value = this.sliderValue; return } + this.beginInteract() + this.preview = Number(event.target.value) + this.startHoldTimer() + }, + onSliderCommit(event) { + if (this.fineScrub) return + this.interacting = false + return this.flushSlider(event.target.value) + }, + onSliderBlur(event) { if (this.interacting && !this.fineScrub) return this.onSliderCommit(event) }, + onSliderPointerDown(event) { + if (this.locked) return + this.beginInteract() + try { event.target.setPointerCapture?.(event.pointerId) } catch {} + const rect = event.target.getBoundingClientRect() + this.fineScrub = { active: false, baseValue: snapNumeric(this.sliderValue, this.bounds), + baseX: event.clientX, lastX: event.clientX, track: rect.width || 200, pointerId: event.pointerId } + this.startHoldTimer() + }, + onSliderPointerMove(event) { + const scrub = this.fineScrub + if (!scrub || event.pointerId !== scrub.pointerId || this.locked) return + if (!scrub.active) { + if (Math.abs(event.clientX - scrub.lastX) > FINE_SCRUB_JITTER_PX) { + scrub.lastX = event.clientX + this.startHoldTimer() + } + return + } + event.preventDefault() + const raw = scrub.baseValue + (event.clientX - scrub.baseX) * (this.bounds.max - this.bounds.min) / scrub.track / FINE_SCRUB_FACTOR + const next = snapNumeric(raw, this.bounds) + if (next === null) return + this.preview = next + if (this.$refs.slider) this.$refs.slider.value = next + }, + releasePointer(event) { + const pointerId = this.fineScrub?.pointerId + this.clearHoldTimer() + this.fineScrub = null + this.isFineScrubbing = this.interacting = false + try { (event?.target || this.$refs.slider)?.releasePointerCapture?.(pointerId) } catch {} + }, + onSliderPointerEnd(event) { + if (!this.fineScrub || event.pointerId !== this.fineScrub.pointerId) return + const value = this.sliderValue + this.releasePointer(event) + return this.flushSlider(value) + }, + onSliderCancel(event) { + if (!this.fineScrub || event.pointerId !== this.fineScrub.pointerId) return + this.releasePointer(event) + this.preview = undefined + if (this.$refs.slider) this.$refs.slider.value = this.sliderValue + }, + }, + beforeUnmount() { this.clearHoldTimer() }, + template: ` +
+
+ {{ row.label }} + {{ row.reason.replace('https://firestar.link/discord', '') }}StarPilot Discord + {{ row.value }} +
+ +
+
{{ displayValue }} + {{ isFineScrubbing ? 'Fine scrubbing' : 'Hold to fine scrub' }}
+ +
{{ bounds.min }} to {{ bounds.numericMax ?? bounds.max }} {{ row.unit }}Step: {{ bounds.step }} {{ row.unit }}
+
+ + + + + + {{ displayValue }} +
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/galaxy.js b/openpilot/starpilot/galaxy/web/js/galaxy.js new file mode 100644 index 0000000000..ea4ca0b782 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/galaxy.js @@ -0,0 +1,81 @@ +import { LocalAccess } from "./local-access.js" + +export const GalaxyPage = { + name: "GalaxyPage", + components: { LocalAccess }, + props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }, + data: () => ({ loading: true, paired: false, url: "", tunnelClientAvailable: false, legacyPassword: false, + legacyPairingAvailable: false, password: "", busy: false, error: "" }), + mounted() { this.load() }, + methods: { + async request(path, body) { + const response = await fetch(path, body === undefined ? { cache: "no-store" } : { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), + }) + if (response.status === 401) this.unauthorized() + const data = await response.json().catch(() => ({})) + if (!response.ok) throw new Error(data.error || "Galaxy request failed") + return data + }, + async load() { + if (this.mode !== "local") { this.loading = false; return } + try { + const data = await this.request("./api/galaxy/status") + this.paired = data.paired + this.url = data.url + this.tunnelClientAvailable = data.tunnelClientAvailable === true + this.legacyPassword = data.legacyPassword === true + this.legacyPairingAvailable = data.legacyPairingAvailable === true + } catch (error) { this.error = error.message } + finally { this.loading = false } + }, + async pair() { + this.busy = true + this.error = "" + try { + const data = await this.request("./api/galaxy/pair", { password: this.password }) + this.paired = data.paired + this.url = data.url + this.password = "" + } catch (error) { this.error = error.message } + finally { this.busy = false } + }, + async unpair() { + if (!window.confirm("Unpair from Galaxy? Remote access will stop until you pair again.")) return + this.busy = true + this.error = "" + try { + await this.request("./api/galaxy/unpair", {}) + this.paired = false + this.url = "" + } catch (error) { this.error = error.message } + finally { this.busy = false } + }, + }, + template: ` +

Galaxy Tunnel

Pair this comma for remote Galaxy access. Installing the Galaxy app is a separate option in the app menu.

+
Pairing is available on your comma.
+
Checking pairing status…
+
+ + + +
+ +
+ `, +} diff --git a/openpilot/starpilot/galaxy/web/js/home.js b/openpilot/starpilot/galaxy/web/js/home.js new file mode 100644 index 0000000000..5170525f43 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/home.js @@ -0,0 +1,270 @@ +import { LocalAccess } from "./local-access.js" +import { MonitorFeed } from "./monitor-feed.js" +import { SoftwareStatusFeed } from "./software-status.js" +import { ModelStatusFeed } from "./models.js" +import { DriveStatsFeed } from "./drive-stats.js" + +// The six lightweight motifs follow the original Galaxy Personal Records row. +const RECORD_ART = { + longestDrive: ["M4 12h16", "m14 6 6 6-6 6"], + mostEngagedDay: ["M20 11a8 8 0 1 1-5-7", "m8 11 4 4 8-9"], + bestWeek: ["M4 4v16h16", "m7 15 5-5 4 2 5-7", "M17 5h4v4"], + highestStreak: ["m13 2-9 12h7l-1 8 10-13h-7z"], + longestUndistractedDrive: ["m12 3 8 3v6c0 5-8 9-8 9s-8-4-8-9V6z", "m8 12 3 3 5-6"], + cleanDriveStreak: ["m12 2 2.5 7.5L22 12l-7.5 2.5L12 22l-2.5-7.5L2 12l7.5-2.5z", "M20 2v4M18 4h4"], +} +export const recordArt = (id) => RECORD_ART[id] || ["m12 3 3 6 7 1-5 5 1 7-6-3-6 3 1-7-5-5 7-1z"] + +const shown = (value) => typeof value === "string" && value.trim() ? value : "Unavailable" +const measure = (value, unit, digits = 0) => + typeof value === "number" && Number.isFinite(value) ? `${value.toFixed(digits)}${unit}` : "Unavailable" +const counted = (value, noun) => `${value} ${noun}${value === 1 ? "" : "s"}` +const distance = (meters, isMetric) => measure(meters === null ? null : meters / (isMetric ? 1000 : 1609.344), + isMetric ? " km" : " mi", 1) +const hours = (seconds) => measure(seconds === null ? null : seconds / 3600, " h", 1) +const percent = (value) => measure(value, "%") +const duration = (seconds) => { + if (seconds === null) return "Unavailable" + const minutes = Math.round(seconds / 60) + return minutes >= 60 ? `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m` : `${minutes}m` +} +const driveDate = (start, end = null) => { + if (start === null) return "Date unavailable" + const date = new Date(start * 1000) + if (!Number.isFinite(date.getTime())) return "Date unavailable" + const calendar = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric" }) + const clock = (value) => new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(new Date(value * 1000)) + const day = calendar.format(date) + const endDay = end !== null ? calendar.format(new Date(end * 1000)) : null + return `${day} · ${clock(start)}${end !== null ? `–${endDay === day ? "" : endDay + " · "}${clock(end)}` : ""}` +} +const dayLabel = (date) => new Intl.DateTimeFormat(undefined, { timeZone: "UTC", weekday: "short" }).format(new Date(`${date}T00:00:00Z`)) +const recordDisplay = (record, isMetric) => record.value === null ? "Unavailable" : + record.unit === "m" ? distance(record.value, isMetric) : record.unit === "s" ? duration(record.value) : + record.unit === "days" || record.unit === "drives" ? counted(record.value, record.unit.slice(0, -1)) : + `${record.value}${record.unit === "%" ? "%" : record.unit ? ` ${record.unit}` : ""}` + +export function driveSummary(feed) { + const data = feed.status === "ready" ? feed.data : null + if (!data) return null + const isMetric = data.isMetric + const last = data.lastDrive + const maximum = Math.max(1, ...data.week.days.map((day) => day.distanceMeters ?? 0)) + const models = data.models.slice(0, 3) + const modelTotal = models.reduce((sum, model) => sum + (model.distanceMeters ?? 0), 0) + const colors = ["#5ec8c8", "#8b6cc5", "#d4a060"] + let offset = 0 + const stops = models.map((model, index) => { + const next = offset + (modelTotal > 0 ? (model.distanceMeters ?? 0) / modelTotal * 100 : 0) + const stop = `${colors[index]} ${offset}% ${next}%` + offset = next + return stop + }) + return { + analysis: data.analysis, + last: last && { routeId: last.routeId, date: driveDate(last.startTime, last.endTime), complete: last.complete, + distance: distance(last.distanceMeters, isMetric), duration: duration(last.durationSeconds), engaged: percent(last.engagedPercent), + model: shown(last.model), distracted: last.distractedMoments === null ? "Unavailable" : String(last.distractedMoments), + unresponsive: last.unresponsiveMoments === null ? "Unavailable" : String(last.unresponsiveMoments) }, + totals: { distance: distance(data.totals.distanceMeters, isMetric), duration: hours(data.totals.durationSeconds), + engaged: duration(data.totals.engagedSeconds), drives: data.totals.drives }, + week: { timezone: data.week.timezone || "UTC", distance: distance(data.week.distanceMeters, isMetric), duration: hours(data.week.durationSeconds), + engaged: percent(data.week.engagedPercent), progress: data.week.engagedPercent ?? 0, drives: data.week.drives, + days: data.week.days.map((day) => ({ date: day.date, label: dayLabel(day.date), + distance: distance(day.distanceMeters, isMetric), height: day.distanceMeters === null ? 0 : day.distanceMeters / maximum * 100 })) }, + recent: data.recentDrives.slice(0, 5).map((drive) => ({ routeId: drive.routeId, date: driveDate(drive.startTime, drive.endTime), + complete: drive.complete, ignored: drive.ignored, distance: distance(drive.distanceMeters, isMetric), + duration: duration(drive.durationSeconds), engaged: percent(drive.engagedPercent), model: shown(drive.model) })), + records: data.records.map((record) => ({ id: record.id, label: record.label, display: recordDisplay(record, isMetric) })), + models: models.map((model, index) => ({ name: model.name, distance: distance(model.distanceMeters, isMetric), + drives: model.drives, color: colors[index] })), + modelStyle: modelTotal > 0 ? `background:conic-gradient(${stops.join(",")})` : "", + } +} + +// Each card retains its own source status. Missing/expired data is never a zero or a healthy state. +export function homeSummary(monitor, model, software) { + const live = monitor.status === "current" ? monitor.snapshot : null + const loaded = model.status === "ready" ? model.data : null + const installed = software.status === "ready" ? software.data : null + return { + system: { + available: !!live, + cpu: measure(live?.cpuPercent, "%"), + memory: measure(live?.memory?.percent, "%"), + storage: live?.storage?.usedGiB != null && live?.storage?.totalGiB != null + ? `${measure(live.storage.usedGiB, " GiB", 1)} / ${measure(live.storage.totalGiB, " GiB", 1)}` : "Unavailable", + observed: live ? new Date(live.sampledAt * 1000).toLocaleString() : "Unavailable", + }, + model: { + available: !!loaded, + health: loaded ? shown(loaded.health).replaceAll("-", " ") : "Unavailable", + variant: loaded?.loadedId ? loaded.variant === "chestnut" ? "Chestnut big" : "Small" : "Identity unavailable", + fallback: loaded?.fallbackReason === "chestnut-run-stalled" ? "Chestnut output stopped; Small restarted for this drive" : + loaded?.fallbackReason === "chestnut-load-failed" ? "Small model after Chestnut load failure" : null, + }, + software: { + available: !!installed, + version: shown(installed?.installed?.displayVersion ?? installed?.installed?.version), + branch: shown(installed?.installed?.branch), + update: installed?.updater?.finalizedUpdateReady === true ? "Finalized update ready" : + installed?.updater?.finalizedUpdateReady === false ? "None reported" : "Unavailable", + }, + } +} + +export const Home = { + name: "Home", + components: { LocalAccess }, + props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, + go: { type: Function, required: true } }, + data: () => ({ + monitor: { snapshot: null, status: "idle", error: "" }, + model: { data: null, status: "idle", error: "" }, + software: { data: null, status: "idle", error: "" }, + drives: { data: null, status: "idle", error: "", busy: false }, + }), + computed: { + summary() { return homeSummary(this.monitor, this.model, this.software) }, + driving() { return driveSummary(this.drives) }, + }, + created() { + const revoked = () => { this.stopFeeds(); this.unauthorized() } + this.monitorFeed = new MonitorFeed({ mode: this.mode, publish: (update) => Object.assign(this.monitor, update), unauthorized: revoked }) + this.modelFeed = new ModelStatusFeed({ publish: (update) => Object.assign(this.model, update), unauthorized: revoked }) + this.softwareFeed = new SoftwareStatusFeed({ publish: (update) => Object.assign(this.software, update), unauthorized: revoked }) + this.driveFeed = new DriveStatsFeed({ publish: (update) => Object.assign(this.drives, update), unauthorized: revoked }) + }, + mounted() { + this.visibility = () => { if (document.hidden) this.stopFeeds(); else this.startFeeds() } + document.addEventListener("visibilitychange", this.visibility) + if (!document.hidden) this.startFeeds() + }, + beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.stopFeeds() }, + methods: { + recordArt, + counted, + startFeeds() { + if (this.mode !== "local") return + this.monitorFeed.start() + this.modelFeed.start() + this.softwareFeed.start() + this.driveFeed.start() + }, + stopFeeds() { + this.monitorFeed.stop() + this.modelFeed.stop() + this.softwareFeed.stop() + this.driveFeed.stop() + this.monitor.snapshot = null + this.monitor.status = "idle" + }, + refresh() { + if (this.mode !== "local" || document.hidden) return + this.monitorFeed.refresh() + this.modelFeed.load() + this.softwareFeed.load() + this.driveFeed.load() + }, + ignoreDrive(drive) { + if (this.mode !== "local" || document.hidden || this.drives.busy) return + return this.driveFeed.ignore(drive.routeId, !drive.ignored) + }, + }, + template: ` +
+
+

Dashboard

Your saved drives and current device

+ +
+

Dashboard information is available on your device.

+ +
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/install-app.js b/openpilot/starpilot/galaxy/web/js/install-app.js new file mode 100644 index 0000000000..6dfdeeef77 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/install-app.js @@ -0,0 +1,41 @@ +export function installHelp({ secure = window.isSecureContext, agent = navigator.userAgent } = {}) { + if (/iPhone|iPad|iPod/.test(agent)) return 'Open Galaxy in Safari, tap Share, then Add to Home Screen. Turn on Open as Web App if shown.' + if (!secure) return 'For the full web app, open your comma at https://galaxy.firestar.link, then choose Install Galaxy. Your local address can also be saved as a browser shortcut.' + return 'Use your browser menu to choose Install app or Add to Home Screen. On a Mac in Safari, choose File → Add to Dock.' +} + +export const InstallApp = { + name: 'InstallApp', + data: () => ({ prompt: null, installed: false, help: false, message: '' }), + mounted() { + this.displayMode = window.matchMedia('(display-mode: standalone)') + this.updateInstalled = () => { this.installed = this.displayMode.matches || navigator.standalone === true } + this.onPrompt = (event) => { event.preventDefault(); this.prompt = event; this.help = false } + this.onInstalled = () => { this.installed = true; this.prompt = null; this.help = false } + this.updateInstalled() + window.addEventListener('beforeinstallprompt', this.onPrompt) + window.addEventListener('appinstalled', this.onInstalled) + this.displayMode.addEventListener('change', this.updateInstalled) + }, + beforeUnmount() { + window.removeEventListener('beforeinstallprompt', this.onPrompt) + window.removeEventListener('appinstalled', this.onInstalled) + this.displayMode?.removeEventListener('change', this.updateInstalled) + this.prompt = null + }, + methods: { + async install() { + if (!this.prompt) { this.message = installHelp(); this.help = !this.help; return } + const prompt = this.prompt + this.prompt = null + try { + await prompt.prompt() + await prompt.userChoice + } catch { this.message = installHelp(); this.help = true } + }, + }, + template: `
+ +

{{ message }}

+
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/layout-backup.js b/openpilot/starpilot/galaxy/web/js/layout-backup.js new file mode 100644 index 0000000000..921a343afb --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/layout-backup.js @@ -0,0 +1,96 @@ +// A portable copy of the single OnroadCustomizations document, not a Params backup. +const FORMAT = "starpilot-visual-layout" +const VERSION = 1 +export const MAX_LAYOUT_BACKUP_BYTES = 32768 + +const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]) +const INITIAL = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19] +const right = (value, count) => (value >>> count) | (value << (32 - count)) + +// Local Galaxy can be served over plain HTTP, where Web Crypto is unavailable. +function sha256Portable(bytes) { + const padded = new Uint8Array(Math.ceil((bytes.length + 9) / 64) * 64) + padded.set(bytes) + padded[bytes.length] = 0x80 + const view = new DataView(padded.buffer) + const bits = bytes.length * 8 + view.setUint32(padded.length - 8, Math.floor(bits / 0x100000000)) + view.setUint32(padded.length - 4, bits >>> 0) + const state = INITIAL.slice() + const words = new Uint32Array(64) + for (let offset = 0; offset < padded.length; offset += 64) { + for (let i = 0; i < 16; i++) words[i] = view.getUint32(offset + i * 4) + for (let i = 16; i < 64; i++) { + const x = words[i - 15], y = words[i - 2] + const s0 = right(x, 7) ^ right(x, 18) ^ (x >>> 3) + const s1 = right(y, 17) ^ right(y, 19) ^ (y >>> 10) + words[i] = (words[i - 16] + s0 + words[i - 7] + s1) >>> 0 + } + let [a, b, c, d, e, f, g, h] = state + for (let i = 0; i < 64; i++) { + const s1 = right(e, 6) ^ right(e, 11) ^ right(e, 25) + const choose = (e & f) ^ (~e & g) + const t1 = (h + s1 + choose + K[i] + words[i]) >>> 0 + const s0 = right(a, 2) ^ right(a, 13) ^ right(a, 22) + const majority = (a & b) ^ (a & c) ^ (b & c) + const t2 = (s0 + majority) >>> 0 + h = g; g = f; f = e; e = (d + t1) >>> 0 + d = c; c = b; b = a; a = (t1 + t2) >>> 0 + } + state[0] = (state[0] + a) >>> 0; state[1] = (state[1] + b) >>> 0 + state[2] = (state[2] + c) >>> 0; state[3] = (state[3] + d) >>> 0 + state[4] = (state[4] + e) >>> 0; state[5] = (state[5] + f) >>> 0 + state[6] = (state[6] + g) >>> 0; state[7] = (state[7] + h) >>> 0 + } + return state.map((word) => word.toString(16).padStart(8, "0")).join("") +} + +export async function sha256(value, cryptoSource = globalThis.crypto) { + const bytes = new TextEncoder().encode(value) + if (!cryptoSource?.subtle) return sha256Portable(bytes) + const digest = await cryptoSource.subtle.digest("SHA-256", bytes) + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("") +} + +function body(document) { + if (!document || typeof document !== "object" || Array.isArray(document)) throw new Error("Visual layout is unavailable.") + return { format: FORMAT, version: VERSION, document } +} + +export async function encodeLayoutBackup(document, cryptoSource = globalThis.crypto) { + const content = body(document) + const sha256Value = await sha256(JSON.stringify(content), cryptoSource) + const encoded = JSON.stringify({ ...content, sha256: sha256Value }) + "\n" + if (new TextEncoder().encode(encoded).length > MAX_LAYOUT_BACKUP_BYTES) throw new Error("Visual layout backup is too large.") + return encoded +} + +export async function decodeLayoutBackup(text, cryptoSource = globalThis.crypto) { + if (typeof text !== "string" || new TextEncoder().encode(text).length > MAX_LAYOUT_BACKUP_BYTES) { + throw new Error("Visual layout backup is too large or unreadable.") + } + let parsed + try { parsed = JSON.parse(text) } catch { throw new Error("Visual layout backup is not valid JSON.") } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || + Object.keys(parsed).join(",") !== "format,version,document,sha256" || + parsed.format !== FORMAT || parsed.version !== VERSION || + !/^[0-9a-f]{64}$/.test(parsed.sha256) || + text !== JSON.stringify(parsed) + "\n") { + throw new Error("Visual layout backup has an unsupported format.") + } + const content = body(parsed.document) + if (await sha256(JSON.stringify(content), cryptoSource) !== parsed.sha256) { + throw new Error("Visual layout backup checksum does not match.") + } + return parsed.document +} diff --git a/openpilot/starpilot/galaxy/web/js/layout-preview.js b/openpilot/starpilot/galaxy/web/js/layout-preview.js new file mode 100644 index 0000000000..49d1b659d2 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/layout-preview.js @@ -0,0 +1,105 @@ +const SCENES = ["engaged", "aol", "long_only", "experimental", "braking", "cem_stop_light", "cem_lead", "cem_curve", "slc_pending"] +export const PREVIEW_SCENES = [ + { id: "engaged", label: "Engaged" }, + { id: "aol", label: "Lateral only" }, + { id: "long_only", label: "Longitudinal only" }, + { id: "experimental", label: "Manual experimental" }, + { id: "braking", label: "Braking" }, + { id: "cem_stop_light", label: "CEM · Traffic light" }, + { id: "cem_lead", label: "CEM · Stopped lead" }, + { id: "cem_curve", label: "CEM · Curve" }, + { id: "slc_pending", label: "Pending speed limit" }, +] + +export class LayoutPreviewFeed { + constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args), + later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id), + urls = URL }) { + Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer, urls }) + this.active = false + this.version = 0 + this.timer = null + this.request = null + this.currentUrl = null + this.pending = null + } + + stop() { + this.active = false + this.version++ + if (this.timer !== null) this.cancelTimer(this.timer) + this.timer = null + this.pending = null + this.inputKey = null + this.request?.abort() + this.clearImage() + } + + clearImage() { + if (this.currentUrl) this.urls.revokeObjectURL(this.currentUrl) + this.currentUrl = null + this.publish({ url: null }) + } + + start() { this.stop(); this.active = true } + + update(document, profile, scene, dragging = false) { + if (!this.active || !document || !["large", "compact", "projection"].includes(profile) || !SCENES.includes(scene)) return + const inputKey = JSON.stringify({ document, profile, scene, dragging }) + if (inputKey === this.inputKey) return + this.inputKey = inputKey + this.version++ + if (this.timer !== null) this.cancelTimer(this.timer) + this.timer = null + this.pending = null + this.request?.abort() + if (dragging) { this.publish({ status: "editing", error: "" }); return } + this.pending = { document: JSON.parse(JSON.stringify(document)), profile, scene, version: this.version } + this.publish({ status: "updating", error: "" }) + this.timer = this.later(() => { this.timer = null; this.dispatch() }, 200) + } + + retry() { + if (!this.active || !this.last) return + this.inputKey = null + this.update(this.last.document, this.last.profile, this.last.scene) + } + + async dispatch() { + if (!this.active || this.request || !this.pending) return + const item = this.pending + this.pending = null + this.last = item + const request = new AbortController() + this.request = request + let timeout = this.later(() => request.abort(), 7000) + let failure = "Preview is unavailable. Try again." + try { + const response = await this.fetcher("./api/ui/layout/preview", { method: "POST", credentials: "same-origin", + cache: "no-store", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ document: item.document, profile: item.profile, scene: item.scene }), signal: request.signal }) + if (!this.active || item.version !== this.version) return + if (response.status === 401) { this.stop(); this.unauthorized(); return } + if (response.status === 403) { failure = "Turn off the vehicle to preview this layout."; throw new Error() } + if (response.status === 429) { failure = "Preview is busy. Try again."; throw new Error() } + if (!response.ok) { const detail = await response.json().catch(() => null); failure = detail?.error || failure; throw new Error() } + if (!response.headers.get("Content-Type")?.toLowerCase().startsWith("image/png")) throw new Error("Preview is unavailable. Try again.") + const blob = await response.blob() + if (!this.active || item.version !== this.version) return + if (!blob.size || blob.size > 4 * 1024 * 1024) throw new Error("Preview is unavailable. Try again.") + const previousUrl = this.currentUrl + this.currentUrl = this.urls.createObjectURL(blob) + this.publish({ status: "ready", error: "", url: this.currentUrl }) + if (previousUrl) this.urls.revokeObjectURL(previousUrl) + } catch { + if (this.active && item.version === this.version) { + this.inputKey = null + this.publish({ status: "unavailable", error: failure }) + } + } finally { + this.cancelTimer(timeout) + if (this.request === request) this.request = null + if (this.active && this.pending && this.timer === null) this.dispatch() + } + } +} diff --git a/openpilot/starpilot/galaxy/web/js/local-access.js b/openpilot/starpilot/galaxy/web/js/local-access.js new file mode 100644 index 0000000000..f719ae40c0 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/local-access.js @@ -0,0 +1,86 @@ +import { reactive } from "../vendor/vue/vue.esm-browser.js" + +export function validLocalAccess(value) { + if (!value || typeof value.available !== "boolean" || typeof value.reason !== "string" || value.reason.length > 1024 || + !Array.isArray(value.addresses) || value.addresses.length > 32 || value.available !== (value.addresses.length > 0)) return false + return value.addresses.every(address => { + if (!address || !["interface", "label", "url"].every(key => typeof address[key] === "string" && address[key].length > 0 && address[key].length <= 512)) return false + try { + const url = new URL(address.url) + return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !!url.hostname + } catch { return false } + }) +} + +export class LocalAccessFeed { + constructor({ publish, onUnauthorized = () => {}, fetcher = (...args) => fetch(...args), + later = (fn, ms) => setTimeout(fn, ms), cancel = id => clearTimeout(id) }) { + Object.assign(this, { publish, onUnauthorized, fetcher, later, cancel }) + this.active = false + this.request = null + this.generation = 0 + } + start(mode) { + this.stop() + this.active = true + this.mode = mode + if (mode !== "local") { + this.publish({ loading: false, data: null, error: "Local comma addresses are unavailable in sample mode." }) + return + } + return this.refresh() + } + stop() { + this.active = false + this.generation++ + this.request?.abort() + this.request = null + } + async refresh() { + if (!this.active || this.mode !== "local" || this.request) return + const request = new AbortController(), generation = ++this.generation + this.request = request + this.publish({ loading: true, error: "" }) + const timer = this.later(() => request.abort(), 4000) + try { + const response = await this.fetcher("./api/local-access", { credentials: "same-origin", cache: "no-store", signal: request.signal }) + if (!this.active || generation !== this.generation) return + if (response.status === 401) { this.stop(); this.onUnauthorized(); return } + const data = await response.json() + if (!this.active || generation !== this.generation) return + if (!response.ok || !validLocalAccess(data)) throw new Error("Local comma addresses are unavailable. Try Refresh.") + this.publish({ data, error: "" }) + } catch (error) { + if (this.active && generation === this.generation) + this.publish({ data: null, error: error.name === "AbortError" ? "Reading local addresses timed out. Try Refresh." : "Local comma addresses are unavailable. Try Refresh." }) + } finally { + this.cancel(timer) + if (this.active && generation === this.generation) { + this.request = null + this.publish({ loading: false }) + } + } + } +} + +export const LocalAccess = { + props: { mode: { type: String, required: true }, onUnauthorized: { type: Function, default: () => {} } }, + setup(props) { + const state = reactive({ loading: false, data: null, error: "" }) + const feed = new LocalAccessFeed({ publish: update => Object.assign(state, update), onUnauthorized: props.onUnauthorized }) + return { state, feed } + }, + mounted() { this.feed.start(this.mode) }, + watch: { mode(value) { this.feed.start(value) } }, + beforeUnmount() { this.feed.stop() }, + template: `
+

Local Comma Access

+

{{ state.error }}

+ +

{{ state.data.reason || 'The comma has no local network address available.' }}

+
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/logs.js b/openpilot/starpilot/galaxy/web/js/logs.js new file mode 100644 index 0000000000..9b62ced043 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/logs.js @@ -0,0 +1,71 @@ +import { TroubleshootPage, TmuxPage } from "./diagnostic-tools.js" +import { navigate } from "./router.js" +import { SystemMonitor } from "./system-monitor.js" +import { CrashReportsFeed } from "./crash-reports.js" + +export const Logs = { + name: "Logs", + components: { SystemMonitor, TroubleshootPage, TmuxPage }, + props: { path: { type: String, required: true }, mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }, + data: () => ({ crashes: { reports: [], scanIncomplete: false, listLimited: false, status: "idle", error: "", selected: null, preview: null, previewStatus: "idle" }, search: "" }), + computed: { + visibleReports() { return this.crashes.reports.filter((item) => item.name.toLowerCase().includes(this.search.toLowerCase())) }, + }, + created() { + this.crashFeed = new CrashReportsFeed({ publish: (update) => Object.assign(this.crashes, update), unauthorized: this.unauthorized }) + }, + mounted() { if (this.path === "/logs/crashes" && this.mode === "local") this.crashFeed.start() }, + beforeUnmount() { this.crashFeed.stop() }, + watch: { + path(next, previous) { + if (next === "/logs/crashes" && this.mode === "local") this.crashFeed.start() + else if (previous === "/logs/crashes") { this.crashFeed.stop(); this.search = "" } + }, + }, + methods: { + go: navigate, + openTroubleshoot() { navigate("/logs/troubleshoot") }, + openTmux() { navigate("/logs/tmux") }, + openMonitor() { navigate("/logs/monitor") }, + openCrashes() { if (this.mode === "local") navigate("/logs/crashes") }, + openLogs() { navigate("/logs") }, + reportDate(seconds) { return new Date(seconds * 1000).toLocaleString() }, + reportSize(bytes) { return `${(bytes / 1024).toFixed(1)} KiB` }, + async copyPreview() { + if (!this.crashes.preview) return + try { await navigator.clipboard.writeText(this.crashes.preview.text) } + catch { this.crashes.error = "Copy is unavailable in this browser." } + }, + }, + template: ` +
+

Logs & Diagnostics

+
+ + +
Crash ReportsUnavailable in preview
+ + +
+ + + + +
This Logs & Diagnostics page is unavailable in the offline preview.
+
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/longitudinal-curves.js b/openpilot/starpilot/galaxy/web/js/longitudinal-curves.js new file mode 100644 index 0000000000..20bdbd141a --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/longitudinal-curves.js @@ -0,0 +1,105 @@ +import { reactive } from "../vendor/vue/vue.esm-browser.js" +import { SettingsFeed } from "./settings.js" +import { GalaxySettingRow } from "./galaxy-setting-row.js" + +export const PROFILES = Object.freeze(["aggressive", "standard", "relaxed"]) +export const CATEGORIES = Object.freeze(["acceleration", "braking", "following"]) + +export function savedCurve(page, rows) { + if (!PROFILES.some((profile) => CATEGORIES.some((category) => page === `${profile}/${category}`))) return null + if (!Array.isArray(rows) || rows[0]?.label !== "Preset" || rows[0]?.value !== "custom") return null + const points = rows.slice(1) + if (points.length !== 10) return null + const unit = page.endsWith("/following") ? "s" : "m/s²" + const values = points.map((row, index) => { + const value = Number(row.value) + if (row.label !== `${index * 10} mph point` || row.unit !== unit || typeof row.value !== "string" || !row.value.trim() || + !Number.isFinite(value) || !Number.isFinite(row.minimum) || !Number.isFinite(row.maximum)) return null + return { index: index + 1, speed: index * 10, value, unit, minimum: row.minimum, maximum: row.maximum, + available: row.available === true } + }) + return values.every(Boolean) ? values : null +} + +export function curveGeometry(points) { + if (!Array.isArray(points) || points.length !== 10) return null + const values = points.map((point) => point.value) + if (values.some((value) => !Number.isFinite(value))) return null + const min = Math.min(...values) + const max = Math.max(...values) + const span = Math.max(max - min, 0.1) + const bottom = min - (span - (max - min)) / 2 + const top = bottom + span + const coords = points.map((point, index) => ({ x: 44 + index * 32, y: 116 - (point.value - bottom) / span * 88 })) + return { coords, line: coords.map(({ x, y }) => `${x},${y}`).join(" "), bottom, top } +} + +export const LongitudinalCurvesPage = { + components: { GalaxySettingRow }, + props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, go: { type: Function, required: true } }, + setup(props) { + const state = reactive({ status: "idle", data: null, pending: null, error: "", profile: "standard", category: "acceleration" }) + const feed = new SettingsFeed({ publish: (update) => Object.assign(state, update), unauthorized: props.unauthorized }) + return { state, feed, profiles: PROFILES, categories: CATEGORIES } + }, + computed: { + page() { return `${this.state.profile}/${this.state.category}` }, + points() { return savedCurve(this.state.data?.page, this.state.data?.rows) }, + graph() { return curveGeometry(this.points) }, + preset() { return this.state.data?.rows?.[0] }, + }, + mounted() { if (this.mode === "local") this.feed.start(this.page) }, + beforeUnmount() { this.feed.stop() }, + methods: { + selectProfile(profile) { if (PROFILES.includes(profile) && !this.feed.saving) { this.state.profile = profile; this.feed.load(this.page) } }, + selectCategory(category) { if (CATEGORIES.includes(category) && !this.feed.saving) { this.state.category = category; this.feed.load(this.page) } }, + }, + template: ` +
+

Saved driving preferences

Longitudinal Curves

+

Review acceleration, braking, and following curves for the next drive.

+
+
Local saved curves are unavailable in preview.
+ +
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/map-operations.js b/openpilot/starpilot/galaxy/web/js/map-operations.js new file mode 100644 index 0000000000..b978807507 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/map-operations.js @@ -0,0 +1,330 @@ +// One parked map operation belongs to the local owner, not to this browser tab. +const STATES = new Set(["idle", "transferring", "validating", "selecting", "completed", "canceled", "failed", "interrupted", "unavailable"]) +const GENERATION = /^(?:|[0-9a-f]{64})$/ +const TOKEN = /^(?:nation|us_state)\.[A-Za-z0-9_-]+$/ +const ERROR_LABELS = { + canceled: "Canceled", interrupted: "Interrupted", selection_changed: "The saved selection changed", + not_parked: "Parked availability was lost", package_unavailable: "The map package is unavailable", + process_failed: "Map preparation failed", unavailable: "Map manager unavailable", +} +const ACTION_ERRORS = { + busy: "Another map download is running. Check its progress below.", + selection_changed: "The selected map changed. Refresh and review the region again.", + not_parked: "Map downloads need a fresh parked status. Refresh and try again.", + operation_changed: "This map download changed. Refresh its status before trying again.", +} +const integer = (value) => Number.isSafeInteger(value) && value >= 0 +const nullableText = (value) => value === null || typeof value === "string" && value.length <= 128 +const bounds = (value) => value === null || Array.isArray(value) && value.length === 4 && + value.every((n) => typeof n === "number" && Number.isFinite(n)) && + value[0] >= -90 && value[2] <= 90 && value[1] >= -180 && value[3] <= 180 && + value[0] < value[2] && value[1] < value[3] + +export function validCatalog(value) { + if (!Array.isArray(value?.regions) || value.regions.length > 500 || + !integer(value.maxGroups) || value.maxGroups < 1 || !integer(value.maxTransferBytes) || + !integer(value.maxNewDiskBytes) || !GENERATION.test(value.selectedGeneration ?? "!")) return false + const seen = new Set() + return value.regions.every((region) => { + if (!TOKEN.test(region?.token) || region.token.length > 73 || seen.has(region.token) || typeof region.name !== "string" || + !region.name.trim() || region.name.length > 128 || (region.available && (!bounds(region.bounds) || region.bounds === null)) || + !integer(region.groups) || typeof region.available !== "boolean" || + !(region.unavailable == null || ["invalid_bounds", "date_line_bounds", "too_large"].includes(region.unavailable)) || + (region.available && (region.unavailable != null || region.groups > value.maxGroups)) || + (!region.available && !["invalid_bounds", "date_line_bounds", "too_large"].includes(region.unavailable))) return false + seen.add(region.token) + return true + }) +} + +export function validSetup(value) { + return value?.schemaVersion === 1 && typeof value.packageReady === "boolean" && + ["ready", "missing_binary", "missing_manifest", "invalid_package"].includes(value.packageState) && + value.packageReady === (value.packageState === "ready") && typeof value.snapshotReady === "boolean" && + GENERATION.test(value.selectedGeneration ?? "!") && value.snapshotReady === Boolean(value.selectedGeneration) && + typeof value.parked === "boolean" && integer(value.freeDiskBytes) && + integer(value.maxTransferBytes) && integer(value.maxNewDiskBytes) +} + +export function validOperation(value) { + return value?.schemaVersion === 1 && STATES.has(value.state) && + typeof value.ownerSession === "string" && value.ownerSession.length <= 128 && + nullableText(value.operationId) && nullableText(value.regionToken) && bounds(value.bounds) && + integer(value.completedGroups) && integer(value.totalGroups) && value.completedGroups <= value.totalGroups && + integer(value.transferredBytes) && integer(value.transferBudgetBytes) && + nullableText(value.preparedGeneration) && GENERATION.test(value.selectedGeneration ?? "!") && + nullableText(value.errorCode) && typeof value.selectedForNextShadowStart === "boolean" && + (!value.selectedForNextShadowStart || value.state === "completed" && value.selectedGeneration !== "") +} + +export const formatBytes = (value) => !integer(value) ? "Unavailable" : value < 1024 ** 2 ? `${value} B` : + value < 1024 ** 3 ? `${(value / (1024 ** 2)).toFixed(1)} MiB` : `${(value / (1024 ** 3)).toFixed(1)} GiB` +export const formatBounds = (value) => bounds(value) && value !== null ? + `latitude ${value[0]}°–${value[2]}°, longitude ${value[1]}°–${value[3]}°` : "extent unavailable" + +export class MapOperationsClient { + constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args), + later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) { + Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer }) + this.active = false + this.generation = 0 + this.requests = new Set() + this.timer = null + this.setup = null + this.catalog = null + this.operation = null + this.busy = false + this.error = "" + this.actionError = "" + } + + emit(update = {}) { + this.publish({ setup: this.setup, catalog: this.catalog, operation: this.operation, busy: this.busy, + error: this.actionError || this.error, ...update }) + } + + stop() { + this.active = false + this.generation++ + if (this.timer !== null) this.cancelTimer(this.timer) + this.timer = null + for (const request of this.requests) request.abort() + this.requests.clear() + this.setup = this.catalog = this.operation = null + this.busy = false + this.error = this.actionError = "" + this.emit() + } + + start() { + this.stop() + this.active = true + this.loadCatalog() + this.loadStatus() + this.loadSetup() + } + + async request(url, body = null) { + const generation = this.generation + const controller = new AbortController() + this.requests.add(controller) + const deadline = this.later(() => controller.abort(), 10000) + try { + const response = await this.fetcher(url, { credentials: "same-origin", cache: "no-store", + signal: controller.signal, ...(body === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }) }) + if (!this.active || generation !== this.generation || controller.signal.aborted) return null + if (response.status === 401) { this.stop(); this.unauthorized(); return null } + if (response.status === 409) { + const body = await response.json().catch(() => null) + if (!this.active || generation !== this.generation || controller.signal.aborted) return null + const message = Object.hasOwn(ACTION_ERRORS, body?.code) ? ACTION_ERRORS[body.code] : + "Map request could not be completed. Refresh status and try again." + throw new Error(message) + } + if (response.status === 503) { + const body = await response.json().catch(() => null) + if (!this.active || generation !== this.generation || controller.signal.aborted) return null + if (["setup_required", "access_unavailable"].includes(body?.code)) { + this.stop(); this.unauthorized(); return null + } + if (body?.code === "package_unavailable") + throw new Error("Offline map package is unavailable on this device.") + throw new Error("Offline maps service is unavailable.") + } + if (!response.ok) throw new Error("Map request was rejected.") + const data = await response.json() + if (!this.active || generation !== this.generation || controller.signal.aborted) return null + return data + } finally { + this.cancelTimer(deadline) + this.requests.delete(controller) + } + } + + async loadSetup() { + if (!this.active) return + try { + const data = await this.request("./api/maps/setup") + if (data === null) return + if (!validSetup(data)) throw new Error("Map setup status is unavailable.") + const wasReady = this.setup?.packageReady + this.setup = data + this.emit() + if (data.packageReady && wasReady === false) await this.loadCatalog() + } catch (error) { if (this.active) { this.error = error.message; this.emit() } } + } + + async loadCatalog() { + if (!this.active) return + try { + const data = await this.request("./api/maps/catalog") + if (data === null) return + if (!validCatalog(data)) throw new Error("Map catalog is unavailable.") + this.catalog = data + if (this.operation !== null) this.error = "" + this.emit() + } catch (error) { if (this.active) { this.error = error.message; this.emit() } } + } + + async loadStatus() { + if (!this.active || this.busy) return + const generation = this.generation + try { + const data = await this.request("./api/maps/operation") + if (data !== null) { + if (!validOperation(data)) throw new Error("Map operation status is unavailable.") + this.operation = data + if (this.catalog !== null) this.error = "" + this.emit() + } + } catch (error) { + if (this.active && generation === this.generation) { + this.operation = null; this.error = error.message; this.emit() + } + } finally { + if (this.active && generation === this.generation && !this.busy) + this.timer = this.later(() => { this.timer = null; this.loadStatus() }, 2500) + } + } + + pausePolling() { + this.generation++ + if (this.timer !== null) this.cancelTimer(this.timer) + this.timer = null + for (const request of this.requests) request.abort() + this.requests.clear() + } + + async startRegion(region, expectedGeneration, expectedOwnerSession) { + if (!this.active || this.busy || !this.catalog || !this.operation || !region?.available || + !this.catalog.regions.some((item) => item.token === region.token && item.available) || + !["idle", "completed", "canceled", "failed", "interrupted"].includes(this.operation.state)) { + this.actionError = "Map operation changed. Refresh status and review again." + this.emit() + return + } + if (this.operation.selectedGeneration !== expectedGeneration || this.operation.ownerSession !== expectedOwnerSession) { + this.actionError = "Map selection changed. Review this region again." + this.emit() + return + } + const expected = this.operation.selectedGeneration + this.pausePolling() + const generation = this.generation + this.busy = true + this.actionError = "" + this.emit() + try { + const data = await this.request("./api/maps/start", { regionToken: region.token, + maxTransferBytes: this.catalog.maxTransferBytes, maxNewDiskBytes: this.catalog.maxNewDiskBytes, + expectedCurrentGeneration: expected }) + if (data === null) return + if (!validOperation(data)) throw new Error("Map start status is unavailable. Refresh status.") + this.operation = data + this.emit() + } catch (error) { if (this.active && generation === this.generation) { this.actionError = error.message; this.emit() } } + finally { if (this.active && generation === this.generation) { this.busy = false; this.emit(); await this.loadStatus() } } + } + + async cancel() { + if (!this.active || this.busy || !this.operation?.operationId || + !["transferring", "validating", "selecting"].includes(this.operation.state)) return + const operationId = this.operation.operationId + this.pausePolling() + const generation = this.generation + this.busy = true + this.actionError = "" + this.emit() + try { + const data = await this.request("./api/maps/cancel", { operationId }) + if (data === null) return + if (!validOperation(data)) throw new Error("Cancellation status is unavailable. Refresh status.") + this.operation = data + this.emit() + } catch (error) { if (this.active && generation === this.generation) { this.actionError = error.message; this.emit() } } + finally { if (this.active && generation === this.generation) { this.busy = false; this.emit(); await this.loadStatus() } } + } +} + +export const MapOperationsPanel = { + name: "MapOperationsPanel", + props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }, + data: () => ({ setup: null, catalog: null, operation: null, busy: false, error: "", search: "", review: null }), + created() { this.client = new MapOperationsClient({ publish: (update) => { + Object.assign(this.$data, update) + if (update.operation === null || update.error) this.review = null + }, unauthorized: () => { this.review = null; this.unauthorized() } }) }, + mounted() { + this.visibility = () => { if (document.hidden) { this.review = null; this.client.stop() } + else if (this.mode === "local") this.client.start() } + document.addEventListener("visibilitychange", this.visibility) + if (this.mode === "local" && !document.hidden) this.client.start() + }, + beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.client.stop() }, + computed: { + regions() { + const query = this.search.trim().toLocaleLowerCase() + return (this.catalog?.regions || []).filter((item) => !query || + `${item.name} ${item.token}`.toLocaleLowerCase().includes(query)) + }, + running() { return ["transferring", "validating", "selecting"].includes(this.operation?.state) }, + }, + methods: { + formatBytes, formatBounds, + errorLabel(code) { return ERROR_LABELS[code] || "Review this operation in Galaxy" }, + reviewRegion(region) { + if (!this.busy && region.available && this.operation) + this.review = { region, selectedGeneration: this.operation.selectedGeneration, ownerSession: this.operation.ownerSession } + }, + async confirm() { + const review = this.review + this.review = null + if (review) await this.client.startRegion(review.region, review.selectedGeneration, review.ownerSession) + }, + }, + template: ` +
+
Offline Maps
+
+

Choose a region to download for offline use. Downloading another region replaces the selected map.

+

Parked map management is unavailable in the offline preview.

+ +
+ +
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/model-laboratory.js b/openpilot/starpilot/galaxy/web/js/model-laboratory.js new file mode 100644 index 0000000000..88b40603b2 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/model-laboratory.js @@ -0,0 +1,185 @@ +import { GalaxySelect } from "./galaxy-select.js" +import { ModelManagerFeed } from "./models.js" + +export const laboratoryModels = data => data?.models || [] +export const laboratoryReady = data => laboratoryModels(data).filter(m => m.small === true && m.modelLabEligible === true && m.modelLabArtifactAvailable === true && m.modelLabArtifactInstalled === true) + +export const laboratoryStatusLabel = model => ({ + unsupported: "Not supported", unpublished: "Not published", checking: "Checking download", + missing: "Not downloaded", "runtime-unavailable": "Downloaded", "gpu-unavailable": "Connect Chestnut", + installed: "Downloaded", +})[model.modelLabStatus] || (model.modelLabArtifactInstalled ? "Downloaded" : "Not downloaded") + +export function laboratorySelectionError(data, configuration) { + if (!data) return "Waiting for parked device status" + if (data.isOnroad !== false) return "Turn off the vehicle before changing the model pair" + if (!data.runtimeSupported) return data.runtimeUnavailableReason || "Model Laboratory runtime is unavailable" + if (!data.chestnutReady) return "Connect a firmware-ready Chestnut to enable a pair" + const ready = laboratoryReady(data) + if (ready.length < 2) return "Download at least two eGPU variants to compose a pair" + if (!configuration.lateralModel || !configuration.longitudinalModel) return "Choose two downloaded eGPU variants" + if (configuration.lateralModel === configuration.longitudinalModel) return "Choose two different models" + if (![configuration.lateralModel, configuration.longitudinalModel].every(id => ready.some(m => m.value === id))) return "Both models must have downloaded eGPU variants" + return "" +} + +export function laboratoryActionAllowed(data, action, model, configuration = data?.configuration) { + if (!data || data.capabilities?.[action === "enable" || action === "disable" ? "configure" : action] !== true) return false + if (action === "cancel") return data.download?.downloading === true && typeof data.download.jobId === "string" + if (data.isOnroad !== false || data.download?.downloading) return false + if (action === "enable") return !laboratorySelectionError(data, configuration) + if (action === "disable") return data.configuration.enabled === true + if (action === "download") return laboratoryModels(data).some(m => m.value === model?.value && m.small === true && m.modelLabEligible === true && m.modelLabArtifactAvailable === true && !m.modelLabArtifactInstalled) + if (action === "delete") return laboratoryReady(data).some(m => m.value === model?.value) && + !(data.configuration.enabled && [data.configuration.lateralModel, data.configuration.longitudinalModel].includes(model.value)) + return action === "refresh" +} + +export function laboratoryDraft(data, draft, dirty) { + const configuration = { ...data.configuration, ...(dirty ? { lateralModel: draft.lateralModel, longitudinalModel: draft.longitudinalModel } : {}) } + if (!dirty) { + const ready = laboratoryReady(data) + if (!configuration.lateralModel) configuration.lateralModel = ready[0]?.value || "" + if (!configuration.longitudinalModel) configuration.longitudinalModel = ready.find(m => m.value !== configuration.lateralModel)?.value || "" + } + return configuration +} + +function validPayload(data) { + return data?.schemaVersion === 1 && typeof data.isOnroad === "boolean" && typeof data.chestnutReady === "boolean" && + typeof data.runtimeSupported === "boolean" && typeof data.configuration?.enabled === "boolean" && + [data.configuration.lateralModel, data.configuration.longitudinalModel].every(id => typeof id === "string") && + data.capabilities && typeof data.capabilities === "object" && typeof data.download?.downloading === "boolean" && + typeof data.runtime?.active === "boolean" && typeof data.runtime?.requested === "boolean" && Array.isArray(data.models) && + data.models.every(m => typeof m?.value === "string" && typeof m.label === "string") && + new Set(data.models.map(m => m.value)).size === data.models.length +} + +export class LaboratoryFeed extends ModelManagerFeed { + stop() { + if (this.expiry != null) this.cancel(this.expiry) + this.expiry = null + super.stop() + } + async load() { + if (!this.active || this.saving) return null + const data = await this.requestJson("laboratory") + if (!this.active) return null + if (data) { + this.data = validPayload(data) ? data : null + this.publish({ loading: false, data: this.data, error: this.data ? "" : "Invalid Model Laboratory response" }) + if (this.expiry != null) this.cancel(this.expiry) + this.expiry = this.later(() => { + this.data = null + this.publish({ loading: false, data: null, error: "Laboratory status refresh delayed" }) + }, 6000) + } + if (this.poll === null) this.poll = this.later(() => this.load(), 2000) + return this.data + } + async action(action, model = null, configuration) { + if (!this.active || this.saving || !laboratoryActionAllowed(this.data, action, model, configuration)) return null + const paths = { enable: "laboratory", disable: "laboratory", download: "laboratory/download", delete: "laboratory/delete", cancel: "cancel", refresh: "refresh_manifest" } + const payload = action === "enable" || action === "disable" ? { ...configuration, enabled: action === "enable" } : + action === "cancel" ? { jobId: this.data.download.jobId } : model ? { model: model.value } : {} + this.saving = true + const result = await this.requestJson(paths[action], payload) + if (!this.active) return null + this.saving = false + const error = this.lastError + await this.load() + if (error && this.active) this.publish({ loading: false, data: this.data, error }) + return result + } +} + +export const LaboratoryPage = { + components: { GalaxySelect }, + props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }, + data: () => ({ loading: true, status: null, error: "", message: "", busy: false, dirty: false, trackingProgress: false, + configuration: { enabled: false, lateralModel: "", longitudinalModel: "" } }), + computed: { + availableModels() { return laboratoryModels(this.status) }, + readyModels() { return laboratoryReady(this.status) }, + candidates() { return this.readyModels.filter(m => m.value !== this.configuration.lateralModel) }, + selectionError() { return laboratorySelectionError(this.status, this.configuration) }, + runtimeState() { return !this.status ? "Status unavailable" : !this.status.runtimeSupported ? "Unavailable" : this.status.runtime.active ? "Pair active" : this.status.runtime.requested ? "Pair requested" : "Inactive" }, + }, + created() { + this.feed = new LaboratoryFeed({ unauthorized: this.unauthorized, publish: update => { + this.loading = update.loading + this.error = update.error + this.status = update.data + if (update.data) { + this.configuration = laboratoryDraft(update.data, this.configuration, this.dirty) + if (this.trackingProgress && update.data.download.progress) { + this.message = update.data.download.progress + if (!update.data.download.downloading) this.trackingProgress = false + } + } + } }) + }, + mounted() { + this.visibility = () => { if (document.hidden) this.feed.stop(); else if (this.mode === "local") this.feed.start() } + document.addEventListener("visibilitychange", this.visibility) + if (this.mode === "local" && !document.hidden) this.feed.start() + }, + beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.feed.stop() }, + methods: { + statusLabel: laboratoryStatusLabel, + can(action, model = null) { return !this.busy && laboratoryActionAllowed(this.status, action, model, this.configuration) }, + modelLabel(id) { return this.status?.models.find(m => m.value === id)?.label || id || "Not selected" }, + changeLateral(value) { + this.configuration.lateralModel = value + if (this.configuration.longitudinalModel === value) this.configuration.longitudinalModel = this.candidates[0]?.value || "" + this.dirty = true + }, + async act(action, model = null) { + if (!this.can(action, model)) return + if (action === "delete" && !window.confirm(`Delete the eGPU variant for "${model.label}"? The normal on-device model will not be removed.`)) return + this.busy = true + this.message = "" + try { + const result = await this.feed.action(action, model, this.configuration) + if (result) { + this.message = result.message || "Updated" + this.trackingProgress = action === "download" || action === "cancel" + if (["enable", "disable", "delete"].includes(action)) { + this.dirty = false + if (this.status) this.configuration = laboratoryDraft(this.status, this.configuration, false) + } + } + } finally { this.busy = false } + }, + }, + template: ` +
+
+
Model Laboratory
+
Use lateral judgment from one model and longitudinal judgment from another.
{{ status ? (status.chestnutReady ? 'Chestnut ready' : 'Chestnut required') : 'Waiting for device status' }}{{ status.isOnroad ? 'Onroad' : 'Parked' }}
+

Model Laboratory is available on the device. Preview does not configure a pair.

+

Loading Model Laboratory…

+ +

{{ status.configurationError }}

+

{{ message }}

+
+
+
Available models{{ availableModels.length }} catalog models · {{ status?.summary?.published || 0 }} Chestnut versions published · {{ readyModels.length }} downloads verified
+

Browse Small and Big models here. Combining models requires compatible Chestnut downloads and support for running them together.

+

The model catalog is unavailable. Refresh to retry.

+
{{ m.label }}{{ m.value }} · {{ m.small ? 'Small' : 'Big' }} · {{ m.series || 'Unknown series' }} · {{ m.version }}{{ m.modelLabReason || 'Pair artifact status unavailable' }}
{{ statusLabel(m) }}
+
{{ status.download.progress || 'Downloading…' }}
+
+
+
Compose a pair{{ configuration.enabled ? 'Enabled' : 'Disabled' }}
+
+ + +

{{ modelLabel(configuration.lateralModel) }} steers · {{ modelLabel(configuration.longitudinalModel) }} paces

+

{{ selectionError }}

+
+
+
+
Runtime{{ runtimeState }}
Lateral: {{ modelLabel(status?.runtime.lateralModel) }}Longitudinal: {{ modelLabel(status?.runtime.longitudinalModel) }}{{ status.runtime.error }}{{ status.runtimeUnavailableReason || 'Pair runtime is unavailable.' }}
+
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/models.js b/openpilot/starpilot/galaxy/web/js/models.js new file mode 100644 index 0000000000..e32489d68e --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/models.js @@ -0,0 +1,584 @@ +import { GalaxySelect } from "./galaxy-select.js" + +// Read-only model identity; a saved label never proves what modeld loaded. +export class ModelStatusFeed { + constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args), + later = (fn, ms) => setTimeout(fn, ms), cancel = (id) => clearTimeout(id) }) { + Object.assign(this, { publish, unauthorized, fetcher, later, cancel }) + this.active = false + this.generation = 0 + this.displayGeneration = 0 + this.request = null + this.poll = null + this.expiry = null + } + + stop() { + this.active = false + this.generation++ + this.displayGeneration++ + this.request?.abort() + this.request = null + if (this.poll !== null) this.cancel(this.poll) + if (this.expiry !== null) this.cancel(this.expiry) + this.poll = this.expiry = null + this.publish({ status: "idle", data: null, error: "" }) + } + + start() { + this.stop() + this.active = true + return this.load() + } + + async load() { + if (!this.active) return + const generation = ++this.generation + this.request?.abort() + if (this.poll !== null) this.cancel(this.poll) + this.poll = null + const request = new AbortController() + this.request = request + const timeout = this.later(() => request.abort(), 2500) + try { + const response = await this.fetcher("./api/models/status", { signal: request.signal, cache: "no-store" }) + if (!this.active || generation !== this.generation || request.signal.aborted) return + if (response.status === 401) { + this.stop() + this.unauthorized() + return + } + if (!response.ok) throw new Error("Model status is unavailable") + const data = await response.json() + if (!this.active || generation !== this.generation || request.signal.aborted) return + const states = ["unavailable", "loading", "identity-unavailable", "active", "stale", "failed"] + const variants = [null, "small", "chestnut"] + const digest = data?.artifactSha256 + const ids = Array.isArray(data?.catalog) ? data.catalog.map(m => m?.id) : [] + if (data?.schemaVersion !== 1 || !ids.length || new Set(ids).size !== ids.length || + data.catalog.some(m => typeof m.id !== "string" || !m.id || typeof m.name !== "string" || typeof m.selectable !== "boolean") || + !ids.includes(data.requestedId) || !(data.loadedId === null || ids.includes(data.loadedId)) || !variants.includes(data.variant) || + !states.includes(data.health) || ![null, "chestnut-load-failed", "chestnut-run-stalled", "selected-load-failed"].includes(data.fallbackReason) || + !(digest === null || typeof digest === "string" && /^[0-9a-f]{64}$/.test(digest)) || + typeof data.pendingNextStart !== "boolean" || + (data.pendingNextStart && (data.loadedId === null || data.requestedId === data.loadedId)) || + (data.loadedId === null ? data.variant !== null || digest !== null : data.variant === null || digest === null) || + (data.health === "active" && data.loadedId === null)) { + throw new Error("Invalid model status") + } + this.publish({ status: "ready", data, error: "" }) + if (this.expiry !== null) this.cancel(this.expiry) + const displayGeneration = ++this.displayGeneration + this.expiry = this.later(() => { + if (this.active && displayGeneration === this.displayGeneration) { + this.publish({ status: "stale", data: null, error: "Model status refresh delayed" }) + } + }, 1500) + } catch (error) { + if (this.active && generation === this.generation) { + this.displayGeneration++ + if (this.expiry !== null) this.cancel(this.expiry) + this.expiry = null + this.publish({ status: "unavailable", data: null, error: error.message }) + } + } finally { + this.cancel(timeout) + if (generation === this.generation) this.request = null + if (this.active && generation === this.generation) this.poll = this.later(() => this.load(), 1000) + } + } +} + + +const FILTER_KEY = "galaxy.modelManager.hardwareFilter"; +const FILTERS = new Set(["both", "gpu", "comma"]); + +function readHardwareFilter() { + try { + const value = localStorage.getItem(FILTER_KEY); + return FILTERS.has(value) ? value : "both"; + } catch { + return "both"; + } +} + +function saveHardwareFilter(value) { + const filter = FILTERS.has(value) ? value : "both"; + try { localStorage.setItem(FILTER_KEY, filter); } catch {} + return filter; +} + +function matchesHardware(model, filter) { + if (filter === "gpu") return model?.requiresGpu === true; + if (filter === "comma") return model?.requiresGpu === false; + return true; +} + +function hardwareLabel(model) { + if (model?.requiresGpu === true) return "GPU · External"; + if (model?.requiresGpu === false) return "Comma · On-device"; + return "Hardware unknown"; +} + +function bytes(value) { + return Number.isSafeInteger(value) && value > 0 ? value : null; +} + +function formatBytes(value) { + return value >= 1e9 ? `${(value / 1e9).toFixed(2)} GB` : `${(value / 1e6).toFixed(1)} MB`; +} + +function fileSizeText(model) { + const installed = bytes(model?.fileSizeBytes); + const declared = bytes(model?.declaredSizeBytes || model?.artifactSize); + const downloaded = bytes(model?.downloadedBytes); + if (model?.partial || model?.sizeStatus === "partial") { + return `Partial: ${downloaded ? formatBytes(downloaded) : "unknown"} / ${declared ? formatBytes(declared) : "unknown"}`; + } + if (installed && declared && installed !== declared) return `${formatBytes(installed)} · size mismatch`; + if (installed) return formatBytes(installed); + if (declared) return `${formatBytes(declared)} · declared`; + return "Unavailable"; +} + + +const actionPaths = { "select-small": "active", "select-big": "active", favorite: "preferences", unfavorite: "preferences", + "enable-randomizer": "preferences", "disable-randomizer": "preferences", exclude: "preferences", include: "preferences", + download: "download", downloadAll: "download_all", cancel: "cancel", delete: "delete", refresh: "refresh_manifest" } +const actionCapabilities = { "select-small": "select", "select-big": "select", favorite: "favorites", unfavorite: "favorites", + "enable-randomizer": "randomizer", "disable-randomizer": "randomizer", exclude: "exclusions", include: "exclusions", + download: "download", downloadAll: "downloadAll", cancel: "cancel", delete: "delete", refresh: "refresh" } + +export function fitsModelProfile(model, profile) { + return Array.isArray(model?.profiles) ? model.profiles.includes(profile) : profile === "big" ? model?.requiresGpu === true : model?.requiresGpu === false +} + +export function modelActionAllowed(data, action, model = null) { + if (!data || data.capabilities?.[actionCapabilities[action]] !== true) return false + if (data.isOnroad !== false && !["favorite", "unfavorite", "cancel"].includes(action)) return false + if (["select-small", "select-big"].includes(action)) { + if (data.downloading || data.randomizer === true) return false + if (action === "select-big" && model === null) return true + return model?.installed === true && model.selectable === true && fitsModelProfile(model, action === "select-big" ? "big" : "small") + } + if (action === "download") return !data.downloading && !!model && !model.installed && model.downloadAvailable === true + if (action === "downloadAll") return !data.downloading && data.models.some(m => !m.installed && m.downloadAvailable === true) + if (action === "cancel") return data.downloading === true + if (action === "delete") return !data.downloading && !!model && !model.builtin && model.deletable !== false && + (model.installed || model.partial) && ![data.currentModel, data.activeSmallModel, data.activeBigModel].includes(model.value) + if (["exclude", "include"].includes(action)) return !!model + if (["enable-randomizer", "disable-randomizer"].includes(action)) return !data.downloading + return ["favorite", "unfavorite"].includes(action) ? !!model : action === "refresh" && !data.downloading +} + +export class ModelManagerFeed { + constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args), + later = (fn, ms) => setTimeout(fn, ms), cancel = id => clearTimeout(id) }) { + Object.assign(this, { publish, unauthorized, fetcher, later, cancel }) + this.active = false + this.generation = 0 + this.request = this.poll = null + this.data = null + this.saving = false + this.lastError = "" + } + stop() { + this.active = false + this.generation++ + this.request?.abort() + this.request = null + if (this.poll !== null) this.cancel(this.poll) + this.poll = null + this.data = null + this.saving = false + this.publish({ loading: false, data: null, error: "" }) + } + start() { + this.stop() + this.active = true + return this.load() + } + async requestJson(path, payload) { + const generation = ++this.generation + this.lastError = "" + this.request?.abort() + if (this.poll !== null) this.cancel(this.poll) + this.poll = null + const request = new AbortController() + this.request = request + const timeout = this.later(() => { + if (!this.active || generation !== this.generation) return + request.abort() + this.generation++ + this.request = null + this.data = null + this.saving = false + this.publish({ loading: false, data: null, error: payload ? "The result is unknown. Refresh before trying again." : "Model Manager is unavailable. Retrying…" }) + this.poll = this.later(() => this.load(), 2000) + }, 10000) + try { + const response = await this.fetcher(`./api/models/${path}`, { signal: request.signal, cache: "no-store", credentials: "same-origin", + ...(payload ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) } : {}) }) + if (!this.active || generation !== this.generation || request.signal.aborted) return null + const data = await response.json() + if (!this.active || generation !== this.generation || request.signal.aborted) return null + if (response.status === 401 || ["access_unavailable", "setup_required"].includes(data?.code)) { + this.stop() + this.unauthorized() + return null + } + if (!response.ok) throw new Error(data?.message || data?.error || "Model request failed") + return data + } catch (error) { + if (this.active && generation === this.generation) { + this.data = null + this.lastError = error.message || "Model Manager is unavailable" + this.publish({ loading: false, data: null, error: this.lastError }) + } + return null + } finally { + this.cancel(timeout) + if (this.request === request) this.request = null + } + } + async load() { + if (!this.active || this.saving) return null + if (!this.data) this.publish({ loading: true, data: null, error: "" }) + const data = await this.requestJson("manager") + if (!this.active) return null + if (data) { + if (data.schemaVersion !== 1 || !Array.isArray(data.models) || + data.models.some(m => !m || typeof m.value !== "string" || typeof m.label !== "string") || + typeof data.isOnroad !== "boolean" || typeof data.capabilities !== "object" || data.capabilities === null) { + this.data = null + this.publish({ loading: false, data: null, error: "Invalid Model Manager response" }) + } else { + this.data = data + this.publish({ loading: false, data, error: "" }) + } + } + if (this.poll === null) this.poll = this.later(() => this.load(), 2000) + return this.data + } + async action(action, model = null, extra = {}) { + if (!this.active || this.saving || !this.data) return null + if (model !== null) { + model = this.data.models.find(m => m.value === model.value) + if (!model) return null + } + if (!modelActionAllowed(this.data, action, model)) return null + const data = this.data + const key = model?.value || "" + let payload = {} + if (action.startsWith("select-")) payload = { profile: action === "select-big" ? "big" : "small", model: key } + else if (["favorite", "unfavorite"].includes(action)) { + const userFavorites = data.models.filter(m => m.userFavorite && m.value !== key).map(m => m.value) + if (action === "favorite") userFavorites.push(key) + payload = { userFavorites } + } else if (["exclude", "include"].includes(action)) { + const blacklistedModels = (data.blacklistedModels || []).filter(value => value !== key) + if (action === "exclude") blacklistedModels.push(key) + payload = { blacklistedModels } + } else if (["enable-randomizer", "disable-randomizer"].includes(action)) payload = { randomizer: action === "enable-randomizer" } + else if (["download", "delete"].includes(action)) payload = { model: key } + if (["download", "downloadAll"].includes(action)) payload.allowGpuWithoutGpu = extra.allowGpuWithoutGpu === true + if (typeof data.view === "string") payload.view = data.view + this.saving = true + this.data = null + const result = await this.requestJson(actionPaths[action], payload) + if (!this.active) return null + this.saving = false + const error = this.lastError + await this.load() + if (error && this.active) this.publish({ loading: false, data: this.data, error }) + return result + } +} + +export const ModelsPage = { + name: "ModelsPage", + components: { GalaxySelect }, + props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }, + data() { + return { loading: true, error: "", message: "", trackingProgress: false, busy: "", selectionUncertain: true, disposed: false, + sortMode: "release_date", userFilter: "all", communityFilter: "all", hardwareFilter: readHardwareFilter(), + models: [], currentModel: "", activeSmallModel: "", activeBigModel: "", capabilities: {}, + summary: { installed: 0, missing: 0, total: 0 }, status: { isOnroad: true }, + runtime: { status: "idle", data: null, error: "" }, dialog: null } + }, + computed: { + currentLabel() { return this.models.find(m => m.value === this.currentModel)?.label || this.currentModel || "none" }, + installedSmallModels() { return this.models.filter(m => (m.value === this.activeSmallModel || m.installed && m.selectable) && fitsModelProfile(m, "small")) }, + installedBigModels() { return this.models.filter(m => (m.value === this.activeBigModel || m.installed && m.selectable) && fitsModelProfile(m, "big")) }, + sorted() { + return this.models.filter(m => matchesHardware(m, this.hardwareFilter)) + .filter(m => this.userFilter === "all" || !!m.userFavorite === (this.userFilter === "yes")) + .filter(m => this.communityFilter === "all" || !!m.communityFavorite === (this.communityFilter === "yes")) + .sort((a, b) => (this.sortMode === "release_date" ? (Date.parse(b.released) || 0) - (Date.parse(a.released) || 0) : 0) || + (a.label || a.value).localeCompare(b.label || b.value, undefined, { sensitivity: "base", numeric: true })) + }, + downloadTargetLabel() { return this.status.downloadAll ? "all missing models" : this.models.find(m => m.value === this.status.modelToDownload)?.label || "a model" }, + }, + created() { + this.feed = new ModelStatusFeed({ publish: update => { this.runtime = update }, unauthorized: this.unauthorized }) + this.manager = new ModelManagerFeed({ publish: update => { + this.loading = update.loading + this.error = update.error + this.selectionUncertain = !update.data + this.capabilities = update.data?.capabilities || {} + if (update.data) { + const p = update.data + this.models = p.models + this.currentModel = p.currentModel || "" + this.activeSmallModel = p.activeSmallModel || "" + this.activeBigModel = p.activeBigModel || "" + this.summary = p.summary || { installed: 0, missing: 0, total: 0 } + this.status = p + if (this.trackingProgress && p.progress) { + this.message = p.progress + if (!p.downloading) this.trackingProgress = false + } + } + }, unauthorized: this.unauthorized }) + }, + mounted() { + this.visibility = () => { + if (document.hidden) { this.finishDialog(false); this.feed.stop(); this.manager.stop() } + else if (this.mode === "local") { this.feed.start(); this.manager.start() } + } + document.addEventListener("visibilitychange", this.visibility) + if (this.mode === "local" && !document.hidden) { this.feed.start(); this.manager.start() } + }, + beforeUnmount() { + this.disposed = true + this.finishDialog(false) + document.removeEventListener("visibilitychange", this.visibility) + this.feed.stop() + this.manager.stop() + }, + methods: { + hardwareLabel, fileSizeText, + setHardwareFilter(value) { this.hardwareFilter = saveHardwareFilter(value) }, + canAction(action, model = null) { return !this.busy && !this.selectionUncertain && modelActionAllowed(this.status, action, model) }, + rowState(model) { + if ([this.currentModel, this.activeSmallModel, this.activeBigModel].includes(model.value)) return "active" + if (this.status.downloading) return this.status.downloadAll || this.status.modelToDownload === model.value ? "cancellable" : "busy" + return model.installed ? "installed" : "available" + }, + confirmAction(dialog) { + return new Promise(resolve => { + this.dialog = dialog + this.resolveDialog = resolve + this.$nextTick(() => this.$refs.confirmButton?.focus()) + }) + }, + finishDialog(value) { + this.resolveDialog?.(value) + this.resolveDialog = null + this.dialog = null + }, + async runAction(action, model = null) { + if (this.disposed || !this.canAction(action, model)) return + this.busy = `${action}:${model?.value || ""}` + this.message = "" + this.trackingProgress = false + try { + let allowGpuWithoutGpu = false + if (action === "enable-randomizer" && !await this.confirmAction({ title: "Model Randomizer", message: "Choose a different installed, verified model each start. Excluded models are skipped. Favorites do not change the random pool.", confirmLabel: "Enable" })) return + if (action === "delete" && !await this.confirmAction({ title: "Delete Model", message: `Delete local files for "${model.label}"?`, confirmLabel: "Delete", danger: true })) return + const needsGpu = action === "download" ? model.requiresGpu && !model.gpuAvailable : action === "downloadAll" && + this.models.some(m => !m.installed && m.requiresGpu && !m.gpuAvailable && m.downloadAvailable === true) + if (needsGpu) { + allowGpuWithoutGpu = await this.confirmAction({ title: "No External GPU Detected", confirmLabel: "Download Anyway", + message: "These model files require an external GPU. You can download them now, but they cannot run until a compatible GPU is connected and detected. GPU model files can be large. Downloading does not activate a model." }) + if (!allowGpuWithoutGpu) return + } + if (this.disposed) return + const result = await this.manager.action(action, model, { allowGpuWithoutGpu }) + if (!this.disposed && result) { + this.trackingProgress = ["download", "downloadAll", "refresh"].includes(action) + this.message = (this.trackingProgress && this.status.progress) || result.message || + (action.startsWith("select-") ? "Selection saved for the next start." : "Saved.") + } + } finally { if (!this.disposed) this.busy = "" } + }, + }, + + template: ` +
+
Model Manager is unavailable in the offline preview.
+
+
Loading models...
+
+ + +
+ +
+
+ `, +} diff --git a/openpilot/starpilot/galaxy/web/js/monitor-feed.js b/openpilot/starpilot/galaxy/web/js/monitor-feed.js new file mode 100644 index 0000000000..8a91cc75f7 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/monitor-feed.js @@ -0,0 +1,88 @@ +import { normalizeSnapshot } from "./system-monitor-data.js" + +// One request at a time. The deadline starts before the request so slow replies +// never obtain a fresh lease merely by arriving late. +export class MonitorFeed { + constructor({ mode, publish, fetcher = (...args) => fetch(...args), clock = () => performance.now(), + schedule = (fn, delay) => setTimeout(fn, delay), cancel = (timer) => clearTimeout(timer), unauthorized = () => {} }) { + if (!["sample", "local"].includes(mode)) throw new Error("Unsupported monitor source") + Object.assign(this, { mode, publish, fetcher, clock, schedule, cancel, unauthorized }) + this.generation = 0 + this.stopped = true + this.timer = this.deadline = this.expiry = this.request = null + } + + start() { this.stop(); this.stopped = false; this.refresh() } + stop() { + this.stopped = true + this.generation++ + this.cancel(this.timer) + this.cancel(this.deadline) + this.cancel(this.expiry) + this.request?.abort() + this.request = null + } + + async refresh() { + if (this.stopped || this.request) return + const generation = ++this.generation + const started = this.clock() + const request = new AbortController() + this.request = request + this.deadline = this.schedule(() => { + if (this.stopped || this.generation !== generation) return + this.publish({ snapshot: null, status: "unavailable", error: "System activity could not be refreshed." }) + this.cancel(this.expiry) + // Retire it even if a stalled response body ignores cancellation. + this.generation++ + this.request = null + request.abort() + if (this.mode === "local") this.timer = this.schedule(() => this.refresh(), 2000) + }, 4000) + try { + const url = this.mode === "local" ? "./api/system/monitor" : "./data/system-monitor.sample.json" + const response = await this.fetcher(url, { signal: request.signal, cache: "no-store" }) + if (this.stopped || this.generation !== generation || request.signal.aborted) return + if (this.mode === "local" && response.status === 401) { + this.stop() + this.publish({ snapshot: null, status: "unavailable", error: "" }) + this.unauthorized() + return + } + if (this.mode === "local" && response.status === 503) { + const body = await response.json().catch(() => null) + if (this.stopped || this.generation !== generation || request.signal.aborted) return + if (["access_unavailable", "setup_required"].includes(body?.code)) { + this.stop() + this.publish({ snapshot: null, status: "unavailable", error: "" }) + this.unauthorized() + return + } + } + if (!response.ok) throw new Error("Monitor unavailable") + const snapshot = normalizeSnapshot(await response.json(), this.mode === "local" ? "local-runtime" : "synthetic-preview") + if (this.stopped || this.generation !== generation || request.signal.aborted) return + const elapsed = this.clock() - started + if (elapsed < 0 || elapsed >= 4000) throw new Error("Expired monitor response") + this.publish({ snapshot, status: this.mode === "local" ? "current" : "sample", error: "" }) + this.cancel(this.deadline) + this.cancel(this.expiry) + if (this.mode === "local") { + this.expiry = this.schedule(() => { + if (!this.stopped) this.publish({ snapshot: null, status: "unavailable", error: "System activity is out of date." }) + }, 4000 - elapsed) + } + } catch { + if (!this.stopped && this.generation === generation) { + this.cancel(this.expiry) + this.publish({ snapshot: null, status: "unavailable", error: "System activity is unavailable." }) + } + } finally { + if (!this.stopped && this.generation === generation) { + this.cancel(this.deadline) + this.request = null + if (this.mode === "local") this.timer = this.schedule(() => this.refresh(), 2000) + } + } + } +} diff --git a/openpilot/starpilot/galaxy/web/js/navigation-map.js b/openpilot/starpilot/galaxy/web/js/navigation-map.js new file mode 100644 index 0000000000..b6eda6c23b --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/navigation-map.js @@ -0,0 +1,262 @@ +import { markRaw } from "../vendor/vue/vue.esm-browser.js" + +const SIZE = 512 +const locationDeadlines = new WeakMap() +export function project(point, zoom) { + const latitude = Math.max(-85.05112878, Math.min(85.05112878, point.latitude)) + const sine = Math.sin(latitude * Math.PI / 180), world = SIZE * 2 ** zoom + return [(point.longitude + 180) / 360 * world, (0.5 - Math.log((1 + sine) / (1 - sine)) / (4 * Math.PI)) * world] +} +export function unproject(x, y, zoom) { + const world = SIZE * 2 ** zoom + return { + longitude: ((x / world * 360) % 360 + 360) % 360 - 180, latitude: Math.atan(Math.sinh(Math.PI * (1 - 2 * y / world))) * 180 / Math.PI + } +} +export function relativeX(x, center, world) { + return center + ((x - center + world / 2) % world + world) % world - world / 2 +} +export class RasterMap { + constructor(canvas, changed = () => {}) { + this.canvas = canvas; + this.changed = changed; + this.zoom = 2; + this.center = { + longitude: 0, latitude: 0 + }; + this.failed = new Set(); + this.tiles = new Map(); + this.pending = new Map(); + this.closed = false; + this.data = {}; + this.initialized = false + this.observer = new ResizeObserver(() => this.draw()); + this.observer.observe(canvas) + this.down = (event) => { + this.userPanned = true + this.drag = [event.clientX, event.clientY, ...project(this.center, this.zoom)]; + canvas.setPointerCapture(event.pointerId) + } + this.move = (event) => { + if (!this.drag) return; + this.center = unproject(this.drag[2] - event.clientX + this.drag[0], this.drag[3] - event.clientY + this.drag[1], this.zoom); + this.draw() + } + this.up = () => { + this.drag = null + } + this.key = (event) => { + const delta = { + ArrowLeft: [-80, 0], ArrowRight: [80, 0], ArrowUp: [0, -80], ArrowDown: [0, 80] + } + [event.key]; + if (delta) { + event.preventDefault(); + this.userPanned = true + const [x,y] = project(this.center,this.zoom); + this.center = unproject(x+delta[0],y+delta[1],this.zoom); + this.draw() + } + } + this.wheel = (event) => { + event.preventDefault(); + this.changeZoom(event.deltaY < 0 ? 1 : -1) + } + for (const [name, listener] of [['pointerdown', this.down], ['pointermove', this.move], ['pointerup', this.up], ['pointercancel', this.up], ['wheel', this.wheel], ['keydown', this.key]]) canvas.addEventListener(name, listener, { + passive: false + }) + } + update(data, stale) { + clearTimeout(this.locationTimer) + const now = performance.now() + if (data && !locationDeadlines.has(data)) { + locationDeadlines.set(data, data.location?.expiresAt ?? now + (data.location?.validForMs ?? 0)) + } + const remaining = Math.max(0, (data && locationDeadlines.get(data) || 0) - now) + this.locationFresh = !stale && remaining > 0 + if (this.locationFresh) { + this.locationTimer = setTimeout(() => { + this.locationFresh = false; + this.changed(); + this.draw() + }, remaining) + } + const firstLiveFix = this.locationFresh && this.lastLocation?.lastKnown === true + if (data?.location && (this.locationFresh || data.location.lastKnown === true && !this.lastLocation)) { + this.lastLocation = { ...data.location, bearing: Number.isFinite(data.location.bearing) ? data.location.bearing : this.lastLocation?.bearing } + } + this.data = data || {} + this.stale = stale + if (firstLiveFix && !this.userPanned) this.recenter() + if (!this.initialized && (data?.location || data?.destination)) { + this.recenter() + this.initialized = true + } + this.changed() + this.draw() + } + recenter() { + const point = this.lastLocation || this.data.destination; + if (point) { + this.center = point; + this.zoom = this.lastLocation ? 15 : 2 + } + this.draw() + } + changeZoom(delta) { + this.zoom = Math.max(0, Math.min(18, this.zoom + delta)); + this.draw() + } + draw() { + if (this.closed) return + const width = Math.max(1, Math.round(this.canvas.clientWidth)), height = Math.max(1, Math.round(this.canvas.clientHeight)); + this.canvas.width = width; + this.canvas.height = height + const ctx = this.canvas.getContext('2d'), [cx, cy] = project(this.center, this.zoom), n = 2 ** this.zoom, world = SIZE * n, needed = new Set() + ctx.fillStyle = '#0e0e1a'; + ctx.fillRect(0, 0, width, height) + for (let ty = Math.floor((cy - height / 2) / SIZE); ty <= Math.floor((cy + height / 2) / SIZE); ty++) { + if (ty < 0 || ty >= n) continue + for (let tx = Math.floor((cx - width / 2) / SIZE); tx <= Math.floor((cx + width / 2) / SIZE); tx++) { + const x = ((tx % n) + n) % n, key = `${this.zoom}/${x}/${ty}`; + needed.add(key) + const image = this.tiles.get(key) + if (image) ctx.drawImage(image, tx * SIZE - cx + width / 2, ty * SIZE - cy + height / 2, SIZE, SIZE) + } + } + for (const [key, controller] of this.pending) if (!needed.has(key)) { + controller.abort(); + this.pending.delete(key) + } + for (const key of needed) if (!this.tiles.has(key) && !this.pending.has(key) && !this.failed.has(key) && this.pending.size < 4) this.load(key) + const pixel = (point) => { + const [x, y] = project(point, this.zoom); + return [relativeX(x, cx, world) - cx + width / 2, y - cy + height / 2] + } + for (const choice of this.data.alternatives || []) { + if (choice.index === this.data.selectedRoute || !choice.geometry?.length) continue + ctx.beginPath(); choice.geometry.forEach((point,index) => { const xy=pixel(point); index ? ctx.lineTo(...xy) : ctx.moveTo(...xy) }); + ctx.strokeStyle='#7885a6'; ctx.lineWidth=4; ctx.stroke() + } + if (this.data.route?.length) { + ctx.beginPath(); + this.data.route.forEach((point, index) => { + const xy = pixel(point); + index ? ctx.lineTo(...xy) : ctx.moveTo(...xy) + }); + ctx.strokeStyle = '#b68cff'; + ctx.lineWidth = 5; + ctx.stroke() + } + for (const [point, color] of [[this.data.destination, '#ffbd59'], [this.lastLocation, this.locationFresh ? '#65c8ff' : '#9aa6bb']]) if (point) { + const xy = pixel(point); + ctx.beginPath(); + ctx.arc(...xy, 7, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 2; + ctx.stroke() + if (point === this.lastLocation && Number.isFinite(point.bearing)) { + ctx.save(); ctx.translate(...xy); ctx.rotate(point.bearing * Math.PI / 180); + ctx.beginPath(); ctx.moveTo(0,-22); ctx.lineTo(-7,-11); ctx.lineTo(7,-11); ctx.closePath(); + ctx.fillStyle = color; ctx.fill(); ctx.restore() + } + } + } + async load(key) { + const controller = new AbortController(); + this.pending.set(key, controller); + let timedOut = false + const timer = setTimeout(() => { + timedOut = true; + controller.abort() + }, 8000) + try { + const response = await fetch(`./api/navigation/map/tiles/${key}.png`, { + credentials: 'same-origin', cache: 'no-store', signal: controller.signal + }); + if (!response.ok) throw new Error('Map tiles unavailable') + const image = await createImageBitmap(await response.blob()); + if (this.closed || controller.signal.aborted) { + image.close(); + return + } + this.tiles.set(key, image); + while (this.tiles.size > 32) { + const oldest = this.tiles.keys().next().value; + this.tiles.get(oldest).close(); + this.tiles.delete(oldest) + } + this.changed('') + } catch (error) { + if (timedOut || !controller.signal.aborted) { + this.failed.add(key); + if (this.failed.size > 64) this.failed.delete(this.failed.values().next().value); + this.changed('Map tiles unavailable. Check the saved key and connection, then retry.') + } + } finally { + clearTimeout(timer); + if (this.pending.get(key) === controller) this.pending.delete(key); + if (!this.closed) { + /* Paint completed tiles without continuously retrying failures. */ this.paintScheduled ??= setTimeout(() => { + this.paintScheduled = null; + this.draw() + }, 200) + } + } + } + retry() { + this.failed.clear(); + this.draw() + } + close() { + this.closed = true; + clearTimeout(this.locationTimer); + this.observer.disconnect(); + clearTimeout(this.paintScheduled); + for (const controller of this.pending.values()) controller.abort(); + for (const image of this.tiles.values()) image.close(); + this.tiles.clear(); + for (const [name, listener] of [['pointerdown', this.down], ['pointermove', this.move], ['pointerup', this.up], ['pointercancel', this.up], ['wheel', this.wheel], ['keydown', this.key]]) this.canvas.removeEventListener(name, listener) + } +} +export const NavigationMap = { + props: ['data', 'stale'], data: () => ({ + error: '', map: null, locationFresh: false, lastLocation: null + }), + mounted() { + this.map = markRaw(new RasterMap(this.$refs.canvas, (error) => { + if (error !== undefined) this.error = error + this.locationFresh = this.map?.locationFresh || false + this.lastLocation = this.map?.lastLocation || null + })); + this.map.update(this.data, this.stale) + }, + beforeUnmount() { + this.map.close() + }, + watch: { + data(value) { + this.map?.update(value, this.stale) + }, stale(value) { + this.map?.update(this.data, value) + } + }, + template: `
+ +
Last known position · waiting for GPS
+
+ + + +
+

{{error}} +

+ + +
` +} diff --git a/openpilot/starpilot/galaxy/web/js/navigation-style.js b/openpilot/starpilot/galaxy/web/js/navigation-style.js new file mode 100644 index 0000000000..a935b3f389 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/navigation-style.js @@ -0,0 +1,47 @@ +if (typeof document !== "undefined") { + const style = document.createElement("style") + style.textContent = ` +.gx-navigation--fullscreen {position:fixed;inset:0;z-index:15;padding:0!important;background:#0e0e1a;overflow:hidden} +.gx-app:has(.gx-navigation--fullscreen) .gx-appbar {background:none;box-shadow:none;pointer-events:none;transform:none} +.gx-app:has(.gx-navigation--fullscreen) .gx-appbar__pill,.gx-app:has(.gx-navigation--fullscreen) .gx-theme-toggle {display:none} +.gx-app:has(.gx-navigation--fullscreen) .gx-appbar__back,.gx-app:has(.gx-navigation--fullscreen) .gx-appbar__menu {position:fixed;top:auto;bottom:16px;pointer-events:auto;background:#161630;border:1px solid #343453;border-radius:12px} +.gx-app:has(.gx-navigation--fullscreen) .gx-appbar__back {left:auto;right:72px} +.gx-app:has(.gx-navigation--fullscreen) .gx-appbar__menu {right:16px} +@media(min-width:768px) {.gx-nav-pinned .gx-navigation--fullscreen {left:320px}} +.gx-navigation--fullscreen>h2 {display:none} +.gx-navigation--fullscreen>.gx-navigation__tabs {position:absolute;bottom:16px;left:16px;z-index:4;margin:0;gap:8px} +.gx-navigation--fullscreen .gx-navigation-map {position:absolute;inset:0;margin:0;border-radius:0;background:#0e0e1a} +.gx-navigation--fullscreen .gx-navigation-map canvas {height:100%;background:#0e0e1a} +.gx-navigation__panel {position:absolute;top:16px;left:16px;width:30%;max-height:calc(100% - 88px);overflow:auto;z-index:3} +.gx-navigation__panel .gx-card {background:#0e0e1a;border:0;border-radius:16px;box-shadow:0 4px 12px #0005} +.gx-navigation__panel .gx-navigation__section {max-width:500px;margin:8px 0;padding:8px} +.gx-navigation__panel .gx-btn,.gx-navigation__tabs .gx-btn {border:1px solid #343453;border-radius:16px;background:#161630;box-shadow:0 2px 6px #0004;font-size:.85rem;min-height:44px;padding:8px 16px} +.gx-navigation__search {display:flex;flex-wrap:nowrap;gap:8px;margin:0 0 8px} +.gx-navigation__search .gx-field {min-width:0;padding:16px;border-radius:16px;border:1px solid #343453;background:#161630;font-size:.85rem;height:49px} +.gx-navigation__search .gx-btn {padding:8px;flex-shrink:0;min-width:112px;height:49px} +.gx-navigation__summary-title {margin:0 0 8px;padding:0;border-radius:12px;background:#161630;text-align:center;font-size:1.2rem;line-height:1.2} +.gx-navigation__summary>div {display:grid;grid-template-columns:32px 96px 1fr;align-items:center;font-size:1rem;font-weight:550;min-height:32px} +.gx-navigation__summary>div>span:first-child {text-align:center} +.gx-navigation__route-actions {display:flex;justify-content:center;gap:8px;margin-top:8px} +.gx-navigation__route-actions .gx-btn {background:#e05577;border:0;border-radius:16px;flex:1;color:#e8e8f0;font-weight:550;letter-spacing:1px} +.gx-navigation__panel .gx-navigation__instruction {font-size:1rem;margin:8px 0} +.gx-navigation__panel .gx-note {font-size:.85rem} +.gx-navigation__places {gap:8px;margin:8px 0} +.gx-navigation__places>li {padding:16px;border:1px solid #343453!important;background:#161630!important;border-radius:12px!important;gap:8px;font-size:.85rem} +.gx-navigation__places .gx-note {display:block;font-size:.75rem} +.gx-navigation__actions {gap:8px} +.gx-navigation__alternatives {display:flex;flex-wrap:wrap;gap:8px;margin:8px 0} +.gx-navigation-map__last {position:absolute;bottom:48px;right:16px;background:#0e0e1ae8;border-radius:12px;padding:8px 12px;font-size:12px;color:#ddd} +.gx-navigation--fullscreen .gx-navigation-map__controls {top:16px;right:16px;flex-direction:column} +.gx-navigation-map__controls .gx-btn {background:#161630;border:1px solid #343453;border-radius:12px;box-shadow:0 2px 6px #0004} +.gx-navigation-map > p {position:absolute;bottom:72px;right:16px;max-width:320px;background:#0e0e1a;border-radius:12px;padding:12px;font-size:12px} +.gx-navigation__tabs button {white-space:nowrap} +@media(max-width:768px) and (orientation:portrait) { +.gx-navigation__panel {left:8px;top:16px;width:calc(100% - 32px);max-height:55%} +.gx-navigation--fullscreen .gx-navigation-map__controls {top:auto;bottom:92px} +.gx-navigation__tabs .gx-btn {font-size:12px;padding:8px} +.gx-navigation__places>li {flex-direction:row;align-items:center} +} +` + document.head.appendChild(style) +} diff --git a/openpilot/starpilot/galaxy/web/js/navigation.js b/openpilot/starpilot/galaxy/web/js/navigation.js new file mode 100644 index 0000000000..2aa1c4d45f --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/navigation.js @@ -0,0 +1,292 @@ +import "./navigation-style.js" +import { NavigationMap } from "./navigation-map.js" +import { MapOperationsPanel } from "./map-operations.js" + +export function searchUuid() { + if (typeof crypto.randomUUID === "function") return crypto.randomUUID() + const bytes = crypto.getRandomValues(new Uint8Array(16)) + bytes[6] = (bytes[6] & 15) | 64 + bytes[8] = (bytes[8] & 63) | 128 + const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join("") + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} + +export function remainingLocationLease(lease, requestStarted, now = performance.now()) { + return Math.max(0, Math.min(2500, lease) - Math.max(0, now - requestStarted)) +} + +const STATUS = new Set(["disabled", "needsKey", "noDestination", "waitingForLocation", "routing", "guiding", "arrived", "routeUnavailable", "stale"]) +const coordinate = (value) => !!value && Number.isFinite(value.latitude) && Math.abs(value.latitude) <= 90 && + Number.isFinite(value.longitude) && Math.abs(value.longitude) <= 180 +export const validDestination = (value) => coordinate(value) && typeof value.id === "string" && value.id.length > 0 && value.id.length <= 256 && + typeof value.name === "string" && value.name.trim().length > 0 && value.name.length <= 256 +export const validSearchResult = (value) => validDestination(value) || !!value && value.temporary === true && + typeof value.id === "string" && value.id.length > 0 && value.id.length <= 256 && + typeof value.name === "string" && value.name.trim().length > 0 && value.name.length <= 256 && + (value.description === undefined || typeof value.description === "string" && value.description.length <= 512) && + typeof value.searchId === "string" && /^[0-9a-f-]{36}$/.test(value.searchId) +export function validNavigation(value) { + const instruction = value?.instruction + if (value?.alternatives !== undefined && (!Array.isArray(value.alternatives) || value.alternatives.length > 3 || + !value.alternatives.every((row,index) => row.index === index && Number.isFinite(row.durationSeconds) && row.durationSeconds >= 0 && + Number.isFinite(row.distanceMeters) && row.distanceMeters >= 0 && Array.isArray(row.geometry) && row.geometry.length <= 512 && row.geometry.every(coordinate)) || + !Number.isInteger(value.selectedRoute) || value.selectedRoute < 0 || value.selectedRoute > 2)) return false + return !!value && typeof value.enabled === "boolean" && typeof value.isMetric === "boolean" && typeof value.hasKey === "boolean" && STATUS.has(value.status) && + typeof value.revision === "string" && value.revision.length > 0 && value.revision.length <= 128 && + (value.destination === null || validDestination(value.destination)) && Array.isArray(value.favorites) && value.favorites.length <= 100 && + (value.location == null || coordinate(value.location) && Number.isFinite(value.location.validForMs) && value.location.validForMs >= 0 && value.location.validForMs <= 2500) && + value.favorites.every(validDestination) && Array.isArray(value.route) && value.route.length <= 512 && value.route.every(coordinate) && + (instruction === null || !!instruction && ["text", "maneuverType", "maneuverModifier"].every((key) => + typeof instruction[key] === "string" && instruction[key].length <= 1024) && + ["distanceMeters", "remainingDistanceMeters", "remainingDurationSeconds"].every((key) => + Number.isFinite(instruction[key]) && instruction[key] >= 0)) +} + +export function routePath(points) { + if (!Array.isArray(points) || points.length < 2 || points.length > 512 || !points.every(coordinate)) return "" + const latitude = points.reduce((sum, point) => sum + point.latitude, 0) / points.length + const scale = Math.max(0.05, Math.cos(latitude * Math.PI / 180)) + const firstLongitude = points[0].longitude + const projected = points.map((point) => [(((point.longitude - firstLongitude + 540) % 360) - 180) * scale, -point.latitude]) + const xs = projected.map((point) => point[0]), ys = projected.map((point) => point[1]) + const left = Math.min(...xs), top = Math.min(...ys) + const width = Math.max(...xs) - left, height = Math.max(...ys) - top + const zoom = Math.min(360 / Math.max(width, 1e-8), 180 / Math.max(height, 1e-8)) + return projected.map(([x, y], index) => `${index ? "L" : "M"}${(200 + (x - left - width / 2) * zoom).toFixed(2)},${(110 + (y - top - height / 2) * zoom).toFixed(2)}`).join(" ") +} + +export class NavigationClient { + constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args), + later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (timer) => clearTimeout(timer) }) { + Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer }) + this.clientId = searchUuid() + this.active = false + this.generation = 0 + this.controller = this.timer = null + this.data = null + this.results = [] + this.searched = false + this.busy = false + this.error = "" + this.stale = false + } + + emit() { this.publish({ data: this.data, results: this.results, searched: this.searched, busy: this.busy, error: this.error, stale: this.stale }) } + stop() { + if (this.searchId && this.data) { + this.fetcher("./api/navigation/action", { method: "POST", credentials: "same-origin", cache: "no-store", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "cancelSearch", + revision: this.data.revision, searchId: this.searchId }) }).catch(() => {}) + } + this.searchId = null + this.active = false + this.generation++ + this.controller?.abort() + if (this.timer !== null) this.cancelTimer(this.timer) + this.controller = this.timer = null + this.data = null + this.results = [] + this.searched = false + this.error = "" + this.busy = this.stale = false + this.emit() + } + start() { this.stop(); this.active = true; return this.load() } + load() { return this.run("status") } + search(query) { + this.searchId = searchUuid() + return this.run("search", { query: query.trim(), searchId: this.searchId, clientId: this.clientId }) + } + choose(place) { + return place.temporary ? this.action("selectPlace", { id: place.id, searchId: place.searchId }) : + this.action("select", { destination: place }) + } + action(action, value = {}) { + if (!this.data || this.busy || this.stale) return Promise.resolve(null) + return this.run("action", { action, revision: this.data.revision, ...value }) + } + + async run(operation, body = null) { + if (!this.active || this.busy) return null + this.controller?.abort() + if (this.timer !== null) this.cancelTimer(this.timer) + this.timer = null + const requestStarted = performance.now() + const generation = ++this.generation + const controller = new AbortController() + this.controller = controller + this.busy = operation !== "status" + this.error = "" + this.emit() + const deadline = this.later(() => controller.abort(), operation === "search" ? 24000 : operation === "action" && body?.action === "selectPlace" ? 12000 : 4000) + try { + const response = await this.fetcher(`./api/navigation/${operation}`, { + credentials: "same-origin", cache: "no-store", signal: controller.signal, + ...(body === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }), + }) + if (!this.active || generation !== this.generation) return null + const payload = await response.json() + if (!this.active || generation !== this.generation) return null + if (controller.signal.aborted) throw new Error("Navigation did not respond.") + if (response.status === 401 || response.status === 503 && ["access_unavailable", "setup_required"].includes(payload?.code)) { + this.stop(); this.unauthorized(); return null + } + if (!response.ok) throw new Error(payload?.error || "Navigation could not complete this request.") + if (operation === "search") { + if (!Array.isArray(payload.results) || payload.results.length > 20 || !payload.results.every(validSearchResult)) + throw new Error("Search results could not be read.") + this.results = payload.results + this.searched = true + } else { + if (!validNavigation(payload)) throw new Error("Navigation status could not be read.") + if (payload.location) { + payload.location.validForMs = remainingLocationLease(payload.location.validForMs, requestStarted) + payload.location.expiresAt = performance.now() + payload.location.validForMs + } + if (JSON.stringify(payload) !== JSON.stringify(this.data)) this.data = payload + this.stale = false + if (operation === "action" && ["select", "selectPlace", "clear"].includes(body.action)) { this.results = []; this.searched = false } + } + return payload + } catch (error) { + if (this.active && generation === this.generation) { + this.error = controller.signal.aborted ? "Navigation did not respond. Reconnecting…" : error.message + if (operation !== "search") this.stale = true + } + return null + } finally { + this.cancelTimer(deadline) + if (this.active && generation === this.generation) { + this.controller = null + this.busy = false + this.emit() + this.timer = this.later(() => { this.timer = null; this.load() }, this.data?.enabled && this.data?.hasKey ? 1000 : this.data?.status === "guiding" ? 2000 : 5000) + } + } + } +} + +export const NavigationPage = { + name: "NavigationPage", + components: { MapOperationsPanel, NavigationMap }, + props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }, + data: () => ({ data: null, results: [], busy: false, error: "", stale: false, tab: "route", favoritesOpen: false, query: "", token: "", searched: false }), + computed: { + available() { return this.mode === "local" && !!this.data && !this.stale && !this.busy }, + path() { return routePath(this.data?.route) }, + summary() { + const instruction = this.data?.instruction, route = this.data?.alternatives?.[this.data?.selectedRoute || 0] + return instruction ? {distance:instruction.remainingDistanceMeters,duration:instruction.remainingDurationSeconds} : + route ? {distance:route.distanceMeters,duration:route.durationSeconds} : null + }, + statusLabel() { + return ({ disabled: "Navigation is off", needsKey: "Add your Mapbox key to get started", noDestination: "Where would you like to go?", + waitingForLocation: "Waiting for GPS", routing: "Finding your route…", guiding: "Route guidance", arrived: "You have arrived", + routeUnavailable: "A route could not be found", stale: "Waiting for navigation" })[this.data?.status] || "Connecting to navigation…" + }, + }, + created() { this.client = new NavigationClient({ publish: (state) => Object.assign(this.$data, state), unauthorized: this.unauthorized }) }, + mounted() { + if (this.mode !== "local") return + this.visibilityHandler = () => document.visibilityState === "hidden" ? this.client.stop() : this.client.start() + document.addEventListener("visibilitychange", this.visibilityHandler) + if (document.visibilityState !== "hidden") this.client.start() + }, + beforeUnmount() { document.removeEventListener("visibilitychange", this.visibilityHandler); this.client.stop(); this.token = "" }, + methods: { + async search() { await this.client.search(this.query) }, + async saveKey() { const token = this.token.trim(); this.token = ""; await this.client.action("configure", { patch: { token } }) }, + distance(value) { + const metric = this.data?.isMetric !== false + return metric ? value < 1000 ? `${Math.round(value)} m` : `${(value / 1000).toFixed(1)} km` : + value < 160.9344 ? `${Math.round(value / 0.3048 / 10) * 10} ft` : `${(value / 1609.344).toFixed(1)} mi` + }, + duration(value) { const minutes = Math.max(1, Math.round(value / 60)); return minutes >= 60 ? `${Math.floor(minutes / 60)} hr ${minutes % 60} min` : `${minutes} min` }, + isFavorite(place) { return this.data?.favorites.some((favorite) => favorite.id === place.id) }, + }, + template: ` +
+

Navigation

+
+ +
+ + +
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/onroad-layout.js b/openpilot/starpilot/galaxy/web/js/onroad-layout.js new file mode 100644 index 0000000000..9335ab000b --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/onroad-layout.js @@ -0,0 +1,862 @@ +import { reactive, watch } from "../vendor/vue/vue.esm-browser.js" +import { LayoutPreviewFeed, PREVIEW_SCENES } from "./layout-preview.js" +import { editorSnapshot, projectionPayload } from "./projection-layout.js" + +const PROFILES = ["large", "compact"] +const COLORS = ["cardFill", "cardBorder", "text"] +const ROAD_COLORS = ["path", "pathEdge", "laneLines"] +const PATH_MODES = ["default", "color", "rainbow", "acceleration"] +const HEX = /^#[0-9a-fA-F]{8}$/ +const clone = (value) => JSON.parse(JSON.stringify(value)) +const keysEqual = (value, keys) => value && typeof value === "object" && !Array.isArray(value) && + Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)) +const positive = (value) => Number.isFinite(value) && value > 0 && value <= 8192 +const object = (value) => value && typeof value === "object" && !Array.isArray(value) + +export function widgetPalette(document, metadata, profile, id) { + const colors = metadata.profiles[profile].widgets[id].colors + return { ...Object.fromEntries(Object.entries(colors).map(([key, value]) => [key, value ?? document.palette[key]])), + ...document.widgetColors[profile][id] } +} + +export const withAlpha = (color, factor) => color.slice(0, 7) + Math.floor(parseInt(color.slice(7), 16) * factor).toString(16).padStart(2, "0") + +export function placementLimits(profile, id, layout = null) { + const widget = profile.widgets[id], bounds = widget.bounds || profile.bounds + const width = widget.resizable ? layout?.[id]?.size ?? widget.width : widget.width + const height = widget.resizable ? layout?.[id]?.size ?? widget.height : widget.height + return { minX: bounds.x, maxX: bounds.x + bounds.width - width, + minY: bounds.y, maxY: bounds.y + bounds.height - height } +} + +export function clampPlacement(profile, id, x, y, layout = null) { + if (!Number.isFinite(x) || !Number.isFinite(y) || !Object.hasOwn(profile.widgets, id)) return null + const { minX, maxX, minY, maxY } = placementLimits(profile, id, layout) + const position = { x: Math.max(minX, Math.min(maxX, Math.round(x))), y: Math.max(minY, Math.min(maxY, Math.round(y))) } + return overlapsReserved(profile, id, position.x, position.y, layout) ? null : position +} + +export function overlapsReserved(profile, id, x, y, layout = null) { + const widget = profile.widgets[id] + if (id === "torque_bar" && widget.kind === "torque_bar" && widget.layer === "underlay") return false + const size = widget.resizable ? layout?.[id]?.size ?? widget.width : null + const width = size ?? widget.width, height = size ?? widget.height + const intersects = (left, top, otherWidth, otherHeight) => x < left + otherWidth && x + width > left && + y < top + otherHeight && y + height > top + if (profile.reservedZones.some((zone) => intersects(zone.x, zone.y, zone.width, zone.height))) return true + const actions = profile.widgets.speed_limit_actions + if (!actions || profile.protectedWidget !== "speed_limit_actions") return false + const positions = layout || Object.fromEntries(Object.entries(profile.widgets).map(([key, item]) => [key, item.default])) + if (id === "speed_limit_actions") return Object.entries(profile.widgets).some(([key, other]) => { + const at = positions[key] + return key !== id && key !== "speed_limit" && key !== "torque_bar" && at && Number.isFinite(at.x) && Number.isFinite(at.y) && + intersects(at.x, at.y, other.resizable ? at.size ?? other.width : other.width, + other.resizable ? at.size ?? other.height : other.height) + }) + if (id === "speed_limit") return false + const at = positions.speed_limit_actions + if (!at || !Number.isFinite(at.x) || !Number.isFinite(at.y)) return true + return intersects(at.x, at.y, actions.width, actions.height) +} + +export function previewPoint(event, rect, profile) { + if (!rect?.width || !rect?.height) return null + return { x: (event.clientX - rect.left) * profile.width / rect.width, + y: (event.clientY - rect.top) * profile.height / rect.height } +} + +export function validDocument(document, metadata) { + if (!object(document)) return false + const profiles = metadata?.projection === true ? ["large"] : PROFILES + if (!keysEqual(document, ["version", "palette", "layouts", "widgetColors", "roadColors", ...(Object.hasOwn(document, "speedSources") ? ["speedSources"] : [])]) || document.version !== 4 || (Object.hasOwn(document, "speedSources") && typeof document.speedSources !== "boolean") || + !keysEqual(document.palette, COLORS) || !COLORS.every((key) => HEX.test(document.palette[key])) || + !keysEqual(document.layouts, profiles) || !keysEqual(document.widgetColors, profiles) || !keysEqual(document.roadColors, profiles)) return false + return profiles.every((name) => { + const profile = metadata.profiles[name], layout = document.layouts[name] + const road = document.roadColors[name] + if (!object(road) || !Object.entries(road).every(([key, value]) => key === "pathMode" ? PATH_MODES.includes(value) : + ROAD_COLORS.includes(key) && typeof value === "string" && HEX.test(value))) return false + const colors = document.widgetColors[name] + if (!object(colors) || !Object.entries(colors).every(([id, overrides]) => { + const fields = profile.widgets[id]?.colors + return fields && Object.keys(fields).length > 0 && object(overrides) && Object.entries(overrides).every(([key, value]) => + Object.hasOwn(fields, key) && typeof value === "string" && HEX.test(value)) + })) return false + return keysEqual(layout, Object.keys(profile.widgets)) && Object.entries(layout).every(([id, position]) => { + const widget = profile.widgets[id], limits = placementLimits(profile, id, layout) + const resizable = widget.resizable + return keysEqual(position, resizable ? ["x", "y", "enabled", "size"] : ["x", "y", "enabled"]) && + (!resizable || (Number.isInteger(position.size) && position.size >= resizable.min && position.size <= resizable.max)) && + typeof position.enabled === "boolean" && + Number.isFinite(position.x) && Number.isFinite(position.y) && + position.x >= limits.minX && position.x <= limits.maxX && position.y >= limits.minY && position.y <= limits.maxY && + !overlapsReserved(profile, id, position.x, position.y, layout) + }) + }) +} + +export function validSnapshot(data) { + const projection = data?.projection === true && data?.metadata?.projection === true + if ((data?.projection === true) !== (data?.metadata?.projection === true)) return false + const profiles = projection ? ["large"] : PROFILES + if (!data || typeof data.revision !== "string" || !data.revision || typeof data.editable !== "boolean" || + typeof data.valid !== "boolean" || !keysEqual(data.metadata?.profiles, profiles) || + !Array.isArray(data.metadata.paletteFields) || data.metadata.paletteFields.length !== COLORS.length || + new Set(data.metadata.paletteFields.map((field) => field.id)).size !== COLORS.length || + !data.metadata.paletteFields.every((field) => COLORS.includes(field.id) && typeof field.label === "string" && HEX.test(field.default)) || + !Array.isArray(data.metadata.roadColorFields) || data.metadata.roadColorFields.length !== ROAD_COLORS.length || + new Set(data.metadata.roadColorFields.map((field) => field.id)).size !== ROAD_COLORS.length || + !data.metadata.roadColorFields.every((field) => ROAD_COLORS.includes(field.id) && typeof field.label === "string" && HEX.test(field.default)) || + (data.activeProfile != null && !profiles.includes(data.activeProfile))) return false + for (const profile of Object.values(data.metadata.profiles)) { + const bounds = profile.bounds + if (profile.protectedWidget != null && (profile.protectedWidget !== "speed_limit_actions" || + profile.widgets?.[profile.protectedWidget]?.kind !== "speed_limit_actions")) return false + if (!positive(profile.width) || !positive(profile.height) || typeof profile.label !== "string" || + !bounds || !Number.isFinite(bounds.x) || !Number.isFinite(bounds.y) || bounds.x < 0 || bounds.y < 0 || + !positive(bounds.width) || !positive(bounds.height) || bounds.x + bounds.width > profile.width || + bounds.y + bounds.height > profile.height || !profile.widgets || Array.isArray(profile.widgets) || + Object.keys(profile.widgets).length > 32 || !Array.isArray(profile.reservedZones) || profile.reservedZones.length > 32) return false + for (const zone of profile.reservedZones) { + if (typeof zone.label !== "string" || !Number.isFinite(zone.x) || !Number.isFinite(zone.y) || + !positive(zone.width) || !positive(zone.height) || zone.x < bounds.x || zone.y < bounds.y || + zone.x + zone.width > bounds.x + bounds.width || zone.y + zone.height > bounds.y + bounds.height) return false + } + for (const widget of Object.values(profile.widgets)) { + const area = widget.bounds || bounds + if (widget.visualInsetTop != null && (!Number.isFinite(widget.visualInsetTop) || widget.visualInsetTop < 0 || widget.visualInsetTop > area.height)) return false + if (!object(widget.colors) || !Object.entries(widget.colors).every(([key, value]) => + COLORS.includes(key) && (value === null || (typeof value === "string" && HEX.test(value))))) return false + if (!Number.isFinite(area.x) || !Number.isFinite(area.y) || area.x < 0 || area.y < 0 || + !positive(area.width) || !positive(area.height) || area.x + area.width > profile.width || + area.y + area.height > profile.height || typeof widget.label !== "string" || typeof widget.kind !== "string" || + !positive(widget.width) || !positive(widget.height) || widget.width > area.width || widget.height > area.height) return false + if (widget.resizable && (widget.kind !== "steering_wheel" || !Number.isInteger(widget.resizable.min) || + !Number.isInteger(widget.resizable.default) || !Number.isInteger(widget.resizable.max) || + widget.resizable.default !== widget.width || widget.width !== widget.height || + widget.resizable.min < 24 || widget.resizable.max > area.width || widget.resizable.max > area.height || + widget.resizable.min > widget.resizable.default || widget.resizable.default > widget.resizable.max)) return false + } + } + return validDocument(data.document, data.metadata) && validDocument(data.defaults, data.metadata) +} + +export class OnroadLayoutFeed { + constructor({ publish, projection = false, unauthorized = () => {}, fetcher = (...args) => fetch(...args), + later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) { + Object.assign(this, { publish, projection, unauthorized, fetcher, later, cancelTimer }) + this.active = false + this.generation = 0 + this.request = this.timer = this.data = null + this.retryTimer = null + this.readRetries = 0 + this.autoRead = false + this.needsReload = false + } + + stop() { + this.active = false + this.generation++ + this.request?.abort() + if (this.timer !== null) this.cancelTimer(this.timer) + if (this.retryTimer !== null) this.cancelTimer(this.retryTimer) + this.request = this.timer = null + this.retryTimer = null + } + + start() { this.stop(); this.active = true; this.data = null; this.needsReload = false; return this.load() } + load() { + if (this.retryTimer !== null) this.cancelTimer(this.retryTimer) + this.retryTimer = null + this.readRetries = 0 + this.autoRead = !this.data?.editable && !this.needsReload + return this.run() + } + save(document) { + if (!this.data?.editable || this.needsReload || !validDocument(document, this.data.metadata)) return + return this.run({ revision: this.data.revision, document: clone(document) }) + } + + async run(body = null) { + if (!this.active || this.request) return + const saving = body !== null, generation = ++this.generation, request = new AbortController() + if (saving) this.autoRead = false + this.request = request + this.publish({ status: saving ? "saving" : "loading", error: "", notice: "" }) + const fail = (message) => { + this.needsReload ||= saving + this.publish({ status: this.data ? "ready" : "unavailable", error: message, needsReload: this.needsReload }) + } + this.timer = this.later(() => { + if (!this.active || generation !== this.generation) return + request.abort() + this.generation++ + this.request = this.timer = null + fail(saving ? "Saving timed out. Your edits are kept here, but the saved result is unknown. Reload before saving again." : + "Reading colors and layouts timed out. Try reloading.") + }, 5000) + try { + const response = await this.fetcher(this.projection ? "./api/android-auto/layout" : "./api/ui/layout", { credentials: "same-origin", cache: "no-store", signal: request.signal, + ...(saving ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(this.projection ? projectionPayload(body, this.data.metadata) : body) } : {}) }) + if (!this.active || generation !== this.generation) return + let data = await response.json().catch(() => null) + if (!this.active || generation !== this.generation) return + if (response.status === 401 || ["access_unavailable", "setup_required"].includes(data?.code)) { + this.stop(); this.unauthorized(); return + } + if (response.status === 409) throw new Error("Saved values or parked status changed. Your edits are kept here. Reload before saving again.") + if (!response.ok) throw new Error(saving ? "Colors and layout could not be saved. Your edits are kept here. Reload before trying again." : + "Colors and layout are unavailable. Try reloading.") + if (this.projection) data = editorSnapshot(data) + if (!validSnapshot(data)) throw new Error("The device returned an unsupported colors and layout document. Reload to try again.") + this.data = data + this.needsReload = false + if (data.editable) this.autoRead = false + this.publish({ status: "ready", data, draft: clone(data.document), error: "", needsReload: false, + notice: saving ? (this.projection ? "Android Auto layout saved." : "Colors and both layouts saved.") : "" }) + if (!saving && !data.editable && this.autoRead && this.readRetries < 3) { + this.readRetries++ + const retryGeneration = this.generation + this.retryTimer = this.later(() => { + if (this.generation !== retryGeneration) return + this.retryTimer = null + if (this.active && !this.needsReload && !this.data?.editable) this.run() + }, 1000) + } + } catch (error) { + if (this.active && generation === this.generation) fail(error?.message || "Colors and layout are unavailable.") + } finally { + if (this.request === request) { + if (this.timer !== null) this.cancelTimer(this.timer) + this.request = this.timer = null + } + } + } +} + +export const LayoutWidgetPreview = { + props: ["widget", "palette", "profile", "scene"], + methods: { withAlpha }, + template: ` + `, +} + +export const OnroadLayoutPage = { + components: { LayoutWidgetPreview }, + props: { projection: { type: Boolean, default: false }, mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }, + emits: ["close", "target"], + setup(props) { + const state = reactive({ status: "idle", data: null, draft: null, needsReload: false, error: "", notice: "", + profile: "large", scene: "engaged", selected: null, drag: null, discard: null, colorError: "", placementError: "", + showFavoriteZones: false, devicePreviewOpen: false, preview: { status: "idle", error: "", url: null }, + history: { undo: [], redo: [], group: null } }) + let deferred = null + const apply = (update) => { + if (state.drag) { deferred = { ...deferred, ...update }; return } + if (deferred) { + update = { ...deferred, ...update } + deferred = null + if (state.draft && state.data && JSON.stringify(state.draft) !== JSON.stringify(state.data.document)) { + update = { ...update } + delete update.draft + if (update.data && update.data.revision !== state.data.revision) { + update.needsReload = true + update.error = "Saved values changed while editing. Your edits are kept here. Reload before saving again." + } + } + } + + if (update.data) { + if (!state.data && update.data.activeProfile) state.profile = update.data.activeProfile + if (!update.data.metadata.profiles[state.profile]) state.profile = Object.keys(update.data.metadata.profiles)[0] + if (!Object.hasOwn(update.data.metadata.profiles[state.profile].widgets, state.selected)) + state.selected = Object.keys(update.data.metadata.profiles[state.profile].widgets)[0] || null + state.colorError = "" + state.placementError = "" + } + const draftChanged = Object.hasOwn(update, "draft") && JSON.stringify(state.draft) !== JSON.stringify(update.draft) + for (const [key, value] of Object.entries(update)) { + if (JSON.stringify(state[key]) !== JSON.stringify(value)) state[key] = value + } + if (draftChanged || ["Colors and both layouts saved.", "Android Auto layout saved."].includes(update.notice)) state.history = { undo: [], redo: [], group: null } + } + const feed = new OnroadLayoutFeed({ projection: props.projection, unauthorized: props.unauthorized, publish: apply }) + watch(() => !!state.drag, (dragging) => { if (!dragging && deferred) apply({}) }, { flush: "sync" }) + const previewFeed = new LayoutPreviewFeed({ unauthorized: props.unauthorized, + publish: (update) => Object.assign(state.preview, update) }) + watch(() => [state.draft && JSON.stringify(state.draft), state.profile, state.scene, !!state.drag, state.data?.revision], + () => { + if (props.projection && state.draft && props.mode === 'local' && !state.devicePreviewOpen) { + state.devicePreviewOpen = true + previewFeed.start() + } + if (state.devicePreviewOpen && props.mode === "local") { + const document = props.projection ? projectionPayload({document: state.draft}, state.data.metadata).document : state.draft + previewFeed.update(document, props.projection ? 'projection' : state.profile, state.scene, !!state.drag) + } + }, { flush: "sync" }) + watch(() => props.mode, (mode) => { + if (mode !== "local") { state.devicePreviewOpen = false; previewFeed.stop() } + }) + return { state, feed, previewFeed, scenes: PREVIEW_SCENES } + }, + computed: { + busy() { return ["loading", "saving"].includes(this.state.status) }, + editable() { return !!this.state.data?.editable && !this.busy && !this.state.needsReload && !this.state.discard }, + dirty() { return !!this.state.draft && JSON.stringify(this.state.draft) !== JSON.stringify(this.state.data.document) }, + stockChanged() { return !!this.state.draft && JSON.stringify(this.state.draft) !== JSON.stringify(this.state.data.defaults) }, + canUndo() { return this.state.history.undo.length > 0 }, + canRedo() { return this.state.history.redo.length > 0 }, + profile() { return this.state.data?.metadata.profiles[this.state.profile] }, + layout() { return this.state.draft?.layouts[this.state.profile] }, + widgets() { return Object.entries(this.profile?.widgets || {}).map(([id, widget]) => ({ id, ...widget })) }, + activeWidgets() { return this.widgets.filter(({ id }) => this.layout[id].enabled) }, + renderWidgets() { return this.activeWidgets.map((item) => { + const below = item.visualInsetTop && this.layout[item.id].y < item.visualInsetTop + const widget = { ...item, visualInsetTop: below ? 0 : item.visualInsetTop || 0, + visualInsetBottom: below ? item.visualInsetTop : 0, visualHeaderY: below ? item.height + 8 : -(item.visualInsetTop || 0) } + return widget.resizable ? + { ...widget, width: this.layout[widget.id].size, height: this.layout[widget.id].size } : widget }) + .sort((a, b) => (a.id === this.state.selected ? 2 : a.layer === "underlay" ? -1 : 0) - + (b.id === this.state.selected ? 2 : b.layer === "underlay" ? -1 : 0)) }, + inactiveWidgets() { return this.widgets.filter(({ id }) => !this.layout[id].enabled || (this.state.drag?.fromTray && this.state.drag.id === id)) }, + availableProfiles() { return this.state.data?.supportedProfiles || (this.state.data?.activeProfile ? [this.state.data.activeProfile] : []) }, + selectedWidget() { return this.profile?.widgets[this.state.selected] }, + selectedPosition() { return this.layout?.[this.state.selected] }, + selectedColors() { return this.selectedWidget ? this.widgetColors(this.state.selected) : {} }, + colorFields() { return (this.state.data?.metadata.paletteFields || []).filter((field) => + Object.hasOwn(this.selectedWidget?.colors || {}, field.id)) }, + roadStyle() { return this.state.draft?.roadColors[this.state.profile] || {} }, + roadMode() { return this.roadStyle.pathMode || 'default' }, + roadColors() { return { ...Object.fromEntries((this.state.data?.metadata.roadColorFields || []).map(field => [field.id, field.default])), ...this.roadStyle } }, + roadGradient() { + if (this.roadMode === 'rainbow') return Array.from({ length: 12 }, (_, i) => ({ offset: i / 11, + color: 'hsl(' + i / 11 * 120 + ',100%,50%)', alpha: .5 - .4 * i / 11 })) + if (this.roadMode === 'color') return [1, .55, .10].map((factor, i) => ({ offset: 1 - i / 2, + color: this.roadColors.path.slice(0, 7), alpha: Math.floor(this.alpha(this.roadColors.path) * factor) / 255 })) + return [{ offset: 0, color: '#72ff5c', alpha: 0 }, { offset: .5, color: '#72ff5c', alpha: 89 / 255 }, + { offset: 1, color: '#0df87a', alpha: 102 / 255 }] + }, + limits() { return this.selectedWidget ? placementLimits(this.profile, this.state.selected, this.layout) : null }, + }, + mounted() { + if (this.mode === "local") this.feed.start() + this._beforeUnload = (event) => { if (this.dirty || this.state.status === "saving") { event.preventDefault(); event.returnValue = "" } } + window.addEventListener("beforeunload", this._beforeUnload) + }, + beforeUnmount() { this.cancelDrag(); this.feed.stop(); this.previewFeed.stop(); window.removeEventListener("beforeunload", this._beforeUnload) }, + methods: { + finishColorEdit() { this.state.history.group = null }, + recordChange(before, group = null) { + if (JSON.stringify(before) === JSON.stringify(this.state.draft)) return + const history = this.state.history + if (!group || history.group !== group) { + history.undo.push(before) + if (history.undo.length > 50) history.undo.shift() + } + history.redo = [] + history.group = group + }, + undo() { + if (!this.editable || this.state.drag || !this.canUndo) return + this.finishColorEdit() + const history = this.state.history + history.redo.push(clone(this.state.draft)) + this.state.draft = history.undo.pop() + this.state.colorError = this.state.placementError = this.state.notice = "" + }, + redo() { + if (!this.editable || this.state.drag || !this.canRedo) return + this.finishColorEdit() + const history = this.state.history + history.undo.push(clone(this.state.draft)) + this.state.draft = history.redo.pop() + this.state.colorError = this.state.placementError = this.state.notice = "" + }, + resetToStock() { + if (!this.editable || this.state.drag || !this.stockChanged) return + const before = clone(this.state.draft) + this.state.draft = clone(this.state.data.defaults) + this.recordChange(before) + this.state.colorError = this.state.placementError = "" + this.state.notice = "Stock StarPilot colors and both layouts are restored in your draft. Save to apply them." + }, + showDevicePreview() { + if (this.mode !== "local" || !this.state.data || !this.state.draft || this.state.drag || this.state.devicePreviewOpen) return + this.state.devicePreviewOpen = true + this.previewFeed.start() + this.previewFeed.update(this.projection ? projectionPayload({document: this.state.draft}, this.state.data.metadata).document : this.state.draft, this.projection ? 'projection' : this.state.profile, this.state.scene) + }, + hideDevicePreview() { + this.state.devicePreviewOpen = false + this.previewFeed.stop() + Object.assign(this.state.preview, { status: "idle", error: "", url: null }) + }, + selectProfile(profile) { + if (!this.availableProfiles.includes(profile) || this.state.drag) return + this.finishColorEdit() + this.state.profile = profile + this.state.selected = Object.keys(this.profile.widgets)[0] || null + this.state.placementError = "" + }, + changePosition(id, x, y) { + if (!this.editable) return + const position = clampPlacement(this.profile, id, x, y, this.layout) + if (position) { + const before = this.state.drag ? null : clone(this.state.draft) + Object.assign(this.layout[id], position) + if (before) this.recordChange(before) + this.state.placementError = "" + } else this.state.placementError = "Keep widgets within the screen and clear of Speed limit actions. The last valid position is kept." + this.state.notice = "" + }, + add(id) { + if (!this.editable || this.state.drag || !Object.hasOwn(this.profile.widgets, id)) return + const before = clone(this.state.draft) + this.layout[id].enabled = true + this.recordChange(before) + this.state.selected = id + this.state.notice = "" + }, + remove(id = this.state.selected) { + if (!this.editable || this.state.drag || !Object.hasOwn(this.profile.widgets, id)) return + const before = clone(this.state.draft) + this.layout[id].enabled = false + this.recordChange(before) + this.state.notice = "" + }, + positionInput(axis, event) { + const number = event.target.value.trim() === "" ? NaN : Number(event.target.value) + if (Number.isFinite(number)) this.changePosition(this.state.selected, + axis === "x" ? number : this.selectedPosition.x, axis === "y" ? number : this.selectedPosition.y) + event.target.value = this.selectedPosition[axis] + }, + resizeSelected(event) { + if (!this.editable || this.state.drag || !this.selectedWidget?.resizable) return + const size = Number(event.target.value), range = this.selectedWidget.resizable + if (!Number.isInteger(size) || size < range.min || size > range.max) { + event.target.value = this.selectedPosition.size + return + } + const before = clone(this.state.draft), position = this.selectedPosition + position.size = size + const next = clampPlacement(this.profile, this.state.selected, position.x, position.y, this.layout) + if (next) { + Object.assign(position, next) + this.recordChange(before) + this.state.placementError = "" + } else { + Object.assign(position, before.layouts[this.state.profile][this.state.selected]) + this.state.placementError = "That size would cover Speed limit actions. The last valid size is kept." + } + event.target.value = position.size + }, + onKey(id, event) { + if (!this.editable) return + const movement = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key] + if (movement) { + event.preventDefault() + const step = event.shiftKey ? 10 : 1, position = this.layout[id] + this.changePosition(id, position.x + movement[0] * step, position.y + movement[1] * step) + } else if (["Delete", "Backspace"].includes(event.key)) { event.preventDefault(); this.remove(id) } + else if (["Enter", " "].includes(event.key)) { event.preventDefault(); this.state.selected = id } + }, + startDrag(id, event, fromTray = false) { + if (!this.editable || event.button > 0 || this.state.drag || !Object.hasOwn(this.profile.widgets, id)) return + const point = previewPoint(event, this.$refs.preview.getBoundingClientRect(), this.profile) + if (!point) return + const position = this.layout[id], widget = this.profile.widgets[id] + const width = widget.resizable ? position.size : widget.width + const height = widget.resizable ? position.size : widget.height + this.state.selected = id + this.finishColorEdit() + this.state.drag = { id, pointerId: event.pointerId, fromTray, before: { ...position }, historyBefore: clone(this.state.draft), inside: !fromTray, + offsetX: fromTray ? width / 2 : point.x - position.x, + offsetY: fromTray ? height / 2 : point.y - position.y } + this._dragTarget = this.$refs.preview + this._dragTarget.setPointerCapture?.(event.pointerId) + event.preventDefault() + }, + moveDrag(event) { + const drag = this.state.drag + if (!drag || drag.pointerId !== event.pointerId) return + const point = previewPoint(event, this.$refs.preview.getBoundingClientRect(), this.profile) + if (!point) return + drag.inside = point.x >= 0 && point.y >= 0 && point.x <= this.profile.width && point.y <= this.profile.height + if (!drag.fromTray || drag.inside) { + const x = point.x - drag.offsetX, y = point.y - drag.offsetY + if (drag.fromTray) drag.inside = clampPlacement(this.profile, drag.id, x, y, this.layout) !== null + this.changePosition(drag.id, x, y) + } + if (drag.fromTray) this.layout[drag.id].enabled = drag.inside + }, + endDrag(event) { + const drag = this.state.drag + if (!drag || drag.pointerId !== event.pointerId) return + this.moveDrag(event) + if (drag.fromTray && !drag.inside) Object.assign(this.layout[drag.id], drag.before) + const before = drag.historyBefore + this.releaseDrag() + this.recordChange(before) + }, + releaseDrag() { + const id = this.state.drag?.pointerId + this.state.drag = null + if (this._dragTarget?.hasPointerCapture?.(id)) this._dragTarget.releasePointerCapture(id) + this._dragTarget = null + }, + cancelDrag() { + const drag = this.state.drag + if (drag) Object.assign(this.layout[drag.id], drag.before) + this.releaseDrag() + }, + resetLayout() { + if (!this.editable || this.state.drag) return + const before = clone(this.state.draft) + this.state.draft.layouts[this.state.profile] = clone(this.state.data.defaults.layouts[this.state.profile]) + this.recordChange(before) + this.state.placementError = "" + this.state.notice = "This layout is reset in your draft. Save to apply it." + }, + resetColors() { + if (!this.editable || this.state.drag || !this.colorFields.length) return + const before = clone(this.state.draft) + const profile = this.state.profile, id = this.state.selected + delete this.state.draft.widgetColors[profile][id] + const inherited = this.selectedColors + const stock = widgetPalette(this.state.data.defaults, this.state.data.metadata, profile, id) + const overrides = Object.fromEntries(Object.entries(stock).filter(([key, value]) => value !== inherited[key])) + if (Object.keys(overrides).length) this.state.draft.widgetColors[profile][id] = overrides + this.recordChange(before) + this.state.colorError = "" + this.state.notice = "This widget’s colors are reset in your draft. Save to apply them." + }, + roadPoints(points) { + const r = this.profile.bounds + return points.map(([x, y]) => (r.x + x*r.width) + ',' + (r.y + y*r.height)).join(' ') + }, + roadLabel(id) { + if (id === 'pathEdge') return this.state.profile === 'large' ? 'Path border' : 'Closest lane markings' + if (id === 'laneLines') return this.state.profile === 'large' ? 'Lane lines' : 'Outer lane lines' + return 'Path' + }, + setRoadMode(mode) { + if (!this.editable || this.state.drag || !PATH_MODES.includes(mode)) return + const before = clone(this.state.draft) + this.state.draft.roadColors[this.state.profile].pathMode = mode + this.recordChange(before) + }, + roadColor(id, value, group = null) { + if (!this.editable || this.state.drag || !ROAD_COLORS.includes(id)) return + if (!HEX.test(value)) { this.state.colorError = "Use eight hex digits, for example #30FF9CFF."; return } + const before = clone(this.state.draft) + this.state.draft.roadColors[this.state.profile][id] = value.toUpperCase() + if (id === 'path') this.state.draft.roadColors[this.state.profile].pathMode = 'color' + this.recordChange(before, group && this.state.profile + ':road:' + group) + this.state.colorError = this.state.notice = '' + }, + roadRgb(id, event) { + const alpha = this.roadColors[id].slice(7) + this.roadColor(id, event.target.value + (alpha === '00' ? 'FF' : alpha), 'rgb:' + id) + }, + roadAlpha(id, event) { + const value = Number(event.target.value) + if (Number.isInteger(value) && value >= 0 && value <= 255) + this.roadColor(id, this.roadColors[id].slice(0, 7) + value.toString(16).padStart(2, '0'), 'alpha:' + id) + }, + resetRoad() { + if (!this.editable || this.state.drag) return + const before = clone(this.state.draft) + this.state.draft.roadColors[this.state.profile] = {} + this.recordChange(before) + this.state.colorError = '' + }, + widgetColors(id) { return widgetPalette(this.state.draft, this.state.data.metadata, this.state.profile, id) }, + color(id, value, group = null) { + if (!this.editable || this.state.drag || !Object.hasOwn(this.selectedWidget?.colors || {}, id)) return + if (!HEX.test(value)) { this.state.colorError = "Use eight hex digits, for example #000000A6. The last two digits set opacity."; return } + const before = clone(this.state.draft) + const profile = this.state.profile, widget = this.state.selected + this.state.draft.widgetColors[profile][widget] ||= {} + this.state.draft.widgetColors[profile][widget][id] = value.toUpperCase() + this.recordChange(before, group && profile + ':' + widget + ':' + group) + this.state.colorError = this.state.notice = "" + }, + colorText(id, event) { this.color(id, event.target.value.trim()); this.finishColorEdit(); event.target.value = this.selectedColors[id] }, + colorRgb(id, event) { + const alpha = this.selectedColors[id]?.slice(7) + if (alpha) this.color(id, event.target.value + (alpha === '00' ? (id === 'cardFill' ? 'A6' : 'FF') : alpha), "rgb:" + id) + }, + colorAlpha(id, event) { + const value = Number(event.target.value) + if (Number.isInteger(value) && value >= 0 && value <= 255) + this.color(id, this.selectedColors[id].slice(0, 7) + value.toString(16).padStart(2, "0"), "alpha:" + id) + }, + alpha(color) { return parseInt(color.slice(7), 16) }, + requestLeave(action) { + if (this.busy || this.state.drag) return + if (this.dirty) this.state.discard = action + else this.leave(action) + }, + leave(action) { + this.state.discard = null + if (action === "back") { this.hideDevicePreview(); this.$emit("close") } + else if (["projection", "device"].includes(action)) { this.hideDevicePreview(); this.$emit("target", action) } + else this.feed.load() + }, + save() { if (this.editable && this.dirty && !this.state.drag) return this.feed.save(this.state.draft) }, + }, + template: ` +
+

{{ projection ? 'Android Auto Layout' : 'Colors & Layout' }}

+

Choose a widget to move it or change its colors. Edit the layout available on this comma.

Move widgets for the last connected Android Auto screen. The comma layout stays separate. Colors follow the comma theme. Changes apply on the next connection.

+ +
+ +

Last usable screen: {{ state.data.screen.width - state.data.screen.margin_width }} × {{ state.data.screen.height - state.data.screen.margin_height }} pixels. {{ state.data.reason || '' }}

+
Connect to local Galaxy to edit this device’s colors and layouts.
+ +
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/pip-geometry.js b/openpilot/starpilot/galaxy/web/js/pip-geometry.js new file mode 100644 index 0000000000..f1da4fadd6 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/pip-geometry.js @@ -0,0 +1,30 @@ +// Saved centers are pixels in the unmirrored cabin frame. Invert changes only +// the displayed crop pixels; vehicle-side selection always uses source sides. +export const FORMATS = [[1928, 1208], [1344, 760]] + +export function displayPoint(center, width, invert) { + return center === null ? null : [invert ? width - center[0] : center[0], center[1]] +} + +export function sourcePoint(clientX, clientY, rect, width, height, invert) { + if (!(rect?.width > 0 && rect.height > 0) || typeof invert !== "boolean" || + !FORMATS.some(([w, h]) => w === width && h === height)) return null + const x = (clientX - rect.left) / rect.width + const y = (clientY - rect.top) / rect.height + if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x > 1 || y < 0 || y > 1) return null + return [Math.round((invert ? 1 - x : x) * width), Math.round(y * height)] +} + +const centerValid = (center, width, height, size) => center === null || + (Array.isArray(center) && center.length === 2 && center.every(Number.isInteger) && + center[0] - size / 2 >= 0 && center[0] + size / 2 <= width && + center[1] - size / 2 >= 0 && center[1] + size / 2 <= height) + +export function maskDraft(width, height, cropSize, centerLeft, centerRight) { + if (!FORMATS.some(([w, h]) => w === width && h === height) || + !Number.isInteger(cropSize) || cropSize < 20 || cropSize > Math.min(width, height) || + (centerLeft === null && centerRight === null) || + !centerValid(centerLeft, width, height, cropSize) || + !centerValid(centerRight, width, height, cropSize)) return null + return { width, height, crop_size: cropSize, center_left: centerLeft, center_right: centerRight } +} diff --git a/openpilot/starpilot/galaxy/web/js/pip.js b/openpilot/starpilot/galaxy/web/js/pip.js new file mode 100644 index 0000000000..8da398574c --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/pip.js @@ -0,0 +1,243 @@ +import { reactive } from "../vendor/vue/vue.esm-browser.js" +import { CameraSnapshotFeed } from "./cameras.js" +import { SettingsFeed } from "./settings.js" +import { GalaxySettingRow } from "./galaxy-setting-row.js" +import { displayPoint, FORMATS, maskDraft, sourcePoint } from "./pip-geometry.js" + +const SIDES = [ + { key: "centerRight", label: "Vehicle left / camera right", color: "#5ee5ee" }, + { key: "centerLeft", label: "Vehicle right / camera left", color: "#ffb865" }, +] + +export const PipPage = { + name: "PipPage", + components: { GalaxySettingRow }, + props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, + go: { type: Function, required: true } }, + setup(props) { + const state = reactive({ status: "idle", data: null, pending: null, error: "", width: 1928, height: 1208, + cropSize: 580, centerLeft: null, centerRight: null, invert: null, activeSide: "centerRight", + localNote: "", imageName: "", reviewing: false }) + let lastView = null + const feed = new SettingsFeed({ unauthorized: props.unauthorized, publish: (update) => { + Object.assign(state, update) + if (update.status === "ready" && update.data?.view && update.data.view !== lastView) { + lastView = update.data.view + const editor = update.data.editor + if (editor) { + state.width = editor.width + state.height = editor.height + state.cropSize = editor.cropSize + state.centerLeft = editor.centerLeft === null ? null : [...editor.centerLeft] + state.centerRight = editor.centerRight === null ? null : [...editor.centerRight] + state.invert = editor.invert + state.localNote = "" + } else { + state.invert = null + state.localNote = "Restore the invalid saved crop or mirror choice before editing." + } + } + } }) + return { state, feed, FORMATS, SIDES } + }, + mounted() { + this._image = null + this._imageGeneration = 0 + if (this.mode === "local") this.feed.start("pip") + this._liveStopped = false + this.snapshots = new CameraSnapshotFeed({ unauthorized: this.unauthorized, publish: (update) => { + if (update.error) { this.state.localNote = update.error; this._image = null; this.state.imageName = "" } + if (!update.image) return + const generation = ++this._imageGeneration + const image = new Image() + image.onload = () => { + if (this._liveStopped || generation !== this._imageGeneration) return + this._image = image + this.state.imageName = "Live cabin camera" + this.state.localNote = "" + this.redraw() + } + image.onerror = () => { this.state.localNote = "Camera frame could not be displayed."; this._image = null } + image.src = update.image + } }) + const refresh = async () => { + if (this._liveStopped) return + if (this.mode === "local" && !document.hidden) await this.snapshots.capture("cabin") + if (!this._liveStopped) this._liveTimer = setTimeout(refresh, 1500) + } + this.visibility = () => { if (document.hidden) { this.snapshots.stop(); this._image = null; this.state.imageName = "" } } + document.addEventListener("visibilitychange", this.visibility) + refresh() + this.$nextTick(() => this.redraw()) + }, + updated() { + this.$nextTick(() => this.redraw()) + }, + beforeUnmount() { + this._liveStopped = true + clearTimeout(this._liveTimer) + document.removeEventListener("visibilitychange", this.visibility) + this.snapshots?.stop() + this.feed.stop() + this.dropImage() + }, + computed: { + controls() { return (this.state.data?.rows || []).map((row, index) => ({ row, index })) + .filter(({ row }) => row.label !== "Visual camera crop editor" && !row.key?.startsWith("pip:mask:")) }, + canEdit() { return !!this.state.imageName && this.mode === "local" && !!this.state.data?.parked && this.state.data?.editorRow >= 0 && + !!this.state.data.rows[this.state.data.editorRow]?.available && this.state.status === "ready" && + !this.state.pending && !this.state.reviewing && typeof this.state.invert === "boolean" && + FORMATS.some(([w, h]) => w === this.state.width && h === this.state.height) }, + }, + methods: { + dropImage() { + this._imageGeneration++ + if (this._image) this._image.src = "" + this._image = null + this.state.imageName = "" + }, + position(axis, event) { + if (!this.canEdit) return + const center = [...(this.state[this.state.activeSide] || [this.state.width / 2, this.state.height / 2])] + center[axis] = Number(event.target.value) + const left = this.state.activeSide === "centerLeft" ? center : this.state.centerLeft + const right = this.state.activeSide === "centerRight" ? center : this.state.centerRight + if (!maskDraft(this.state.width, this.state.height, this.state.cropSize, left, right)) return + this.state[this.state.activeSide] = center + this.redraw() + }, + choose(side) { if (this.canEdit) this.state.activeSide = side }, + place(event) { + if (!this.canEdit) return + const canvas = this.$refs.canvas + const point = sourcePoint(event.clientX, event.clientY, canvas?.getBoundingClientRect(), + this.state.width, this.state.height, this.state.invert) + if (!point) return + const nextLeft = this.state.activeSide === "centerLeft" ? point : this.state.centerLeft + const nextRight = this.state.activeSide === "centerRight" ? point : this.state.centerRight + if (!maskDraft(this.state.width, this.state.height, this.state.cropSize, nextLeft, nextRight)) { + this.state.localNote = "Place the center far enough from the frame edge for the whole crop." + return + } + this.state.centerLeft = nextLeft + this.state.centerRight = nextRight + this.state.localNote = "Draft only; review both centers and crop size before saving." + this.redraw() + }, + clear(side) { + if (!this.canEdit) return + this.state[side] = null + this.redraw() + }, + size(event) { + if (!this.canEdit) return + const value = Number(event.target.value) + if (!maskDraft(this.state.width, this.state.height, value, this.state.centerLeft, this.state.centerRight)) { + this.state.localNote = "That crop size would cross the image edge or leave no configured side." + event.target.value = String(this.state.cropSize) + return + } + this.state.cropSize = value + this.redraw() + }, + async saveCrop() { + if (!this.canEdit) return + const draft = maskDraft(this.state.width, this.state.height, this.state.cropSize, + this.state.centerLeft, this.state.centerRight) + if (!draft) { this.state.localNote = "Keep at least one complete crop inside the image."; return } + this.state.reviewing = true + try { await this.feed.preview(this.state.data.editorRow, 0, draft) } + finally { this.state.reviewing = false } + }, + redraw() { + const canvas = this.$refs.canvas + if (!canvas || !FORMATS.some(([w, h]) => w === this.state.width && h === this.state.height)) return + if (canvas.width !== this.state.width) canvas.width = this.state.width + if (canvas.height !== this.state.height) canvas.height = this.state.height + const ctx = canvas.getContext("2d") + if (!ctx) return + const w = canvas.width, h = canvas.height + ctx.fillStyle = "#151821" + ctx.fillRect(0, 0, w, h) + if (this._image) { + if (this.state.invert) { ctx.save(); ctx.translate(w, 0); ctx.scale(-1, 1) } + ctx.drawImage(this._image, 0, 0, w, h) + if (this.state.invert) ctx.restore() + } else { + ctx.strokeStyle = "#38404e" + ctx.beginPath(); ctx.moveTo(w / 2, 0); ctx.lineTo(w / 2, h); ctx.stroke() + } + const preview = this.$refs.preview + const center = this.state[this.state.activeSide] + if (preview && this._image && center) { + preview.width = preview.height = 300 + const crop = preview.getContext("2d") + crop.save() + if (this.state.invert) { crop.translate(300, 0); crop.scale(-1, 1) } + const size = this.state.cropSize + crop.drawImage(this._image, (center[0] - size / 2) * this._image.naturalWidth / w, + (center[1] - size / 2) * this._image.naturalHeight / h, + size * this._image.naturalWidth / w, size * this._image.naturalHeight / h, 0, 0, 300, 300) + crop.restore() + } + for (const side of SIDES) { + const center = this.state[side.key] + if (!center) continue + const [x, y] = displayPoint(center, w, this.state.invert) + const half = this.state.cropSize / 2 + ctx.strokeStyle = side.color + ctx.lineWidth = Math.max(2, w / 640) + ctx.strokeRect(x - half, y - half, this.state.cropSize, this.state.cropSize) + ctx.beginPath(); ctx.arc(x, y, Math.max(4, w / 320), 0, Math.PI * 2); ctx.fillStyle = side.color; ctx.fill() + } + }, + }, + template: ` +
+

Saved preferences

Blind Spot Camera

+

Adjust the native Blind Spot Camera crop using a live cabin preview.

+
+
Local saved settings are unavailable in preview.
+ +
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/plots.js b/openpilot/starpilot/galaxy/web/js/plots.js new file mode 100644 index 0000000000..503c5fb035 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/plots.js @@ -0,0 +1,249 @@ +// Bounded, read-only local control history. No layout or telemetry is persisted. +export const MAX_POINTS = 240 +export const POLL_MS = 750 +const SIGNALS = ['desiredLateralAccel', 'actualLateralAccel', 'desiredLongitudinalAccel', 'actualLongitudinalAccel', + 'lateralP', 'lateralI', 'lateralD', 'lateralF', 'longitudinalP', 'longitudinalI', 'longitudinalF', 'speedMps'] +const STATES = ['current', 'stale', 'unavailable'] +const SOURCES = { + lateralSource: ['unavailable', 'torqueState', 'curvature'], + longitudinalSource: ['unavailable', 'aTarget', 'pidSum'], + lateralTermsSource: ['unavailable', 'torqueState', 'pidState'], + longitudinalTermsSource: ['unavailable', 'controlsState'], + speedSource: ['unavailable', 'deviceMotion', 'carState'], +} + +export function validatePlotPayload(data) { + if (data?.schemaVersion !== 1 || !/^[0-9a-f]{16}$/.test(data.sessionId) || !STATES.includes(data.state) || + !Number.isSafeInteger(data.sampleIndex) || data.sampleIndex < 0 || + !(data.sampleAgeSeconds === null || Number.isFinite(data.sampleAgeSeconds) && data.sampleAgeSeconds >= 0) || + typeof data.error !== 'string' || data.error.length > 80 || typeof data.bootStabilizing !== 'boolean' || + !(data.values === null || typeof data.values === 'object')) throw new Error('Invalid plot sample') + if (data.state !== 'current') { + if (data.values !== null) throw new Error('Stale plot values') + return data + } + if (!data.values || data.sampleAgeSeconds === null || data.sampleAgeSeconds > 1.5 || + data.values.controlsFresh !== true || typeof data.values.poseFresh !== 'boolean' || + typeof data.values.controlsActive !== 'boolean' || typeof data.values.lateralControlActive !== 'boolean' || + typeof data.values.longitudinalControlActive !== 'boolean') throw new Error('Invalid plot source') + for (const key of SIGNALS) if (!(data.values[key] === null || typeof data.values[key] === 'number' && Number.isFinite(data.values[key]))) throw new Error('Invalid plot value') + for (const [key, allowed] of Object.entries(SOURCES)) if (!allowed.includes(data.values[key])) throw new Error('Invalid plot source') + if (data.values.lateralSource === 'unavailable' && (data.values.desiredLateralAccel !== null || data.values.actualLateralAccel !== null)) throw new Error('Invalid lateral source') + if (data.values.longitudinalSource === 'unavailable' && data.values.desiredLongitudinalAccel !== null) throw new Error('Invalid longitudinal source') + if (!data.values.poseFresh && data.values.actualLongitudinalAccel !== null) throw new Error('Invalid pose source') + if (data.values.speedSource === 'unavailable' && data.values.speedMps !== null) throw new Error('Invalid speed source') + return data +} + +export function appendPlotSample(history, payload, observedAt) { + if (payload.state !== 'current' || !payload.values) return history + if (history.at(-1)?.sessionId !== undefined && history.at(-1).sessionId !== payload.sessionId) history = [] + if (payload.sampleIndex === (history.at(-1)?.index ?? 0)) return history + if (payload.sampleIndex < (history.at(-1)?.index ?? 0)) history = [] + const sample = { ...payload.values, sessionId: payload.sessionId, index: payload.sampleIndex, + at: observedAt - payload.sampleAgeSeconds * 1000 } + return [...history, sample].slice(-MAX_POINTS) +} + +export function matchQuality(history, desired, actual, { minSpeed = 0, minDemand = 0, great, good, fair, activeKey = 'controlsActive', + warnError = null, severeError = null }) { + const latest = history.at(-1)?.at + if (!Number.isFinite(latest)) return { label: 'N/A', detail: 'Waiting for data' } + const errors = history.filter((sample) => sample.at >= latest - 30000 && sample[activeKey] && + Number.isFinite(sample[desired]) && Number.isFinite(sample[actual]) && + (sample.speedMps ?? 0) >= minSpeed && Math.max(Math.abs(sample[desired]), Math.abs(sample[actual])) >= minDemand) + .map((sample) => Math.abs(sample[desired] - sample[actual])).sort((a, b) => a - b) + if (errors.length < 8) return { label: 'N/A', detail: `${errors.length} of 8 valid driving samples` } + const percentile = (p) => { const i = (errors.length - 1) * p; const lo = Math.floor(i); return errors[lo] + (errors[Math.ceil(i)] - errors[lo]) * (i - lo) } + const value = 0.7 * percentile(0.5) + 0.3 * percentile(0.9) + const warnFraction = warnError === null ? 0 : errors.filter((error) => error > warnError).length / errors.length + const severeFraction = severeError === null ? 0 : errors.filter((error) => error > severeError).length / errors.length + const pass = (limit, maxWarn, maxSevere) => value <= limit && warnFraction <= maxWarn && severeFraction <= maxSevere + return { label: pass(great, .18, .05) ? 'Great' : pass(good, .34, .12) ? 'Good' : pass(fair, .55, .24) ? 'Fair' : 'Poor', + detail: `${errors.length} samples / 30 s · robust error ${value.toFixed(2)} m/s²` } +} + +export function chartGeometry(history, keys) { + const valid = history.filter((sample) => keys.some((key) => Number.isFinite(sample[key]))) + const lo = Math.min(0, ...valid.flatMap((sample) => keys.map((key) => sample[key]).filter(Number.isFinite))) + const hi = Math.max(0, ...valid.flatMap((sample) => keys.map((key) => sample[key]).filter(Number.isFinite))) + const span = Math.max(0.01, hi - lo) + const first = history[0]?.at ?? 0, elapsed = Math.max(1, (history.at(-1)?.at ?? first) - first) + const y = (value) => 132 - (value - lo) / span * 114 + const duration = valid.length > 1 ? Math.max(0, (history.at(-1).at - first) / 1000) : 0 + const paths = keys.map((key) => { + let points = [], paths = [], previousAt = null + for (const sample of history) { + if (previousAt !== null && sample.at - previousAt > 1500) { + if (points.length > 1) paths.push(points.join(' ')); points = [] + } + previousAt = sample.at + if (!Number.isFinite(sample[key])) { if (points.length > 1) paths.push(points.join(' ')); points = []; continue } + const x = Math.max(48, Math.min(624, 48 + (sample.at - first) / elapsed * 576)) + points.push(`${x.toFixed(1)},${y(sample[key]).toFixed(1)}`) + } + if (points.length > 1) paths.push(points.join(' ')) + return paths + }) + return { paths, valid: valid.length > 0, top: hi.toFixed(2), bottom: lo.toFixed(2), zeroY: y(0), + from: duration > 0 ? `−${duration < 10 ? duration.toFixed(1) : Math.round(duration)} s` : '0 s' } +} + +export function seriesPoints(history, keys) { + return chartGeometry(history, keys).paths +} + +export class PlotsFeed { + constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args), + now = () => performance.now(), later = (fn, ms) => setTimeout(fn, ms), cancel = (id) => clearTimeout(id) }) { + Object.assign(this, { publish, unauthorized, fetcher, now, later, cancel }) + this.active = false; this.paused = false; this.generation = 0; this.displayGeneration = 0 + this.timer = this.deadline = this.expiry = this.request = null + this.history = []; this.lastData = null + } + clear() { + for (const key of ['timer', 'deadline', 'expiry']) { if (this[key] !== null) this.cancel(this[key]); this[key] = null } + this.request?.abort(); this.request = null + } + stop() { + this.active = false; this.generation++; this.displayGeneration++; this.clear(); this.history = []; this.lastData = null + this.publish({ status: 'idle', data: null, history: [], error: '' }) + } + start() { this.stop(); this.active = true; if (!this.paused) return this.load() } + setPaused(paused) { + this.paused = paused + if (paused) { this.generation++; this.displayGeneration++; this.clear(); this.publish({ status: 'paused', data: this.lastData, history: this.history, error: '' }) } + else if (this.active) this.load() + } + async load() { + if (!this.active || this.paused || this.request) return + const generation = ++this.generation, start = this.now(), request = new AbortController() + this.request = request + this.deadline = this.later(() => { + if (!this.active || generation !== this.generation) return + this.generation++; this.displayGeneration++; request.abort(); this.request = null; this.clear() + this.publish({ status: this.lastData ? 'stale' : 'unavailable', data: this.lastData, history: this.history, error: 'Plot refresh timed out' }) + this.timer = this.later(() => { this.timer = null; this.load() }, POLL_MS) + }, 1500) + try { + const response = await this.fetcher('./api/plots/live', { signal: request.signal, cache: 'no-store' }) + if (!this.active || generation !== this.generation || request.signal.aborted) return + if (response.status === 401) { this.stop(); this.unauthorized(); return } + if (response.status === 503) { + const body = await response.json().catch(() => null) + if (!this.active || generation !== this.generation || request.signal.aborted) return + if (['access_unavailable', 'setup_required'].includes(body?.code)) { this.stop(); this.unauthorized(); return } + } + if (!response.ok) throw new Error('Live plots unavailable') + const data = validatePlotPayload(await response.json()) + if (!this.active || generation !== this.generation || request.signal.aborted) return + const elapsed = this.now() - start + if (!Number.isFinite(elapsed) || elapsed < 0 || elapsed >= 1500) throw new Error('Plot sample delayed') + if (this.expiry !== null) this.cancel(this.expiry) + this.expiry = null + const displayGeneration = ++this.displayGeneration + const remaining = data.sampleAgeSeconds === null ? 0 : 1500 - data.sampleAgeSeconds * 1000 - elapsed + if (data.state === 'current' && remaining > 0) { + this.history = appendPlotSample(this.history, data, this.now() - elapsed) + this.lastData = data + this.publish({ status: 'current', data, history: this.history, error: '' }) + this.expiry = this.later(() => { + if (this.active && displayGeneration === this.displayGeneration) this.publish({ status: 'stale', data: this.lastData, history: this.history, error: 'Showing the last received sample' }) + }, remaining) + } else this.publish({ status: this.lastData ? 'stale' : data.state === 'unavailable' ? 'unavailable' : 'stale', data: this.lastData, history: this.history, error: data.error || 'Waiting for fresh control data' }) + } catch (error) { + if (this.active && generation === this.generation) { + this.displayGeneration++ + if (this.expiry !== null) this.cancel(this.expiry) + this.expiry = null + this.publish({ status: this.lastData ? 'stale' : 'unavailable', data: this.lastData, history: this.history, error: error.message }) + } + } finally { + if (generation === this.generation) { + if (this.deadline !== null) this.cancel(this.deadline); this.deadline = null + this.request = null + if (this.active && !this.paused) this.timer = this.later(() => { this.timer = null; this.load() }, POLL_MS) + } + } + } +} + +export const PlotGraph = { + name: 'PlotGraph', + props: { chart: { type: Object, required: true }, title: { type: String, required: true }, terms: { type: Boolean, default: false } }, + template: ` + + + + + `, +} + +export const PlotsPage = { + name: 'PlotsPage', + components: { PlotGraph }, + props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }, + data: () => ({ status: 'idle', data: null, history: [], error: '', paused: false, advanced: false }), + created() { this.feed = new PlotsFeed({ publish: (update) => Object.assign(this.$data, update), unauthorized: this.unauthorized }) }, + mounted() { + this.visibility = () => { + if (document.hidden) this.feed.setPaused(true) + else if (!this.paused && this.mode === 'local') { + if (!this.feed.active) this.feed.start() + else this.feed.setPaused(false) + } + } + document.addEventListener('visibilitychange', this.visibility) + if (this.mode === 'local' && !document.hidden) this.feed.start() + }, + beforeUnmount() { document.removeEventListener('visibilitychange', this.visibility); this.feed.stop() }, + computed: { + lateralQuality() { return matchQuality(this.history, 'desiredLateralAccel', 'actualLateralAccel', { minSpeed: 0.5, minDemand: 0.008, great: 0.15, good: 0.30, fair: 0.50, activeKey: 'lateralControlActive' }) }, + longQuality() { return matchQuality(this.history, 'desiredLongitudinalAccel', 'actualLongitudinalAccel', { minDemand: 0.05, great: 0.32, good: 0.52, fair: 0.78, activeKey: 'longitudinalControlActive', warnError: .52, severeError: .90 }) }, + lateralChart() { return chartGeometry(this.history, ['desiredLateralAccel', 'actualLateralAccel']) }, + longChart() { return chartGeometry(this.history, ['desiredLongitudinalAccel', 'actualLongitudinalAccel']) }, + lateralTermsChart() { return chartGeometry(this.history, ['lateralP', 'lateralI', 'lateralD', 'lateralF']) }, + longTermsChart() { return chartGeometry(this.history, ['longitudinalP', 'longitudinalI', 'longitudinalF']) }, + }, + methods: { + togglePause() { this.paused = !this.paused; this.feed.setPaused(this.paused) }, + label(value, unit = 'm/s²') { return value === null || value === undefined ? 'Unavailable' : `${value.toFixed(2)} ${unit}` }, + source(kind) { return ({ torqueState: 'Steering torque controller', curvature: 'Curvature × speed² estimate', aTarget: 'Planner target acceleration', + pidSum: 'PID term sum', pidState: 'Steering PID', controlsState: 'Longitudinal controller', + deviceMotion: 'Device motion', carState: 'Vehicle speed', unavailable: 'Unavailable' })[kind] || 'Unavailable' }, + }, + template: ` +
+

Plots

Live local control observations. Graphs and match scores are diagnostic, not driving-control checks.

+ Offline tracking analysis +
Live plots require the authenticated local Galaxy service.
+ +
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/projection-layout.js b/openpilot/starpilot/galaxy/web/js/projection-layout.js new file mode 100644 index 0000000000..1b30b9e79d --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/projection-layout.js @@ -0,0 +1,25 @@ +// Translate the independent AA placement document into the existing editor model. +const clone = (value) => JSON.parse(JSON.stringify(value)) + +export function editorSnapshot(raw) { + if (!raw || raw.version !== 1 || !raw.screen || !raw.metadata || !raw.document || !raw.defaults) + throw new Error(raw?.reason || "Connect Android Auto once to obtain the actual screen.") + const colors = raw.colors + const document = (value) => { + if (value.version !== 1 || value.canvas?.width !== raw.metadata.width || value.canvas?.height !== raw.metadata.height) + throw new Error("The saved Android Auto layout does not match its screen. Reload before editing.") + return { version: 4, palette: clone(colors.palette), layouts: { large: clone(value.widgets) }, + widgetColors: { large: clone(colors.widgetColors) }, roadColors: { large: clone(colors.roadColors) } } + } + return { ...raw, projection: true, activeProfile: "large", document: document(raw.document), defaults: document(raw.defaults), + metadata: { projection: true, profiles: { large: clone(raw.metadata) }, + paletteFields: Object.entries(colors.palette).map(([id, value]) => ({ id, label: id, default: value })), + roadColorFields: [{ id: "path", label: "Path", default: "#30FF9CFF" }, + { id: "pathEdge", label: "Path edges", default: "#00FF40FF" }, { id: "laneLines", label: "Lane lines", default: "#FFFFFFFF" }] } } +} + +export function projectionPayload(body, metadata) { + return { revision: body.revision, document: { version: 1, + canvas: { width: metadata.profiles.large.width, height: metadata.profiles.large.height }, + widgets: clone(body.document.layouts.large) } } +} diff --git a/openpilot/starpilot/galaxy/web/js/record-history.js b/openpilot/starpilot/galaxy/web/js/record-history.js new file mode 100644 index 0000000000..3a890cb02d --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/record-history.js @@ -0,0 +1,357 @@ +// Local recording inventory and closed Quick road playback only. +const LOCAL = /^(?:[a-f0-9]{8}--[a-f0-9]{10}|[0-9]{4}-[0-9]{2}-[0-9]{2}--[0-9]{2}-[0-9]{2}-[0-9]{2}|[a-f0-9]{16}\|(?:[a-f0-9]{8}--[a-f0-9]{10}|[0-9]{4}-[0-9]{2}-[0-9]{2}--[0-9]{2}-[0-9]{2}-[0-9]{2}))$/ +const SEGMENT = /^(?:[a-f0-9]{8}--[a-f0-9]{10}|[0-9]{4}-[0-9]{2}-[0-9]{2}--[0-9]{2}-[0-9]{2}-[0-9]{2}|[a-f0-9]{16}[|_](?:[a-f0-9]{8}--[a-f0-9]{10}|[0-9]{4}-[0-9]{2}-[0-9]{2}--[0-9]{2}-[0-9]{2}-[0-9]{2}))--[0-9]{1,6}$/ +const FILES = ["rlog", "qlog", "fcamera", "dcamera", "ecamera", "qcamera"] +const LABELS = { rlog: "Full log", qlog: "Quick log", fcamera: "Road video", dcamera: "Driver video", + ecamera: "Wide video", qcamera: "Quick video" } + +export function validLocalHistory(value) { + if (value?.schemaVersion !== 1 || value.source !== "local" || value.partialHistory !== true || + typeof value.scanIncomplete !== "boolean" || !Array.isArray(value.routes) || value.routes.length > 100) return false + let count = 0 + const routes = new Set() + return value.routes.every((route) => { + if (typeof route?.routeId !== "string" || !LOCAL.test(route.routeId) || routes.has(route.routeId) || + !Array.isArray(route.segments) || route.segments.length > 64 || + !Number.isSafeInteger(route.segmentCount) || route.segmentCount !== route.segments.length) return false + routes.add(route.routeId) + const numbers = new Set() + count += route.segmentCount + return count <= 512 && route.segments.every((segment) => { + if (!Number.isSafeInteger(segment?.number) || segment.number < 0 || segment.number > 999999 || + typeof segment.segmentName !== "string" || !SEGMENT.test(segment.segmentName) || + (!segment.segmentName.startsWith(route.routeId.replace('|', '_') + '--') && + !segment.segmentName.startsWith(route.routeId + '--')) || + Number(segment.segmentName.slice(segment.segmentName.lastIndexOf('--') + 2)) !== segment.number || + numbers.has(segment.number) || segment.files === null || typeof segment.files !== "object" || + Object.keys(segment.files).length !== FILES.length || + !FILES.every((key) => typeof segment.files[key] === "boolean") || + !FILES.some((key) => segment.files[key])) return false + numbers.add(segment.number) + return true + }) + }) +} + +export const availableFiles = (files) => FILES.filter((key) => files?.[key]).map((key) => LABELS[key]) +export const routeFiles = (route) => availableFiles(Object.fromEntries(FILES.map((key) => + [key, route.segments.some((segment) => segment.files[key])]))) +export const firstQuickVideo = (route) => route.segments.find((segment) => segment.files.qcamera) || null +export function routeDate(route, locale = undefined, timeZone = undefined) { + const validTime = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 && Number.isFinite(new Date(value * 1000).getTime()) + const captured = validTime(route.startTime) + const value = captured ? route.startTime : validTime(route.fileTime) ? route.fileTime : null + if (value === null) return { label: "Date unavailable", source: "", datetime: null } + const date = new Date(value * 1000) + return { label: new Intl.DateTimeFormat(locale, { year: "numeric", month: "short", day: "numeric", + hour: "numeric", minute: "2-digit", ...(timeZone ? { timeZone } : {}) }).format(date), + source: captured ? "" : "File date", datetime: date.toISOString() } +} +export function connectRouteUrl(route) { + if (typeof route?.routeId !== "string" || !LOCAL.test(route.routeId) || typeof route.connectUrl !== "string") return null + const host = route.provider === "konik" ? "stable.konik.ai" : route.provider === undefined || route.provider === "comma" ? "connect.comma.ai" : null + if (host === null) return null + const match = /^https:\/\/(connect\.comma\.ai|stable\.konik\.ai)\/([a-f0-9]{16})\/([^/?#]+)$/.exec(route.connectUrl) + const [device, identifier] = route.routeId.includes('|') ? route.routeId.split('|') : [null, route.routeId] + return match && match[1] === host && match[3] === identifier && (device === null || match[2] === device) ? route.connectUrl : null +} +export const quickRoadUrl = (segmentName) => SEGMENT.test(segmentName) ? + `./api/recordings/media/${encodeURIComponent(segmentName)}` : null +export const segmentSummaryUrl = (segmentName) => SEGMENT.test(segmentName) ? + `./api/recordings/segment-summary?segmentName=${encodeURIComponent(segmentName)}` : null + +export function validSegmentSummary(value, segmentName) { + const metric = (v) => v === null || (typeof v === "number" && Number.isFinite(v) && v >= 0) + return value?.schemaVersion === 1 && value.source === "closed_local_rlog" && + value.segmentName === segmentName && typeof value.sourceSha256 === "string" && + /^[a-f0-9]{64}$/.test(value.sourceSha256) && + [value.observedCarSpanSeconds, value.estimatedDistanceMeters, + value.observedLatActiveSeconds, value.observedLongActiveSeconds].every(metric) && + value.gaps && Number.isSafeInteger(value.gaps.carState) && value.gaps.carState >= 0 && + Number.isSafeInteger(value.gaps.carControl) && value.gaps.carControl >= 0 && + typeof value.sampleCoverageComplete === "boolean" +} + +export const detailMetric = (seconds, unit = "s") => seconds === null ? "Unavailable" : + `${seconds.toFixed(unit === "m" ? 1 : 2)} ${unit}` + +export class LocalHistoryFeed { + static endpoint = "./api/recordings/local" + static valid = validLocalHistory + static subject = "local recordings" + static unavailable = "Local recordings are unavailable." + static invalid = "Local recording inventory is unavailable." + + constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args), + later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) { + Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer }) + this.active = false + this.generation = 0 + this.request = null + this.timer = null + this.status = "idle" + this.data = null + this.error = "" + } + + emit() { this.publish({ status: this.status, data: this.data, error: this.error }) } + stop() { + this.active = false + this.generation++ + this.request?.abort() + if (this.timer !== null) this.cancelTimer(this.timer) + this.request = this.timer = null + this.status = "idle" + this.data = null + this.error = "" + this.emit() + } + start() { this.stop(); this.active = true; return this.load() } + + async load() { + if (!this.active || this.request !== null) return + const generation = this.generation + const request = new AbortController() + this.request = request + this.status = "loading" + this.error = "" + this.emit() + this.timer = this.later(() => { + if (!this.active || generation !== this.generation || this.request !== request) return + request.abort() + this.generation++ + this.request = this.timer = null + this.data = null + this.status = "unavailable" + this.error = `Reading ${this.constructor.subject} timed out. Refresh to try again.` + this.emit() + }, 4000) + try { + const response = await this.fetcher(this.constructor.endpoint, { credentials: "same-origin", cache: "no-store", signal: request.signal }) + if (!this.active || generation !== this.generation || request.signal.aborted) return + if (response.status === 401) { this.stop(); this.unauthorized(); return } + if (response.status === 503) { + const body = await response.json().catch(() => null) + if (!this.active || generation !== this.generation || request.signal.aborted) return + if (["setup_required", "access_unavailable"].includes(body?.code)) { this.stop(); this.unauthorized(); return } + throw new Error(this.constructor.unavailable) + } + if (!response.ok) throw new Error(this.constructor.unavailable) + const data = await response.json() + if (!this.active || generation !== this.generation || request.signal.aborted) return + if (!this.constructor.valid(data)) throw new Error(this.constructor.invalid) + this.data = data + this.status = "ready" + this.emit() + } catch (error) { + if (this.active && generation === this.generation) { + this.data = null + this.status = "unavailable" + this.error = error instanceof Error ? error.message : this.constructor.unavailable + this.emit() + } + } finally { + if (generation === this.generation) { + if (this.timer !== null) this.cancelTimer(this.timer) + this.timer = null + this.request = null + } + } + } +} + +export const LocalRecordingsPage = { + name: "LocalRecordingsPage", + props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }, + data: () => ({ status: "idle", data: null, error: "", playing: null, playerError: "", playerGeneration: 0, + details: null, detailsName: null, detailsStatus: "idle", detailsError: "", detailsGeneration: 0 }), + computed: { + routeCards() { + return (this.data?.routes || []).map((route) => ({ ...route, date: routeDate(route), + connect: connectRouteUrl(route), fileLabels: routeFiles(route), firstQuick: firstQuickVideo(route) })) + }, + }, + created() { this.feed = new LocalHistoryFeed({ publish: (update) => Object.assign(this.$data, update), unauthorized: this.unauthorized }) }, + mounted() { + this.visibility = () => { if (document.hidden) { this.closePlayer(); this.closeDetails(); this.feed.stop() } else if (this.mode === "local") this.feed.start() } + document.addEventListener("visibilitychange", this.visibility) + if (this.mode === "local" && !document.hidden) this.feed.start() + }, + beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.closePlayer(); this.closeDetails(); this.feed.stop() }, + watch: { mode(value) { if (value !== "local") { this.closePlayer(); this.closeDetails() } } }, + methods: { + availableFiles, + quickRoadUrl, + detailMetric, + refresh() { this.closePlayer(); this.closeDetails(); return this.feed.load() }, + closeDetails() { + this.detailsRequest?.abort() + if (this.detailsTimer !== undefined) clearTimeout(this.detailsTimer) + this.detailsRequest = null + this.detailsTimer = undefined + this.detailsGeneration++ + this.details = null + this.detailsName = null + this.detailsStatus = "idle" + this.detailsError = "" + }, + async openDetails(segment) { + if (this.mode !== "local" || this.status !== "ready" || !segment?.files?.rlog || document.hidden) return + const url = segmentSummaryUrl(segment.segmentName) + if (!url) return + this.closeDetails() + const generation = this.detailsGeneration + const controller = new AbortController() + this.detailsRequest = controller + this.detailsName = segment.segmentName + this.detailsStatus = "loading" + this.detailsTimer = setTimeout(() => { + if (generation !== this.detailsGeneration || this.detailsRequest !== controller) return + controller.abort() + this.detailsRequest = null + this.detailsGeneration++ + this.detailsTimer = undefined + this.detailsStatus = "unavailable" + this.detailsError = "Reading this segment timed out. Select Details to try again." + }, 10000) + try { + const response = await fetch(url, { credentials: "same-origin", cache: "no-store", signal: controller.signal }) + if (generation !== this.detailsGeneration || controller.signal.aborted) return + if (response.status === 401) { this.closeDetails(); this.unauthorized(); return } + if (response.status === 503) { + const body = await response.json().catch(() => null) + if (generation !== this.detailsGeneration || controller.signal.aborted) return + if (["setup_required", "access_unavailable"].includes(body?.code)) { this.closeDetails(); this.unauthorized(); return } + } + if (!response.ok) throw new Error("Recorded segment details are unavailable.") + const result = await response.json() + if (generation !== this.detailsGeneration || controller.signal.aborted) return + if (!validSegmentSummary(result, segment.segmentName)) throw new Error("Recorded segment details are unavailable.") + this.details = result + this.detailsStatus = "ready" + this.$nextTick?.(() => this.$refs.detailsPanel?.scrollIntoView?.({ block: "nearest", behavior: "smooth" })) + } catch (error) { + if (generation === this.detailsGeneration) { + this.detailsStatus = "unavailable" + this.detailsError = error instanceof Error ? error.message : "Recorded segment details are unavailable." + } + } finally { + if (generation === this.detailsGeneration) { + if (this.detailsTimer !== undefined) clearTimeout(this.detailsTimer) + this.detailsTimer = undefined + this.detailsRequest = null + } + } + }, + openPlayer(route, segment) { + if (this.mode !== "local" || this.status !== "ready" || !segment.files.qcamera) return + const segments = route.segments.filter((item) => item.files.qcamera && quickRoadUrl(item.segmentName)) + const index = segments.findIndex((item) => item.segmentName === segment.segmentName) + if (index < 0) return + this.closePlayer() + this.playing = { routeId: route.routeId, segments, index, url: quickRoadUrl(segment.segmentName) } + this.playerGeneration++ + this.$nextTick?.(() => this.$refs.playerPanel?.scrollIntoView?.({ block: "nearest", behavior: "smooth" })) + }, + chooseSegment(offset) { + if (!this.playing) return + const index = this.playing.index + offset + if (index < 0 || index >= this.playing.segments.length) return + this.sessionProbe?.abort() + this.sessionProbe = null + this.$refs.quickVideo?.pause() + this.$refs.quickVideo?.removeAttribute("src") + this.playing = { ...this.playing, index, url: quickRoadUrl(this.playing.segments[index].segmentName) } + this.playerError = "" + this.playerGeneration++ + }, + closePlayer() { + this.sessionProbe?.abort() + this.sessionProbe = null + this.$refs.quickVideo?.pause() + this.$refs.quickVideo?.removeAttribute("src") + this.$refs.quickVideo?.load() + this.playing = null + this.playerError = "" + this.playerGeneration++ + }, + async videoError(event) { + if (!this.playing || event.currentTarget !== this.$refs.quickVideo || + Number(event.currentTarget.dataset.playerGeneration) !== this.playerGeneration) return + const generation = this.playerGeneration + this.playerError = "This Quick road recording could not play in this browser." + const controller = new AbortController() + this.sessionProbe?.abort() + this.sessionProbe = controller + const deadline = setTimeout(() => controller.abort(), 4000) + let revoke = false + try { + const response = await fetch("./api/auth/session", { credentials: "same-origin", cache: "no-store", signal: controller.signal }) + if (!this.playing || generation !== this.playerGeneration || controller.signal.aborted) return + const state = await response.json() + if (!this.playing || generation !== this.playerGeneration || controller.signal.aborted) return + revoke = response.status === 401 || state.authenticated === false + } catch { /* A media or session request may fail; keep the explicit playback error. */ } + finally { clearTimeout(deadline); if (this.sessionProbe === controller) this.sessionProbe = null } + if (revoke && this.playing && generation === this.playerGeneration) { this.closePlayer(); this.unauthorized() } + }, + }, + template: ` +
+

Local Recordings

+

Drives and video saved on this device. Older recordings may have been removed to free space.

+

Local recordings are unavailable in the offline preview.

+ +
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/router.js b/openpilot/starpilot/galaxy/web/js/router.js new file mode 100644 index 0000000000..e1f3f0dd15 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/router.js @@ -0,0 +1,22 @@ +import { reactive } from "../vendor/vue/vue.esm-browser.js" + +// This preview deliberately has no compatibility iframe or device endpoint. +const start = () => { + const hash = location.hash.slice(1) || "/" + try { return decodeURIComponent(hash) } catch { return hash } +} +export const route = reactive({ path: start() }) + +export function navigate(path) { + route.path = path + location.hash = encodeURI(path) + window.scrollTo(0, 0) +} + +export function startRouter() { + if (!location.hash) location.replace(`${location.pathname}${location.search}#/`) + window.addEventListener("hashchange", () => { + route.path = start() + window.scrollTo(0, 0) + }) +} diff --git a/openpilot/starpilot/galaxy/web/js/sentry-notifications.js b/openpilot/starpilot/galaxy/web/js/sentry-notifications.js new file mode 100644 index 0000000000..a8e012fc02 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/sentry-notifications.js @@ -0,0 +1,139 @@ +export const channels = ["webPush", "discord", "webhook", "ntfy"] +export const labels = { webPush: "Browser push", discord: "Discord", webhook: "General webhook", ntfy: "ntfy" } + +export function validNotifications(value) { + return value?.schemaVersion === 1 && typeof value.queueFull === "boolean" && + Number.isInteger(value.subscriptionCount) && value.subscriptionCount >= 0 && value.subscriptionCount <= 16 && + Array.isArray(value.subscriptions) && value.subscriptions.length === value.subscriptionCount && + value.subscriptions.every(s => typeof s?.id === "string" && /^[0-9a-f]{32}$/.test(s.id) && Object.keys(s).length === 1) && + typeof value.deliverySemantics === "string" && value.deliverySemantics.length < 256 && + (value.applicationServerKey === undefined || typeof value.applicationServerKey === "string" && /^[A-Za-z0-9_-]{87}$/.test(value.applicationServerKey)) && + channels.every(name => { + const c = value.channels?.[name] + return typeof c?.enabled === "boolean" && typeof c.configured === "boolean" && Number.isInteger(c.pending) && c.pending >= 0 && c.pending <= 512 && + ["idle", "queued", "sending", "sent", "failed", "cancelled"].includes(c.lastState) && + ["", "http", "expired", "timeout", "transport", "delivery_unknown", "stale"].includes(c.lastError) + }) +} + +export class NotificationClient { + constructor({ publish, unauthorized, fetcher = (...args) => fetch(...args) }) { + this.publish = publish; this.unauthorized = unauthorized; this.fetcher = fetcher + this.revision = 0; this.controller = null; this.pending = false; this.hasStatus = false; this.timer = null + } + stop() { this.revision++; this.controller?.abort(); this.controller = null; this.pending = false; clearTimeout(this.timer); this.timer = null } + async run(payload = null) { + this.stop() + const revision = this.revision + this.controller = new AbortController() + this.pending = true + const foreground = payload !== null || !this.hasStatus + if (foreground) this.publish({ notificationBusy: true, notificationError: "" }) + this.timer = setTimeout(() => { + if (revision !== this.revision) return + this.stop() + this.publish({ notificationBusy: false, notificationError: "Notification request timed out. Refresh status before repeating a test." }) + }, 4000) + try { + const response = await this.fetcher("./api/sentry/notifications", { credentials: "same-origin", cache: "no-store", + signal: this.controller.signal, ...(payload === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }) }) + if (revision !== this.revision) return false + if (response.status === 401) { this.unauthorized(); return false } + const value = await response.json() + if (revision !== this.revision) return false + if (!response.ok) throw new Error(value?.error || "Sentry notifications are unavailable.") + if (!validNotifications(value)) throw new Error("Notification status is unavailable.") + this.hasStatus = true + this.publish({ notifications: value, notificationBusy: false, notificationError: "" }) + return true + } catch (error) { + if (revision === this.revision && error.name !== "AbortError") this.publish({ notificationBusy: false, notificationError: error.message || "Notification request failed." }) + return false + } finally { + if (revision === this.revision) { clearTimeout(this.timer); this.timer = null; this.pending = false; if (foreground) this.publish({ notificationBusy: false }) } + } + } +} + +export const SentryNotifications = { + name: "SentryNotifications", + props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }, + data: () => ({ channels, labels, notifications: null, notificationBusy: false, notificationError: "", + drafts: Object.fromEntries(channels.map(name => [name, { url: "", token: "" }])) }), + created() { this.client = new NotificationClient({ publish: update => Object.assign(this.$data, update), unauthorized: this.unauthorized }) }, + mounted() { + this.visibility = () => { if (document.hidden) { this.client.stop(); clearInterval(this.poll) } else this.start() } + document.addEventListener("visibilitychange", this.visibility) + this.start() + }, + beforeUnmount() { this.client.stop(); clearInterval(this.poll); document.removeEventListener("visibilitychange", this.visibility) }, + methods: { + start() { + clearInterval(this.poll) + if (this.mode !== "local" || document.hidden) return + this.client.run() + this.poll = setInterval(() => { if (!this.notificationBusy && !this.client.pending) this.client.run() }, 5000) + }, + async save(name, enabled) { + const draft = this.drafts[name] + if (await this.client.run({ action: "configure", channel: name, enabled, url: draft.url, token: draft.token })) { + draft.url = ""; draft.token = "" + } + }, + test(name) { return this.client.run({ action: "test", channel: name }) }, + async forget(name) { if (await this.client.run({ action: "forget", channel: name })) { this.drafts[name].url = ""; this.drafts[name].token = "" } }, + remove(id) { return this.client.run({ action: "unsubscribe", id }) }, + async subscribe() { + if (!isSecureContext || !("serviceWorker" in navigator) || !("PushManager" in window)) { + this.notificationError = "Browser push needs HTTPS and a browser with Push API support." + return + } + try { + const permission = await Notification.requestPermission() + if (permission !== "granted") throw new Error("Allow browser notifications to subscribe.") + if (!await this.client.run({ action: "pushKey" })) return + const key = this.notifications.applicationServerKey + const bytes = Uint8Array.from(atob(key.replace(/-/g, "+").replace(/_/g, "/") + "="), c => c.charCodeAt(0)) + const registration = await navigator.serviceWorker.register("./sentry-push-worker.js") + await navigator.serviceWorker.ready + const subscription = await registration.pushManager.getSubscription() || await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: bytes }) + const value = subscription.toJSON() + if (!await this.client.run({ action: "subscribe", subscription: { endpoint: value.endpoint, keys: value.keys } })) return + await this.save("webPush", true) + } catch (error) { this.notificationError = error.message || "Browser push subscription failed." } + }, + }, + template: ` +
+

Notifications

+

Receive new locally recorded motion events. Notification channels work independently.

+

Notification configuration is available on your connected device.

+ +
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/sentry.js b/openpilot/starpilot/galaxy/web/js/sentry.js new file mode 100644 index 0000000000..556c7a8bb4 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/sentry.js @@ -0,0 +1,72 @@ +import { SentryNotifications } from "./sentry-notifications.js" +import { LocalHistoryFeed } from "./record-history.js" + +// Historical motion records and independently configured notifications. +const ID = /^[0-9a-f]{32}$/ + +export function validSentryEvents(value) { + if (value?.schemaVersion !== 1 || value.source !== "local" || typeof value.scanIncomplete !== "boolean" || + value.capacity !== 512 || !Array.isArray(value.events) || value.events.length > 512) return false + const seen = new Set() + return value.events.every((event) => { + if (event === null || typeof event !== "object" || Object.keys(event).some((key) => !["eventId", "kind", "systemTimeMs", "images"].includes(key)) || + (event.images !== undefined && (!Array.isArray(event.images) || event.images.length > 2 || + new Set(event.images).size !== event.images.length || event.images.some((camera) => !["wide", "cabin"].includes(camera)))) || + typeof event.eventId !== "string" || !ID.test(event.eventId) || seen.has(event.eventId) || + !["warning", "alarm"].includes(event.kind) || !Number.isSafeInteger(event.systemTimeMs) || + event.systemTimeMs <= 0 || event.systemTimeMs > 8_640_000_000_000_000) return false + seen.add(event.eventId) + return true + }) +} + +export const systemTime = (milliseconds) => Number.isSafeInteger(milliseconds) && milliseconds > 0 && milliseconds <= 8_640_000_000_000_000 + ? new Date(milliseconds).toLocaleString() : "Unavailable" + +export class SentryEventsFeed extends LocalHistoryFeed { + static endpoint = "./api/sentry/events" + static valid = validSentryEvents + static subject = "local motion events" + static unavailable = "Local motion events are unavailable." + static invalid = "Local motion records are unavailable." +} + +export const SentryEventsPage = { + name: "SentryEventsPage", + components: { SentryNotifications }, + props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, + go: { type: Function, required: true } }, + data: () => ({ status: "idle", data: null, error: "" }), + created() { this.feed = new SentryEventsFeed({ publish: (update) => Object.assign(this.$data, update), unauthorized: this.unauthorized }) }, + mounted() { + this.visibility = () => { if (document.hidden) this.feed.stop(); else if (this.mode === "local") this.feed.start() } + document.addEventListener("visibilitychange", this.visibility) + if (this.mode === "local" && !document.hidden) this.feed.start() + }, + beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.feed.stop() }, + methods: { systemTime }, + template: ` +
+

Motion Events

+

Local motion records only · System clock times may be inaccurate

+
+ + +

Local motion records are unavailable in preview.

+ +
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/settings.js b/openpilot/starpilot/galaxy/web/js/settings.js new file mode 100644 index 0000000000..46422ad237 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/settings.js @@ -0,0 +1,347 @@ +import { reactive } from "../vendor/vue/vue.esm-browser.js" +import { GalaxySettingRow, settingControl } from "./galaxy-setting-row.js" +import { OnroadLayoutPage } from "./onroad-layout.js" +import { FavoritesPage } from "./favorites.js" +import { SoundPacks } from "./sound-packs.js" +import { CloudProviderPage } from "./cloud-provider.js" + +const post = (fetcher, path, body, signal) => fetcher(path, { + method: "POST", credentials: "same-origin", cache: "no-store", signal, + headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), +}) + +export const SETTINGS_SECTIONS = Object.freeze([ + { id: "lateral", label: "Lateral (Steering)", icon: "bi-arrows-move", pages: ["aol", "lane_change", "torque", "lane"] }, + { id: "longitudinal", label: "Longitudinal (Speed & Following)", icon: "bi-speedometer2", pages: ["conditional", "curve", "profiles", "slc", "traffic", "aggressive", "standard", "relaxed"] }, + { id: "wheel", label: "Wheel Controls", icon: "bi-controller", pages: ["wheel"] }, + { id: "visual", label: "Visual (Display & UI)", icon: "bi-eye", pages: ["appearance", "ui_layout", "favorites", "pip"] }, + { id: "sounds", label: "Sounds & Alerts", icon: "bi-volume-up", pages: ["sounds"] }, + { id: "device", label: "Device & Data", icon: "bi-hdd", pages: ["display", "data"] }, + { id: "developer", label: "Developer", icon: "bi-code-slash", pages: ["developer"] }, +]) + +const SECTION_LINKS = { + device: [{ label: "Display", page: "display" }, { label: "Data Uploads", page: "data" }], + visual: [{ label: "Driving Screen Widgets", page: "appearance" }, { label: "Colors & Layout", page: "ui_layout" }, { label: "Quick Select", page: "favorites" }, { label: "Blind Spot Camera", page: "pip" }], +} + +export class SettingsFeed { + constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args), + later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) { + Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer }) + this.active = false + this.generation = 0 + this.request = null + this.timer = null + this.pollTimer = null + this.polling = false + this.saving = false + this.page = "hub" + this.data = null + this.pending = null + } + + stop() { + this.active = false + this.generation++ + this.request?.abort() + if (this.timer !== null) this.cancelTimer(this.timer) + if (this.pollTimer !== null) this.cancelTimer(this.pollTimer) + this.request = this.timer = this.pollTimer = null + this.polling = false + this.saving = false + this.data = null + this.pending = null + this.publish({ status: "idle", data: null, pending: null, error: "" }) + } + + start(page = "hub") { + this.stop() + this.active = true + this.page = page + return this.load(page) + } + + async run(operation, { saving = false, background = false } = {}) { + if (!this.active) return null + const generation = ++this.generation + this.request?.abort() + if (this.timer !== null) this.cancelTimer(this.timer) + if (this.pollTimer !== null) this.cancelTimer(this.pollTimer) + this.pollTimer = null + const request = new AbortController() + this.request = request + this.saving = saving + this.polling = background + this.timer = this.later(() => { + if (!this.active || generation !== this.generation || this.request !== request) return + request.abort() + this.generation++ + this.request = this.timer = null + this.saving = false + this.polling = false + if (!background) this.data = this.pending = null + else this.expireMonitorStatus() + this.publish({ status: background && this.data ? "ready" : "unavailable", data: this.data, pending: this.pending, + error: saving ? "Saving timed out. The result is unknown. Refresh saved values before trying again." : + "Reading settings timed out. Refresh to try again." }) + }, 4000) + try { + const response = await operation(request.signal) + if (!this.active || generation !== this.generation || request.signal.aborted) return null + const accessCode = response.status === 503 ? (await response.clone().json().catch(() => null))?.code : null + if (!this.active || generation !== this.generation || request.signal.aborted) return null + if (response.status === 401 || ["access_unavailable", "setup_required"].includes(accessCode)) { + this.stop() + this.unauthorized() + return null + } + if (response.status === 409) { + const error = new Error("Saved settings changed. Refresh this page.") + error.stale = true + throw error + } + if (!response.ok) throw new Error(response.status === 503 ? "Saved settings are unavailable." : "Settings request failed.") + const data = await response.json().catch(() => { throw new Error("Galaxy could not load settings. Refresh to reconnect.") }) + if (!this.active || generation !== this.generation || request.signal.aborted) return null + return data + } catch (error) { + if (this.active && generation === this.generation && !request.signal.aborted) { + this.pending = null + this.expireMonitorStatus() + if (error?.stale) this.data = null + this.publish({ status: this.data ? "ready" : "unavailable", data: this.data, pending: null, + error: error?.message || "Settings request failed." }) + } + return null + } finally { + if (this.request === request) { + if (this.timer !== null) this.cancelTimer(this.timer) + this.request = this.timer = null + this.saving = false + this.polling = false + } + } + } + + expireMonitorStatus() { + if (this.data?.page === "sentry") this.data = { ...this.data, subtitle: "Motion monitor status is unavailable. Refresh to check again." } + } + + scheduleParkedRefresh() { + if (!this.active || this.pending || this.request || this.saving || this.pollTimer !== null || + (this.data?.parked !== false && this.page !== "sentry") || this.data?.page !== this.page || typeof this.data.view !== "string" || + !Array.isArray(this.data.rows)) return + const generation = this.generation + this.pollTimer = this.later(() => { + this.pollTimer = null + if (this.active && this.generation === generation && !this.pending && !this.request && !this.saving && (this.data?.parked === false || this.page === "sentry")) + this.load(this.page, { keepData: true, quiet: true }) + }, 1000) + } + + async load(page = this.page, { keepData = page === this.page && this.data !== null, quiet = keepData } = {}) { + if (!this.active || this.saving || (quiet && (this.pending || this.request))) return + this.page = page + this.pending = null + if (!keepData) this.data = null + this.publish({ status: quiet ? "ready" : keepData ? "saving" : "loading", data: this.data, pending: null, error: "" }) + const operation = this.run((signal) => this.fetcher(`./api/settings/pages/${encodeURIComponent(page)}`, + { credentials: "same-origin", cache: "no-store", signal }), { background: quiet }) + const attempt = this.generation + const data = await operation + if (data && this.active && this.generation === attempt && this.page === page) { + this.data = data + this.publish({ status: "ready", data, pending: null, error: "" }) + this.scheduleParkedRefresh() + } + } + + async preview(row, direction, draft = null) { + if (!this.active || !this.data?.view || this.pending || (this.request && !this.polling)) return + const data = await this.run((signal) => post(this.fetcher, "./api/settings/preview", + { view: this.data.view, row, direction, ...(draft === null ? {} : { draft }) }, signal)) + if (data) { + this.pending = data + this.publish({ status: "ready", data: this.data, pending: data, error: "" }) + } + } + + async previewValue(row, value) { + if (!this.active || !this.data?.view || this.pending || (this.request && !this.polling)) return + this.publish({ status: "updating", data: this.data, pending: null, error: "" }) + const data = await this.run((signal) => post(this.fetcher, "./api/settings/preview", + { view: this.data.view, row, value }, signal)) + if (data) { + this.pending = data + await this.confirm() + } + } + + cancel() { + this.pending = null + if (this.active) this.publish({ status: "ready", data: this.data, pending: null, error: "" }) + this.scheduleParkedRefresh() + } + + async confirm() { + if (!this.active || !this.pending?.intent) return + const intent = this.pending.intent + this.pending = null + this.publish({ status: "saving", data: this.data, pending: null, error: "" }) + const page = this.page + const operation = this.run((signal) => post(this.fetcher, "./api/settings/confirm", + { intent, confirmed: true }, signal), { saving: true }) + const attempt = this.generation + const result = await operation + if (this.active && this.generation === attempt) { + const reload = this.load(page, { keepData: true, quiet: false }) + const refreshed = this.generation + await reload + if (result?.saved !== true && this.active && this.generation === refreshed && this.data) this.publish({ status: "ready", data: this.data, pending: null, + error: "Save could not be confirmed. Current saved values are shown; review them before trying again." }) + } + } +} + +export const SettingsPage = { + components: { GalaxySettingRow, OnroadLayoutPage, FavoritesPage, SoundPacks, CloudProviderPage }, + props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, + initialPage: { type: String, default: "hub" }, title: { type: String, default: "Toggles" }, + initialSection: { type: String, default: null }, returnTo: { type: Function, default: null } }, + setup(props) { + const state = reactive({ status: "idle", data: null, pending: null, error: "", query: "", section: "lateral", layoutOpen: false, favoritesOpen: false, soundChoicesDirty: false, developerOpen: props.initialSection === "developer" }) + const feed = new SettingsFeed({ publish: (update) => Object.assign(state, update), unauthorized: props.unauthorized }) + return { state, feed, sections: SETTINGS_SECTIONS } + }, + computed: { + busy() { return ["updating", "saving"].includes(this.state.status) || !!this.state.pending }, + activeSection() { + if (this.state.developerOpen) return this.sections.at(-1) + const page = (this.state.data?.page || "hub").split("/")[0] + return this.sections.find((section) => page === "hub" ? section.id === this.state.section : section.pages.includes(page)) || this.sections[0] + }, + atSectionRoot() { + if (this.state.developerOpen) return true + return this.state.data?.page === "hub" || + (this.activeSection.pages.length === 1 && this.state.data?.page === this.activeSection.pages[0]) + }, + visibleRows() { + const needle = this.state.query.trim().toLocaleLowerCase() + const rows = this.state.data?.rows || [] + const inHub = this.initialPage === "hub" && this.state.data?.page === "hub" + const section = this.activeSection + const entries = inHub && SECTION_LINKS[section.id] + ? SECTION_LINKS[section.id].map((row, index) => ({ row: { ...row, available: true, action: false, value: "" }, index })) + : rows.map((row, index) => ({ row, index })).filter(({ row }) => !inHub || section.pages.includes(row.page)) + return entries.filter(({ row, index }) => { + const previous = rows[index - 1] + return !(["sounds", "display"].includes(this.state.data?.page) && row.repairValue === "Auto" && + previous?.choices?.includes("Auto") && settingControl(previous) === "slider" && row.label === `Use Auto ${previous.label}`) + }).filter(({ row }) => + !needle || `${row.label} ${row.value} ${row.reason}`.toLocaleLowerCase().includes(needle)) + }, + }, + watch: { + "state.pending"() { this.flushSoundChoices() }, + "state.status"(status, previous) { + if (previous === "saving" && status === "ready") this.state.soundChoicesDirty = false + else this.flushSoundChoices() + }, + }, + mounted() { if (this.mode === "local" && !this.state.developerOpen) this.feed.start(this.initialPage) }, + beforeUnmount() { this.feed.stop() }, + methods: { + open(page) { + this.state.query = "" + if (page === "ui_layout") { this.feed.stop(); this.state.layoutOpen = true } + else if (page === "favorites") { this.feed.stop(); this.state.favoritesOpen = true } + else this.feed.load(page) + }, + rowKey(row, index) { return JSON.stringify([this.state.data.page, index, row.revision ?? this.state.data.view, row]) }, + closeLayout() { this.state.layoutOpen = false; this.state.section = "visual"; this.feed.start(this.initialPage) }, + closeFavorites() { this.state.favoritesOpen = false; this.state.section = "visual"; this.feed.start("hub") }, + selectSection(section) { + if (this.busy) return + this.state.section = section.id + this.state.developerOpen = section.id === "developer" + if (this.state.developerOpen) { this.feed.stop(); return } + if (!this.feed.active) { this.feed.start(section.pages.length === 1 ? section.pages[0] : "hub"); return } + this.state.query = "" + const page = section.pages.length === 1 ? section.pages[0] : "hub" + if (this.state.data?.page !== page) this.feed.load(page) + }, + back() { + if (this.state.developerOpen) return + const page = this.state.data?.page || this.initialPage + this.state.section = this.activeSection?.id || this.state.section + this.state.query = "" + if (page === this.initialPage && this.returnTo) { this.returnTo(); return } + if (this.initialPage !== "hub") { this.feed.load(this.initialPage); return } + this.feed.load(page.includes("/") ? page.split("/")[0] : + ["aggressive", "standard", "relaxed"].includes(page) ? "profiles" : "hub") + }, + refreshSoundChoices() { + this.state.soundChoicesDirty = true + this.flushSoundChoices() + }, + flushSoundChoices() { + if (!this.state.soundChoicesDirty || this.state.data?.page !== "sounds" || this.feed.saving || this.state.pending) return + this.state.soundChoicesDirty = false + this.feed.load("sounds", { keepData: true }) + }, + }, + template: ` + + +
+

{{ title }}

+

Change the saved camera settings. Live camera preview is unavailable here.

+

Adjust alert and chime volumes. Immediate warnings keep their safety volume ramp.

+

Adjust brightness and screen timing when custom display settings are on.

+

Choose which driving information to show. Use Theme Maker to arrange widgets and change colors.

+

Close gap adjusts following distance during a lane change when StarPilot controls acceleration and braking. It is Off by default.

+

Choose when to switch between Chill and Experimental. Applies on supported vehicles when StarPilot controls acceleration and braking.

+

Braking response works without saved personality curves. A selected personality braking preset or Traffic takes priority.

+

Traffic follow and jerk blend toward saved Relaxed values at higher speeds. Saved curves require Use saved profiles and the Traffic profile switch.

+

{{ state.data?.subtitle || "Checking motion monitor…" }}

+
+ +
+
Local settings are unavailable in preview.
+ +
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/software-status.js b/openpilot/starpilot/galaxy/web/js/software-status.js new file mode 100644 index 0000000000..ad068c0f53 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/software-status.js @@ -0,0 +1,456 @@ +import { decodeLayoutBackup, encodeLayoutBackup, MAX_LAYOUT_BACKUP_BYTES } from "./layout-backup.js" + +const ACTIONS = new Set(["check", "download", "select", "install", "preferences"]) +const REQUEST_STATES = new Set(["pending", "complete", "failed"]) +const UPDATER_ACTIVE = new Set(["checking...", "downloading...", "finalizing update..."]) +const PRIMARY_BRANCHES = ["StarPilot", "Dom"] +const unavailableOperations = () => ({ parked: false, availableBranches: [], selectedTarget: null, + canCheck: false, canDownload: false, canSelect: false, canInstall: false, + reason: "Update controls are unavailable", request: null }) + +export function validSoftwareSnapshot(data) { + const text = (value) => value === null || typeof value === "string" + const flag = (value) => value === null || typeof value === "boolean" + const updater = data?.updater, operations = data?.operations, request = operations?.request + return data?.schemaVersion === 1 && !!data.installed && !!updater && + [data.installed.version, data.installed.branch, data.installed.commit, updater.state, + updater.targetBranch, updater.lastSuccessAt, updater.lastFetchAt].every(text) && + (data.installed.displayVersion === undefined || text(data.installed.displayVersion)) && + [updater.targetChangeFound, updater.finalizedUpdateReady].every(flag) && + (updater.failedCount === null || Number.isSafeInteger(updater.failedCount) && updater.failedCount >= 0) && + (operations === undefined || !!operations && typeof operations.parked === "boolean" && Array.isArray(operations.availableBranches) && + operations.availableBranches.length <= 256 && operations.availableBranches.every((branch) => + typeof branch === "string" && branch.length > 0 && branch.length <= 128) && + text(operations.selectedTarget) && text(operations.reason) && + (operations.automaticDownloads === undefined || flag(operations.automaticDownloads)) && + (operations.canConfigure === undefined || typeof operations.canConfigure === "boolean") && + (operations.history === undefined || validHistory(operations.history)) && + [operations.canCheck, operations.canDownload, operations.canSelect, operations.canInstall].every((value) => typeof value === "boolean") && + (request === null || !!request && typeof request.id === "string" && request.id.length <= 100 && + ACTIONS.has(request.action) && text(request.target) && REQUEST_STATES.has(request.state) && text(request.error))) +} + +export function validHistory(value) { + const rows = (items) => Array.isArray(items) && items.length <= 20 && items.every((row) => + /^[0-9a-f]{40}$/.test(row?.hash) && typeof row.date === "string" && row.date.length <= 64 && + typeof row.subject === "string" && row.subject.length <= 512) + return !!value && rows(value.installed) && rows(value.downloaded) && + [value.currentReleaseNotes, value.downloadedReleaseNotes].every((notes) => notes === null || typeof notes === "string" && notes.length <= 65536) +} + +export class SoftwareStatusFeed { + constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args), + later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) { + Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer }) + this.active = false + this.generation = 0 + this.controller = null + this.timeout = null + this.pollTimer = null + this.data = null + this.attempted = false + this.busy = false + this.mutating = false + this.blocked = false + this.uncertain = false + this.uncertainAction = null + this.actionPriorRequestId = null + this.installBaseline = null + this.notice = "" + this.error = "" + } + + emit(status) { this.publish({ status, data: this.data, busy: this.busy, uncertain: this.uncertain, + notice: this.notice, error: this.error }) } + + stop() { + this.active = false + this.generation++ + this.controller?.abort() + if (this.timeout !== null) this.cancelTimer(this.timeout) + if (this.pollTimer !== null) this.cancelTimer(this.pollTimer) + this.controller = this.timeout = this.pollTimer = null + this.data = this.installBaseline = this.uncertainAction = this.actionPriorRequestId = null + this.attempted = false + this.busy = this.mutating = this.blocked = this.uncertain = false + this.notice = this.error = "" + this.emit("idle") + } + + start() { this.stop(); this.active = true; return this.load() } + + needsRapidPoll() { + return this.uncertain || !!this.installBaseline || this.data?.operations?.parked === false || + this.data?.operations?.request?.state === "pending" || UPDATER_ACTIVE.has(this.data?.updater?.state) + } + + schedulePoll(delay = null) { + if (!this.active || this.pollTimer !== null || this.controller) return + const generation = this.generation + this.pollTimer = this.later(() => { + this.pollTimer = null + if (this.active && generation === this.generation) this.load() + }, delay ?? (this.needsRapidPoll() ? 1000 : 5000)) + } + + async run(body = null) { + if (!this.active || this.mutating) return null + const generation = ++this.generation + this.controller?.abort() + if (this.timeout !== null) this.cancelTimer(this.timeout) + if (this.pollTimer !== null) this.cancelTimer(this.pollTimer) + this.pollTimer = null + const controller = new AbortController() + this.controller = controller + this.busy = body !== null || this.data === null + this.mutating = body !== null + if (body !== null) this.error = "" + this.emit(this.data ? "ready" : this.attempted ? "unavailable" : "loading") + this.timeout = this.later(() => { + if (!this.active || this.generation !== generation || this.controller !== controller) return + controller.abort() + this.generation++ + this.controller = this.timeout = null + this.busy = this.mutating = false + this.attempted = true + this.blocked = true + if (body !== null) { this.uncertain = true; this.uncertainAction = body } + this.error = body !== null ? "The request timed out. Its result is unknown; wait for a fresh status before trying another action." : + "Software status timed out. Refresh to try again." + this.emit(this.data ? "ready" : "unavailable") + this.schedulePoll(2000) + }, 5000) + try { + const response = await this.fetcher(body === null ? "./api/software/status" : "./api/software/action", { + credentials: "same-origin", cache: "no-store", signal: controller.signal, + ...(body === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }), + }) + if (!this.active || generation !== this.generation || controller.signal.aborted) return null + const payload = typeof response.json === "function" ? await response.json().catch(() => null) : null + if (!this.active || generation !== this.generation || controller.signal.aborted) return null + if (response.status === 401 || response.status === 503 && + ["access_unavailable", "setup_required"].includes(payload?.code)) { + this.stop() + this.unauthorized() + return null + } + if (!response.ok) { + const error = new Error(payload?.error || payload?.message || "Software request failed. Refresh to try again.") + error.rejected = response.status >= 400 && response.status < 500 + throw error + } + if (!validSoftwareSnapshot(payload)) throw new Error("Software response is unavailable. Refresh to try again.") + this.data = payload.operations === undefined ? { ...payload, operations: unavailableOperations() } : payload + this.attempted = true + this.blocked = false + if (body !== null) { + this.uncertain = false + this.uncertainAction = null + this.actionPriorRequestId = null + if (body.action === "install") { + this.installBaseline = this.installBaseline || { commit: payload.installed.commit, branch: payload.installed.branch, target: body.branch } + this.notice = "Restart requested. Waiting to reconnect and verify the installed build." + } else this.notice = body.action === "select" ? `Target branch set to ${body.branch}. Check and download the update when ready.` : + body.action === "preferences" ? "Automatic download preference saved." : "" + } else { + const observed = payload.operations.request + const requestObserved = !!observed && !!this.uncertainAction && observed.action === this.uncertainAction.action && + observed.id !== this.actionPriorRequestId && + (!this.uncertainAction?.branch || observed.target === this.uncertainAction.branch) + const selectionObserved = this.uncertainAction?.action === "select" && + payload.operations.selectedTarget === this.uncertainAction.branch + const preferenceObserved = this.uncertainAction?.action === "preferences" && + payload.operations.automaticDownloads === this.uncertainAction.automaticDownloads + if (this.uncertain && (requestObserved || selectionObserved || preferenceObserved)) { + this.uncertain = false + this.uncertainAction = null + this.actionPriorRequestId = null + } + if (this.installBaseline && payload.installed.branch === this.installBaseline.target && + (payload.installed.commit && payload.installed.commit !== this.installBaseline.commit || + payload.installed.branch !== this.installBaseline.branch)) { + this.installBaseline = null + this.uncertain = false + this.uncertainAction = null + this.actionPriorRequestId = null + this.notice = "Installed build verified after reconnect." + } + } + this.error = "" + this.emit("ready") + return payload + } catch (error) { + if (this.active && generation === this.generation && !controller.signal.aborted) { + this.attempted = true + this.blocked = true + if (body !== null) { + this.uncertain = !error?.rejected + this.uncertainAction = this.uncertain ? body : null + if (error?.rejected && body.action === "install") this.installBaseline = null + } + this.error = error?.message || "Software request failed. Refresh to try again." + this.emit(this.data ? "ready" : this.attempted ? "unavailable" : "loading") + } + return null + } finally { + if (this.controller === controller) { + if (this.timeout !== null) this.cancelTimer(this.timeout) + this.controller = this.timeout = null + this.busy = this.mutating = false + this.emit(this.data ? "ready" : "unavailable") + this.schedulePoll(this.error ? 2000 : null) + } + } + } + + load() { return this.run() } + + configureAutomaticDownloads(enabled) { + if (!this.active || this.mutating || this.blocked || this.uncertain || this.data?.operations?.canConfigure !== true || + typeof enabled !== "boolean") return null + return this.run({ action: "preferences", automaticDownloads: enabled, + expectedAutomaticDownloads: this.data.operations.automaticDownloads }) + } + + action(action, branch = null) { + const operations = this.data?.operations + const capability = { check: "canCheck", download: "canDownload", select: "canSelect", install: "canInstall" }[action] + if (!this.active || !capability || this.mutating || this.blocked || this.uncertain || !operations?.parked || + operations[capability] !== true || operations.request?.state === "pending") return null + if (action === "select" && (branch === "other:" || !operations.availableBranches.includes(branch) || branch === operations.selectedTarget)) return null + if (["download", "install"].includes(action) && (!branch || branch !== operations.selectedTarget)) return null + this.actionPriorRequestId = operations.request?.id ?? null + if (action === "install") this.installBaseline = { commit: this.data.installed.commit, + branch: this.data.installed.branch, target: branch } + return this.run(action === "check" ? { action } : { action, branch }) + } +} + +export const SoftwarePage = { + name: "SoftwarePage", + props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }, + data: () => ({ status: "idle", data: null, busy: false, uncertain: false, notice: "", error: "", + primaryChoice: "", draftBranch: "", draftTouched: false, dialog: null, + layoutBusy: false, layoutNotice: "", layoutError: "", layoutDraft: null }), + created() { + this.feed = new SoftwareStatusFeed({ publish: (update) => { + Object.assign(this.$data, update) + if (update.data && !this.draftTouched) this.syncDraftBranch(update.data.operations.selectedTarget) + }, unauthorized: this.unauthorized }) + }, + mounted() { if (this.mode === "local") this.feed.start() }, + beforeUnmount() { this.feed.stop() }, + computed: { + operations() { return this.data?.operations }, + pending() { return this.operations?.request?.state === "pending" }, + actionDisabled() { return this.busy || this.uncertain || this.pending || !!this.error || !this.operations?.parked }, + primaryBranchHelp() { + return this.primaryChoice === "StarPilot" ? "Stable releases. Recommended for most users." : + this.primaryChoice === "Dom" ? "Latest features and fixes under development. Updates regularly and may introduce bugs." : "" + }, + otherBranches() { + const available = this.operations?.availableBranches || [] + const selected = this.operations?.selectedTarget + const installed = this.data?.installed?.branch + return [...new Set([selected, installed, ...available].filter((branch) => branch && branch !== "other:" && !PRIMARY_BRANCHES.includes(branch)))].map((branch) => ({ + name: branch, listed: available.includes(branch), current: branch === installed, + })) + }, + canStageBranch() { + return !!this.draftBranch && this.draftBranch !== "other:" && + this.operations?.availableBranches.includes(this.draftBranch) && + this.draftBranch !== this.operations.selectedTarget && + (this.primaryChoice === "other:" ? !PRIMARY_BRANCHES.includes(this.draftBranch) : this.primaryChoice === this.draftBranch) + }, + }, + methods: { + async layoutRequest(document = undefined, revision = undefined) { + const response = await fetch("./api/ui/layout", document === undefined ? { + credentials: "same-origin", cache: "no-store", + } : { + method: "POST", credentials: "same-origin", cache: "no-store", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ revision, document }), + }) + if (response.status === 401) this.unauthorized() + const value = await response.json().catch(() => ({})) + if (!response.ok) throw new Error(value.error || "Visual layout is unavailable. Refresh and try again.") + return value + }, + async exportLayout() { + if (this.layoutBusy) return + this.layoutBusy = true + this.layoutNotice = this.layoutError = "" + try { + const snapshot = await this.layoutRequest() + if (snapshot.valid !== true) throw new Error("Saved visual layout is invalid. Repair it in Theme Maker before exporting.") + const contents = await encodeLayoutBackup(snapshot.document) + const url = URL.createObjectURL(new Blob([contents], { type: "application/json" })) + try { + const link = document.createElement("a") + link.href = url + link.download = "StarPilot-visual-layout-backup.json" + document.body.append(link) + link.click() + link.remove() + } finally { setTimeout(() => URL.revokeObjectURL(url), 1000) } + this.layoutNotice = "Visual layout backup downloaded." + } catch (error) { this.layoutError = error.message || "Could not export visual layout." } + finally { this.layoutBusy = false } + }, + async chooseLayoutFile(event) { + const file = event.target.files?.[0] + event.target.value = "" + if (!file || this.layoutBusy) return + this.layoutError = this.layoutNotice = "" + try { + if (file.size > MAX_LAYOUT_BACKUP_BYTES) throw new Error("Visual layout backup is too large.") + this.layoutDraft = await decodeLayoutBackup(await file.text()) + this.dialog = { action: "restoreLayout", title: "Restore visual layout", + message: "Replace the saved onroad layout and colors with this backup? Turn off the vehicle first. Other settings stay as they are.", + label: "Restore layout" } + } catch (error) { this.layoutDraft = null; this.layoutError = error.message || "Could not read visual layout backup." } + }, + async restoreLayout() { + const draft = this.layoutDraft + this.layoutDraft = null + if (!draft || this.layoutBusy) return + this.layoutBusy = true + this.layoutError = this.layoutNotice = "" + try { + const current = await this.layoutRequest() + if (current.editable !== true) throw new Error("Park the vehicle before restoring the visual layout.") + const saved = await this.layoutRequest(draft, current.revision) + if (saved.valid !== true) throw new Error("Visual layout could not be verified after restore.") + this.layoutNotice = "Visual layout restored." + } catch (error) { this.layoutError = error.message || "Could not restore visual layout." } + finally { this.layoutBusy = false } + }, + syncDraftBranch(branch) { + this.draftBranch = branch || "" + this.primaryChoice = PRIMARY_BRANCHES.includes(this.draftBranch) ? this.draftBranch : this.draftBranch ? "other:" : "" + }, + onPrimaryBranchChange() { + this.draftTouched = true + if (this.primaryChoice === "other:") { + if (!this.otherBranches.some((option) => option.name === this.draftBranch)) { + const selected = this.operations?.selectedTarget + this.draftBranch = this.otherBranches.some((option) => option.name === selected) ? selected : "" + } + } else this.draftBranch = this.primaryChoice + }, + onOtherBranchChange() { this.draftTouched = true }, + shown(value) { return value ?? "Unavailable" }, + reported(value) { return value ? new Date(value).toLocaleString() : "Unavailable" }, + shortCommit(value) { return value ? value.slice(0, 12) : "Unavailable" }, + requestMessage(request) { + if (!request) return "" + if (request.state === "failed") return request.error || "Update request failed. Refresh and try again." + if (request.action === "install") return "Restart requested. Waiting to reconnect and verify the installed build." + if (request.state === "pending") return request.action === "check" ? "Checking for updates…" : "Downloading and preparing update…" + return request.action === "check" ? "Update check finished." : request.action === "download" ? "Download finished." : "Target branch saved." + }, + chooseBranch() { + if (!this.operations?.canSelect || this.actionDisabled || !this.canStageBranch) return + this.dialog = { action: "select", branch: this.draftBranch, title: "Change target branch", + message: `Set ${this.draftBranch} as the target branch? This only stages the choice. Check for updates and download separately.`, label: "Set target branch" } + }, + askInstall() { + if (this.actionDisabled || !this.operations?.canInstall || !this.operations.selectedTarget) return + this.dialog = { action: "install", branch: this.operations.selectedTarget, title: "Restart and install update", + message: `Restart the device to install the finalized update for ${this.operations.selectedTarget}? Keep the vehicle parked and the device accessible.`, label: "Restart & install" } + }, + closeDialog() { this.dialog = null; this.layoutDraft = null }, + async confirmDialog() { + const choice = this.dialog + if (!choice) return + this.dialog = null + if (choice.action === "restoreLayout") { await this.restoreLayout(); return } + const result = await this.feed.action(choice.action, choice.branch) + if (result && choice.action === "select") { + this.draftTouched = false + this.syncDraftBranch(result.operations.selectedTarget) + } + }, + }, + template: ` +
+

Software & Updates

+

Turn off the vehicle before checking, downloading or installing updates. Selecting a branch saves the target for the next check.

+
Software updates are unavailable in preview.
+ +
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/sound-packs.js b/openpilot/starpilot/galaxy/web/js/sound-packs.js new file mode 100644 index 0000000000..647dc347b8 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/sound-packs.js @@ -0,0 +1,172 @@ +import { reactive } from "../vendor/vue/vue.esm-browser.js" + +const RUNNING = new Set(["downloading", "verifying"]) + +export class SoundPacksFeed { + constructor({ publish, installed = () => {}, unauthorized = () => {}, + fetcher = (...args) => fetch(...args), later = (fn, ms) => setTimeout(fn, ms), + cancelTimer = (id) => clearTimeout(id) }) { + Object.assign(this, { publish, installed, unauthorized, fetcher, later, cancelTimer }) + this.active = false + this.generation = 0 + this.request = null + this.timeout = null + this.pollTimer = null + this.snapshot = null + this.seenComplete = new Set() + this.busy = false + this.blocked = false + } + + stop() { + this.active = false + this.generation++ + this.request?.abort() + if (this.timeout !== null) this.cancelTimer(this.timeout) + if (this.pollTimer !== null) this.cancelTimer(this.pollTimer) + this.request = this.timeout = this.pollTimer = null + this.busy = false + this.blocked = false + this.snapshot = null + this.publish({ status: "idle", snapshot: null, busy: false, error: "" }) + } + + start() { + this.stop() + this.active = true + return this.refresh() + } + + async run(path, body = null) { + if (!this.active || this.busy) return + const generation = ++this.generation + if (this.pollTimer !== null) this.cancelTimer(this.pollTimer) + this.pollTimer = null + const request = new AbortController() + this.request = request + this.busy = true + this.publish({ status: this.snapshot ? "ready" : "loading", snapshot: this.snapshot, busy: true, error: "" }) + this.timeout = this.later(() => { + if (!this.active || generation !== this.generation || this.request !== request) return + request.abort() + this.generation++ + this.request = this.timeout = null + this.busy = false + this.blocked = true + this.publish({ status: this.snapshot ? "ready" : "unavailable", snapshot: this.snapshot, busy: false, + error: body ? "The request timed out. Its result is unknown; refresh the catalog before trying again." : + "The sound catalog timed out. Refresh to try again." }) + }, 4000) + try { + const response = await this.fetcher(path, body === null + ? { credentials: "same-origin", cache: "no-store", signal: request.signal } + : { method: "POST", credentials: "same-origin", cache: "no-store", signal: request.signal, + headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }) + if (!this.active || generation !== this.generation || request.signal.aborted) return + const payload = await response.json().catch(() => null) + if (!this.active || generation !== this.generation || request.signal.aborted) return + if (response.status === 401 || (response.status === 503 && + ["access_unavailable", "setup_required"].includes(payload?.code))) { + this.stop() + this.unauthorized() + return + } + if (!response.ok) throw new Error(payload?.error || payload?.message || "Sound request failed.") + if (!payload || !Array.isArray(payload.packs) || typeof payload.parked !== "boolean" || + (payload.job !== null && typeof payload.job !== "object")) throw new Error("Sound catalog is unavailable.") + this.snapshot = payload + this.blocked = false + this.publish({ status: "ready", snapshot: payload, busy: false, error: "" }) + if (payload.job?.state === "complete" && payload.job.id != null && !this.seenComplete.has(payload.job.id)) { + this.seenComplete.add(payload.job.id) + this.installed(payload.job.pack) + } + if (RUNNING.has(payload.job?.state) || !payload.parked) this.pollTimer = this.later(() => { + this.pollTimer = null + this.refresh() + }, 1000) + } catch (error) { + if (this.active && generation === this.generation && !request.signal.aborted) { + this.blocked = true + this.publish({ status: this.snapshot ? "ready" : "unavailable", snapshot: this.snapshot, busy: false, + error: error?.message || "Sound request failed." }) + } + } finally { + if (this.request === request) { + if (this.timeout !== null) this.cancelTimer(this.timeout) + this.request = this.timeout = null + this.busy = false + } + } + } + + refresh() { return this.run("./api/sounds") } + + download(pack) { + if (!this.active || this.busy || this.blocked || !this.snapshot?.parked || !Array.isArray(this.snapshot.packs) || + !this.snapshot.packs.some((item) => item.id === pack && item.installed === false) || + RUNNING.has(this.snapshot.job?.state)) return + return this.run("./api/sounds/download", { pack }) + } + + cancel() { + const job = this.snapshot?.job + if (!this.active || this.busy || this.blocked || !this.snapshot?.parked || !RUNNING.has(job?.state) || job.id == null) return + return this.run("./api/sounds/cancel", { job: job.id }) + } +} + +export const SoundPacks = { + props: { unauthorized: { type: Function, required: true }, disabled: { type: Boolean, default: false } }, + emits: ["installed"], + setup(props, { emit }) { + const state = reactive({ status: "idle", snapshot: null, busy: false, error: "" }) + const feed = new SoundPacksFeed({ publish: (update) => Object.assign(state, update), + installed: (pack) => emit("installed", pack), unauthorized: props.unauthorized }) + return { state, feed } + }, + computed: { + job() { return this.state.snapshot?.job }, + running() { return RUNNING.has(this.job?.state) }, + progress() { + const bytes = Number(this.job?.bytes), total = Number(this.job?.total) + return Number.isFinite(bytes) && Number.isFinite(total) && total > 0 + ? Math.min(100, Math.max(0, Math.round(bytes / total * 100))) : null + }, + }, + mounted() { this.feed.start() }, + beforeUnmount() { this.feed.stop() }, + methods: { + canDownload(pack) { return !this.disabled && !this.state.busy && !this.state.error && this.state.status === "ready" && + this.state.snapshot?.parked === true && !this.running && pack.installed === false }, + }, + template: ` +
+
+ Sound pack catalog + +
+
+

Loading sound packs…

+

Sound packs are unavailable.

+

{{ state.error }}

+

Park the vehicle to download sound packs.

+

No sound packs are available.

+
+ {{ pack.name }} +
+ Installed + +
+
+
+

{{ job.state === 'verifying' ? 'Verifying' : 'Downloading' }} {{ state.snapshot.packs.find(pack => pack.id === job.pack)?.name || 'sound pack' }} · {{ progress }}%

+

Sound pack installed. Choose it from the pack selector above when ready.

+

Download cancelled.

+

Download failed: {{ job.error || 'Unknown error.' }}

+ {{ progress }}% + +
+
+
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/startup.js b/openpilot/starpilot/galaxy/web/js/startup.js new file mode 100644 index 0000000000..cbe1c52771 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/startup.js @@ -0,0 +1,36 @@ +export async function requestJson(url, { fetcher = (...args) => fetch(...args), timeout = 8000, + later = (fn, ms) => setTimeout(fn, ms), cancel = id => clearTimeout(id) } = {}) { + const controller = new AbortController() + let timer + try { + return await Promise.race([ + (async () => { + const response = await fetcher(url, { cache: "no-store", credentials: "same-origin", signal: controller.signal }) + if (!response.ok) throw new Error("Galaxy could not be reached") + return response.json() + })(), + new Promise((_, reject) => { + timer = later(() => { controller.abort(); reject(new Error("Galaxy took too long to respond")) }, timeout) + }), + ]) + } finally { + cancel(timer) + controller.abort() + } +} + +export async function loadCatalog(options) { + const [catalog, runtime] = await Promise.all([ + requestJson("./data/catalog.json", options), requestJson("./data/runtime.json", options), + ]) + if (runtime?.schemaVersion !== 1 || !["sample", "local"].includes(runtime.monitor)) throw new Error("Invalid Galaxy runtime") + const available = ["/system", "/navigation", "/manage_models", "/model_laboratory", "/cameras", "/appearance", + "/developer/connect", "/device-preferences", "/driving", "/tuning", "/bluetooth", "/vehicle", "/theme_maker", "/galaxy", "/android-auto"] + if (catalog?.mode !== "offline-preview" || !Array.isArray(catalog.tools) || catalog.tools.some(tool => + typeof tool?.path !== "string" || !tool.path.startsWith("/") || typeof tool.name !== "string" || + (tool.path === "/logs" ? tool.availability !== "partial-preview" : + available.includes(tool.path) ? tool.availability !== "local-only" : tool.availability !== "unavailable"))) { + throw new Error("Invalid Galaxy catalog") + } + return { tools: catalog.tools.slice().sort((a, b) => a.name.localeCompare(b.name)), mode: runtime.monitor } +} diff --git a/openpilot/starpilot/galaxy/web/js/system-monitor-data.js b/openpilot/starpilot/galaxy/web/js/system-monitor-data.js new file mode 100644 index 0000000000..5e822fb035 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/system-monitor-data.js @@ -0,0 +1,76 @@ +// Shared boundary; the selected transport must explicitly choose its source mode. +const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value) +const number = (value, min = 0, max = Infinity) => typeof value === "number" && Number.isFinite(value) && value >= min && value <= max ? value : null +const text = (value) => typeof value === "string" && value.trim() ? value : null +const percent = (value) => number(value, 0, 100) +const integer = (value, min = 0) => Number.isSafeInteger(value) && value >= min ? value : null + +export function normalizeSnapshot(raw, expectedMode = "synthetic-preview") { + if (!["synthetic-preview", "local-runtime"].includes(expectedMode) || + !object(raw) || raw.mode !== expectedMode || raw.schemaVersion !== 1 || + !Array.isArray(raw.cores) || !Array.isArray(raw.processes) || !object(raw.memory) || !object(raw.storage) || + number(raw.sampledAt, 1) === null || !text(raw.source)) { + throw new Error("Invalid System Monitor sample") + } + const cores = raw.cores.map((core) => { + if (!object(core) || !text(core.name)) throw new Error("Invalid CPU core in sample") + return { name: core.name, percent: percent(core.percent) } + }) + if (new Set(cores.map((core) => core.name)).size !== cores.length) throw new Error("Duplicate CPU core in sample") + const processes = raw.processes.map((process) => { + if (!object(process) || integer(process.pid, 1) === null || !text(process.name) || !text(process.user) || + typeof process.kernel !== "boolean" || !text(process.state)) throw new Error("Invalid process in sample") + return { pid: process.pid, name: process.name, user: process.user, kernel: process.kernel, + state: process.state, cpu: percent(process.cpu), memoryMiB: number(process.memoryMiB) } + }) + if (new Set(processes.map((process) => process.pid)).size !== processes.length) throw new Error("Duplicate process PID in sample") + const memory = { totalMiB: number(raw.memory.totalMiB, 0.01), usedMiB: number(raw.memory.usedMiB), + availableMiB: number(raw.memory.availableMiB), percent: percent(raw.memory.percent) } + const storage = { usedGiB: number(raw.storage.usedGiB), totalGiB: number(raw.storage.totalGiB) } + if (memory.totalMiB !== null && (memory.usedMiB !== null && memory.usedMiB > memory.totalMiB || + memory.availableMiB !== null && memory.availableMiB > memory.totalMiB)) { + memory.usedMiB = null; memory.availableMiB = null; memory.percent = null + } + if (storage.totalGiB !== null && storage.usedGiB !== null && storage.usedGiB > storage.totalGiB) storage.usedGiB = null + const v = object(raw.vitals) ? raw.vitals : {} + const vitals = Object.fromEntries(["cpuTempC", "gpuTempC", "hotspotTempC", "gpuEdgeTempC", "memoryUsedBytes", "memoryTotalBytes", + "onboardMaxAgeMs", "maxAgeMs", "memoryMaxAgeMs"].map((key) => [key, number(v[key])])) + return { source: raw.source, sampledAt: raw.sampledAt, cpuPercent: percent(raw.cpuPercent), cores, memory, storage, + uptimeSeconds: number(raw.uptimeSeconds), processCount: integer(raw.processCount), processes, vitals } +} + +const FEATURE_LABELS = new Map([ + ["selfdrive.controls.controlsd", "Steering and speed control"], + ["selfdrive.modeld.modeld", "Driving model"], + ["system.hardware.hardwared", "Power and temperature management"], + ["system.loggerd.uploader", "Log uploads"], + ["system.manager.manager", "Process manager"], +]) +export const processFeature = (process) => process.kernel ? "" : FEATURE_LABELS.get(process.name.replace(/^openpilot\./, "")) || "" +const PROCESS_STATES = new Map([["R", "Running"], ["S", "Sleeping"], ["D", "Waiting"], ["T", "Stopped"], + ["t", "Tracing"], ["Z", "Zombie"], ["I", "Idle"]]) +export const processState = (state) => PROCESS_STATES.get(state) || state +export const displayNumber = (value, suffix = "") => value === null || value === undefined ? "—" : `${value.toFixed(1)}${suffix}` + +export function vital(snapshot, field, budget, elapsedMs = 0) { + const v = snapshot?.vitals + if ((field === "memoryUsedBytes" || field === "memoryTotalBytes") && v?.memoryUsedBytes != null && v?.memoryTotalBytes != null && + v.memoryUsedBytes > v.memoryTotalBytes) return null + return v && v[budget] > 0 && elapsedMs >= 0 && elapsedMs < v[budget] ? v[field] ?? null : null +} + +export function processRows(snapshot, { query = "", scope = "comma", sort = "cpu", descending = true } = {}) { + const q = query.trim().toLowerCase() + const keys = new Set(["name", "pid", "cpu", "memoryMiB", "user", "state"]) + const key = keys.has(sort) ? sort : "cpu" + return (snapshot?.processes || []).filter((p) => + (scope === "all" || (scope === "users" ? !p.kernel : p.user === "comma")) && + (!q || `${p.name} ${processFeature(p)} ${p.pid} ${p.user} ${processState(p.state)}`.toLowerCase().includes(q)) + ).slice().sort((a, b) => { + const av = a[key], bv = b[key] + if (av === null) return bv === null ? a.pid - b.pid : 1 + if (bv === null) return -1 + const cmp = typeof av === "number" ? av - bv : String(av).localeCompare(String(bv)) + return (descending ? -cmp : cmp) || a.pid - b.pid + }) +} diff --git a/openpilot/starpilot/galaxy/web/js/system-monitor.js b/openpilot/starpilot/galaxy/web/js/system-monitor.js new file mode 100644 index 0000000000..25af4d96b8 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/system-monitor.js @@ -0,0 +1,60 @@ +import { displayNumber, processFeature, processRows, processState, vital } from "./system-monitor-data.js" +import { MonitorFeed } from "./monitor-feed.js" + +export const SystemMonitor = { + name: "SystemMonitor", + props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }, + data: () => ({ snapshot: null, status: "loading", error: "", query: "", scope: "comma", sort: "cpu", descending: true }), + mounted() { + this.feed = new MonitorFeed({ mode: this.mode, publish: (state) => Object.assign(this, state), unauthorized: this.unauthorized }) + this.feed.start() + }, + beforeUnmount() { this.feed?.stop() }, + computed: { + rows() { return processRows(this.snapshot, this) }, + captured() { return this.snapshot ? new Date(this.snapshot.sampledAt * 1000).toLocaleString() : "—" }, + uptime() { + const s = this.snapshot?.uptimeSeconds + return s === null || s === undefined ? "—" : `${Math.floor(s / 3600)}h ${Math.floor(s / 60) % 60}m` + }, + vramUsed() { return vital(this.snapshot, "memoryUsedBytes", "memoryMaxAgeMs") }, + vramTotal() { return vital(this.snapshot, "memoryTotalBytes", "memoryMaxAgeMs") }, + }, + methods: { + number: displayNumber, + feature: processFeature, + state: processState, + sensor(field, budget) { return vital(this.snapshot, field, budget) }, + sortBy(key) { if (this.sort === key) this.descending = !this.descending; else { this.sort = key; this.descending = ["cpu", "memoryMiB"].includes(key) } }, + arrow(key) { return this.sort === key ? (this.descending ? " ↓" : " ↑") : "" }, + ariaSort(key) { return this.sort !== key ? "none" : this.descending ? "descending" : "ascending" }, + }, + template: ` +
+

System Monitor

{{ mode === 'sample' ? 'Synthetic sample' : 'Local system' }} · Captured {{ captured }}
{{ mode === 'sample' ? 'Offline preview' : status === 'current' ? 'Live' : 'Unavailable' }}
+

Illustrative values only. No device was read.

+

Read-only system activity. Updates every few seconds.

+ +
Loading system activity…
+ +
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/toggle-search.js b/openpilot/starpilot/galaxy/web/js/toggle-search.js new file mode 100644 index 0000000000..7386526009 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/toggle-search.js @@ -0,0 +1,159 @@ +import { reactive } from "../vendor/vue/vue.esm-browser.js" +import { SETTINGS_SECTIONS } from "./settings.js" + +const SPECIAL_PAGES = new Set(["ui_layout", "favorites"]) +const PAGE_SECTIONS = new Map(SETTINGS_SECTIONS.flatMap((section) => + section.pages.filter((page) => !SPECIAL_PAGES.has(page)).map((page) => [page, section.label]))) +const INITIAL_PAGES = [...PAGE_SECTIONS.keys()] + +const searchable = (row, title, section) => [row.label, row.value, row.reason, row.unit, title, section] + .filter((value) => typeof value === "string").join(" ").toLocaleLowerCase() + +export function searchSettings(entries, query, limit = 30) { + const words = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean) + if (!words.length) return [] + return entries.filter((entry) => words.every((word) => entry.search.includes(word))) + .sort((a, b) => Number(b.label.toLocaleLowerCase().startsWith(words[0])) - Number(a.label.toLocaleLowerCase().startsWith(words[0])) || + a.section.localeCompare(b.section) || a.label.localeCompare(b.label)).slice(0, limit) +} + +export class SettingsSearchIndex { + constructor({ fetcher = (...args) => fetch(...args), publish = () => {}, unauthorized = () => {} } = {}) { + Object.assign(this, { fetcher, publish, unauthorized }) + this.entries = [] + this.status = "idle" + this.generation = 0 + this.controller = null + this.pending = null + } + + stop() { + this.generation++ + this.controller?.abort() + this.controller = this.pending = null + this.entries = [] + this.status = "idle" + this.publish({ status: "idle", entries: [], failed: 0 }) + } + + async load() { + if (this.pending) return this.pending + if (this.status === "ready") return + const generation = ++this.generation + const controller = new AbortController() + this.controller = controller + this.status = "loading" + this.entries = [] + this.publish({ status: "loading", entries: [], failed: 0 }) + this.pending = this.collect(generation, controller).finally(() => { + if (this.generation === generation) this.pending = null + }) + return this.pending + } + + async collect(generation, controller) { + const seen = new Set(INITIAL_PAGES) + let queue = [...INITIAL_PAGES] + let failed = 0 + while (queue.length && this.generation === generation) { + const batch = queue + queue = [] + let cursor = 0 + const worker = async () => { + while (cursor < batch.length && this.generation === generation) { + const page = batch[cursor++] + let timer + const timed = new AbortController() + const abort = () => timed.abort() + controller.signal.addEventListener("abort", abort, { once: true }) + try { + timer = setTimeout(abort, 5000) + const response = await this.fetcher(`./api/settings/pages/${encodeURIComponent(page)}`, + { credentials: "same-origin", cache: "no-store", signal: timed.signal }) + if (this.generation !== generation) return + if (response.status === 401) { + this.stop() + this.unauthorized() + return + } + if (!response.ok) throw new Error("Settings unavailable") + const data = await response.json() + if (this.generation !== generation) return + if (data?.page !== page || !Array.isArray(data.rows) || typeof data.title !== "string") throw new Error("Invalid settings page") + const section = PAGE_SECTIONS.get(page.split("/")[0]) || "Toggles" + for (const row of data.rows) { + if (typeof row?.label !== "string" || !row.label) continue + if (typeof row.page === "string" && row.page && !seen.has(row.page) && seen.size < 64) { + seen.add(row.page) + queue.push(row.page) + } + if (row.page) continue + this.entries.push({ page, label: row.label, title: data.title, section, + search: searchable(row, data.title, section) }) + } + this.publish({ status: "loading", entries: [...this.entries], failed }) + } catch { + if (this.generation === generation) failed++ + } finally { + clearTimeout(timer) + controller.signal.removeEventListener("abort", abort) + } + } + } + await Promise.all(Array.from({ length: Math.min(4, batch.length) }, worker)) + } + if (this.generation !== generation) return + this.status = failed ? "partial" : "ready" + this.publish({ status: this.status, entries: [...this.entries], failed }) + } +} + +export const ToggleSearch = { + props: { enabled: { type: Boolean, required: true }, unauthorized: { type: Function, required: true }, + openPage: { type: Function, required: true } }, + setup(props) { + const state = reactive({ query: "", open: false, active: 0, status: "idle", entries: [], failed: 0 }) + const index = new SettingsSearchIndex({ publish: (update) => Object.assign(state, update), unauthorized: props.unauthorized }) + return { state, index } + }, + computed: { + results() { return searchSettings(this.state.entries, this.state.query) }, + showResults() { return this.enabled && this.state.open && !!this.state.query.trim() }, + }, + watch: { + enabled(value) { if (!value) { this.index.stop(); this.state.query = ""; this.state.open = false } }, + }, + beforeUnmount() { this.index.stop() }, + methods: { + onInput() { this.state.open = true; this.state.active = 0; if (this.enabled) this.index.load() }, + onFocus() { if (this.state.query.trim()) { this.state.open = true; this.index.load() } }, + onBlur(event) { if (!event.currentTarget.contains(event.relatedTarget)) this.state.open = false }, + onKey(event) { + if (event.key === "Escape") { this.state.query = ""; this.state.open = false; event.preventDefault(); return } + if (!this.showResults) return + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault() + this.state.active = (this.state.active + (event.key === "ArrowDown" ? 1 : -1) + this.results.length) % Math.max(1, this.results.length) + } else if (event.key === "Enter" && this.results.length) { event.preventDefault(); this.choose(this.results[this.state.active] || this.results[0]) } + }, + choose(hit) { this.state.query = ""; this.state.open = false; this.openPage(hit) }, + }, + template: ` +
+ +
+

Searching saved settings…

+ +

Some settings could not be searched. Try again.

+

No matching saved settings.

+
+
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/tools.js b/openpilot/starpilot/galaxy/web/js/tools.js new file mode 100644 index 0000000000..9f4df5cc9d --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/tools.js @@ -0,0 +1,20 @@ +import { navigate } from "./router.js" + +export const Tools = { + name: "Tools", + props: { tools: { type: Array, required: true }, mode: { type: String, required: true } }, + methods: { open(tool) { navigate(tool.path) } }, + template: ` +
+

Tools

+
+ +
+
+ `, +} diff --git a/openpilot/starpilot/galaxy/web/js/vasm-geometry.js b/openpilot/starpilot/galaxy/web/js/vasm-geometry.js new file mode 100644 index 0000000000..e53e3825c4 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/vasm-geometry.js @@ -0,0 +1,50 @@ +// Coordinates are saved in the unmirrored source camera frame. The editor is mirrored for the driver. +export const FORMATS = [[1928, 1208], [1344, 760]] +export const displaySide = (cameraSide) => cameraSide === "cameraRight" ? "Vehicle left (camera right)" : + cameraSide === "cameraLeft" ? "Vehicle right (camera left)" : "" + +export function sourcePoint(clientX, clientY, rect, width, height) { + if (!(rect?.width > 0 && rect.height > 0) || !FORMATS.some(([w, h]) => w === width && h === height)) return null + const x = (clientX - rect.left) / rect.width + const y = (clientY - rect.top) / rect.height + if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x > 1 || y < 0 || y > 1) return null + return [Math.round((1 - x) * width), Math.round(y * height)] +} + +const orient = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) +const between = (a, b, c) => b >= Math.min(a, c) && b <= Math.max(a, c) +function intersects(a, b, c, d) { + const abc = orient(a, b, c), abToD = orient(a, b, d), cda = orient(c, d, a), cdb = orient(c, d, b) + const on = (p, q, r) => orient(p, q, r) === 0 && between(p[0], q[0], r[0]) && between(p[1], q[1], r[1]) + return on(a, c, b) || on(a, d, b) || on(c, a, d) || on(c, b, d) || + ((abc > 0) !== (abToD > 0) && (cda > 0) !== (cdb > 0)) +} + +export function validPolygon(points, width, height) { + if (!Array.isArray(points) || points.length < 3 || points.length > 32) return false + if (points.some((point) => !Array.isArray(point) || point.length !== 2 || + point.some((v) => !Number.isFinite(v)) || point[0] < 0 || point[0] > width || + point[1] < 0 || point[1] > height)) return false + if (new Set(points.map((p) => `${p[0]},${p[1]}`)).size !== points.length) return false + const area = points.reduce((sum, a, i) => { + const b = points[(i + 1) % points.length] + return sum + a[0] * b[1] - b[0] * a[1] + }, 0) + if (Math.abs(area) <= 1e-9 * width * height) return false + for (let i = 0; i < points.length; i++) { + for (let j = i + 2; j < points.length; j++) { + if (i === 0 && j === points.length - 1) continue + if (intersects(points[i], points[(i + 1) % points.length], points[j], points[(j + 1) % points.length])) return false + } + } + return true +} + +export function annotationDraft(width, height, cameraLeft, cameraRight) { + if (!FORMATS.some(([w, h]) => w === width && h === height)) return null + if (!Array.isArray(cameraLeft) || !Array.isArray(cameraRight) || + (!cameraLeft.length && !cameraRight.length) || + (cameraLeft.length && !validPolygon(cameraLeft, width, height)) || + (cameraRight.length && !validPolygon(cameraRight, width, height))) return null + return { version: 1, width, height, poly_left: cameraLeft, poly_right: cameraRight } +} diff --git a/openpilot/starpilot/galaxy/web/js/vasm.js b/openpilot/starpilot/galaxy/web/js/vasm.js new file mode 100644 index 0000000000..1a9d2a355a --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/vasm.js @@ -0,0 +1,274 @@ +import { reactive } from "../vendor/vue/vue.esm-browser.js" +import { SettingsFeed } from "./settings.js" +import { GalaxySettingRow } from "./galaxy-setting-row.js" +import { FORMATS, annotationDraft, displaySide, sourcePoint } from "./vasm-geometry.js" + +const MAX_STILL_BYTES = 8 * 1024 * 1024 + +export const VasmPage = { + name: "VasmPage", + components: { GalaxySettingRow }, + props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, + go: { type: Function, required: true } }, + setup(props) { + const state = reactive({ status: "idle", data: null, pending: null, error: "", width: 1928, height: 1208, + cameraLeft: [], cameraRight: [], activeSide: "cameraRight", localNote: "", imageName: "", reviewing: false }) + let lastView = null + const feed = new SettingsFeed({ unauthorized: props.unauthorized, publish: (update) => { + Object.assign(state, update) + if (update.status === "ready" && update.data?.view && update.data.view !== lastView) { + lastView = update.data.view + const editor = update.data.editor + state.width = editor.width + state.height = editor.height + state.cameraLeft = editor.cameraLeft.map((point) => [...point]) + state.cameraRight = editor.cameraRight.map((point) => [...point]) + state.localNote = FORMATS.some(([w, h]) => w === editor.width && h === editor.height) ? "" : + "This saved camera size cannot be edited here. Choose a supported size and redraw the regions." + } + } }) + return { state, feed, FORMATS, displaySide } + }, + mounted() { + this._image = null + this._imageUrl = null + this._imageView = null + this._imageGeneration = 0 + this._dragIndex = -1 + this._dragSide = null + if (this.mode === "local") this.feed.start("vasm") + this.$nextTick(() => this.redraw()) + }, + updated() { + if (this._imageView && this._imageView !== this.state.data?.view) this.dropImage() + this.$nextTick(() => this.redraw()) + }, + beforeUnmount() { + this.feed.stop() + this._imageGeneration++ + if (this._image) this._image.src = "" + if (this._imageUrl) URL.revokeObjectURL(this._imageUrl) + this._image = this._imageUrl = null + }, + computed: { + controls() { return (this.state.data?.rows || []).map((row, index) => ({ row, index })) + .filter(({ index }) => index !== this.state.data?.editorRow && !["Saved spot-monitor settings"].includes(this.state.data.rows[index].label)) }, + canEdit() { return this.mode === "local" && !!this.state.data?.parked && this.state.data?.editorRow >= 0 && + !!this.state.data.rows[this.state.data.editorRow]?.available && this.state.status === "ready" && + !this.state.pending && !this.state.reviewing }, + format() { return `${this.state.width}x${this.state.height}` }, + supportedFormat() { return FORMATS.some(([w, h]) => w === this.state.width && h === this.state.height) }, + canDraw() { return this.canEdit && this.supportedFormat }, + }, + methods: { + changeFormat(event) { + const [width, height] = String(event.target.value).split("x").map(Number) + if (!FORMATS.some(([w, h]) => w === width && h === height)) return + if (width === this.state.width && height === this.state.height) return + this.state.width = width + this.state.height = height + this.state.cameraLeft = [] + this.state.cameraRight = [] + this.state.localNote = "Format changed in this draft. Draw at least one region; nothing is saved until confirmation." + this.dropImage() + this.redraw() + }, + dropImage() { + this._imageGeneration++ + if (this._image) this._image.src = "" + if (this._imageUrl) URL.revokeObjectURL(this._imageUrl) + this._image = this._imageUrl = null + this._imageView = null + this.state.imageName = "" + }, + selectImage(event) { + const file = event.target.files?.[0] + event.target.value = "" + if (!file) return + if (!["image/jpeg", "image/png"].includes(file.type) || file.size > MAX_STILL_BYTES || file.size === 0) { + this.state.localNote = "Choose a PNG or JPEG still under 8 MB." + return + } + this.dropImage() + const generation = this._imageGeneration + const selectedWidth = this.state.width, selectedHeight = this.state.height + const selectedView = this.state.data?.view + const url = URL.createObjectURL(file) + const image = new Image() + this._imageUrl = url + image.onload = () => { + if (generation !== this._imageGeneration) return + if (selectedView !== this.state.data?.view || selectedWidth !== this.state.width || + selectedHeight !== this.state.height) { + this.dropImage() + return + } + if (image.naturalWidth !== selectedWidth || image.naturalHeight !== selectedHeight) { + this.state.localNote = `Still image must match ${selectedWidth} × ${selectedHeight}.` + this.dropImage() + return + } + this._image = image + this._imageView = selectedView + this.state.imageName = file.name + this.state.localNote = "This still stays in browser memory and is never uploaded." + this.redraw() + } + image.onerror = () => { + if (generation !== this._imageGeneration) return + this.state.localNote = "Still image could not be decoded." + this.dropImage() + } + image.src = url + }, + point(event) { + const canvas = this.$refs.canvas + return canvas ? sourcePoint(event.clientX, event.clientY, canvas.getBoundingClientRect(), + this.state.width, this.state.height) : null + }, + pointerDown(event) { + if (!this.canDraw || !this.state.activeSide) return + const point = this.point(event) + if (!point) return + const side = this.state.activeSide + const points = this.state[side] + const rect = this.$refs.canvas.getBoundingClientRect() + const radius = 16 * this.state.width / rect.width + const index = points.findIndex(([x, y]) => Math.hypot(x - point[0], y - point[1]) <= radius) + if (index >= 0) { + this._dragIndex = index + this._dragSide = side + event.currentTarget.setPointerCapture?.(event.pointerId) + } else if (points.length < 32) { + this.state[side] = [...points, point] + this.state.localNote = "Draft only; review the regions before saving." + } else this.state.localNote = "Each side allows at most 32 vertices." + this.redraw() + }, + pointerMove(event) { + if (this._dragIndex < 0 || !this._dragSide) return + if (!this.canDraw) { this._dragIndex = -1; this._dragSide = null; return } + const point = this.point(event) + if (!point) return + const points = [...this.state[this._dragSide]] + points[this._dragIndex] = point + this.state[this._dragSide] = points + this.redraw() + }, + pointerUp(event) { + this._dragIndex = -1 + this._dragSide = null + if (event.currentTarget.hasPointerCapture?.(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId) + }, + undo(side) { + this.state[side] = this.state[side].slice(0, -1) + this.redraw() + }, + clear(side) { + this.state[side] = [] + this.redraw() + }, + async saveRegions() { + if (!this.canDraw) return + const draft = annotationDraft(this.state.width, this.state.height, + this.state.cameraLeft, this.state.cameraRight) + if (!draft) { + this.state.localNote = "Draw 3–32 vertices around at least one window. Regions cannot cross or leave the frame." + return + } + this.state.localNote = "" + this.state.reviewing = true + try { + await this.feed.preview(this.state.data.editorRow, 0, draft) + } finally { + this.state.reviewing = false + } + }, + redraw() { + const canvas = this.$refs.canvas + if (!canvas || !FORMATS.some(([w, h]) => w === this.state.width && h === this.state.height)) return + if (canvas.width !== this.state.width) canvas.width = this.state.width + if (canvas.height !== this.state.height) canvas.height = this.state.height + const ctx = canvas.getContext("2d") + if (!ctx) return + const w = canvas.width, h = canvas.height + ctx.fillStyle = "#151821" + ctx.fillRect(0, 0, w, h) + if (this._image) { + ctx.save() + ctx.translate(w, 0) + ctx.scale(-1, 1) + ctx.drawImage(this._image, 0, 0, w, h) + ctx.restore() + } + for (const [side, color] of [["cameraRight", "#5ee5ee"], ["cameraLeft", "#ffb865"]]) { + const points = this.state[side] + if (!points.length) continue + ctx.beginPath() + ctx.moveTo(w - points[0][0], points[0][1]) + for (const [x, y] of points.slice(1)) ctx.lineTo(w - x, y) + if (points.length >= 3) ctx.closePath() + ctx.lineWidth = Math.max(2, w / 640) + ctx.strokeStyle = color + ctx.fillStyle = side === "cameraRight" ? "#5ee5ee38" : "#ffb86538" + if (points.length >= 3) ctx.fill() + ctx.stroke() + for (const [x, y] of points) { + ctx.beginPath() + ctx.arc(w - x, y, Math.max(5, w / 320), 0, Math.PI * 2) + ctx.fillStyle = color + ctx.fill() + } + } + }, + }, + template: ` +
+

Saved preferences

V-ASM Spot Monitoring

+

Draw camera window regions for a saved visual warning preference. Camera capture, model qualification, and current warnings are unavailable here.

+
+
Local saved settings are unavailable in preview.
+ +
`, +} diff --git a/openpilot/starpilot/galaxy/web/js/vehicle-controls.js b/openpilot/starpilot/galaxy/web/js/vehicle-controls.js new file mode 100644 index 0000000000..b60e29f626 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/vehicle-controls.js @@ -0,0 +1,202 @@ +import { reactive } from "../vendor/vue/vue.esm-browser.js" +import { SettingsPage } from "./settings.js" + +export function validVehiclePage(data) { + return data?.version === 1 && typeof data.parked === "boolean" && typeof data.readable === "boolean" && + typeof data.valid === "boolean" && (data.selected === null || typeof data.selected === "string") && + typeof data.selectedLabel === "string" && data.selectedLabel.length <= 120 && + (data.reported === null || (typeof data.reported?.platform === "string" && typeof data.reported?.label === "string" && + typeof data.reported?.make === "string")) && + (data.view === null || typeof data.view === "string" && data.view.length <= 64) && + Array.isArray(data.choices) && data.choices.length <= 1000 && data.choices.every((item) => + typeof item?.platform === "string" && item.platform.length <= 100 && item.platform !== "MOCK" && + typeof item.make === "string" && item.make.length <= 100 && typeof item.label === "string" && item.label.length <= 120) && + (!data.readable || data.view !== null) && (!data.valid || data.selected === null || + data.choices.some((item) => item.platform === data.selected)) +} + +export class VehicleSelectionFeed { + constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args), + later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) { + Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer }) + this.active = false + this.generation = 0 + this.request = null + this.timer = null + this.pollTimer = null + this.data = null + this.pending = null + this.busy = false + this.error = "" + } + + emit(status, error = this.error) { this.error = error; this.publish({ status, data: this.data, pending: this.pending, busy: this.busy, error }) } + stop() { + this.active = false + this.generation++ + this.request?.abort() + if (this.timer !== null) this.cancelTimer(this.timer) + if (this.pollTimer !== null) this.cancelTimer(this.pollTimer) + this.request = this.timer = this.pollTimer = null + this.data = this.pending = null + this.busy = false + this.emit("idle", "") + } + start() { this.stop(); this.active = true; return this.refresh() } + + async run(path, body = null) { + if (!this.active || this.request) return null + if (this.pollTimer !== null) this.cancelTimer(this.pollTimer) + this.pollTimer = null + const generation = this.generation + const request = new AbortController() + this.request = request + this.busy = true + this.emit(body === null ? (this.data ? "ready" : "loading") : "saving") + this.timer = this.later(() => { + if (!this.active || this.generation !== generation || this.request !== request) return + this.request = null + this.timer = null + this.generation++ + this.busy = false + this.data = this.pending = null + request.abort() + this.emit("unavailable", "Vehicle selection timed out. Refresh saved values before trying again.") + }, 8000) + try { + const response = await this.fetcher(path, { credentials: "same-origin", cache: "no-store", signal: request.signal, + ...(body === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }) }) + if (!this.active || this.generation !== generation || this.request !== request || request.signal.aborted) return null + const result = await response.json() + if (!this.active || this.generation !== generation || this.request !== request || request.signal.aborted) return null + if (response.status === 401 || response.status === 503 && ["setup_required", "access_unavailable"].includes(result?.code)) { + this.stop(); this.unauthorized(); return null + } + if (response.status === 409) { + this.data = this.pending = null + this.emit("unavailable", result?.code === "unverified" ? + "Save could not be confirmed. Refresh saved values before trying again." : + "Saved vehicle changed. Refresh before choosing again.") + return null + } + if (!response.ok) throw new Error("Vehicle selection is unavailable. Refresh to try again.") + this.error = "" + return result + } catch (error) { + if (this.active && this.generation === generation && this.request === request && !request.signal.aborted) { + this.data = this.pending = null + this.emit("unavailable", error?.message || "Vehicle selection is unavailable.") + } + return null + } finally { + if (this.request === request) { + if (this.timer !== null) this.cancelTimer(this.timer) + this.request = this.timer = null + this.busy = false + this.emit(this.data ? "ready" : "unavailable") + } + } + } + + async refresh() { + if (!this.active || this.request) return + this.pending = null + const generation = this.generation + const data = await this.run("./api/vehicle-selection") + if (!this.active || this.generation !== generation) return + if (data && validVehiclePage(data)) { + this.data = data + this.emit("ready") + if (!data.parked && data.readable) this.pollTimer = this.later(() => { + this.pollTimer = null + this.refresh() + }, 1000) + } else if (data) { + this.data = null + this.emit("unavailable", "Vehicle selection response was invalid.") + } + } + + async preview(platform) { + if (!this.active || this.request || this.pending || !this.data?.parked || !this.data.readable || + (!this.data.valid && platform !== null) || + (platform !== null && !this.data.choices.some((item) => item.platform === platform))) return + const result = await this.run("./api/vehicle-selection/preview", { view: this.data.view, platform }) + if (result && typeof result.intent === "string" && result.intent.length <= 64 && typeof result.question === "string") { + this.pending = result + this.emit("ready") + } + } + + cancel() { this.pending = null; if (this.active) this.emit(this.data ? "ready" : "unavailable") } + + async confirm() { + if (!this.active || this.request || !this.pending?.intent) return + const intent = this.pending.intent + this.pending = null + this.data = null + const result = await this.run("./api/vehicle-selection/confirm", { intent, confirmed: true }) + if (!this.active) return + if (result?.saved !== true) { + if (!this.error) this.emit("unavailable", "Save could not be confirmed. Refresh saved values before trying again.") + return + } + await this.refresh() + } +} + +export const VehicleControlsPage = { + components: { SettingsPage }, + props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }, + setup(props) { + const state = reactive({ status: "idle", data: null, pending: null, busy: false, error: "", query: "", make: "" }) + const feed = new VehicleSelectionFeed({ publish: (update) => Object.assign(state, update), unauthorized: props.unauthorized }) + return { state, feed } + }, + computed: { + makes() { return [...new Set((this.state.data?.choices || []).map((item) => item.make))] }, + models() { + const needle = this.state.query.trim().toLocaleLowerCase() + return (this.state.data?.choices || []).filter((item) => (!this.state.make || item.make === this.state.make) && + (!needle || `${item.make} ${item.label} ${item.platform}`.toLocaleLowerCase().includes(needle))) + }, + }, + mounted() { if (this.mode === "local") this.feed.start() }, + beforeUnmount() { this.feed.stop() }, + methods: { choose(platform) { this.feed.preview(platform) } }, + template: ` +
+

Vehicle Controls

Vehicle Selection

+

Auto detects your car. A manual choice is saved for the next start; it does not change the car reported now.

+
Local vehicle selection is unavailable in preview.
+ +
`, +} diff --git a/openpilot/starpilot/galaxy/web/manifest.webmanifest b/openpilot/starpilot/galaxy/web/manifest.webmanifest new file mode 100644 index 0000000000..a2c4ef63a2 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/manifest.webmanifest @@ -0,0 +1,30 @@ +{ + "name": "StarPilot Galaxy", + "short_name": "Galaxy", + "id": "./", + "start_url": "./", + "scope": "./", + "display": "standalone", + "background_color": "#0e0a1a", + "theme_color": "#6f42a6", + "icons": [ + { + "src": "./assets/galaxy-icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + }, + { + "src": "./assets/galaxy-icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "./assets/galaxy-icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/openpilot/starpilot/galaxy/web/sentry-push-worker.js b/openpilot/starpilot/galaxy/web/sentry-push-worker.js new file mode 100644 index 0000000000..2c1d429939 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/sentry-push-worker.js @@ -0,0 +1,20 @@ +self.addEventListener("push", event => { + event.waitUntil((async () => { + let value + try { value = event.data.json() } catch { value = {} } + const id = typeof value?.eventId === "string" && /^[0-9a-f]{32}$/.test(value.eventId) ? value.eventId : "sentry" + const body = typeof value?.body === "string" ? value.body.slice(0, 256) : "Parked motion detected." + await self.registration.showNotification("StarPilot Sentry Mode", { body, tag: id, + data: { url: new URL("./#/cameras/events", self.registration.scope).href } }) + })()) +}) +self.addEventListener("notificationclick", event => { + event.notification.close() + event.waitUntil((async () => { + const url = new URL("./#/cameras/events", self.registration.scope).href + const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }) + const existing = windows.find(client => client.url.startsWith(self.registration.scope)) + if (existing) { await existing.navigate(url); return existing.focus() } + return self.clients.openWindow(url) + })()) +}) diff --git a/openpilot/starpilot/galaxy/web/vendor/.gitattributes b/openpilot/starpilot/galaxy/web/vendor/.gitattributes new file mode 100644 index 0000000000..1b2b7bc8f8 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/vendor/.gitattributes @@ -0,0 +1,2 @@ +# Preserve the exact upstream Vue bundle, including its existing whitespace. +vue/vue.esm-browser.js -text whitespace=-blank-at-eol diff --git a/openpilot/starpilot/galaxy/web/vendor/bootstrap-icons/bootstrap-icons.min.css b/openpilot/starpilot/galaxy/web/vendor/bootstrap-icons/bootstrap-icons.min.css new file mode 100644 index 0000000000..dadd6dcac4 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/vendor/bootstrap-icons/bootstrap-icons.min.css @@ -0,0 +1,5 @@ +/*! + * Bootstrap Icons v1.11.3 (https://icons.getbootstrap.com/) + * Copyright 2019-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/icons/blob/main/LICENSE) + */@font-face{font-display:block;font-family:bootstrap-icons;src:url("fonts/bootstrap-icons.woff2?dd67030699838ea613ee6dbda90effa6") format("woff2"),url("fonts/bootstrap-icons.woff?dd67030699838ea613ee6dbda90effa6") format("woff")}.bi::before,[class*=" bi-"]::before,[class^=bi-]::before{display:inline-block;font-family:bootstrap-icons!important;font-style:normal;font-weight:400!important;font-variant:normal;text-transform:none;line-height:1;vertical-align:-.125em;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.bi-123::before{content:"\f67f"}.bi-alarm-fill::before{content:"\f101"}.bi-alarm::before{content:"\f102"}.bi-align-bottom::before{content:"\f103"}.bi-align-center::before{content:"\f104"}.bi-align-end::before{content:"\f105"}.bi-align-middle::before{content:"\f106"}.bi-align-start::before{content:"\f107"}.bi-align-top::before{content:"\f108"}.bi-alt::before{content:"\f109"}.bi-app-indicator::before{content:"\f10a"}.bi-app::before{content:"\f10b"}.bi-archive-fill::before{content:"\f10c"}.bi-archive::before{content:"\f10d"}.bi-arrow-90deg-down::before{content:"\f10e"}.bi-arrow-90deg-left::before{content:"\f10f"}.bi-arrow-90deg-right::before{content:"\f110"}.bi-arrow-90deg-up::before{content:"\f111"}.bi-arrow-bar-down::before{content:"\f112"}.bi-arrow-bar-left::before{content:"\f113"}.bi-arrow-bar-right::before{content:"\f114"}.bi-arrow-bar-up::before{content:"\f115"}.bi-arrow-clockwise::before{content:"\f116"}.bi-arrow-counterclockwise::before{content:"\f117"}.bi-arrow-down-circle-fill::before{content:"\f118"}.bi-arrow-down-circle::before{content:"\f119"}.bi-arrow-down-left-circle-fill::before{content:"\f11a"}.bi-arrow-down-left-circle::before{content:"\f11b"}.bi-arrow-down-left-square-fill::before{content:"\f11c"}.bi-arrow-down-left-square::before{content:"\f11d"}.bi-arrow-down-left::before{content:"\f11e"}.bi-arrow-down-right-circle-fill::before{content:"\f11f"}.bi-arrow-down-right-circle::before{content:"\f120"}.bi-arrow-down-right-square-fill::before{content:"\f121"}.bi-arrow-down-right-square::before{content:"\f122"}.bi-arrow-down-right::before{content:"\f123"}.bi-arrow-down-short::before{content:"\f124"}.bi-arrow-down-square-fill::before{content:"\f125"}.bi-arrow-down-square::before{content:"\f126"}.bi-arrow-down-up::before{content:"\f127"}.bi-arrow-down::before{content:"\f128"}.bi-arrow-left-circle-fill::before{content:"\f129"}.bi-arrow-left-circle::before{content:"\f12a"}.bi-arrow-left-right::before{content:"\f12b"}.bi-arrow-left-short::before{content:"\f12c"}.bi-arrow-left-square-fill::before{content:"\f12d"}.bi-arrow-left-square::before{content:"\f12e"}.bi-arrow-left::before{content:"\f12f"}.bi-arrow-repeat::before{content:"\f130"}.bi-arrow-return-left::before{content:"\f131"}.bi-arrow-return-right::before{content:"\f132"}.bi-arrow-right-circle-fill::before{content:"\f133"}.bi-arrow-right-circle::before{content:"\f134"}.bi-arrow-right-short::before{content:"\f135"}.bi-arrow-right-square-fill::before{content:"\f136"}.bi-arrow-right-square::before{content:"\f137"}.bi-arrow-right::before{content:"\f138"}.bi-arrow-up-circle-fill::before{content:"\f139"}.bi-arrow-up-circle::before{content:"\f13a"}.bi-arrow-up-left-circle-fill::before{content:"\f13b"}.bi-arrow-up-left-circle::before{content:"\f13c"}.bi-arrow-up-left-square-fill::before{content:"\f13d"}.bi-arrow-up-left-square::before{content:"\f13e"}.bi-arrow-up-left::before{content:"\f13f"}.bi-arrow-up-right-circle-fill::before{content:"\f140"}.bi-arrow-up-right-circle::before{content:"\f141"}.bi-arrow-up-right-square-fill::before{content:"\f142"}.bi-arrow-up-right-square::before{content:"\f143"}.bi-arrow-up-right::before{content:"\f144"}.bi-arrow-up-short::before{content:"\f145"}.bi-arrow-up-square-fill::before{content:"\f146"}.bi-arrow-up-square::before{content:"\f147"}.bi-arrow-up::before{content:"\f148"}.bi-arrows-angle-contract::before{content:"\f149"}.bi-arrows-angle-expand::before{content:"\f14a"}.bi-arrows-collapse::before{content:"\f14b"}.bi-arrows-expand::before{content:"\f14c"}.bi-arrows-fullscreen::before{content:"\f14d"}.bi-arrows-move::before{content:"\f14e"}.bi-aspect-ratio-fill::before{content:"\f14f"}.bi-aspect-ratio::before{content:"\f150"}.bi-asterisk::before{content:"\f151"}.bi-at::before{content:"\f152"}.bi-award-fill::before{content:"\f153"}.bi-award::before{content:"\f154"}.bi-back::before{content:"\f155"}.bi-backspace-fill::before{content:"\f156"}.bi-backspace-reverse-fill::before{content:"\f157"}.bi-backspace-reverse::before{content:"\f158"}.bi-backspace::before{content:"\f159"}.bi-badge-3d-fill::before{content:"\f15a"}.bi-badge-3d::before{content:"\f15b"}.bi-badge-4k-fill::before{content:"\f15c"}.bi-badge-4k::before{content:"\f15d"}.bi-badge-8k-fill::before{content:"\f15e"}.bi-badge-8k::before{content:"\f15f"}.bi-badge-ad-fill::before{content:"\f160"}.bi-badge-ad::before{content:"\f161"}.bi-badge-ar-fill::before{content:"\f162"}.bi-badge-ar::before{content:"\f163"}.bi-badge-cc-fill::before{content:"\f164"}.bi-badge-cc::before{content:"\f165"}.bi-badge-hd-fill::before{content:"\f166"}.bi-badge-hd::before{content:"\f167"}.bi-badge-tm-fill::before{content:"\f168"}.bi-badge-tm::before{content:"\f169"}.bi-badge-vo-fill::before{content:"\f16a"}.bi-badge-vo::before{content:"\f16b"}.bi-badge-vr-fill::before{content:"\f16c"}.bi-badge-vr::before{content:"\f16d"}.bi-badge-wc-fill::before{content:"\f16e"}.bi-badge-wc::before{content:"\f16f"}.bi-bag-check-fill::before{content:"\f170"}.bi-bag-check::before{content:"\f171"}.bi-bag-dash-fill::before{content:"\f172"}.bi-bag-dash::before{content:"\f173"}.bi-bag-fill::before{content:"\f174"}.bi-bag-plus-fill::before{content:"\f175"}.bi-bag-plus::before{content:"\f176"}.bi-bag-x-fill::before{content:"\f177"}.bi-bag-x::before{content:"\f178"}.bi-bag::before{content:"\f179"}.bi-bar-chart-fill::before{content:"\f17a"}.bi-bar-chart-line-fill::before{content:"\f17b"}.bi-bar-chart-line::before{content:"\f17c"}.bi-bar-chart-steps::before{content:"\f17d"}.bi-bar-chart::before{content:"\f17e"}.bi-basket-fill::before{content:"\f17f"}.bi-basket::before{content:"\f180"}.bi-basket2-fill::before{content:"\f181"}.bi-basket2::before{content:"\f182"}.bi-basket3-fill::before{content:"\f183"}.bi-basket3::before{content:"\f184"}.bi-battery-charging::before{content:"\f185"}.bi-battery-full::before{content:"\f186"}.bi-battery-half::before{content:"\f187"}.bi-battery::before{content:"\f188"}.bi-bell-fill::before{content:"\f189"}.bi-bell::before{content:"\f18a"}.bi-bezier::before{content:"\f18b"}.bi-bezier2::before{content:"\f18c"}.bi-bicycle::before{content:"\f18d"}.bi-binoculars-fill::before{content:"\f18e"}.bi-binoculars::before{content:"\f18f"}.bi-blockquote-left::before{content:"\f190"}.bi-blockquote-right::before{content:"\f191"}.bi-book-fill::before{content:"\f192"}.bi-book-half::before{content:"\f193"}.bi-book::before{content:"\f194"}.bi-bookmark-check-fill::before{content:"\f195"}.bi-bookmark-check::before{content:"\f196"}.bi-bookmark-dash-fill::before{content:"\f197"}.bi-bookmark-dash::before{content:"\f198"}.bi-bookmark-fill::before{content:"\f199"}.bi-bookmark-heart-fill::before{content:"\f19a"}.bi-bookmark-heart::before{content:"\f19b"}.bi-bookmark-plus-fill::before{content:"\f19c"}.bi-bookmark-plus::before{content:"\f19d"}.bi-bookmark-star-fill::before{content:"\f19e"}.bi-bookmark-star::before{content:"\f19f"}.bi-bookmark-x-fill::before{content:"\f1a0"}.bi-bookmark-x::before{content:"\f1a1"}.bi-bookmark::before{content:"\f1a2"}.bi-bookmarks-fill::before{content:"\f1a3"}.bi-bookmarks::before{content:"\f1a4"}.bi-bookshelf::before{content:"\f1a5"}.bi-bootstrap-fill::before{content:"\f1a6"}.bi-bootstrap-reboot::before{content:"\f1a7"}.bi-bootstrap::before{content:"\f1a8"}.bi-border-all::before{content:"\f1a9"}.bi-border-bottom::before{content:"\f1aa"}.bi-border-center::before{content:"\f1ab"}.bi-border-inner::before{content:"\f1ac"}.bi-border-left::before{content:"\f1ad"}.bi-border-middle::before{content:"\f1ae"}.bi-border-outer::before{content:"\f1af"}.bi-border-right::before{content:"\f1b0"}.bi-border-style::before{content:"\f1b1"}.bi-border-top::before{content:"\f1b2"}.bi-border-width::before{content:"\f1b3"}.bi-border::before{content:"\f1b4"}.bi-bounding-box-circles::before{content:"\f1b5"}.bi-bounding-box::before{content:"\f1b6"}.bi-box-arrow-down-left::before{content:"\f1b7"}.bi-box-arrow-down-right::before{content:"\f1b8"}.bi-box-arrow-down::before{content:"\f1b9"}.bi-box-arrow-in-down-left::before{content:"\f1ba"}.bi-box-arrow-in-down-right::before{content:"\f1bb"}.bi-box-arrow-in-down::before{content:"\f1bc"}.bi-box-arrow-in-left::before{content:"\f1bd"}.bi-box-arrow-in-right::before{content:"\f1be"}.bi-box-arrow-in-up-left::before{content:"\f1bf"}.bi-box-arrow-in-up-right::before{content:"\f1c0"}.bi-box-arrow-in-up::before{content:"\f1c1"}.bi-box-arrow-left::before{content:"\f1c2"}.bi-box-arrow-right::before{content:"\f1c3"}.bi-box-arrow-up-left::before{content:"\f1c4"}.bi-box-arrow-up-right::before{content:"\f1c5"}.bi-box-arrow-up::before{content:"\f1c6"}.bi-box-seam::before{content:"\f1c7"}.bi-box::before{content:"\f1c8"}.bi-braces::before{content:"\f1c9"}.bi-bricks::before{content:"\f1ca"}.bi-briefcase-fill::before{content:"\f1cb"}.bi-briefcase::before{content:"\f1cc"}.bi-brightness-alt-high-fill::before{content:"\f1cd"}.bi-brightness-alt-high::before{content:"\f1ce"}.bi-brightness-alt-low-fill::before{content:"\f1cf"}.bi-brightness-alt-low::before{content:"\f1d0"}.bi-brightness-high-fill::before{content:"\f1d1"}.bi-brightness-high::before{content:"\f1d2"}.bi-brightness-low-fill::before{content:"\f1d3"}.bi-brightness-low::before{content:"\f1d4"}.bi-broadcast-pin::before{content:"\f1d5"}.bi-broadcast::before{content:"\f1d6"}.bi-brush-fill::before{content:"\f1d7"}.bi-brush::before{content:"\f1d8"}.bi-bucket-fill::before{content:"\f1d9"}.bi-bucket::before{content:"\f1da"}.bi-bug-fill::before{content:"\f1db"}.bi-bug::before{content:"\f1dc"}.bi-building::before{content:"\f1dd"}.bi-bullseye::before{content:"\f1de"}.bi-calculator-fill::before{content:"\f1df"}.bi-calculator::before{content:"\f1e0"}.bi-calendar-check-fill::before{content:"\f1e1"}.bi-calendar-check::before{content:"\f1e2"}.bi-calendar-date-fill::before{content:"\f1e3"}.bi-calendar-date::before{content:"\f1e4"}.bi-calendar-day-fill::before{content:"\f1e5"}.bi-calendar-day::before{content:"\f1e6"}.bi-calendar-event-fill::before{content:"\f1e7"}.bi-calendar-event::before{content:"\f1e8"}.bi-calendar-fill::before{content:"\f1e9"}.bi-calendar-minus-fill::before{content:"\f1ea"}.bi-calendar-minus::before{content:"\f1eb"}.bi-calendar-month-fill::before{content:"\f1ec"}.bi-calendar-month::before{content:"\f1ed"}.bi-calendar-plus-fill::before{content:"\f1ee"}.bi-calendar-plus::before{content:"\f1ef"}.bi-calendar-range-fill::before{content:"\f1f0"}.bi-calendar-range::before{content:"\f1f1"}.bi-calendar-week-fill::before{content:"\f1f2"}.bi-calendar-week::before{content:"\f1f3"}.bi-calendar-x-fill::before{content:"\f1f4"}.bi-calendar-x::before{content:"\f1f5"}.bi-calendar::before{content:"\f1f6"}.bi-calendar2-check-fill::before{content:"\f1f7"}.bi-calendar2-check::before{content:"\f1f8"}.bi-calendar2-date-fill::before{content:"\f1f9"}.bi-calendar2-date::before{content:"\f1fa"}.bi-calendar2-day-fill::before{content:"\f1fb"}.bi-calendar2-day::before{content:"\f1fc"}.bi-calendar2-event-fill::before{content:"\f1fd"}.bi-calendar2-event::before{content:"\f1fe"}.bi-calendar2-fill::before{content:"\f1ff"}.bi-calendar2-minus-fill::before{content:"\f200"}.bi-calendar2-minus::before{content:"\f201"}.bi-calendar2-month-fill::before{content:"\f202"}.bi-calendar2-month::before{content:"\f203"}.bi-calendar2-plus-fill::before{content:"\f204"}.bi-calendar2-plus::before{content:"\f205"}.bi-calendar2-range-fill::before{content:"\f206"}.bi-calendar2-range::before{content:"\f207"}.bi-calendar2-week-fill::before{content:"\f208"}.bi-calendar2-week::before{content:"\f209"}.bi-calendar2-x-fill::before{content:"\f20a"}.bi-calendar2-x::before{content:"\f20b"}.bi-calendar2::before{content:"\f20c"}.bi-calendar3-event-fill::before{content:"\f20d"}.bi-calendar3-event::before{content:"\f20e"}.bi-calendar3-fill::before{content:"\f20f"}.bi-calendar3-range-fill::before{content:"\f210"}.bi-calendar3-range::before{content:"\f211"}.bi-calendar3-week-fill::before{content:"\f212"}.bi-calendar3-week::before{content:"\f213"}.bi-calendar3::before{content:"\f214"}.bi-calendar4-event::before{content:"\f215"}.bi-calendar4-range::before{content:"\f216"}.bi-calendar4-week::before{content:"\f217"}.bi-calendar4::before{content:"\f218"}.bi-camera-fill::before{content:"\f219"}.bi-camera-reels-fill::before{content:"\f21a"}.bi-camera-reels::before{content:"\f21b"}.bi-camera-video-fill::before{content:"\f21c"}.bi-camera-video-off-fill::before{content:"\f21d"}.bi-camera-video-off::before{content:"\f21e"}.bi-camera-video::before{content:"\f21f"}.bi-camera::before{content:"\f220"}.bi-camera2::before{content:"\f221"}.bi-capslock-fill::before{content:"\f222"}.bi-capslock::before{content:"\f223"}.bi-card-checklist::before{content:"\f224"}.bi-card-heading::before{content:"\f225"}.bi-card-image::before{content:"\f226"}.bi-card-list::before{content:"\f227"}.bi-card-text::before{content:"\f228"}.bi-caret-down-fill::before{content:"\f229"}.bi-caret-down-square-fill::before{content:"\f22a"}.bi-caret-down-square::before{content:"\f22b"}.bi-caret-down::before{content:"\f22c"}.bi-caret-left-fill::before{content:"\f22d"}.bi-caret-left-square-fill::before{content:"\f22e"}.bi-caret-left-square::before{content:"\f22f"}.bi-caret-left::before{content:"\f230"}.bi-caret-right-fill::before{content:"\f231"}.bi-caret-right-square-fill::before{content:"\f232"}.bi-caret-right-square::before{content:"\f233"}.bi-caret-right::before{content:"\f234"}.bi-caret-up-fill::before{content:"\f235"}.bi-caret-up-square-fill::before{content:"\f236"}.bi-caret-up-square::before{content:"\f237"}.bi-caret-up::before{content:"\f238"}.bi-cart-check-fill::before{content:"\f239"}.bi-cart-check::before{content:"\f23a"}.bi-cart-dash-fill::before{content:"\f23b"}.bi-cart-dash::before{content:"\f23c"}.bi-cart-fill::before{content:"\f23d"}.bi-cart-plus-fill::before{content:"\f23e"}.bi-cart-plus::before{content:"\f23f"}.bi-cart-x-fill::before{content:"\f240"}.bi-cart-x::before{content:"\f241"}.bi-cart::before{content:"\f242"}.bi-cart2::before{content:"\f243"}.bi-cart3::before{content:"\f244"}.bi-cart4::before{content:"\f245"}.bi-cash-stack::before{content:"\f246"}.bi-cash::before{content:"\f247"}.bi-cast::before{content:"\f248"}.bi-chat-dots-fill::before{content:"\f249"}.bi-chat-dots::before{content:"\f24a"}.bi-chat-fill::before{content:"\f24b"}.bi-chat-left-dots-fill::before{content:"\f24c"}.bi-chat-left-dots::before{content:"\f24d"}.bi-chat-left-fill::before{content:"\f24e"}.bi-chat-left-quote-fill::before{content:"\f24f"}.bi-chat-left-quote::before{content:"\f250"}.bi-chat-left-text-fill::before{content:"\f251"}.bi-chat-left-text::before{content:"\f252"}.bi-chat-left::before{content:"\f253"}.bi-chat-quote-fill::before{content:"\f254"}.bi-chat-quote::before{content:"\f255"}.bi-chat-right-dots-fill::before{content:"\f256"}.bi-chat-right-dots::before{content:"\f257"}.bi-chat-right-fill::before{content:"\f258"}.bi-chat-right-quote-fill::before{content:"\f259"}.bi-chat-right-quote::before{content:"\f25a"}.bi-chat-right-text-fill::before{content:"\f25b"}.bi-chat-right-text::before{content:"\f25c"}.bi-chat-right::before{content:"\f25d"}.bi-chat-square-dots-fill::before{content:"\f25e"}.bi-chat-square-dots::before{content:"\f25f"}.bi-chat-square-fill::before{content:"\f260"}.bi-chat-square-quote-fill::before{content:"\f261"}.bi-chat-square-quote::before{content:"\f262"}.bi-chat-square-text-fill::before{content:"\f263"}.bi-chat-square-text::before{content:"\f264"}.bi-chat-square::before{content:"\f265"}.bi-chat-text-fill::before{content:"\f266"}.bi-chat-text::before{content:"\f267"}.bi-chat::before{content:"\f268"}.bi-check-all::before{content:"\f269"}.bi-check-circle-fill::before{content:"\f26a"}.bi-check-circle::before{content:"\f26b"}.bi-check-square-fill::before{content:"\f26c"}.bi-check-square::before{content:"\f26d"}.bi-check::before{content:"\f26e"}.bi-check2-all::before{content:"\f26f"}.bi-check2-circle::before{content:"\f270"}.bi-check2-square::before{content:"\f271"}.bi-check2::before{content:"\f272"}.bi-chevron-bar-contract::before{content:"\f273"}.bi-chevron-bar-down::before{content:"\f274"}.bi-chevron-bar-expand::before{content:"\f275"}.bi-chevron-bar-left::before{content:"\f276"}.bi-chevron-bar-right::before{content:"\f277"}.bi-chevron-bar-up::before{content:"\f278"}.bi-chevron-compact-down::before{content:"\f279"}.bi-chevron-compact-left::before{content:"\f27a"}.bi-chevron-compact-right::before{content:"\f27b"}.bi-chevron-compact-up::before{content:"\f27c"}.bi-chevron-contract::before{content:"\f27d"}.bi-chevron-double-down::before{content:"\f27e"}.bi-chevron-double-left::before{content:"\f27f"}.bi-chevron-double-right::before{content:"\f280"}.bi-chevron-double-up::before{content:"\f281"}.bi-chevron-down::before{content:"\f282"}.bi-chevron-expand::before{content:"\f283"}.bi-chevron-left::before{content:"\f284"}.bi-chevron-right::before{content:"\f285"}.bi-chevron-up::before{content:"\f286"}.bi-circle-fill::before{content:"\f287"}.bi-circle-half::before{content:"\f288"}.bi-circle-square::before{content:"\f289"}.bi-circle::before{content:"\f28a"}.bi-clipboard-check::before{content:"\f28b"}.bi-clipboard-data::before{content:"\f28c"}.bi-clipboard-minus::before{content:"\f28d"}.bi-clipboard-plus::before{content:"\f28e"}.bi-clipboard-x::before{content:"\f28f"}.bi-clipboard::before{content:"\f290"}.bi-clock-fill::before{content:"\f291"}.bi-clock-history::before{content:"\f292"}.bi-clock::before{content:"\f293"}.bi-cloud-arrow-down-fill::before{content:"\f294"}.bi-cloud-arrow-down::before{content:"\f295"}.bi-cloud-arrow-up-fill::before{content:"\f296"}.bi-cloud-arrow-up::before{content:"\f297"}.bi-cloud-check-fill::before{content:"\f298"}.bi-cloud-check::before{content:"\f299"}.bi-cloud-download-fill::before{content:"\f29a"}.bi-cloud-download::before{content:"\f29b"}.bi-cloud-drizzle-fill::before{content:"\f29c"}.bi-cloud-drizzle::before{content:"\f29d"}.bi-cloud-fill::before{content:"\f29e"}.bi-cloud-fog-fill::before{content:"\f29f"}.bi-cloud-fog::before{content:"\f2a0"}.bi-cloud-fog2-fill::before{content:"\f2a1"}.bi-cloud-fog2::before{content:"\f2a2"}.bi-cloud-hail-fill::before{content:"\f2a3"}.bi-cloud-hail::before{content:"\f2a4"}.bi-cloud-haze-fill::before{content:"\f2a6"}.bi-cloud-haze::before{content:"\f2a7"}.bi-cloud-haze2-fill::before{content:"\f2a8"}.bi-cloud-lightning-fill::before{content:"\f2a9"}.bi-cloud-lightning-rain-fill::before{content:"\f2aa"}.bi-cloud-lightning-rain::before{content:"\f2ab"}.bi-cloud-lightning::before{content:"\f2ac"}.bi-cloud-minus-fill::before{content:"\f2ad"}.bi-cloud-minus::before{content:"\f2ae"}.bi-cloud-moon-fill::before{content:"\f2af"}.bi-cloud-moon::before{content:"\f2b0"}.bi-cloud-plus-fill::before{content:"\f2b1"}.bi-cloud-plus::before{content:"\f2b2"}.bi-cloud-rain-fill::before{content:"\f2b3"}.bi-cloud-rain-heavy-fill::before{content:"\f2b4"}.bi-cloud-rain-heavy::before{content:"\f2b5"}.bi-cloud-rain::before{content:"\f2b6"}.bi-cloud-slash-fill::before{content:"\f2b7"}.bi-cloud-slash::before{content:"\f2b8"}.bi-cloud-sleet-fill::before{content:"\f2b9"}.bi-cloud-sleet::before{content:"\f2ba"}.bi-cloud-snow-fill::before{content:"\f2bb"}.bi-cloud-snow::before{content:"\f2bc"}.bi-cloud-sun-fill::before{content:"\f2bd"}.bi-cloud-sun::before{content:"\f2be"}.bi-cloud-upload-fill::before{content:"\f2bf"}.bi-cloud-upload::before{content:"\f2c0"}.bi-cloud::before{content:"\f2c1"}.bi-clouds-fill::before{content:"\f2c2"}.bi-clouds::before{content:"\f2c3"}.bi-cloudy-fill::before{content:"\f2c4"}.bi-cloudy::before{content:"\f2c5"}.bi-code-slash::before{content:"\f2c6"}.bi-code-square::before{content:"\f2c7"}.bi-code::before{content:"\f2c8"}.bi-collection-fill::before{content:"\f2c9"}.bi-collection-play-fill::before{content:"\f2ca"}.bi-collection-play::before{content:"\f2cb"}.bi-collection::before{content:"\f2cc"}.bi-columns-gap::before{content:"\f2cd"}.bi-columns::before{content:"\f2ce"}.bi-command::before{content:"\f2cf"}.bi-compass-fill::before{content:"\f2d0"}.bi-compass::before{content:"\f2d1"}.bi-cone-striped::before{content:"\f2d2"}.bi-cone::before{content:"\f2d3"}.bi-controller::before{content:"\f2d4"}.bi-cpu-fill::before{content:"\f2d5"}.bi-cpu::before{content:"\f2d6"}.bi-credit-card-2-back-fill::before{content:"\f2d7"}.bi-credit-card-2-back::before{content:"\f2d8"}.bi-credit-card-2-front-fill::before{content:"\f2d9"}.bi-credit-card-2-front::before{content:"\f2da"}.bi-credit-card-fill::before{content:"\f2db"}.bi-credit-card::before{content:"\f2dc"}.bi-crop::before{content:"\f2dd"}.bi-cup-fill::before{content:"\f2de"}.bi-cup-straw::before{content:"\f2df"}.bi-cup::before{content:"\f2e0"}.bi-cursor-fill::before{content:"\f2e1"}.bi-cursor-text::before{content:"\f2e2"}.bi-cursor::before{content:"\f2e3"}.bi-dash-circle-dotted::before{content:"\f2e4"}.bi-dash-circle-fill::before{content:"\f2e5"}.bi-dash-circle::before{content:"\f2e6"}.bi-dash-square-dotted::before{content:"\f2e7"}.bi-dash-square-fill::before{content:"\f2e8"}.bi-dash-square::before{content:"\f2e9"}.bi-dash::before{content:"\f2ea"}.bi-diagram-2-fill::before{content:"\f2eb"}.bi-diagram-2::before{content:"\f2ec"}.bi-diagram-3-fill::before{content:"\f2ed"}.bi-diagram-3::before{content:"\f2ee"}.bi-diamond-fill::before{content:"\f2ef"}.bi-diamond-half::before{content:"\f2f0"}.bi-diamond::before{content:"\f2f1"}.bi-dice-1-fill::before{content:"\f2f2"}.bi-dice-1::before{content:"\f2f3"}.bi-dice-2-fill::before{content:"\f2f4"}.bi-dice-2::before{content:"\f2f5"}.bi-dice-3-fill::before{content:"\f2f6"}.bi-dice-3::before{content:"\f2f7"}.bi-dice-4-fill::before{content:"\f2f8"}.bi-dice-4::before{content:"\f2f9"}.bi-dice-5-fill::before{content:"\f2fa"}.bi-dice-5::before{content:"\f2fb"}.bi-dice-6-fill::before{content:"\f2fc"}.bi-dice-6::before{content:"\f2fd"}.bi-disc-fill::before{content:"\f2fe"}.bi-disc::before{content:"\f2ff"}.bi-discord::before{content:"\f300"}.bi-display-fill::before{content:"\f301"}.bi-display::before{content:"\f302"}.bi-distribute-horizontal::before{content:"\f303"}.bi-distribute-vertical::before{content:"\f304"}.bi-door-closed-fill::before{content:"\f305"}.bi-door-closed::before{content:"\f306"}.bi-door-open-fill::before{content:"\f307"}.bi-door-open::before{content:"\f308"}.bi-dot::before{content:"\f309"}.bi-download::before{content:"\f30a"}.bi-droplet-fill::before{content:"\f30b"}.bi-droplet-half::before{content:"\f30c"}.bi-droplet::before{content:"\f30d"}.bi-earbuds::before{content:"\f30e"}.bi-easel-fill::before{content:"\f30f"}.bi-easel::before{content:"\f310"}.bi-egg-fill::before{content:"\f311"}.bi-egg-fried::before{content:"\f312"}.bi-egg::before{content:"\f313"}.bi-eject-fill::before{content:"\f314"}.bi-eject::before{content:"\f315"}.bi-emoji-angry-fill::before{content:"\f316"}.bi-emoji-angry::before{content:"\f317"}.bi-emoji-dizzy-fill::before{content:"\f318"}.bi-emoji-dizzy::before{content:"\f319"}.bi-emoji-expressionless-fill::before{content:"\f31a"}.bi-emoji-expressionless::before{content:"\f31b"}.bi-emoji-frown-fill::before{content:"\f31c"}.bi-emoji-frown::before{content:"\f31d"}.bi-emoji-heart-eyes-fill::before{content:"\f31e"}.bi-emoji-heart-eyes::before{content:"\f31f"}.bi-emoji-laughing-fill::before{content:"\f320"}.bi-emoji-laughing::before{content:"\f321"}.bi-emoji-neutral-fill::before{content:"\f322"}.bi-emoji-neutral::before{content:"\f323"}.bi-emoji-smile-fill::before{content:"\f324"}.bi-emoji-smile-upside-down-fill::before{content:"\f325"}.bi-emoji-smile-upside-down::before{content:"\f326"}.bi-emoji-smile::before{content:"\f327"}.bi-emoji-sunglasses-fill::before{content:"\f328"}.bi-emoji-sunglasses::before{content:"\f329"}.bi-emoji-wink-fill::before{content:"\f32a"}.bi-emoji-wink::before{content:"\f32b"}.bi-envelope-fill::before{content:"\f32c"}.bi-envelope-open-fill::before{content:"\f32d"}.bi-envelope-open::before{content:"\f32e"}.bi-envelope::before{content:"\f32f"}.bi-eraser-fill::before{content:"\f330"}.bi-eraser::before{content:"\f331"}.bi-exclamation-circle-fill::before{content:"\f332"}.bi-exclamation-circle::before{content:"\f333"}.bi-exclamation-diamond-fill::before{content:"\f334"}.bi-exclamation-diamond::before{content:"\f335"}.bi-exclamation-octagon-fill::before{content:"\f336"}.bi-exclamation-octagon::before{content:"\f337"}.bi-exclamation-square-fill::before{content:"\f338"}.bi-exclamation-square::before{content:"\f339"}.bi-exclamation-triangle-fill::before{content:"\f33a"}.bi-exclamation-triangle::before{content:"\f33b"}.bi-exclamation::before{content:"\f33c"}.bi-exclude::before{content:"\f33d"}.bi-eye-fill::before{content:"\f33e"}.bi-eye-slash-fill::before{content:"\f33f"}.bi-eye-slash::before{content:"\f340"}.bi-eye::before{content:"\f341"}.bi-eyedropper::before{content:"\f342"}.bi-eyeglasses::before{content:"\f343"}.bi-facebook::before{content:"\f344"}.bi-file-arrow-down-fill::before{content:"\f345"}.bi-file-arrow-down::before{content:"\f346"}.bi-file-arrow-up-fill::before{content:"\f347"}.bi-file-arrow-up::before{content:"\f348"}.bi-file-bar-graph-fill::before{content:"\f349"}.bi-file-bar-graph::before{content:"\f34a"}.bi-file-binary-fill::before{content:"\f34b"}.bi-file-binary::before{content:"\f34c"}.bi-file-break-fill::before{content:"\f34d"}.bi-file-break::before{content:"\f34e"}.bi-file-check-fill::before{content:"\f34f"}.bi-file-check::before{content:"\f350"}.bi-file-code-fill::before{content:"\f351"}.bi-file-code::before{content:"\f352"}.bi-file-diff-fill::before{content:"\f353"}.bi-file-diff::before{content:"\f354"}.bi-file-earmark-arrow-down-fill::before{content:"\f355"}.bi-file-earmark-arrow-down::before{content:"\f356"}.bi-file-earmark-arrow-up-fill::before{content:"\f357"}.bi-file-earmark-arrow-up::before{content:"\f358"}.bi-file-earmark-bar-graph-fill::before{content:"\f359"}.bi-file-earmark-bar-graph::before{content:"\f35a"}.bi-file-earmark-binary-fill::before{content:"\f35b"}.bi-file-earmark-binary::before{content:"\f35c"}.bi-file-earmark-break-fill::before{content:"\f35d"}.bi-file-earmark-break::before{content:"\f35e"}.bi-file-earmark-check-fill::before{content:"\f35f"}.bi-file-earmark-check::before{content:"\f360"}.bi-file-earmark-code-fill::before{content:"\f361"}.bi-file-earmark-code::before{content:"\f362"}.bi-file-earmark-diff-fill::before{content:"\f363"}.bi-file-earmark-diff::before{content:"\f364"}.bi-file-earmark-easel-fill::before{content:"\f365"}.bi-file-earmark-easel::before{content:"\f366"}.bi-file-earmark-excel-fill::before{content:"\f367"}.bi-file-earmark-excel::before{content:"\f368"}.bi-file-earmark-fill::before{content:"\f369"}.bi-file-earmark-font-fill::before{content:"\f36a"}.bi-file-earmark-font::before{content:"\f36b"}.bi-file-earmark-image-fill::before{content:"\f36c"}.bi-file-earmark-image::before{content:"\f36d"}.bi-file-earmark-lock-fill::before{content:"\f36e"}.bi-file-earmark-lock::before{content:"\f36f"}.bi-file-earmark-lock2-fill::before{content:"\f370"}.bi-file-earmark-lock2::before{content:"\f371"}.bi-file-earmark-medical-fill::before{content:"\f372"}.bi-file-earmark-medical::before{content:"\f373"}.bi-file-earmark-minus-fill::before{content:"\f374"}.bi-file-earmark-minus::before{content:"\f375"}.bi-file-earmark-music-fill::before{content:"\f376"}.bi-file-earmark-music::before{content:"\f377"}.bi-file-earmark-person-fill::before{content:"\f378"}.bi-file-earmark-person::before{content:"\f379"}.bi-file-earmark-play-fill::before{content:"\f37a"}.bi-file-earmark-play::before{content:"\f37b"}.bi-file-earmark-plus-fill::before{content:"\f37c"}.bi-file-earmark-plus::before{content:"\f37d"}.bi-file-earmark-post-fill::before{content:"\f37e"}.bi-file-earmark-post::before{content:"\f37f"}.bi-file-earmark-ppt-fill::before{content:"\f380"}.bi-file-earmark-ppt::before{content:"\f381"}.bi-file-earmark-richtext-fill::before{content:"\f382"}.bi-file-earmark-richtext::before{content:"\f383"}.bi-file-earmark-ruled-fill::before{content:"\f384"}.bi-file-earmark-ruled::before{content:"\f385"}.bi-file-earmark-slides-fill::before{content:"\f386"}.bi-file-earmark-slides::before{content:"\f387"}.bi-file-earmark-spreadsheet-fill::before{content:"\f388"}.bi-file-earmark-spreadsheet::before{content:"\f389"}.bi-file-earmark-text-fill::before{content:"\f38a"}.bi-file-earmark-text::before{content:"\f38b"}.bi-file-earmark-word-fill::before{content:"\f38c"}.bi-file-earmark-word::before{content:"\f38d"}.bi-file-earmark-x-fill::before{content:"\f38e"}.bi-file-earmark-x::before{content:"\f38f"}.bi-file-earmark-zip-fill::before{content:"\f390"}.bi-file-earmark-zip::before{content:"\f391"}.bi-file-earmark::before{content:"\f392"}.bi-file-easel-fill::before{content:"\f393"}.bi-file-easel::before{content:"\f394"}.bi-file-excel-fill::before{content:"\f395"}.bi-file-excel::before{content:"\f396"}.bi-file-fill::before{content:"\f397"}.bi-file-font-fill::before{content:"\f398"}.bi-file-font::before{content:"\f399"}.bi-file-image-fill::before{content:"\f39a"}.bi-file-image::before{content:"\f39b"}.bi-file-lock-fill::before{content:"\f39c"}.bi-file-lock::before{content:"\f39d"}.bi-file-lock2-fill::before{content:"\f39e"}.bi-file-lock2::before{content:"\f39f"}.bi-file-medical-fill::before{content:"\f3a0"}.bi-file-medical::before{content:"\f3a1"}.bi-file-minus-fill::before{content:"\f3a2"}.bi-file-minus::before{content:"\f3a3"}.bi-file-music-fill::before{content:"\f3a4"}.bi-file-music::before{content:"\f3a5"}.bi-file-person-fill::before{content:"\f3a6"}.bi-file-person::before{content:"\f3a7"}.bi-file-play-fill::before{content:"\f3a8"}.bi-file-play::before{content:"\f3a9"}.bi-file-plus-fill::before{content:"\f3aa"}.bi-file-plus::before{content:"\f3ab"}.bi-file-post-fill::before{content:"\f3ac"}.bi-file-post::before{content:"\f3ad"}.bi-file-ppt-fill::before{content:"\f3ae"}.bi-file-ppt::before{content:"\f3af"}.bi-file-richtext-fill::before{content:"\f3b0"}.bi-file-richtext::before{content:"\f3b1"}.bi-file-ruled-fill::before{content:"\f3b2"}.bi-file-ruled::before{content:"\f3b3"}.bi-file-slides-fill::before{content:"\f3b4"}.bi-file-slides::before{content:"\f3b5"}.bi-file-spreadsheet-fill::before{content:"\f3b6"}.bi-file-spreadsheet::before{content:"\f3b7"}.bi-file-text-fill::before{content:"\f3b8"}.bi-file-text::before{content:"\f3b9"}.bi-file-word-fill::before{content:"\f3ba"}.bi-file-word::before{content:"\f3bb"}.bi-file-x-fill::before{content:"\f3bc"}.bi-file-x::before{content:"\f3bd"}.bi-file-zip-fill::before{content:"\f3be"}.bi-file-zip::before{content:"\f3bf"}.bi-file::before{content:"\f3c0"}.bi-files-alt::before{content:"\f3c1"}.bi-files::before{content:"\f3c2"}.bi-film::before{content:"\f3c3"}.bi-filter-circle-fill::before{content:"\f3c4"}.bi-filter-circle::before{content:"\f3c5"}.bi-filter-left::before{content:"\f3c6"}.bi-filter-right::before{content:"\f3c7"}.bi-filter-square-fill::before{content:"\f3c8"}.bi-filter-square::before{content:"\f3c9"}.bi-filter::before{content:"\f3ca"}.bi-flag-fill::before{content:"\f3cb"}.bi-flag::before{content:"\f3cc"}.bi-flower1::before{content:"\f3cd"}.bi-flower2::before{content:"\f3ce"}.bi-flower3::before{content:"\f3cf"}.bi-folder-check::before{content:"\f3d0"}.bi-folder-fill::before{content:"\f3d1"}.bi-folder-minus::before{content:"\f3d2"}.bi-folder-plus::before{content:"\f3d3"}.bi-folder-symlink-fill::before{content:"\f3d4"}.bi-folder-symlink::before{content:"\f3d5"}.bi-folder-x::before{content:"\f3d6"}.bi-folder::before{content:"\f3d7"}.bi-folder2-open::before{content:"\f3d8"}.bi-folder2::before{content:"\f3d9"}.bi-fonts::before{content:"\f3da"}.bi-forward-fill::before{content:"\f3db"}.bi-forward::before{content:"\f3dc"}.bi-front::before{content:"\f3dd"}.bi-fullscreen-exit::before{content:"\f3de"}.bi-fullscreen::before{content:"\f3df"}.bi-funnel-fill::before{content:"\f3e0"}.bi-funnel::before{content:"\f3e1"}.bi-gear-fill::before{content:"\f3e2"}.bi-gear-wide-connected::before{content:"\f3e3"}.bi-gear-wide::before{content:"\f3e4"}.bi-gear::before{content:"\f3e5"}.bi-gem::before{content:"\f3e6"}.bi-geo-alt-fill::before{content:"\f3e7"}.bi-geo-alt::before{content:"\f3e8"}.bi-geo-fill::before{content:"\f3e9"}.bi-geo::before{content:"\f3ea"}.bi-gift-fill::before{content:"\f3eb"}.bi-gift::before{content:"\f3ec"}.bi-github::before{content:"\f3ed"}.bi-globe::before{content:"\f3ee"}.bi-globe2::before{content:"\f3ef"}.bi-google::before{content:"\f3f0"}.bi-graph-down::before{content:"\f3f1"}.bi-graph-up::before{content:"\f3f2"}.bi-grid-1x2-fill::before{content:"\f3f3"}.bi-grid-1x2::before{content:"\f3f4"}.bi-grid-3x2-gap-fill::before{content:"\f3f5"}.bi-grid-3x2-gap::before{content:"\f3f6"}.bi-grid-3x2::before{content:"\f3f7"}.bi-grid-3x3-gap-fill::before{content:"\f3f8"}.bi-grid-3x3-gap::before{content:"\f3f9"}.bi-grid-3x3::before{content:"\f3fa"}.bi-grid-fill::before{content:"\f3fb"}.bi-grid::before{content:"\f3fc"}.bi-grip-horizontal::before{content:"\f3fd"}.bi-grip-vertical::before{content:"\f3fe"}.bi-hammer::before{content:"\f3ff"}.bi-hand-index-fill::before{content:"\f400"}.bi-hand-index-thumb-fill::before{content:"\f401"}.bi-hand-index-thumb::before{content:"\f402"}.bi-hand-index::before{content:"\f403"}.bi-hand-thumbs-down-fill::before{content:"\f404"}.bi-hand-thumbs-down::before{content:"\f405"}.bi-hand-thumbs-up-fill::before{content:"\f406"}.bi-hand-thumbs-up::before{content:"\f407"}.bi-handbag-fill::before{content:"\f408"}.bi-handbag::before{content:"\f409"}.bi-hash::before{content:"\f40a"}.bi-hdd-fill::before{content:"\f40b"}.bi-hdd-network-fill::before{content:"\f40c"}.bi-hdd-network::before{content:"\f40d"}.bi-hdd-rack-fill::before{content:"\f40e"}.bi-hdd-rack::before{content:"\f40f"}.bi-hdd-stack-fill::before{content:"\f410"}.bi-hdd-stack::before{content:"\f411"}.bi-hdd::before{content:"\f412"}.bi-headphones::before{content:"\f413"}.bi-headset::before{content:"\f414"}.bi-heart-fill::before{content:"\f415"}.bi-heart-half::before{content:"\f416"}.bi-heart::before{content:"\f417"}.bi-heptagon-fill::before{content:"\f418"}.bi-heptagon-half::before{content:"\f419"}.bi-heptagon::before{content:"\f41a"}.bi-hexagon-fill::before{content:"\f41b"}.bi-hexagon-half::before{content:"\f41c"}.bi-hexagon::before{content:"\f41d"}.bi-hourglass-bottom::before{content:"\f41e"}.bi-hourglass-split::before{content:"\f41f"}.bi-hourglass-top::before{content:"\f420"}.bi-hourglass::before{content:"\f421"}.bi-house-door-fill::before{content:"\f422"}.bi-house-door::before{content:"\f423"}.bi-house-fill::before{content:"\f424"}.bi-house::before{content:"\f425"}.bi-hr::before{content:"\f426"}.bi-hurricane::before{content:"\f427"}.bi-image-alt::before{content:"\f428"}.bi-image-fill::before{content:"\f429"}.bi-image::before{content:"\f42a"}.bi-images::before{content:"\f42b"}.bi-inbox-fill::before{content:"\f42c"}.bi-inbox::before{content:"\f42d"}.bi-inboxes-fill::before{content:"\f42e"}.bi-inboxes::before{content:"\f42f"}.bi-info-circle-fill::before{content:"\f430"}.bi-info-circle::before{content:"\f431"}.bi-info-square-fill::before{content:"\f432"}.bi-info-square::before{content:"\f433"}.bi-info::before{content:"\f434"}.bi-input-cursor-text::before{content:"\f435"}.bi-input-cursor::before{content:"\f436"}.bi-instagram::before{content:"\f437"}.bi-intersect::before{content:"\f438"}.bi-journal-album::before{content:"\f439"}.bi-journal-arrow-down::before{content:"\f43a"}.bi-journal-arrow-up::before{content:"\f43b"}.bi-journal-bookmark-fill::before{content:"\f43c"}.bi-journal-bookmark::before{content:"\f43d"}.bi-journal-check::before{content:"\f43e"}.bi-journal-code::before{content:"\f43f"}.bi-journal-medical::before{content:"\f440"}.bi-journal-minus::before{content:"\f441"}.bi-journal-plus::before{content:"\f442"}.bi-journal-richtext::before{content:"\f443"}.bi-journal-text::before{content:"\f444"}.bi-journal-x::before{content:"\f445"}.bi-journal::before{content:"\f446"}.bi-journals::before{content:"\f447"}.bi-joystick::before{content:"\f448"}.bi-justify-left::before{content:"\f449"}.bi-justify-right::before{content:"\f44a"}.bi-justify::before{content:"\f44b"}.bi-kanban-fill::before{content:"\f44c"}.bi-kanban::before{content:"\f44d"}.bi-key-fill::before{content:"\f44e"}.bi-key::before{content:"\f44f"}.bi-keyboard-fill::before{content:"\f450"}.bi-keyboard::before{content:"\f451"}.bi-ladder::before{content:"\f452"}.bi-lamp-fill::before{content:"\f453"}.bi-lamp::before{content:"\f454"}.bi-laptop-fill::before{content:"\f455"}.bi-laptop::before{content:"\f456"}.bi-layer-backward::before{content:"\f457"}.bi-layer-forward::before{content:"\f458"}.bi-layers-fill::before{content:"\f459"}.bi-layers-half::before{content:"\f45a"}.bi-layers::before{content:"\f45b"}.bi-layout-sidebar-inset-reverse::before{content:"\f45c"}.bi-layout-sidebar-inset::before{content:"\f45d"}.bi-layout-sidebar-reverse::before{content:"\f45e"}.bi-layout-sidebar::before{content:"\f45f"}.bi-layout-split::before{content:"\f460"}.bi-layout-text-sidebar-reverse::before{content:"\f461"}.bi-layout-text-sidebar::before{content:"\f462"}.bi-layout-text-window-reverse::before{content:"\f463"}.bi-layout-text-window::before{content:"\f464"}.bi-layout-three-columns::before{content:"\f465"}.bi-layout-wtf::before{content:"\f466"}.bi-life-preserver::before{content:"\f467"}.bi-lightbulb-fill::before{content:"\f468"}.bi-lightbulb-off-fill::before{content:"\f469"}.bi-lightbulb-off::before{content:"\f46a"}.bi-lightbulb::before{content:"\f46b"}.bi-lightning-charge-fill::before{content:"\f46c"}.bi-lightning-charge::before{content:"\f46d"}.bi-lightning-fill::before{content:"\f46e"}.bi-lightning::before{content:"\f46f"}.bi-link-45deg::before{content:"\f470"}.bi-link::before{content:"\f471"}.bi-linkedin::before{content:"\f472"}.bi-list-check::before{content:"\f473"}.bi-list-nested::before{content:"\f474"}.bi-list-ol::before{content:"\f475"}.bi-list-stars::before{content:"\f476"}.bi-list-task::before{content:"\f477"}.bi-list-ul::before{content:"\f478"}.bi-list::before{content:"\f479"}.bi-lock-fill::before{content:"\f47a"}.bi-lock::before{content:"\f47b"}.bi-mailbox::before{content:"\f47c"}.bi-mailbox2::before{content:"\f47d"}.bi-map-fill::before{content:"\f47e"}.bi-map::before{content:"\f47f"}.bi-markdown-fill::before{content:"\f480"}.bi-markdown::before{content:"\f481"}.bi-mask::before{content:"\f482"}.bi-megaphone-fill::before{content:"\f483"}.bi-megaphone::before{content:"\f484"}.bi-menu-app-fill::before{content:"\f485"}.bi-menu-app::before{content:"\f486"}.bi-menu-button-fill::before{content:"\f487"}.bi-menu-button-wide-fill::before{content:"\f488"}.bi-menu-button-wide::before{content:"\f489"}.bi-menu-button::before{content:"\f48a"}.bi-menu-down::before{content:"\f48b"}.bi-menu-up::before{content:"\f48c"}.bi-mic-fill::before{content:"\f48d"}.bi-mic-mute-fill::before{content:"\f48e"}.bi-mic-mute::before{content:"\f48f"}.bi-mic::before{content:"\f490"}.bi-minecart-loaded::before{content:"\f491"}.bi-minecart::before{content:"\f492"}.bi-moisture::before{content:"\f493"}.bi-moon-fill::before{content:"\f494"}.bi-moon-stars-fill::before{content:"\f495"}.bi-moon-stars::before{content:"\f496"}.bi-moon::before{content:"\f497"}.bi-mouse-fill::before{content:"\f498"}.bi-mouse::before{content:"\f499"}.bi-mouse2-fill::before{content:"\f49a"}.bi-mouse2::before{content:"\f49b"}.bi-mouse3-fill::before{content:"\f49c"}.bi-mouse3::before{content:"\f49d"}.bi-music-note-beamed::before{content:"\f49e"}.bi-music-note-list::before{content:"\f49f"}.bi-music-note::before{content:"\f4a0"}.bi-music-player-fill::before{content:"\f4a1"}.bi-music-player::before{content:"\f4a2"}.bi-newspaper::before{content:"\f4a3"}.bi-node-minus-fill::before{content:"\f4a4"}.bi-node-minus::before{content:"\f4a5"}.bi-node-plus-fill::before{content:"\f4a6"}.bi-node-plus::before{content:"\f4a7"}.bi-nut-fill::before{content:"\f4a8"}.bi-nut::before{content:"\f4a9"}.bi-octagon-fill::before{content:"\f4aa"}.bi-octagon-half::before{content:"\f4ab"}.bi-octagon::before{content:"\f4ac"}.bi-option::before{content:"\f4ad"}.bi-outlet::before{content:"\f4ae"}.bi-paint-bucket::before{content:"\f4af"}.bi-palette-fill::before{content:"\f4b0"}.bi-palette::before{content:"\f4b1"}.bi-palette2::before{content:"\f4b2"}.bi-paperclip::before{content:"\f4b3"}.bi-paragraph::before{content:"\f4b4"}.bi-patch-check-fill::before{content:"\f4b5"}.bi-patch-check::before{content:"\f4b6"}.bi-patch-exclamation-fill::before{content:"\f4b7"}.bi-patch-exclamation::before{content:"\f4b8"}.bi-patch-minus-fill::before{content:"\f4b9"}.bi-patch-minus::before{content:"\f4ba"}.bi-patch-plus-fill::before{content:"\f4bb"}.bi-patch-plus::before{content:"\f4bc"}.bi-patch-question-fill::before{content:"\f4bd"}.bi-patch-question::before{content:"\f4be"}.bi-pause-btn-fill::before{content:"\f4bf"}.bi-pause-btn::before{content:"\f4c0"}.bi-pause-circle-fill::before{content:"\f4c1"}.bi-pause-circle::before{content:"\f4c2"}.bi-pause-fill::before{content:"\f4c3"}.bi-pause::before{content:"\f4c4"}.bi-peace-fill::before{content:"\f4c5"}.bi-peace::before{content:"\f4c6"}.bi-pen-fill::before{content:"\f4c7"}.bi-pen::before{content:"\f4c8"}.bi-pencil-fill::before{content:"\f4c9"}.bi-pencil-square::before{content:"\f4ca"}.bi-pencil::before{content:"\f4cb"}.bi-pentagon-fill::before{content:"\f4cc"}.bi-pentagon-half::before{content:"\f4cd"}.bi-pentagon::before{content:"\f4ce"}.bi-people-fill::before{content:"\f4cf"}.bi-people::before{content:"\f4d0"}.bi-percent::before{content:"\f4d1"}.bi-person-badge-fill::before{content:"\f4d2"}.bi-person-badge::before{content:"\f4d3"}.bi-person-bounding-box::before{content:"\f4d4"}.bi-person-check-fill::before{content:"\f4d5"}.bi-person-check::before{content:"\f4d6"}.bi-person-circle::before{content:"\f4d7"}.bi-person-dash-fill::before{content:"\f4d8"}.bi-person-dash::before{content:"\f4d9"}.bi-person-fill::before{content:"\f4da"}.bi-person-lines-fill::before{content:"\f4db"}.bi-person-plus-fill::before{content:"\f4dc"}.bi-person-plus::before{content:"\f4dd"}.bi-person-square::before{content:"\f4de"}.bi-person-x-fill::before{content:"\f4df"}.bi-person-x::before{content:"\f4e0"}.bi-person::before{content:"\f4e1"}.bi-phone-fill::before{content:"\f4e2"}.bi-phone-landscape-fill::before{content:"\f4e3"}.bi-phone-landscape::before{content:"\f4e4"}.bi-phone-vibrate-fill::before{content:"\f4e5"}.bi-phone-vibrate::before{content:"\f4e6"}.bi-phone::before{content:"\f4e7"}.bi-pie-chart-fill::before{content:"\f4e8"}.bi-pie-chart::before{content:"\f4e9"}.bi-pin-angle-fill::before{content:"\f4ea"}.bi-pin-angle::before{content:"\f4eb"}.bi-pin-fill::before{content:"\f4ec"}.bi-pin::before{content:"\f4ed"}.bi-pip-fill::before{content:"\f4ee"}.bi-pip::before{content:"\f4ef"}.bi-play-btn-fill::before{content:"\f4f0"}.bi-play-btn::before{content:"\f4f1"}.bi-play-circle-fill::before{content:"\f4f2"}.bi-play-circle::before{content:"\f4f3"}.bi-play-fill::before{content:"\f4f4"}.bi-play::before{content:"\f4f5"}.bi-plug-fill::before{content:"\f4f6"}.bi-plug::before{content:"\f4f7"}.bi-plus-circle-dotted::before{content:"\f4f8"}.bi-plus-circle-fill::before{content:"\f4f9"}.bi-plus-circle::before{content:"\f4fa"}.bi-plus-square-dotted::before{content:"\f4fb"}.bi-plus-square-fill::before{content:"\f4fc"}.bi-plus-square::before{content:"\f4fd"}.bi-plus::before{content:"\f4fe"}.bi-power::before{content:"\f4ff"}.bi-printer-fill::before{content:"\f500"}.bi-printer::before{content:"\f501"}.bi-puzzle-fill::before{content:"\f502"}.bi-puzzle::before{content:"\f503"}.bi-question-circle-fill::before{content:"\f504"}.bi-question-circle::before{content:"\f505"}.bi-question-diamond-fill::before{content:"\f506"}.bi-question-diamond::before{content:"\f507"}.bi-question-octagon-fill::before{content:"\f508"}.bi-question-octagon::before{content:"\f509"}.bi-question-square-fill::before{content:"\f50a"}.bi-question-square::before{content:"\f50b"}.bi-question::before{content:"\f50c"}.bi-rainbow::before{content:"\f50d"}.bi-receipt-cutoff::before{content:"\f50e"}.bi-receipt::before{content:"\f50f"}.bi-reception-0::before{content:"\f510"}.bi-reception-1::before{content:"\f511"}.bi-reception-2::before{content:"\f512"}.bi-reception-3::before{content:"\f513"}.bi-reception-4::before{content:"\f514"}.bi-record-btn-fill::before{content:"\f515"}.bi-record-btn::before{content:"\f516"}.bi-record-circle-fill::before{content:"\f517"}.bi-record-circle::before{content:"\f518"}.bi-record-fill::before{content:"\f519"}.bi-record::before{content:"\f51a"}.bi-record2-fill::before{content:"\f51b"}.bi-record2::before{content:"\f51c"}.bi-reply-all-fill::before{content:"\f51d"}.bi-reply-all::before{content:"\f51e"}.bi-reply-fill::before{content:"\f51f"}.bi-reply::before{content:"\f520"}.bi-rss-fill::before{content:"\f521"}.bi-rss::before{content:"\f522"}.bi-rulers::before{content:"\f523"}.bi-save-fill::before{content:"\f524"}.bi-save::before{content:"\f525"}.bi-save2-fill::before{content:"\f526"}.bi-save2::before{content:"\f527"}.bi-scissors::before{content:"\f528"}.bi-screwdriver::before{content:"\f529"}.bi-search::before{content:"\f52a"}.bi-segmented-nav::before{content:"\f52b"}.bi-server::before{content:"\f52c"}.bi-share-fill::before{content:"\f52d"}.bi-share::before{content:"\f52e"}.bi-shield-check::before{content:"\f52f"}.bi-shield-exclamation::before{content:"\f530"}.bi-shield-fill-check::before{content:"\f531"}.bi-shield-fill-exclamation::before{content:"\f532"}.bi-shield-fill-minus::before{content:"\f533"}.bi-shield-fill-plus::before{content:"\f534"}.bi-shield-fill-x::before{content:"\f535"}.bi-shield-fill::before{content:"\f536"}.bi-shield-lock-fill::before{content:"\f537"}.bi-shield-lock::before{content:"\f538"}.bi-shield-minus::before{content:"\f539"}.bi-shield-plus::before{content:"\f53a"}.bi-shield-shaded::before{content:"\f53b"}.bi-shield-slash-fill::before{content:"\f53c"}.bi-shield-slash::before{content:"\f53d"}.bi-shield-x::before{content:"\f53e"}.bi-shield::before{content:"\f53f"}.bi-shift-fill::before{content:"\f540"}.bi-shift::before{content:"\f541"}.bi-shop-window::before{content:"\f542"}.bi-shop::before{content:"\f543"}.bi-shuffle::before{content:"\f544"}.bi-signpost-2-fill::before{content:"\f545"}.bi-signpost-2::before{content:"\f546"}.bi-signpost-fill::before{content:"\f547"}.bi-signpost-split-fill::before{content:"\f548"}.bi-signpost-split::before{content:"\f549"}.bi-signpost::before{content:"\f54a"}.bi-sim-fill::before{content:"\f54b"}.bi-sim::before{content:"\f54c"}.bi-skip-backward-btn-fill::before{content:"\f54d"}.bi-skip-backward-btn::before{content:"\f54e"}.bi-skip-backward-circle-fill::before{content:"\f54f"}.bi-skip-backward-circle::before{content:"\f550"}.bi-skip-backward-fill::before{content:"\f551"}.bi-skip-backward::before{content:"\f552"}.bi-skip-end-btn-fill::before{content:"\f553"}.bi-skip-end-btn::before{content:"\f554"}.bi-skip-end-circle-fill::before{content:"\f555"}.bi-skip-end-circle::before{content:"\f556"}.bi-skip-end-fill::before{content:"\f557"}.bi-skip-end::before{content:"\f558"}.bi-skip-forward-btn-fill::before{content:"\f559"}.bi-skip-forward-btn::before{content:"\f55a"}.bi-skip-forward-circle-fill::before{content:"\f55b"}.bi-skip-forward-circle::before{content:"\f55c"}.bi-skip-forward-fill::before{content:"\f55d"}.bi-skip-forward::before{content:"\f55e"}.bi-skip-start-btn-fill::before{content:"\f55f"}.bi-skip-start-btn::before{content:"\f560"}.bi-skip-start-circle-fill::before{content:"\f561"}.bi-skip-start-circle::before{content:"\f562"}.bi-skip-start-fill::before{content:"\f563"}.bi-skip-start::before{content:"\f564"}.bi-slack::before{content:"\f565"}.bi-slash-circle-fill::before{content:"\f566"}.bi-slash-circle::before{content:"\f567"}.bi-slash-square-fill::before{content:"\f568"}.bi-slash-square::before{content:"\f569"}.bi-slash::before{content:"\f56a"}.bi-sliders::before{content:"\f56b"}.bi-smartwatch::before{content:"\f56c"}.bi-snow::before{content:"\f56d"}.bi-snow2::before{content:"\f56e"}.bi-snow3::before{content:"\f56f"}.bi-sort-alpha-down-alt::before{content:"\f570"}.bi-sort-alpha-down::before{content:"\f571"}.bi-sort-alpha-up-alt::before{content:"\f572"}.bi-sort-alpha-up::before{content:"\f573"}.bi-sort-down-alt::before{content:"\f574"}.bi-sort-down::before{content:"\f575"}.bi-sort-numeric-down-alt::before{content:"\f576"}.bi-sort-numeric-down::before{content:"\f577"}.bi-sort-numeric-up-alt::before{content:"\f578"}.bi-sort-numeric-up::before{content:"\f579"}.bi-sort-up-alt::before{content:"\f57a"}.bi-sort-up::before{content:"\f57b"}.bi-soundwave::before{content:"\f57c"}.bi-speaker-fill::before{content:"\f57d"}.bi-speaker::before{content:"\f57e"}.bi-speedometer::before{content:"\f57f"}.bi-speedometer2::before{content:"\f580"}.bi-spellcheck::before{content:"\f581"}.bi-square-fill::before{content:"\f582"}.bi-square-half::before{content:"\f583"}.bi-square::before{content:"\f584"}.bi-stack::before{content:"\f585"}.bi-star-fill::before{content:"\f586"}.bi-star-half::before{content:"\f587"}.bi-star::before{content:"\f588"}.bi-stars::before{content:"\f589"}.bi-stickies-fill::before{content:"\f58a"}.bi-stickies::before{content:"\f58b"}.bi-sticky-fill::before{content:"\f58c"}.bi-sticky::before{content:"\f58d"}.bi-stop-btn-fill::before{content:"\f58e"}.bi-stop-btn::before{content:"\f58f"}.bi-stop-circle-fill::before{content:"\f590"}.bi-stop-circle::before{content:"\f591"}.bi-stop-fill::before{content:"\f592"}.bi-stop::before{content:"\f593"}.bi-stoplights-fill::before{content:"\f594"}.bi-stoplights::before{content:"\f595"}.bi-stopwatch-fill::before{content:"\f596"}.bi-stopwatch::before{content:"\f597"}.bi-subtract::before{content:"\f598"}.bi-suit-club-fill::before{content:"\f599"}.bi-suit-club::before{content:"\f59a"}.bi-suit-diamond-fill::before{content:"\f59b"}.bi-suit-diamond::before{content:"\f59c"}.bi-suit-heart-fill::before{content:"\f59d"}.bi-suit-heart::before{content:"\f59e"}.bi-suit-spade-fill::before{content:"\f59f"}.bi-suit-spade::before{content:"\f5a0"}.bi-sun-fill::before{content:"\f5a1"}.bi-sun::before{content:"\f5a2"}.bi-sunglasses::before{content:"\f5a3"}.bi-sunrise-fill::before{content:"\f5a4"}.bi-sunrise::before{content:"\f5a5"}.bi-sunset-fill::before{content:"\f5a6"}.bi-sunset::before{content:"\f5a7"}.bi-symmetry-horizontal::before{content:"\f5a8"}.bi-symmetry-vertical::before{content:"\f5a9"}.bi-table::before{content:"\f5aa"}.bi-tablet-fill::before{content:"\f5ab"}.bi-tablet-landscape-fill::before{content:"\f5ac"}.bi-tablet-landscape::before{content:"\f5ad"}.bi-tablet::before{content:"\f5ae"}.bi-tag-fill::before{content:"\f5af"}.bi-tag::before{content:"\f5b0"}.bi-tags-fill::before{content:"\f5b1"}.bi-tags::before{content:"\f5b2"}.bi-telegram::before{content:"\f5b3"}.bi-telephone-fill::before{content:"\f5b4"}.bi-telephone-forward-fill::before{content:"\f5b5"}.bi-telephone-forward::before{content:"\f5b6"}.bi-telephone-inbound-fill::before{content:"\f5b7"}.bi-telephone-inbound::before{content:"\f5b8"}.bi-telephone-minus-fill::before{content:"\f5b9"}.bi-telephone-minus::before{content:"\f5ba"}.bi-telephone-outbound-fill::before{content:"\f5bb"}.bi-telephone-outbound::before{content:"\f5bc"}.bi-telephone-plus-fill::before{content:"\f5bd"}.bi-telephone-plus::before{content:"\f5be"}.bi-telephone-x-fill::before{content:"\f5bf"}.bi-telephone-x::before{content:"\f5c0"}.bi-telephone::before{content:"\f5c1"}.bi-terminal-fill::before{content:"\f5c2"}.bi-terminal::before{content:"\f5c3"}.bi-text-center::before{content:"\f5c4"}.bi-text-indent-left::before{content:"\f5c5"}.bi-text-indent-right::before{content:"\f5c6"}.bi-text-left::before{content:"\f5c7"}.bi-text-paragraph::before{content:"\f5c8"}.bi-text-right::before{content:"\f5c9"}.bi-textarea-resize::before{content:"\f5ca"}.bi-textarea-t::before{content:"\f5cb"}.bi-textarea::before{content:"\f5cc"}.bi-thermometer-half::before{content:"\f5cd"}.bi-thermometer-high::before{content:"\f5ce"}.bi-thermometer-low::before{content:"\f5cf"}.bi-thermometer-snow::before{content:"\f5d0"}.bi-thermometer-sun::before{content:"\f5d1"}.bi-thermometer::before{content:"\f5d2"}.bi-three-dots-vertical::before{content:"\f5d3"}.bi-three-dots::before{content:"\f5d4"}.bi-toggle-off::before{content:"\f5d5"}.bi-toggle-on::before{content:"\f5d6"}.bi-toggle2-off::before{content:"\f5d7"}.bi-toggle2-on::before{content:"\f5d8"}.bi-toggles::before{content:"\f5d9"}.bi-toggles2::before{content:"\f5da"}.bi-tools::before{content:"\f5db"}.bi-tornado::before{content:"\f5dc"}.bi-trash-fill::before{content:"\f5dd"}.bi-trash::before{content:"\f5de"}.bi-trash2-fill::before{content:"\f5df"}.bi-trash2::before{content:"\f5e0"}.bi-tree-fill::before{content:"\f5e1"}.bi-tree::before{content:"\f5e2"}.bi-triangle-fill::before{content:"\f5e3"}.bi-triangle-half::before{content:"\f5e4"}.bi-triangle::before{content:"\f5e5"}.bi-trophy-fill::before{content:"\f5e6"}.bi-trophy::before{content:"\f5e7"}.bi-tropical-storm::before{content:"\f5e8"}.bi-truck-flatbed::before{content:"\f5e9"}.bi-truck::before{content:"\f5ea"}.bi-tsunami::before{content:"\f5eb"}.bi-tv-fill::before{content:"\f5ec"}.bi-tv::before{content:"\f5ed"}.bi-twitch::before{content:"\f5ee"}.bi-twitter::before{content:"\f5ef"}.bi-type-bold::before{content:"\f5f0"}.bi-type-h1::before{content:"\f5f1"}.bi-type-h2::before{content:"\f5f2"}.bi-type-h3::before{content:"\f5f3"}.bi-type-italic::before{content:"\f5f4"}.bi-type-strikethrough::before{content:"\f5f5"}.bi-type-underline::before{content:"\f5f6"}.bi-type::before{content:"\f5f7"}.bi-ui-checks-grid::before{content:"\f5f8"}.bi-ui-checks::before{content:"\f5f9"}.bi-ui-radios-grid::before{content:"\f5fa"}.bi-ui-radios::before{content:"\f5fb"}.bi-umbrella-fill::before{content:"\f5fc"}.bi-umbrella::before{content:"\f5fd"}.bi-union::before{content:"\f5fe"}.bi-unlock-fill::before{content:"\f5ff"}.bi-unlock::before{content:"\f600"}.bi-upc-scan::before{content:"\f601"}.bi-upc::before{content:"\f602"}.bi-upload::before{content:"\f603"}.bi-vector-pen::before{content:"\f604"}.bi-view-list::before{content:"\f605"}.bi-view-stacked::before{content:"\f606"}.bi-vinyl-fill::before{content:"\f607"}.bi-vinyl::before{content:"\f608"}.bi-voicemail::before{content:"\f609"}.bi-volume-down-fill::before{content:"\f60a"}.bi-volume-down::before{content:"\f60b"}.bi-volume-mute-fill::before{content:"\f60c"}.bi-volume-mute::before{content:"\f60d"}.bi-volume-off-fill::before{content:"\f60e"}.bi-volume-off::before{content:"\f60f"}.bi-volume-up-fill::before{content:"\f610"}.bi-volume-up::before{content:"\f611"}.bi-vr::before{content:"\f612"}.bi-wallet-fill::before{content:"\f613"}.bi-wallet::before{content:"\f614"}.bi-wallet2::before{content:"\f615"}.bi-watch::before{content:"\f616"}.bi-water::before{content:"\f617"}.bi-whatsapp::before{content:"\f618"}.bi-wifi-1::before{content:"\f619"}.bi-wifi-2::before{content:"\f61a"}.bi-wifi-off::before{content:"\f61b"}.bi-wifi::before{content:"\f61c"}.bi-wind::before{content:"\f61d"}.bi-window-dock::before{content:"\f61e"}.bi-window-sidebar::before{content:"\f61f"}.bi-window::before{content:"\f620"}.bi-wrench::before{content:"\f621"}.bi-x-circle-fill::before{content:"\f622"}.bi-x-circle::before{content:"\f623"}.bi-x-diamond-fill::before{content:"\f624"}.bi-x-diamond::before{content:"\f625"}.bi-x-octagon-fill::before{content:"\f626"}.bi-x-octagon::before{content:"\f627"}.bi-x-square-fill::before{content:"\f628"}.bi-x-square::before{content:"\f629"}.bi-x::before{content:"\f62a"}.bi-youtube::before{content:"\f62b"}.bi-zoom-in::before{content:"\f62c"}.bi-zoom-out::before{content:"\f62d"}.bi-bank::before{content:"\f62e"}.bi-bank2::before{content:"\f62f"}.bi-bell-slash-fill::before{content:"\f630"}.bi-bell-slash::before{content:"\f631"}.bi-cash-coin::before{content:"\f632"}.bi-check-lg::before{content:"\f633"}.bi-coin::before{content:"\f634"}.bi-currency-bitcoin::before{content:"\f635"}.bi-currency-dollar::before{content:"\f636"}.bi-currency-euro::before{content:"\f637"}.bi-currency-exchange::before{content:"\f638"}.bi-currency-pound::before{content:"\f639"}.bi-currency-yen::before{content:"\f63a"}.bi-dash-lg::before{content:"\f63b"}.bi-exclamation-lg::before{content:"\f63c"}.bi-file-earmark-pdf-fill::before{content:"\f63d"}.bi-file-earmark-pdf::before{content:"\f63e"}.bi-file-pdf-fill::before{content:"\f63f"}.bi-file-pdf::before{content:"\f640"}.bi-gender-ambiguous::before{content:"\f641"}.bi-gender-female::before{content:"\f642"}.bi-gender-male::before{content:"\f643"}.bi-gender-trans::before{content:"\f644"}.bi-headset-vr::before{content:"\f645"}.bi-info-lg::before{content:"\f646"}.bi-mastodon::before{content:"\f647"}.bi-messenger::before{content:"\f648"}.bi-piggy-bank-fill::before{content:"\f649"}.bi-piggy-bank::before{content:"\f64a"}.bi-pin-map-fill::before{content:"\f64b"}.bi-pin-map::before{content:"\f64c"}.bi-plus-lg::before{content:"\f64d"}.bi-question-lg::before{content:"\f64e"}.bi-recycle::before{content:"\f64f"}.bi-reddit::before{content:"\f650"}.bi-safe-fill::before{content:"\f651"}.bi-safe2-fill::before{content:"\f652"}.bi-safe2::before{content:"\f653"}.bi-sd-card-fill::before{content:"\f654"}.bi-sd-card::before{content:"\f655"}.bi-skype::before{content:"\f656"}.bi-slash-lg::before{content:"\f657"}.bi-translate::before{content:"\f658"}.bi-x-lg::before{content:"\f659"}.bi-safe::before{content:"\f65a"}.bi-apple::before{content:"\f65b"}.bi-microsoft::before{content:"\f65d"}.bi-windows::before{content:"\f65e"}.bi-behance::before{content:"\f65c"}.bi-dribbble::before{content:"\f65f"}.bi-line::before{content:"\f660"}.bi-medium::before{content:"\f661"}.bi-paypal::before{content:"\f662"}.bi-pinterest::before{content:"\f663"}.bi-signal::before{content:"\f664"}.bi-snapchat::before{content:"\f665"}.bi-spotify::before{content:"\f666"}.bi-stack-overflow::before{content:"\f667"}.bi-strava::before{content:"\f668"}.bi-wordpress::before{content:"\f669"}.bi-vimeo::before{content:"\f66a"}.bi-activity::before{content:"\f66b"}.bi-easel2-fill::before{content:"\f66c"}.bi-easel2::before{content:"\f66d"}.bi-easel3-fill::before{content:"\f66e"}.bi-easel3::before{content:"\f66f"}.bi-fan::before{content:"\f670"}.bi-fingerprint::before{content:"\f671"}.bi-graph-down-arrow::before{content:"\f672"}.bi-graph-up-arrow::before{content:"\f673"}.bi-hypnotize::before{content:"\f674"}.bi-magic::before{content:"\f675"}.bi-person-rolodex::before{content:"\f676"}.bi-person-video::before{content:"\f677"}.bi-person-video2::before{content:"\f678"}.bi-person-video3::before{content:"\f679"}.bi-person-workspace::before{content:"\f67a"}.bi-radioactive::before{content:"\f67b"}.bi-webcam-fill::before{content:"\f67c"}.bi-webcam::before{content:"\f67d"}.bi-yin-yang::before{content:"\f67e"}.bi-bandaid-fill::before{content:"\f680"}.bi-bandaid::before{content:"\f681"}.bi-bluetooth::before{content:"\f682"}.bi-body-text::before{content:"\f683"}.bi-boombox::before{content:"\f684"}.bi-boxes::before{content:"\f685"}.bi-dpad-fill::before{content:"\f686"}.bi-dpad::before{content:"\f687"}.bi-ear-fill::before{content:"\f688"}.bi-ear::before{content:"\f689"}.bi-envelope-check-fill::before{content:"\f68b"}.bi-envelope-check::before{content:"\f68c"}.bi-envelope-dash-fill::before{content:"\f68e"}.bi-envelope-dash::before{content:"\f68f"}.bi-envelope-exclamation-fill::before{content:"\f691"}.bi-envelope-exclamation::before{content:"\f692"}.bi-envelope-plus-fill::before{content:"\f693"}.bi-envelope-plus::before{content:"\f694"}.bi-envelope-slash-fill::before{content:"\f696"}.bi-envelope-slash::before{content:"\f697"}.bi-envelope-x-fill::before{content:"\f699"}.bi-envelope-x::before{content:"\f69a"}.bi-explicit-fill::before{content:"\f69b"}.bi-explicit::before{content:"\f69c"}.bi-git::before{content:"\f69d"}.bi-infinity::before{content:"\f69e"}.bi-list-columns-reverse::before{content:"\f69f"}.bi-list-columns::before{content:"\f6a0"}.bi-meta::before{content:"\f6a1"}.bi-nintendo-switch::before{content:"\f6a4"}.bi-pc-display-horizontal::before{content:"\f6a5"}.bi-pc-display::before{content:"\f6a6"}.bi-pc-horizontal::before{content:"\f6a7"}.bi-pc::before{content:"\f6a8"}.bi-playstation::before{content:"\f6a9"}.bi-plus-slash-minus::before{content:"\f6aa"}.bi-projector-fill::before{content:"\f6ab"}.bi-projector::before{content:"\f6ac"}.bi-qr-code-scan::before{content:"\f6ad"}.bi-qr-code::before{content:"\f6ae"}.bi-quora::before{content:"\f6af"}.bi-quote::before{content:"\f6b0"}.bi-robot::before{content:"\f6b1"}.bi-send-check-fill::before{content:"\f6b2"}.bi-send-check::before{content:"\f6b3"}.bi-send-dash-fill::before{content:"\f6b4"}.bi-send-dash::before{content:"\f6b5"}.bi-send-exclamation-fill::before{content:"\f6b7"}.bi-send-exclamation::before{content:"\f6b8"}.bi-send-fill::before{content:"\f6b9"}.bi-send-plus-fill::before{content:"\f6ba"}.bi-send-plus::before{content:"\f6bb"}.bi-send-slash-fill::before{content:"\f6bc"}.bi-send-slash::before{content:"\f6bd"}.bi-send-x-fill::before{content:"\f6be"}.bi-send-x::before{content:"\f6bf"}.bi-send::before{content:"\f6c0"}.bi-steam::before{content:"\f6c1"}.bi-terminal-dash::before{content:"\f6c3"}.bi-terminal-plus::before{content:"\f6c4"}.bi-terminal-split::before{content:"\f6c5"}.bi-ticket-detailed-fill::before{content:"\f6c6"}.bi-ticket-detailed::before{content:"\f6c7"}.bi-ticket-fill::before{content:"\f6c8"}.bi-ticket-perforated-fill::before{content:"\f6c9"}.bi-ticket-perforated::before{content:"\f6ca"}.bi-ticket::before{content:"\f6cb"}.bi-tiktok::before{content:"\f6cc"}.bi-window-dash::before{content:"\f6cd"}.bi-window-desktop::before{content:"\f6ce"}.bi-window-fullscreen::before{content:"\f6cf"}.bi-window-plus::before{content:"\f6d0"}.bi-window-split::before{content:"\f6d1"}.bi-window-stack::before{content:"\f6d2"}.bi-window-x::before{content:"\f6d3"}.bi-xbox::before{content:"\f6d4"}.bi-ethernet::before{content:"\f6d5"}.bi-hdmi-fill::before{content:"\f6d6"}.bi-hdmi::before{content:"\f6d7"}.bi-usb-c-fill::before{content:"\f6d8"}.bi-usb-c::before{content:"\f6d9"}.bi-usb-fill::before{content:"\f6da"}.bi-usb-plug-fill::before{content:"\f6db"}.bi-usb-plug::before{content:"\f6dc"}.bi-usb-symbol::before{content:"\f6dd"}.bi-usb::before{content:"\f6de"}.bi-boombox-fill::before{content:"\f6df"}.bi-displayport::before{content:"\f6e1"}.bi-gpu-card::before{content:"\f6e2"}.bi-memory::before{content:"\f6e3"}.bi-modem-fill::before{content:"\f6e4"}.bi-modem::before{content:"\f6e5"}.bi-motherboard-fill::before{content:"\f6e6"}.bi-motherboard::before{content:"\f6e7"}.bi-optical-audio-fill::before{content:"\f6e8"}.bi-optical-audio::before{content:"\f6e9"}.bi-pci-card::before{content:"\f6ea"}.bi-router-fill::before{content:"\f6eb"}.bi-router::before{content:"\f6ec"}.bi-thunderbolt-fill::before{content:"\f6ef"}.bi-thunderbolt::before{content:"\f6f0"}.bi-usb-drive-fill::before{content:"\f6f1"}.bi-usb-drive::before{content:"\f6f2"}.bi-usb-micro-fill::before{content:"\f6f3"}.bi-usb-micro::before{content:"\f6f4"}.bi-usb-mini-fill::before{content:"\f6f5"}.bi-usb-mini::before{content:"\f6f6"}.bi-cloud-haze2::before{content:"\f6f7"}.bi-device-hdd-fill::before{content:"\f6f8"}.bi-device-hdd::before{content:"\f6f9"}.bi-device-ssd-fill::before{content:"\f6fa"}.bi-device-ssd::before{content:"\f6fb"}.bi-displayport-fill::before{content:"\f6fc"}.bi-mortarboard-fill::before{content:"\f6fd"}.bi-mortarboard::before{content:"\f6fe"}.bi-terminal-x::before{content:"\f6ff"}.bi-arrow-through-heart-fill::before{content:"\f700"}.bi-arrow-through-heart::before{content:"\f701"}.bi-badge-sd-fill::before{content:"\f702"}.bi-badge-sd::before{content:"\f703"}.bi-bag-heart-fill::before{content:"\f704"}.bi-bag-heart::before{content:"\f705"}.bi-balloon-fill::before{content:"\f706"}.bi-balloon-heart-fill::before{content:"\f707"}.bi-balloon-heart::before{content:"\f708"}.bi-balloon::before{content:"\f709"}.bi-box2-fill::before{content:"\f70a"}.bi-box2-heart-fill::before{content:"\f70b"}.bi-box2-heart::before{content:"\f70c"}.bi-box2::before{content:"\f70d"}.bi-braces-asterisk::before{content:"\f70e"}.bi-calendar-heart-fill::before{content:"\f70f"}.bi-calendar-heart::before{content:"\f710"}.bi-calendar2-heart-fill::before{content:"\f711"}.bi-calendar2-heart::before{content:"\f712"}.bi-chat-heart-fill::before{content:"\f713"}.bi-chat-heart::before{content:"\f714"}.bi-chat-left-heart-fill::before{content:"\f715"}.bi-chat-left-heart::before{content:"\f716"}.bi-chat-right-heart-fill::before{content:"\f717"}.bi-chat-right-heart::before{content:"\f718"}.bi-chat-square-heart-fill::before{content:"\f719"}.bi-chat-square-heart::before{content:"\f71a"}.bi-clipboard-check-fill::before{content:"\f71b"}.bi-clipboard-data-fill::before{content:"\f71c"}.bi-clipboard-fill::before{content:"\f71d"}.bi-clipboard-heart-fill::before{content:"\f71e"}.bi-clipboard-heart::before{content:"\f71f"}.bi-clipboard-minus-fill::before{content:"\f720"}.bi-clipboard-plus-fill::before{content:"\f721"}.bi-clipboard-pulse::before{content:"\f722"}.bi-clipboard-x-fill::before{content:"\f723"}.bi-clipboard2-check-fill::before{content:"\f724"}.bi-clipboard2-check::before{content:"\f725"}.bi-clipboard2-data-fill::before{content:"\f726"}.bi-clipboard2-data::before{content:"\f727"}.bi-clipboard2-fill::before{content:"\f728"}.bi-clipboard2-heart-fill::before{content:"\f729"}.bi-clipboard2-heart::before{content:"\f72a"}.bi-clipboard2-minus-fill::before{content:"\f72b"}.bi-clipboard2-minus::before{content:"\f72c"}.bi-clipboard2-plus-fill::before{content:"\f72d"}.bi-clipboard2-plus::before{content:"\f72e"}.bi-clipboard2-pulse-fill::before{content:"\f72f"}.bi-clipboard2-pulse::before{content:"\f730"}.bi-clipboard2-x-fill::before{content:"\f731"}.bi-clipboard2-x::before{content:"\f732"}.bi-clipboard2::before{content:"\f733"}.bi-emoji-kiss-fill::before{content:"\f734"}.bi-emoji-kiss::before{content:"\f735"}.bi-envelope-heart-fill::before{content:"\f736"}.bi-envelope-heart::before{content:"\f737"}.bi-envelope-open-heart-fill::before{content:"\f738"}.bi-envelope-open-heart::before{content:"\f739"}.bi-envelope-paper-fill::before{content:"\f73a"}.bi-envelope-paper-heart-fill::before{content:"\f73b"}.bi-envelope-paper-heart::before{content:"\f73c"}.bi-envelope-paper::before{content:"\f73d"}.bi-filetype-aac::before{content:"\f73e"}.bi-filetype-ai::before{content:"\f73f"}.bi-filetype-bmp::before{content:"\f740"}.bi-filetype-cs::before{content:"\f741"}.bi-filetype-css::before{content:"\f742"}.bi-filetype-csv::before{content:"\f743"}.bi-filetype-doc::before{content:"\f744"}.bi-filetype-docx::before{content:"\f745"}.bi-filetype-exe::before{content:"\f746"}.bi-filetype-gif::before{content:"\f747"}.bi-filetype-heic::before{content:"\f748"}.bi-filetype-html::before{content:"\f749"}.bi-filetype-java::before{content:"\f74a"}.bi-filetype-jpg::before{content:"\f74b"}.bi-filetype-js::before{content:"\f74c"}.bi-filetype-jsx::before{content:"\f74d"}.bi-filetype-key::before{content:"\f74e"}.bi-filetype-m4p::before{content:"\f74f"}.bi-filetype-md::before{content:"\f750"}.bi-filetype-mdx::before{content:"\f751"}.bi-filetype-mov::before{content:"\f752"}.bi-filetype-mp3::before{content:"\f753"}.bi-filetype-mp4::before{content:"\f754"}.bi-filetype-otf::before{content:"\f755"}.bi-filetype-pdf::before{content:"\f756"}.bi-filetype-php::before{content:"\f757"}.bi-filetype-png::before{content:"\f758"}.bi-filetype-ppt::before{content:"\f75a"}.bi-filetype-psd::before{content:"\f75b"}.bi-filetype-py::before{content:"\f75c"}.bi-filetype-raw::before{content:"\f75d"}.bi-filetype-rb::before{content:"\f75e"}.bi-filetype-sass::before{content:"\f75f"}.bi-filetype-scss::before{content:"\f760"}.bi-filetype-sh::before{content:"\f761"}.bi-filetype-svg::before{content:"\f762"}.bi-filetype-tiff::before{content:"\f763"}.bi-filetype-tsx::before{content:"\f764"}.bi-filetype-ttf::before{content:"\f765"}.bi-filetype-txt::before{content:"\f766"}.bi-filetype-wav::before{content:"\f767"}.bi-filetype-woff::before{content:"\f768"}.bi-filetype-xls::before{content:"\f76a"}.bi-filetype-xml::before{content:"\f76b"}.bi-filetype-yml::before{content:"\f76c"}.bi-heart-arrow::before{content:"\f76d"}.bi-heart-pulse-fill::before{content:"\f76e"}.bi-heart-pulse::before{content:"\f76f"}.bi-heartbreak-fill::before{content:"\f770"}.bi-heartbreak::before{content:"\f771"}.bi-hearts::before{content:"\f772"}.bi-hospital-fill::before{content:"\f773"}.bi-hospital::before{content:"\f774"}.bi-house-heart-fill::before{content:"\f775"}.bi-house-heart::before{content:"\f776"}.bi-incognito::before{content:"\f777"}.bi-magnet-fill::before{content:"\f778"}.bi-magnet::before{content:"\f779"}.bi-person-heart::before{content:"\f77a"}.bi-person-hearts::before{content:"\f77b"}.bi-phone-flip::before{content:"\f77c"}.bi-plugin::before{content:"\f77d"}.bi-postage-fill::before{content:"\f77e"}.bi-postage-heart-fill::before{content:"\f77f"}.bi-postage-heart::before{content:"\f780"}.bi-postage::before{content:"\f781"}.bi-postcard-fill::before{content:"\f782"}.bi-postcard-heart-fill::before{content:"\f783"}.bi-postcard-heart::before{content:"\f784"}.bi-postcard::before{content:"\f785"}.bi-search-heart-fill::before{content:"\f786"}.bi-search-heart::before{content:"\f787"}.bi-sliders2-vertical::before{content:"\f788"}.bi-sliders2::before{content:"\f789"}.bi-trash3-fill::before{content:"\f78a"}.bi-trash3::before{content:"\f78b"}.bi-valentine::before{content:"\f78c"}.bi-valentine2::before{content:"\f78d"}.bi-wrench-adjustable-circle-fill::before{content:"\f78e"}.bi-wrench-adjustable-circle::before{content:"\f78f"}.bi-wrench-adjustable::before{content:"\f790"}.bi-filetype-json::before{content:"\f791"}.bi-filetype-pptx::before{content:"\f792"}.bi-filetype-xlsx::before{content:"\f793"}.bi-1-circle-fill::before{content:"\f796"}.bi-1-circle::before{content:"\f797"}.bi-1-square-fill::before{content:"\f798"}.bi-1-square::before{content:"\f799"}.bi-2-circle-fill::before{content:"\f79c"}.bi-2-circle::before{content:"\f79d"}.bi-2-square-fill::before{content:"\f79e"}.bi-2-square::before{content:"\f79f"}.bi-3-circle-fill::before{content:"\f7a2"}.bi-3-circle::before{content:"\f7a3"}.bi-3-square-fill::before{content:"\f7a4"}.bi-3-square::before{content:"\f7a5"}.bi-4-circle-fill::before{content:"\f7a8"}.bi-4-circle::before{content:"\f7a9"}.bi-4-square-fill::before{content:"\f7aa"}.bi-4-square::before{content:"\f7ab"}.bi-5-circle-fill::before{content:"\f7ae"}.bi-5-circle::before{content:"\f7af"}.bi-5-square-fill::before{content:"\f7b0"}.bi-5-square::before{content:"\f7b1"}.bi-6-circle-fill::before{content:"\f7b4"}.bi-6-circle::before{content:"\f7b5"}.bi-6-square-fill::before{content:"\f7b6"}.bi-6-square::before{content:"\f7b7"}.bi-7-circle-fill::before{content:"\f7ba"}.bi-7-circle::before{content:"\f7bb"}.bi-7-square-fill::before{content:"\f7bc"}.bi-7-square::before{content:"\f7bd"}.bi-8-circle-fill::before{content:"\f7c0"}.bi-8-circle::before{content:"\f7c1"}.bi-8-square-fill::before{content:"\f7c2"}.bi-8-square::before{content:"\f7c3"}.bi-9-circle-fill::before{content:"\f7c6"}.bi-9-circle::before{content:"\f7c7"}.bi-9-square-fill::before{content:"\f7c8"}.bi-9-square::before{content:"\f7c9"}.bi-airplane-engines-fill::before{content:"\f7ca"}.bi-airplane-engines::before{content:"\f7cb"}.bi-airplane-fill::before{content:"\f7cc"}.bi-airplane::before{content:"\f7cd"}.bi-alexa::before{content:"\f7ce"}.bi-alipay::before{content:"\f7cf"}.bi-android::before{content:"\f7d0"}.bi-android2::before{content:"\f7d1"}.bi-box-fill::before{content:"\f7d2"}.bi-box-seam-fill::before{content:"\f7d3"}.bi-browser-chrome::before{content:"\f7d4"}.bi-browser-edge::before{content:"\f7d5"}.bi-browser-firefox::before{content:"\f7d6"}.bi-browser-safari::before{content:"\f7d7"}.bi-c-circle-fill::before{content:"\f7da"}.bi-c-circle::before{content:"\f7db"}.bi-c-square-fill::before{content:"\f7dc"}.bi-c-square::before{content:"\f7dd"}.bi-capsule-pill::before{content:"\f7de"}.bi-capsule::before{content:"\f7df"}.bi-car-front-fill::before{content:"\f7e0"}.bi-car-front::before{content:"\f7e1"}.bi-cassette-fill::before{content:"\f7e2"}.bi-cassette::before{content:"\f7e3"}.bi-cc-circle-fill::before{content:"\f7e6"}.bi-cc-circle::before{content:"\f7e7"}.bi-cc-square-fill::before{content:"\f7e8"}.bi-cc-square::before{content:"\f7e9"}.bi-cup-hot-fill::before{content:"\f7ea"}.bi-cup-hot::before{content:"\f7eb"}.bi-currency-rupee::before{content:"\f7ec"}.bi-dropbox::before{content:"\f7ed"}.bi-escape::before{content:"\f7ee"}.bi-fast-forward-btn-fill::before{content:"\f7ef"}.bi-fast-forward-btn::before{content:"\f7f0"}.bi-fast-forward-circle-fill::before{content:"\f7f1"}.bi-fast-forward-circle::before{content:"\f7f2"}.bi-fast-forward-fill::before{content:"\f7f3"}.bi-fast-forward::before{content:"\f7f4"}.bi-filetype-sql::before{content:"\f7f5"}.bi-fire::before{content:"\f7f6"}.bi-google-play::before{content:"\f7f7"}.bi-h-circle-fill::before{content:"\f7fa"}.bi-h-circle::before{content:"\f7fb"}.bi-h-square-fill::before{content:"\f7fc"}.bi-h-square::before{content:"\f7fd"}.bi-indent::before{content:"\f7fe"}.bi-lungs-fill::before{content:"\f7ff"}.bi-lungs::before{content:"\f800"}.bi-microsoft-teams::before{content:"\f801"}.bi-p-circle-fill::before{content:"\f804"}.bi-p-circle::before{content:"\f805"}.bi-p-square-fill::before{content:"\f806"}.bi-p-square::before{content:"\f807"}.bi-pass-fill::before{content:"\f808"}.bi-pass::before{content:"\f809"}.bi-prescription::before{content:"\f80a"}.bi-prescription2::before{content:"\f80b"}.bi-r-circle-fill::before{content:"\f80e"}.bi-r-circle::before{content:"\f80f"}.bi-r-square-fill::before{content:"\f810"}.bi-r-square::before{content:"\f811"}.bi-repeat-1::before{content:"\f812"}.bi-repeat::before{content:"\f813"}.bi-rewind-btn-fill::before{content:"\f814"}.bi-rewind-btn::before{content:"\f815"}.bi-rewind-circle-fill::before{content:"\f816"}.bi-rewind-circle::before{content:"\f817"}.bi-rewind-fill::before{content:"\f818"}.bi-rewind::before{content:"\f819"}.bi-train-freight-front-fill::before{content:"\f81a"}.bi-train-freight-front::before{content:"\f81b"}.bi-train-front-fill::before{content:"\f81c"}.bi-train-front::before{content:"\f81d"}.bi-train-lightrail-front-fill::before{content:"\f81e"}.bi-train-lightrail-front::before{content:"\f81f"}.bi-truck-front-fill::before{content:"\f820"}.bi-truck-front::before{content:"\f821"}.bi-ubuntu::before{content:"\f822"}.bi-unindent::before{content:"\f823"}.bi-unity::before{content:"\f824"}.bi-universal-access-circle::before{content:"\f825"}.bi-universal-access::before{content:"\f826"}.bi-virus::before{content:"\f827"}.bi-virus2::before{content:"\f828"}.bi-wechat::before{content:"\f829"}.bi-yelp::before{content:"\f82a"}.bi-sign-stop-fill::before{content:"\f82b"}.bi-sign-stop-lights-fill::before{content:"\f82c"}.bi-sign-stop-lights::before{content:"\f82d"}.bi-sign-stop::before{content:"\f82e"}.bi-sign-turn-left-fill::before{content:"\f82f"}.bi-sign-turn-left::before{content:"\f830"}.bi-sign-turn-right-fill::before{content:"\f831"}.bi-sign-turn-right::before{content:"\f832"}.bi-sign-turn-slight-left-fill::before{content:"\f833"}.bi-sign-turn-slight-left::before{content:"\f834"}.bi-sign-turn-slight-right-fill::before{content:"\f835"}.bi-sign-turn-slight-right::before{content:"\f836"}.bi-sign-yield-fill::before{content:"\f837"}.bi-sign-yield::before{content:"\f838"}.bi-ev-station-fill::before{content:"\f839"}.bi-ev-station::before{content:"\f83a"}.bi-fuel-pump-diesel-fill::before{content:"\f83b"}.bi-fuel-pump-diesel::before{content:"\f83c"}.bi-fuel-pump-fill::before{content:"\f83d"}.bi-fuel-pump::before{content:"\f83e"}.bi-0-circle-fill::before{content:"\f83f"}.bi-0-circle::before{content:"\f840"}.bi-0-square-fill::before{content:"\f841"}.bi-0-square::before{content:"\f842"}.bi-rocket-fill::before{content:"\f843"}.bi-rocket-takeoff-fill::before{content:"\f844"}.bi-rocket-takeoff::before{content:"\f845"}.bi-rocket::before{content:"\f846"}.bi-stripe::before{content:"\f847"}.bi-subscript::before{content:"\f848"}.bi-superscript::before{content:"\f849"}.bi-trello::before{content:"\f84a"}.bi-envelope-at-fill::before{content:"\f84b"}.bi-envelope-at::before{content:"\f84c"}.bi-regex::before{content:"\f84d"}.bi-text-wrap::before{content:"\f84e"}.bi-sign-dead-end-fill::before{content:"\f84f"}.bi-sign-dead-end::before{content:"\f850"}.bi-sign-do-not-enter-fill::before{content:"\f851"}.bi-sign-do-not-enter::before{content:"\f852"}.bi-sign-intersection-fill::before{content:"\f853"}.bi-sign-intersection-side-fill::before{content:"\f854"}.bi-sign-intersection-side::before{content:"\f855"}.bi-sign-intersection-t-fill::before{content:"\f856"}.bi-sign-intersection-t::before{content:"\f857"}.bi-sign-intersection-y-fill::before{content:"\f858"}.bi-sign-intersection-y::before{content:"\f859"}.bi-sign-intersection::before{content:"\f85a"}.bi-sign-merge-left-fill::before{content:"\f85b"}.bi-sign-merge-left::before{content:"\f85c"}.bi-sign-merge-right-fill::before{content:"\f85d"}.bi-sign-merge-right::before{content:"\f85e"}.bi-sign-no-left-turn-fill::before{content:"\f85f"}.bi-sign-no-left-turn::before{content:"\f860"}.bi-sign-no-parking-fill::before{content:"\f861"}.bi-sign-no-parking::before{content:"\f862"}.bi-sign-no-right-turn-fill::before{content:"\f863"}.bi-sign-no-right-turn::before{content:"\f864"}.bi-sign-railroad-fill::before{content:"\f865"}.bi-sign-railroad::before{content:"\f866"}.bi-building-add::before{content:"\f867"}.bi-building-check::before{content:"\f868"}.bi-building-dash::before{content:"\f869"}.bi-building-down::before{content:"\f86a"}.bi-building-exclamation::before{content:"\f86b"}.bi-building-fill-add::before{content:"\f86c"}.bi-building-fill-check::before{content:"\f86d"}.bi-building-fill-dash::before{content:"\f86e"}.bi-building-fill-down::before{content:"\f86f"}.bi-building-fill-exclamation::before{content:"\f870"}.bi-building-fill-gear::before{content:"\f871"}.bi-building-fill-lock::before{content:"\f872"}.bi-building-fill-slash::before{content:"\f873"}.bi-building-fill-up::before{content:"\f874"}.bi-building-fill-x::before{content:"\f875"}.bi-building-fill::before{content:"\f876"}.bi-building-gear::before{content:"\f877"}.bi-building-lock::before{content:"\f878"}.bi-building-slash::before{content:"\f879"}.bi-building-up::before{content:"\f87a"}.bi-building-x::before{content:"\f87b"}.bi-buildings-fill::before{content:"\f87c"}.bi-buildings::before{content:"\f87d"}.bi-bus-front-fill::before{content:"\f87e"}.bi-bus-front::before{content:"\f87f"}.bi-ev-front-fill::before{content:"\f880"}.bi-ev-front::before{content:"\f881"}.bi-globe-americas::before{content:"\f882"}.bi-globe-asia-australia::before{content:"\f883"}.bi-globe-central-south-asia::before{content:"\f884"}.bi-globe-europe-africa::before{content:"\f885"}.bi-house-add-fill::before{content:"\f886"}.bi-house-add::before{content:"\f887"}.bi-house-check-fill::before{content:"\f888"}.bi-house-check::before{content:"\f889"}.bi-house-dash-fill::before{content:"\f88a"}.bi-house-dash::before{content:"\f88b"}.bi-house-down-fill::before{content:"\f88c"}.bi-house-down::before{content:"\f88d"}.bi-house-exclamation-fill::before{content:"\f88e"}.bi-house-exclamation::before{content:"\f88f"}.bi-house-gear-fill::before{content:"\f890"}.bi-house-gear::before{content:"\f891"}.bi-house-lock-fill::before{content:"\f892"}.bi-house-lock::before{content:"\f893"}.bi-house-slash-fill::before{content:"\f894"}.bi-house-slash::before{content:"\f895"}.bi-house-up-fill::before{content:"\f896"}.bi-house-up::before{content:"\f897"}.bi-house-x-fill::before{content:"\f898"}.bi-house-x::before{content:"\f899"}.bi-person-add::before{content:"\f89a"}.bi-person-down::before{content:"\f89b"}.bi-person-exclamation::before{content:"\f89c"}.bi-person-fill-add::before{content:"\f89d"}.bi-person-fill-check::before{content:"\f89e"}.bi-person-fill-dash::before{content:"\f89f"}.bi-person-fill-down::before{content:"\f8a0"}.bi-person-fill-exclamation::before{content:"\f8a1"}.bi-person-fill-gear::before{content:"\f8a2"}.bi-person-fill-lock::before{content:"\f8a3"}.bi-person-fill-slash::before{content:"\f8a4"}.bi-person-fill-up::before{content:"\f8a5"}.bi-person-fill-x::before{content:"\f8a6"}.bi-person-gear::before{content:"\f8a7"}.bi-person-lock::before{content:"\f8a8"}.bi-person-slash::before{content:"\f8a9"}.bi-person-up::before{content:"\f8aa"}.bi-scooter::before{content:"\f8ab"}.bi-taxi-front-fill::before{content:"\f8ac"}.bi-taxi-front::before{content:"\f8ad"}.bi-amd::before{content:"\f8ae"}.bi-database-add::before{content:"\f8af"}.bi-database-check::before{content:"\f8b0"}.bi-database-dash::before{content:"\f8b1"}.bi-database-down::before{content:"\f8b2"}.bi-database-exclamation::before{content:"\f8b3"}.bi-database-fill-add::before{content:"\f8b4"}.bi-database-fill-check::before{content:"\f8b5"}.bi-database-fill-dash::before{content:"\f8b6"}.bi-database-fill-down::before{content:"\f8b7"}.bi-database-fill-exclamation::before{content:"\f8b8"}.bi-database-fill-gear::before{content:"\f8b9"}.bi-database-fill-lock::before{content:"\f8ba"}.bi-database-fill-slash::before{content:"\f8bb"}.bi-database-fill-up::before{content:"\f8bc"}.bi-database-fill-x::before{content:"\f8bd"}.bi-database-fill::before{content:"\f8be"}.bi-database-gear::before{content:"\f8bf"}.bi-database-lock::before{content:"\f8c0"}.bi-database-slash::before{content:"\f8c1"}.bi-database-up::before{content:"\f8c2"}.bi-database-x::before{content:"\f8c3"}.bi-database::before{content:"\f8c4"}.bi-houses-fill::before{content:"\f8c5"}.bi-houses::before{content:"\f8c6"}.bi-nvidia::before{content:"\f8c7"}.bi-person-vcard-fill::before{content:"\f8c8"}.bi-person-vcard::before{content:"\f8c9"}.bi-sina-weibo::before{content:"\f8ca"}.bi-tencent-qq::before{content:"\f8cb"}.bi-wikipedia::before{content:"\f8cc"}.bi-alphabet-uppercase::before{content:"\f2a5"}.bi-alphabet::before{content:"\f68a"}.bi-amazon::before{content:"\f68d"}.bi-arrows-collapse-vertical::before{content:"\f690"}.bi-arrows-expand-vertical::before{content:"\f695"}.bi-arrows-vertical::before{content:"\f698"}.bi-arrows::before{content:"\f6a2"}.bi-ban-fill::before{content:"\f6a3"}.bi-ban::before{content:"\f6b6"}.bi-bing::before{content:"\f6c2"}.bi-cake::before{content:"\f6e0"}.bi-cake2::before{content:"\f6ed"}.bi-cookie::before{content:"\f6ee"}.bi-copy::before{content:"\f759"}.bi-crosshair::before{content:"\f769"}.bi-crosshair2::before{content:"\f794"}.bi-emoji-astonished-fill::before{content:"\f795"}.bi-emoji-astonished::before{content:"\f79a"}.bi-emoji-grimace-fill::before{content:"\f79b"}.bi-emoji-grimace::before{content:"\f7a0"}.bi-emoji-grin-fill::before{content:"\f7a1"}.bi-emoji-grin::before{content:"\f7a6"}.bi-emoji-surprise-fill::before{content:"\f7a7"}.bi-emoji-surprise::before{content:"\f7ac"}.bi-emoji-tear-fill::before{content:"\f7ad"}.bi-emoji-tear::before{content:"\f7b2"}.bi-envelope-arrow-down-fill::before{content:"\f7b3"}.bi-envelope-arrow-down::before{content:"\f7b8"}.bi-envelope-arrow-up-fill::before{content:"\f7b9"}.bi-envelope-arrow-up::before{content:"\f7be"}.bi-feather::before{content:"\f7bf"}.bi-feather2::before{content:"\f7c4"}.bi-floppy-fill::before{content:"\f7c5"}.bi-floppy::before{content:"\f7d8"}.bi-floppy2-fill::before{content:"\f7d9"}.bi-floppy2::before{content:"\f7e4"}.bi-gitlab::before{content:"\f7e5"}.bi-highlighter::before{content:"\f7f8"}.bi-marker-tip::before{content:"\f802"}.bi-nvme-fill::before{content:"\f803"}.bi-nvme::before{content:"\f80c"}.bi-opencollective::before{content:"\f80d"}.bi-pci-card-network::before{content:"\f8cd"}.bi-pci-card-sound::before{content:"\f8ce"}.bi-radar::before{content:"\f8cf"}.bi-send-arrow-down-fill::before{content:"\f8d0"}.bi-send-arrow-down::before{content:"\f8d1"}.bi-send-arrow-up-fill::before{content:"\f8d2"}.bi-send-arrow-up::before{content:"\f8d3"}.bi-sim-slash-fill::before{content:"\f8d4"}.bi-sim-slash::before{content:"\f8d5"}.bi-sourceforge::before{content:"\f8d6"}.bi-substack::before{content:"\f8d7"}.bi-threads-fill::before{content:"\f8d8"}.bi-threads::before{content:"\f8d9"}.bi-transparency::before{content:"\f8da"}.bi-twitter-x::before{content:"\f8db"}.bi-type-h4::before{content:"\f8dc"}.bi-type-h5::before{content:"\f8dd"}.bi-type-h6::before{content:"\f8de"}.bi-backpack-fill::before{content:"\f8df"}.bi-backpack::before{content:"\f8e0"}.bi-backpack2-fill::before{content:"\f8e1"}.bi-backpack2::before{content:"\f8e2"}.bi-backpack3-fill::before{content:"\f8e3"}.bi-backpack3::before{content:"\f8e4"}.bi-backpack4-fill::before{content:"\f8e5"}.bi-backpack4::before{content:"\f8e6"}.bi-brilliance::before{content:"\f8e7"}.bi-cake-fill::before{content:"\f8e8"}.bi-cake2-fill::before{content:"\f8e9"}.bi-duffle-fill::before{content:"\f8ea"}.bi-duffle::before{content:"\f8eb"}.bi-exposure::before{content:"\f8ec"}.bi-gender-neuter::before{content:"\f8ed"}.bi-highlights::before{content:"\f8ee"}.bi-luggage-fill::before{content:"\f8ef"}.bi-luggage::before{content:"\f8f0"}.bi-mailbox-flag::before{content:"\f8f1"}.bi-mailbox2-flag::before{content:"\f8f2"}.bi-noise-reduction::before{content:"\f8f3"}.bi-passport-fill::before{content:"\f8f4"}.bi-passport::before{content:"\f8f5"}.bi-person-arms-up::before{content:"\f8f6"}.bi-person-raised-hand::before{content:"\f8f7"}.bi-person-standing-dress::before{content:"\f8f8"}.bi-person-standing::before{content:"\f8f9"}.bi-person-walking::before{content:"\f8fa"}.bi-person-wheelchair::before{content:"\f8fb"}.bi-shadows::before{content:"\f8fc"}.bi-suitcase-fill::before{content:"\f8fd"}.bi-suitcase-lg-fill::before{content:"\f8fe"}.bi-suitcase-lg::before{content:"\f8ff"}.bi-suitcase::before{content:"\f900"}.bi-suitcase2-fill::before{content:"\f901"}.bi-suitcase2::before{content:"\f902"}.bi-vignette::before{content:"\f903"} \ No newline at end of file diff --git a/openpilot/starpilot/galaxy/web/vendor/bootstrap-icons/fonts/bootstrap-icons.woff b/openpilot/starpilot/galaxy/web/vendor/bootstrap-icons/fonts/bootstrap-icons.woff new file mode 100644 index 0000000000000000000000000000000000000000..51204d27de92c7bb0f8bed6165b9dc888f38ff38 GIT binary patch literal 176032 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 literal 0 HcmV?d00001 diff --git a/openpilot/starpilot/galaxy/web/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 b/openpilot/starpilot/galaxy/web/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..92c4830216044ba21db9f4294b887312e80da38e GIT binary patch literal 130396 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Ã=hYmg-7dH4?&*NqqtUL18qtx> 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 z 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 set.has(val.toLowerCase()) : (val) => set.has(val); +} + +const EMPTY_OBJ = Object.freeze({}) ; +const EMPTY_ARR = Object.freeze([]) ; +const NOOP = () => { +}; +const NO = () => false; +const isOn = (key) => key.charCodeAt(0) === 111 && key.charCodeAt(1) === 110 && // uppercase letter +(key.charCodeAt(2) > 122 || key.charCodeAt(2) < 97); +const isModelListener = (key) => key.startsWith("onUpdate:"); +const extend = Object.assign; +const remove = (arr, el) => { + const i = arr.indexOf(el); + if (i > -1) { + arr.splice(i, 1); + } +}; +const hasOwnProperty$1 = Object.prototype.hasOwnProperty; +const hasOwn = (val, key) => hasOwnProperty$1.call(val, key); +const isArray = Array.isArray; +const isMap = (val) => toTypeString(val) === "[object Map]"; +const isSet = (val) => toTypeString(val) === "[object Set]"; +const isDate = (val) => toTypeString(val) === "[object Date]"; +const isRegExp = (val) => toTypeString(val) === "[object RegExp]"; +const isFunction = (val) => typeof val === "function"; +const isString = (val) => typeof val === "string"; +const isSymbol = (val) => typeof val === "symbol"; +const isObject = (val) => val !== null && typeof val === "object"; +const isPromise = (val) => { + return (isObject(val) || isFunction(val)) && isFunction(val.then) && isFunction(val.catch); +}; +const objectToString = Object.prototype.toString; +const toTypeString = (value) => objectToString.call(value); +const toRawType = (value) => { + return toTypeString(value).slice(8, -1); +}; +const isPlainObject = (val) => toTypeString(val) === "[object Object]"; +const isIntegerKey = (key) => isString(key) && key !== "NaN" && key[0] !== "-" && "" + parseInt(key, 10) === key; +const isReservedProp = /* @__PURE__ */ makeMap( + // the leading comma is intentional so empty string "" is also included + ",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted" +); +const isBuiltInDirective = /* @__PURE__ */ makeMap( + "bind,cloak,else-if,else,for,html,if,model,on,once,pre,show,slot,text,memo" +); +const cacheStringFunction = (fn) => { + const cache = /* @__PURE__ */ Object.create(null); + return (str) => { + const hit = cache[str]; + return hit || (cache[str] = fn(str)); + }; +}; +const camelizeRE = /-(\w)/g; +const camelize = cacheStringFunction((str) => { + return str.replace(camelizeRE, (_, c) => c ? c.toUpperCase() : ""); +}); +const hyphenateRE = /\B([A-Z])/g; +const hyphenate = cacheStringFunction( + (str) => str.replace(hyphenateRE, "-$1").toLowerCase() +); +const capitalize = cacheStringFunction((str) => { + return str.charAt(0).toUpperCase() + str.slice(1); +}); +const toHandlerKey = cacheStringFunction((str) => { + const s = str ? `on${capitalize(str)}` : ``; + return s; +}); +const hasChanged = (value, oldValue) => !Object.is(value, oldValue); +const invokeArrayFns = (fns, ...arg) => { + for (let i = 0; i < fns.length; i++) { + fns[i](...arg); + } +}; +const def = (obj, key, value, writable = false) => { + Object.defineProperty(obj, key, { + configurable: true, + enumerable: false, + writable, + value + }); +}; +const looseToNumber = (val) => { + const n = parseFloat(val); + return isNaN(n) ? val : n; +}; +const toNumber = (val) => { + const n = isString(val) ? Number(val) : NaN; + return isNaN(n) ? val : n; +}; +let _globalThis; +const getGlobalThis = () => { + return _globalThis || (_globalThis = typeof globalThis !== "undefined" ? globalThis : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : typeof global !== "undefined" ? global : {}); +}; + +const PatchFlagNames = { + [1]: `TEXT`, + [2]: `CLASS`, + [4]: `STYLE`, + [8]: `PROPS`, + [16]: `FULL_PROPS`, + [32]: `NEED_HYDRATION`, + [64]: `STABLE_FRAGMENT`, + [128]: `KEYED_FRAGMENT`, + [256]: `UNKEYED_FRAGMENT`, + [512]: `NEED_PATCH`, + [1024]: `DYNAMIC_SLOTS`, + [2048]: `DEV_ROOT_FRAGMENT`, + [-1]: `HOISTED`, + [-2]: `BAIL` +}; + +const slotFlagsText = { + [1]: "STABLE", + [2]: "DYNAMIC", + [3]: "FORWARDED" +}; + +const GLOBALS_ALLOWED = "Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt,console,Error"; +const isGloballyAllowed = /* @__PURE__ */ makeMap(GLOBALS_ALLOWED); + +const range = 2; +function generateCodeFrame(source, start = 0, end = source.length) { + start = Math.max(0, Math.min(start, source.length)); + end = Math.max(0, Math.min(end, source.length)); + if (start > end) return ""; + let lines = source.split(/(\r?\n)/); + const newlineSequences = lines.filter((_, idx) => idx % 2 === 1); + lines = lines.filter((_, idx) => idx % 2 === 0); + let count = 0; + const res = []; + for (let i = 0; i < lines.length; i++) { + count += lines[i].length + (newlineSequences[i] && newlineSequences[i].length || 0); + if (count >= start) { + for (let j = i - range; j <= i + range || end > count; j++) { + if (j < 0 || j >= lines.length) continue; + const line = j + 1; + res.push( + `${line}${" ".repeat(Math.max(3 - String(line).length, 0))}| ${lines[j]}` + ); + const lineLength = lines[j].length; + const newLineSeqLength = newlineSequences[j] && newlineSequences[j].length || 0; + if (j === i) { + const pad = start - (count - (lineLength + newLineSeqLength)); + const length = Math.max( + 1, + end > count ? lineLength - pad : end - start + ); + res.push(` | ` + " ".repeat(pad) + "^".repeat(length)); + } else if (j > i) { + if (end > count) { + const length = Math.max(Math.min(end - count, lineLength), 1); + res.push(` | ` + "^".repeat(length)); + } + count += lineLength + newLineSeqLength; + } + } + break; + } + } + return res.join("\n"); +} + +function normalizeStyle(value) { + if (isArray(value)) { + const res = {}; + for (let i = 0; i < value.length; i++) { + const item = value[i]; + const normalized = isString(item) ? parseStringStyle(item) : normalizeStyle(item); + if (normalized) { + for (const key in normalized) { + res[key] = normalized[key]; + } + } + } + return res; + } else if (isString(value) || isObject(value)) { + return value; + } +} +const listDelimiterRE = /;(?![^(]*\))/g; +const propertyDelimiterRE = /:([^]+)/; +const styleCommentRE = /\/\*[^]*?\*\//g; +function parseStringStyle(cssText) { + const ret = {}; + cssText.replace(styleCommentRE, "").split(listDelimiterRE).forEach((item) => { + if (item) { + const tmp = item.split(propertyDelimiterRE); + tmp.length > 1 && (ret[tmp[0].trim()] = tmp[1].trim()); + } + }); + return ret; +} +function stringifyStyle(styles) { + let ret = ""; + if (!styles || isString(styles)) { + return ret; + } + for (const key in styles) { + const value = styles[key]; + if (isString(value) || typeof value === "number") { + const normalizedKey = key.startsWith(`--`) ? key : hyphenate(key); + ret += `${normalizedKey}:${value};`; + } + } + return ret; +} +function normalizeClass(value) { + let res = ""; + if (isString(value)) { + res = value; + } else if (isArray(value)) { + for (let i = 0; i < value.length; i++) { + const normalized = normalizeClass(value[i]); + if (normalized) { + res += normalized + " "; + } + } + } else if (isObject(value)) { + for (const name in value) { + if (value[name]) { + res += name + " "; + } + } + } + return res.trim(); +} +function normalizeProps(props) { + if (!props) return null; + let { class: klass, style } = props; + if (klass && !isString(klass)) { + props.class = normalizeClass(klass); + } + if (style) { + props.style = normalizeStyle(style); + } + return props; +} + +const HTML_TAGS = "html,body,base,head,link,meta,style,title,address,article,aside,footer,header,hgroup,h1,h2,h3,h4,h5,h6,nav,section,div,dd,dl,dt,figcaption,figure,picture,hr,img,li,main,ol,p,pre,ul,a,b,abbr,bdi,bdo,br,cite,code,data,dfn,em,i,kbd,mark,q,rp,rt,ruby,s,samp,small,span,strong,sub,sup,time,u,var,wbr,area,audio,map,track,video,embed,object,param,source,canvas,script,noscript,del,ins,caption,col,colgroup,table,thead,tbody,td,th,tr,button,datalist,fieldset,form,input,label,legend,meter,optgroup,option,output,progress,select,textarea,details,dialog,menu,summary,template,blockquote,iframe,tfoot"; +const SVG_TAGS = "svg,animate,animateMotion,animateTransform,circle,clipPath,color-profile,defs,desc,discard,ellipse,feBlend,feColorMatrix,feComponentTransfer,feComposite,feConvolveMatrix,feDiffuseLighting,feDisplacementMap,feDistantLight,feDropShadow,feFlood,feFuncA,feFuncB,feFuncG,feFuncR,feGaussianBlur,feImage,feMerge,feMergeNode,feMorphology,feOffset,fePointLight,feSpecularLighting,feSpotLight,feTile,feTurbulence,filter,foreignObject,g,hatch,hatchpath,image,line,linearGradient,marker,mask,mesh,meshgradient,meshpatch,meshrow,metadata,mpath,path,pattern,polygon,polyline,radialGradient,rect,set,solidcolor,stop,switch,symbol,text,textPath,title,tspan,unknown,use,view"; +const MATH_TAGS = "annotation,annotation-xml,maction,maligngroup,malignmark,math,menclose,merror,mfenced,mfrac,mfraction,mglyph,mi,mlabeledtr,mlongdiv,mmultiscripts,mn,mo,mover,mpadded,mphantom,mprescripts,mroot,mrow,ms,mscarries,mscarry,msgroup,msline,mspace,msqrt,msrow,mstack,mstyle,msub,msubsup,msup,mtable,mtd,mtext,mtr,munder,munderover,none,semantics"; +const VOID_TAGS = "area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr"; +const isHTMLTag = /* @__PURE__ */ makeMap(HTML_TAGS); +const isSVGTag = /* @__PURE__ */ makeMap(SVG_TAGS); +const isMathMLTag = /* @__PURE__ */ makeMap(MATH_TAGS); +const isVoidTag = /* @__PURE__ */ makeMap(VOID_TAGS); + +const specialBooleanAttrs = `itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly`; +const isSpecialBooleanAttr = /* @__PURE__ */ makeMap(specialBooleanAttrs); +const isBooleanAttr = /* @__PURE__ */ makeMap( + specialBooleanAttrs + `,async,autofocus,autoplay,controls,default,defer,disabled,hidden,inert,loop,open,required,reversed,scoped,seamless,checked,muted,multiple,selected` +); +function includeBooleanAttr(value) { + return !!value || value === ""; +} +const isKnownHtmlAttr = /* @__PURE__ */ makeMap( + `accept,accept-charset,accesskey,action,align,allow,alt,async,autocapitalize,autocomplete,autofocus,autoplay,background,bgcolor,border,buffered,capture,challenge,charset,checked,cite,class,code,codebase,color,cols,colspan,content,contenteditable,contextmenu,controls,coords,crossorigin,csp,data,datetime,decoding,default,defer,dir,dirname,disabled,download,draggable,dropzone,enctype,enterkeyhint,for,form,formaction,formenctype,formmethod,formnovalidate,formtarget,headers,height,hidden,high,href,hreflang,http-equiv,icon,id,importance,inert,integrity,ismap,itemprop,keytype,kind,label,lang,language,loading,list,loop,low,manifest,max,maxlength,minlength,media,min,multiple,muted,name,novalidate,open,optimum,pattern,ping,placeholder,poster,preload,radiogroup,readonly,referrerpolicy,rel,required,reversed,rows,rowspan,sandbox,scope,scoped,selected,shape,size,sizes,slot,span,spellcheck,src,srcdoc,srclang,srcset,start,step,style,summary,tabindex,target,title,translate,type,usemap,value,width,wrap` +); +const isKnownSvgAttr = /* @__PURE__ */ makeMap( + `xmlns,accent-height,accumulate,additive,alignment-baseline,alphabetic,amplitude,arabic-form,ascent,attributeName,attributeType,azimuth,baseFrequency,baseline-shift,baseProfile,bbox,begin,bias,by,calcMode,cap-height,class,clip,clipPathUnits,clip-path,clip-rule,color,color-interpolation,color-interpolation-filters,color-profile,color-rendering,contentScriptType,contentStyleType,crossorigin,cursor,cx,cy,d,decelerate,descent,diffuseConstant,direction,display,divisor,dominant-baseline,dur,dx,dy,edgeMode,elevation,enable-background,end,exponent,fill,fill-opacity,fill-rule,filter,filterRes,filterUnits,flood-color,flood-opacity,font-family,font-size,font-size-adjust,font-stretch,font-style,font-variant,font-weight,format,from,fr,fx,fy,g1,g2,glyph-name,glyph-orientation-horizontal,glyph-orientation-vertical,glyphRef,gradientTransform,gradientUnits,hanging,height,href,hreflang,horiz-adv-x,horiz-origin-x,id,ideographic,image-rendering,in,in2,intercept,k,k1,k2,k3,k4,kernelMatrix,kernelUnitLength,kerning,keyPoints,keySplines,keyTimes,lang,lengthAdjust,letter-spacing,lighting-color,limitingConeAngle,local,marker-end,marker-mid,marker-start,markerHeight,markerUnits,markerWidth,mask,maskContentUnits,maskUnits,mathematical,max,media,method,min,mode,name,numOctaves,offset,opacity,operator,order,orient,orientation,origin,overflow,overline-position,overline-thickness,panose-1,paint-order,path,pathLength,patternContentUnits,patternTransform,patternUnits,ping,pointer-events,points,pointsAtX,pointsAtY,pointsAtZ,preserveAlpha,preserveAspectRatio,primitiveUnits,r,radius,referrerPolicy,refX,refY,rel,rendering-intent,repeatCount,repeatDur,requiredExtensions,requiredFeatures,restart,result,rotate,rx,ry,scale,seed,shape-rendering,slope,spacing,specularConstant,specularExponent,speed,spreadMethod,startOffset,stdDeviation,stemh,stemv,stitchTiles,stop-color,stop-opacity,strikethrough-position,strikethrough-thickness,string,stroke,stroke-dasharray,stroke-dashoffset,stroke-linecap,stroke-linejoin,stroke-miterlimit,stroke-opacity,stroke-width,style,surfaceScale,systemLanguage,tabindex,tableValues,target,targetX,targetY,text-anchor,text-decoration,text-rendering,textLength,to,transform,transform-origin,type,u1,u2,underline-position,underline-thickness,unicode,unicode-bidi,unicode-range,units-per-em,v-alphabetic,v-hanging,v-ideographic,v-mathematical,values,vector-effect,version,vert-adv-y,vert-origin-x,vert-origin-y,viewBox,viewTarget,visibility,width,widths,word-spacing,writing-mode,x,x-height,x1,x2,xChannelSelector,xlink:actuate,xlink:arcrole,xlink:href,xlink:role,xlink:show,xlink:title,xlink:type,xmlns:xlink,xml:base,xml:lang,xml:space,y,y1,y2,yChannelSelector,z,zoomAndPan` +); +function isRenderableAttrValue(value) { + if (value == null) { + return false; + } + const type = typeof value; + return type === "string" || type === "number" || type === "boolean"; +} + +function looseCompareArrays(a, b) { + if (a.length !== b.length) return false; + let equal = true; + for (let i = 0; equal && i < a.length; i++) { + equal = looseEqual(a[i], b[i]); + } + return equal; +} +function looseEqual(a, b) { + if (a === b) return true; + let aValidType = isDate(a); + let bValidType = isDate(b); + if (aValidType || bValidType) { + return aValidType && bValidType ? a.getTime() === b.getTime() : false; + } + aValidType = isSymbol(a); + bValidType = isSymbol(b); + if (aValidType || bValidType) { + return a === b; + } + aValidType = isArray(a); + bValidType = isArray(b); + if (aValidType || bValidType) { + return aValidType && bValidType ? looseCompareArrays(a, b) : false; + } + aValidType = isObject(a); + bValidType = isObject(b); + if (aValidType || bValidType) { + if (!aValidType || !bValidType) { + return false; + } + const aKeysCount = Object.keys(a).length; + const bKeysCount = Object.keys(b).length; + if (aKeysCount !== bKeysCount) { + return false; + } + for (const key in a) { + const aHasKey = a.hasOwnProperty(key); + const bHasKey = b.hasOwnProperty(key); + if (aHasKey && !bHasKey || !aHasKey && bHasKey || !looseEqual(a[key], b[key])) { + return false; + } + } + } + return String(a) === String(b); +} +function looseIndexOf(arr, val) { + return arr.findIndex((item) => looseEqual(item, val)); +} + +const isRef$1 = (val) => { + return !!(val && val.__v_isRef === true); +}; +const toDisplayString = (val) => { + return isString(val) ? val : val == null ? "" : isArray(val) || isObject(val) && (val.toString === objectToString || !isFunction(val.toString)) ? isRef$1(val) ? toDisplayString(val.value) : JSON.stringify(val, replacer, 2) : String(val); +}; +const replacer = (_key, val) => { + if (isRef$1(val)) { + return replacer(_key, val.value); + } else if (isMap(val)) { + return { + [`Map(${val.size})`]: [...val.entries()].reduce( + (entries, [key, val2], i) => { + entries[stringifySymbol(key, i) + " =>"] = val2; + return entries; + }, + {} + ) + }; + } else if (isSet(val)) { + return { + [`Set(${val.size})`]: [...val.values()].map((v) => stringifySymbol(v)) + }; + } else if (isSymbol(val)) { + return stringifySymbol(val); + } else if (isObject(val) && !isArray(val) && !isPlainObject(val)) { + return String(val); + } + return val; +}; +const stringifySymbol = (v, i = "") => { + var _a; + return ( + // Symbol.description in es2019+ so we need to cast here to pass + // the lib: es2016 check + isSymbol(v) ? `Symbol(${(_a = v.description) != null ? _a : i})` : v + ); +}; + +function warn$2(msg, ...args) { + console.warn(`[Vue warn] ${msg}`, ...args); +} + +let activeEffectScope; +class EffectScope { + constructor(detached = false) { + this.detached = detached; + /** + * @internal + */ + this._active = true; + /** + * @internal + */ + this.effects = []; + /** + * @internal + */ + this.cleanups = []; + this.parent = activeEffectScope; + if (!detached && activeEffectScope) { + this.index = (activeEffectScope.scopes || (activeEffectScope.scopes = [])).push( + this + ) - 1; + } + } + get active() { + return this._active; + } + run(fn) { + if (this._active) { + const currentEffectScope = activeEffectScope; + try { + activeEffectScope = this; + return fn(); + } finally { + activeEffectScope = currentEffectScope; + } + } else { + warn$2(`cannot run an inactive effect scope.`); + } + } + /** + * This should only be called on non-detached scopes + * @internal + */ + on() { + activeEffectScope = this; + } + /** + * This should only be called on non-detached scopes + * @internal + */ + off() { + activeEffectScope = this.parent; + } + stop(fromParent) { + if (this._active) { + let i, l; + for (i = 0, l = this.effects.length; i < l; i++) { + this.effects[i].stop(); + } + for (i = 0, l = this.cleanups.length; i < l; i++) { + this.cleanups[i](); + } + if (this.scopes) { + for (i = 0, l = this.scopes.length; i < l; i++) { + this.scopes[i].stop(true); + } + } + if (!this.detached && this.parent && !fromParent) { + const last = this.parent.scopes.pop(); + if (last && last !== this) { + this.parent.scopes[this.index] = last; + last.index = this.index; + } + } + this.parent = void 0; + this._active = false; + } + } +} +function effectScope(detached) { + return new EffectScope(detached); +} +function recordEffectScope(effect, scope = activeEffectScope) { + if (scope && scope.active) { + scope.effects.push(effect); + } +} +function getCurrentScope() { + return activeEffectScope; +} +function onScopeDispose(fn) { + if (activeEffectScope) { + activeEffectScope.cleanups.push(fn); + } else { + warn$2( + `onScopeDispose() is called when there is no active effect scope to be associated with.` + ); + } +} + +let activeEffect; +class ReactiveEffect { + constructor(fn, trigger, scheduler, scope) { + this.fn = fn; + this.trigger = trigger; + this.scheduler = scheduler; + this.active = true; + this.deps = []; + /** + * @internal + */ + this._dirtyLevel = 4; + /** + * @internal + */ + this._trackId = 0; + /** + * @internal + */ + this._runnings = 0; + /** + * @internal + */ + this._shouldSchedule = false; + /** + * @internal + */ + this._depsLength = 0; + recordEffectScope(this, scope); + } + get dirty() { + if (this._dirtyLevel === 2 || this._dirtyLevel === 3) { + this._dirtyLevel = 1; + pauseTracking(); + for (let i = 0; i < this._depsLength; i++) { + const dep = this.deps[i]; + if (dep.computed) { + triggerComputed(dep.computed); + if (this._dirtyLevel >= 4) { + break; + } + } + } + if (this._dirtyLevel === 1) { + this._dirtyLevel = 0; + } + resetTracking(); + } + return this._dirtyLevel >= 4; + } + set dirty(v) { + this._dirtyLevel = v ? 4 : 0; + } + run() { + this._dirtyLevel = 0; + if (!this.active) { + return this.fn(); + } + let lastShouldTrack = shouldTrack; + let lastEffect = activeEffect; + try { + shouldTrack = true; + activeEffect = this; + this._runnings++; + preCleanupEffect(this); + return this.fn(); + } finally { + postCleanupEffect(this); + this._runnings--; + activeEffect = lastEffect; + shouldTrack = lastShouldTrack; + } + } + stop() { + if (this.active) { + preCleanupEffect(this); + postCleanupEffect(this); + this.onStop && this.onStop(); + this.active = false; + } + } +} +function triggerComputed(computed) { + return computed.value; +} +function preCleanupEffect(effect2) { + effect2._trackId++; + effect2._depsLength = 0; +} +function postCleanupEffect(effect2) { + if (effect2.deps.length > effect2._depsLength) { + for (let i = effect2._depsLength; i < effect2.deps.length; i++) { + cleanupDepEffect(effect2.deps[i], effect2); + } + effect2.deps.length = effect2._depsLength; + } +} +function cleanupDepEffect(dep, effect2) { + const trackId = dep.get(effect2); + if (trackId !== void 0 && effect2._trackId !== trackId) { + dep.delete(effect2); + if (dep.size === 0) { + dep.cleanup(); + } + } +} +function effect(fn, options) { + if (fn.effect instanceof ReactiveEffect) { + fn = fn.effect.fn; + } + const _effect = new ReactiveEffect(fn, NOOP, () => { + if (_effect.dirty) { + _effect.run(); + } + }); + if (options) { + extend(_effect, options); + if (options.scope) recordEffectScope(_effect, options.scope); + } + if (!options || !options.lazy) { + _effect.run(); + } + const runner = _effect.run.bind(_effect); + runner.effect = _effect; + return runner; +} +function stop(runner) { + runner.effect.stop(); +} +let shouldTrack = true; +let pauseScheduleStack = 0; +const trackStack = []; +function pauseTracking() { + trackStack.push(shouldTrack); + shouldTrack = false; +} +function resetTracking() { + const last = trackStack.pop(); + shouldTrack = last === void 0 ? true : last; +} +function pauseScheduling() { + pauseScheduleStack++; +} +function resetScheduling() { + pauseScheduleStack--; + while (!pauseScheduleStack && queueEffectSchedulers.length) { + queueEffectSchedulers.shift()(); + } +} +function trackEffect(effect2, dep, debuggerEventExtraInfo) { + var _a; + if (dep.get(effect2) !== effect2._trackId) { + dep.set(effect2, effect2._trackId); + const oldDep = effect2.deps[effect2._depsLength]; + if (oldDep !== dep) { + if (oldDep) { + cleanupDepEffect(oldDep, effect2); + } + effect2.deps[effect2._depsLength++] = dep; + } else { + effect2._depsLength++; + } + { + (_a = effect2.onTrack) == null ? void 0 : _a.call(effect2, extend({ effect: effect2 }, debuggerEventExtraInfo)); + } + } +} +const queueEffectSchedulers = []; +function triggerEffects(dep, dirtyLevel, debuggerEventExtraInfo) { + var _a; + pauseScheduling(); + for (const effect2 of dep.keys()) { + let tracking; + if (effect2._dirtyLevel < dirtyLevel && (tracking != null ? tracking : tracking = dep.get(effect2) === effect2._trackId)) { + effect2._shouldSchedule || (effect2._shouldSchedule = effect2._dirtyLevel === 0); + effect2._dirtyLevel = dirtyLevel; + } + if (effect2._shouldSchedule && (tracking != null ? tracking : tracking = dep.get(effect2) === effect2._trackId)) { + { + (_a = effect2.onTrigger) == null ? void 0 : _a.call(effect2, extend({ effect: effect2 }, debuggerEventExtraInfo)); + } + effect2.trigger(); + if ((!effect2._runnings || effect2.allowRecurse) && effect2._dirtyLevel !== 2) { + effect2._shouldSchedule = false; + if (effect2.scheduler) { + queueEffectSchedulers.push(effect2.scheduler); + } + } + } + } + resetScheduling(); +} + +const createDep = (cleanup, computed) => { + const dep = /* @__PURE__ */ new Map(); + dep.cleanup = cleanup; + dep.computed = computed; + return dep; +}; + +const targetMap = /* @__PURE__ */ new WeakMap(); +const ITERATE_KEY = Symbol("iterate" ); +const MAP_KEY_ITERATE_KEY = Symbol("Map key iterate" ); +function track(target, type, key) { + if (shouldTrack && activeEffect) { + let depsMap = targetMap.get(target); + if (!depsMap) { + targetMap.set(target, depsMap = /* @__PURE__ */ new Map()); + } + let dep = depsMap.get(key); + if (!dep) { + depsMap.set(key, dep = createDep(() => depsMap.delete(key))); + } + trackEffect( + activeEffect, + dep, + { + target, + type, + key + } + ); + } +} +function trigger(target, type, key, newValue, oldValue, oldTarget) { + const depsMap = targetMap.get(target); + if (!depsMap) { + return; + } + let deps = []; + if (type === "clear") { + deps = [...depsMap.values()]; + } else if (key === "length" && isArray(target)) { + const newLength = Number(newValue); + depsMap.forEach((dep, key2) => { + if (key2 === "length" || !isSymbol(key2) && key2 >= newLength) { + deps.push(dep); + } + }); + } else { + if (key !== void 0) { + deps.push(depsMap.get(key)); + } + switch (type) { + case "add": + if (!isArray(target)) { + deps.push(depsMap.get(ITERATE_KEY)); + if (isMap(target)) { + deps.push(depsMap.get(MAP_KEY_ITERATE_KEY)); + } + } else if (isIntegerKey(key)) { + deps.push(depsMap.get("length")); + } + break; + case "delete": + if (!isArray(target)) { + deps.push(depsMap.get(ITERATE_KEY)); + if (isMap(target)) { + deps.push(depsMap.get(MAP_KEY_ITERATE_KEY)); + } + } + break; + case "set": + if (isMap(target)) { + deps.push(depsMap.get(ITERATE_KEY)); + } + break; + } + } + pauseScheduling(); + for (const dep of deps) { + if (dep) { + triggerEffects( + dep, + 4, + { + target, + type, + key, + newValue, + oldValue, + oldTarget + } + ); + } + } + resetScheduling(); +} +function getDepFromReactive(object, key) { + const depsMap = targetMap.get(object); + return depsMap && depsMap.get(key); +} + +const isNonTrackableKeys = /* @__PURE__ */ makeMap(`__proto__,__v_isRef,__isVue`); +const builtInSymbols = new Set( + /* @__PURE__ */ Object.getOwnPropertyNames(Symbol).filter((key) => key !== "arguments" && key !== "caller").map((key) => Symbol[key]).filter(isSymbol) +); +const arrayInstrumentations = /* @__PURE__ */ createArrayInstrumentations(); +function createArrayInstrumentations() { + const instrumentations = {}; + ["includes", "indexOf", "lastIndexOf"].forEach((key) => { + instrumentations[key] = function(...args) { + const arr = toRaw(this); + for (let i = 0, l = this.length; i < l; i++) { + track(arr, "get", i + ""); + } + const res = arr[key](...args); + if (res === -1 || res === false) { + return arr[key](...args.map(toRaw)); + } else { + return res; + } + }; + }); + ["push", "pop", "shift", "unshift", "splice"].forEach((key) => { + instrumentations[key] = function(...args) { + pauseTracking(); + pauseScheduling(); + const res = toRaw(this)[key].apply(this, args); + resetScheduling(); + resetTracking(); + return res; + }; + }); + return instrumentations; +} +function hasOwnProperty(key) { + if (!isSymbol(key)) key = String(key); + const obj = toRaw(this); + track(obj, "has", key); + return obj.hasOwnProperty(key); +} +class BaseReactiveHandler { + constructor(_isReadonly = false, _isShallow = false) { + this._isReadonly = _isReadonly; + this._isShallow = _isShallow; + } + get(target, key, receiver) { + const isReadonly2 = this._isReadonly, isShallow2 = this._isShallow; + if (key === "__v_isReactive") { + return !isReadonly2; + } else if (key === "__v_isReadonly") { + return isReadonly2; + } else if (key === "__v_isShallow") { + return isShallow2; + } else if (key === "__v_raw") { + if (receiver === (isReadonly2 ? isShallow2 ? shallowReadonlyMap : readonlyMap : isShallow2 ? shallowReactiveMap : reactiveMap).get(target) || // receiver is not the reactive proxy, but has the same prototype + // this means the receiver is a user proxy of the reactive proxy + Object.getPrototypeOf(target) === Object.getPrototypeOf(receiver)) { + return target; + } + return; + } + const targetIsArray = isArray(target); + if (!isReadonly2) { + if (targetIsArray && hasOwn(arrayInstrumentations, key)) { + return Reflect.get(arrayInstrumentations, key, receiver); + } + if (key === "hasOwnProperty") { + return hasOwnProperty; + } + } + const res = Reflect.get(target, key, receiver); + if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) { + return res; + } + if (!isReadonly2) { + track(target, "get", key); + } + if (isShallow2) { + return res; + } + if (isRef(res)) { + return targetIsArray && isIntegerKey(key) ? res : res.value; + } + if (isObject(res)) { + return isReadonly2 ? readonly(res) : reactive(res); + } + return res; + } +} +class MutableReactiveHandler extends BaseReactiveHandler { + constructor(isShallow2 = false) { + super(false, isShallow2); + } + set(target, key, value, receiver) { + let oldValue = target[key]; + if (!this._isShallow) { + const isOldValueReadonly = isReadonly(oldValue); + if (!isShallow(value) && !isReadonly(value)) { + oldValue = toRaw(oldValue); + value = toRaw(value); + } + if (!isArray(target) && isRef(oldValue) && !isRef(value)) { + if (isOldValueReadonly) { + return false; + } else { + oldValue.value = value; + return true; + } + } + } + const hadKey = isArray(target) && isIntegerKey(key) ? Number(key) < target.length : hasOwn(target, key); + const result = Reflect.set(target, key, value, receiver); + if (target === toRaw(receiver)) { + if (!hadKey) { + trigger(target, "add", key, value); + } else if (hasChanged(value, oldValue)) { + trigger(target, "set", key, value, oldValue); + } + } + return result; + } + deleteProperty(target, key) { + const hadKey = hasOwn(target, key); + const oldValue = target[key]; + const result = Reflect.deleteProperty(target, key); + if (result && hadKey) { + trigger(target, "delete", key, void 0, oldValue); + } + return result; + } + has(target, key) { + const result = Reflect.has(target, key); + if (!isSymbol(key) || !builtInSymbols.has(key)) { + track(target, "has", key); + } + return result; + } + ownKeys(target) { + track( + target, + "iterate", + isArray(target) ? "length" : ITERATE_KEY + ); + return Reflect.ownKeys(target); + } +} +class ReadonlyReactiveHandler extends BaseReactiveHandler { + constructor(isShallow2 = false) { + super(true, isShallow2); + } + set(target, key) { + { + warn$2( + `Set operation on key "${String(key)}" failed: target is readonly.`, + target + ); + } + return true; + } + deleteProperty(target, key) { + { + warn$2( + `Delete operation on key "${String(key)}" failed: target is readonly.`, + target + ); + } + return true; + } +} +const mutableHandlers = /* @__PURE__ */ new MutableReactiveHandler(); +const readonlyHandlers = /* @__PURE__ */ new ReadonlyReactiveHandler(); +const shallowReactiveHandlers = /* @__PURE__ */ new MutableReactiveHandler( + true +); +const shallowReadonlyHandlers = /* @__PURE__ */ new ReadonlyReactiveHandler(true); + +const toShallow = (value) => value; +const getProto = (v) => Reflect.getPrototypeOf(v); +function get(target, key, isReadonly2 = false, isShallow2 = false) { + target = target["__v_raw"]; + const rawTarget = toRaw(target); + const rawKey = toRaw(key); + if (!isReadonly2) { + if (hasChanged(key, rawKey)) { + track(rawTarget, "get", key); + } + track(rawTarget, "get", rawKey); + } + const { has: has2 } = getProto(rawTarget); + const wrap = isShallow2 ? toShallow : isReadonly2 ? toReadonly : toReactive; + if (has2.call(rawTarget, key)) { + return wrap(target.get(key)); + } else if (has2.call(rawTarget, rawKey)) { + return wrap(target.get(rawKey)); + } else if (target !== rawTarget) { + target.get(key); + } +} +function has(key, isReadonly2 = false) { + const target = this["__v_raw"]; + const rawTarget = toRaw(target); + const rawKey = toRaw(key); + if (!isReadonly2) { + if (hasChanged(key, rawKey)) { + track(rawTarget, "has", key); + } + track(rawTarget, "has", rawKey); + } + return key === rawKey ? target.has(key) : target.has(key) || target.has(rawKey); +} +function size(target, isReadonly2 = false) { + target = target["__v_raw"]; + !isReadonly2 && track(toRaw(target), "iterate", ITERATE_KEY); + return Reflect.get(target, "size", target); +} +function add(value, _isShallow = false) { + if (!_isShallow && !isShallow(value) && !isReadonly(value)) { + value = toRaw(value); + } + const target = toRaw(this); + const proto = getProto(target); + const hadKey = proto.has.call(target, value); + if (!hadKey) { + target.add(value); + trigger(target, "add", value, value); + } + return this; +} +function set(key, value, _isShallow = false) { + if (!_isShallow && !isShallow(value) && !isReadonly(value)) { + value = toRaw(value); + } + const target = toRaw(this); + const { has: has2, get: get2 } = getProto(target); + let hadKey = has2.call(target, key); + if (!hadKey) { + key = toRaw(key); + hadKey = has2.call(target, key); + } else { + checkIdentityKeys(target, has2, key); + } + const oldValue = get2.call(target, key); + target.set(key, value); + if (!hadKey) { + trigger(target, "add", key, value); + } else if (hasChanged(value, oldValue)) { + trigger(target, "set", key, value, oldValue); + } + return this; +} +function deleteEntry(key) { + const target = toRaw(this); + const { has: has2, get: get2 } = getProto(target); + let hadKey = has2.call(target, key); + if (!hadKey) { + key = toRaw(key); + hadKey = has2.call(target, key); + } else { + checkIdentityKeys(target, has2, key); + } + const oldValue = get2 ? get2.call(target, key) : void 0; + const result = target.delete(key); + if (hadKey) { + trigger(target, "delete", key, void 0, oldValue); + } + return result; +} +function clear() { + const target = toRaw(this); + const hadItems = target.size !== 0; + const oldTarget = isMap(target) ? new Map(target) : new Set(target) ; + const result = target.clear(); + if (hadItems) { + trigger(target, "clear", void 0, void 0, oldTarget); + } + return result; +} +function createForEach(isReadonly2, isShallow2) { + return function forEach(callback, thisArg) { + const observed = this; + const target = observed["__v_raw"]; + const rawTarget = toRaw(target); + const wrap = isShallow2 ? toShallow : isReadonly2 ? toReadonly : toReactive; + !isReadonly2 && track(rawTarget, "iterate", ITERATE_KEY); + return target.forEach((value, key) => { + return callback.call(thisArg, wrap(value), wrap(key), observed); + }); + }; +} +function createIterableMethod(method, isReadonly2, isShallow2) { + return function(...args) { + const target = this["__v_raw"]; + const rawTarget = toRaw(target); + const targetIsMap = isMap(rawTarget); + const isPair = method === "entries" || method === Symbol.iterator && targetIsMap; + const isKeyOnly = method === "keys" && targetIsMap; + const innerIterator = target[method](...args); + const wrap = isShallow2 ? toShallow : isReadonly2 ? toReadonly : toReactive; + !isReadonly2 && track( + rawTarget, + "iterate", + isKeyOnly ? MAP_KEY_ITERATE_KEY : ITERATE_KEY + ); + return { + // iterator protocol + next() { + const { value, done } = innerIterator.next(); + return done ? { value, done } : { + value: isPair ? [wrap(value[0]), wrap(value[1])] : wrap(value), + done + }; + }, + // iterable protocol + [Symbol.iterator]() { + return this; + } + }; + }; +} +function createReadonlyMethod(type) { + return function(...args) { + { + const key = args[0] ? `on key "${args[0]}" ` : ``; + warn$2( + `${capitalize(type)} operation ${key}failed: target is readonly.`, + toRaw(this) + ); + } + return type === "delete" ? false : type === "clear" ? void 0 : this; + }; +} +function createInstrumentations() { + const mutableInstrumentations2 = { + get(key) { + return get(this, key); + }, + get size() { + return size(this); + }, + has, + add, + set, + delete: deleteEntry, + clear, + forEach: createForEach(false, false) + }; + const shallowInstrumentations2 = { + get(key) { + return get(this, key, false, true); + }, + get size() { + return size(this); + }, + has, + add(value) { + return add.call(this, value, true); + }, + set(key, value) { + return set.call(this, key, value, true); + }, + delete: deleteEntry, + clear, + forEach: createForEach(false, true) + }; + const readonlyInstrumentations2 = { + get(key) { + return get(this, key, true); + }, + get size() { + return size(this, true); + }, + has(key) { + return has.call(this, key, true); + }, + add: createReadonlyMethod("add"), + set: createReadonlyMethod("set"), + delete: createReadonlyMethod("delete"), + clear: createReadonlyMethod("clear"), + forEach: createForEach(true, false) + }; + const shallowReadonlyInstrumentations2 = { + get(key) { + return get(this, key, true, true); + }, + get size() { + return size(this, true); + }, + has(key) { + return has.call(this, key, true); + }, + add: createReadonlyMethod("add"), + set: createReadonlyMethod("set"), + delete: createReadonlyMethod("delete"), + clear: createReadonlyMethod("clear"), + forEach: createForEach(true, true) + }; + const iteratorMethods = [ + "keys", + "values", + "entries", + Symbol.iterator + ]; + iteratorMethods.forEach((method) => { + mutableInstrumentations2[method] = createIterableMethod(method, false, false); + readonlyInstrumentations2[method] = createIterableMethod(method, true, false); + shallowInstrumentations2[method] = createIterableMethod(method, false, true); + shallowReadonlyInstrumentations2[method] = createIterableMethod( + method, + true, + true + ); + }); + return [ + mutableInstrumentations2, + readonlyInstrumentations2, + shallowInstrumentations2, + shallowReadonlyInstrumentations2 + ]; +} +const [ + mutableInstrumentations, + readonlyInstrumentations, + shallowInstrumentations, + shallowReadonlyInstrumentations +] = /* @__PURE__ */ createInstrumentations(); +function createInstrumentationGetter(isReadonly2, shallow) { + const instrumentations = shallow ? isReadonly2 ? shallowReadonlyInstrumentations : shallowInstrumentations : isReadonly2 ? readonlyInstrumentations : mutableInstrumentations; + return (target, key, receiver) => { + if (key === "__v_isReactive") { + return !isReadonly2; + } else if (key === "__v_isReadonly") { + return isReadonly2; + } else if (key === "__v_raw") { + return target; + } + return Reflect.get( + hasOwn(instrumentations, key) && key in target ? instrumentations : target, + key, + receiver + ); + }; +} +const mutableCollectionHandlers = { + get: /* @__PURE__ */ createInstrumentationGetter(false, false) +}; +const shallowCollectionHandlers = { + get: /* @__PURE__ */ createInstrumentationGetter(false, true) +}; +const readonlyCollectionHandlers = { + get: /* @__PURE__ */ createInstrumentationGetter(true, false) +}; +const shallowReadonlyCollectionHandlers = { + get: /* @__PURE__ */ createInstrumentationGetter(true, true) +}; +function checkIdentityKeys(target, has2, key) { + const rawKey = toRaw(key); + if (rawKey !== key && has2.call(target, rawKey)) { + const type = toRawType(target); + warn$2( + `Reactive ${type} contains both the raw and reactive versions of the same object${type === `Map` ? ` as keys` : ``}, which can lead to inconsistencies. Avoid differentiating between the raw and reactive versions of an object and only use the reactive version if possible.` + ); + } +} + +const reactiveMap = /* @__PURE__ */ new WeakMap(); +const shallowReactiveMap = /* @__PURE__ */ new WeakMap(); +const readonlyMap = /* @__PURE__ */ new WeakMap(); +const shallowReadonlyMap = /* @__PURE__ */ new WeakMap(); +function targetTypeMap(rawType) { + switch (rawType) { + case "Object": + case "Array": + return 1 /* COMMON */; + case "Map": + case "Set": + case "WeakMap": + case "WeakSet": + return 2 /* COLLECTION */; + default: + return 0 /* INVALID */; + } +} +function getTargetType(value) { + return value["__v_skip"] || !Object.isExtensible(value) ? 0 /* INVALID */ : targetTypeMap(toRawType(value)); +} +function reactive(target) { + if (isReadonly(target)) { + return target; + } + return createReactiveObject( + target, + false, + mutableHandlers, + mutableCollectionHandlers, + reactiveMap + ); +} +function shallowReactive(target) { + return createReactiveObject( + target, + false, + shallowReactiveHandlers, + shallowCollectionHandlers, + shallowReactiveMap + ); +} +function readonly(target) { + return createReactiveObject( + target, + true, + readonlyHandlers, + readonlyCollectionHandlers, + readonlyMap + ); +} +function shallowReadonly(target) { + return createReactiveObject( + target, + true, + shallowReadonlyHandlers, + shallowReadonlyCollectionHandlers, + shallowReadonlyMap + ); +} +function createReactiveObject(target, isReadonly2, baseHandlers, collectionHandlers, proxyMap) { + if (!isObject(target)) { + { + warn$2( + `value cannot be made ${isReadonly2 ? "readonly" : "reactive"}: ${String( + target + )}` + ); + } + return target; + } + if (target["__v_raw"] && !(isReadonly2 && target["__v_isReactive"])) { + return target; + } + const existingProxy = proxyMap.get(target); + if (existingProxy) { + return existingProxy; + } + const targetType = getTargetType(target); + if (targetType === 0 /* INVALID */) { + return target; + } + const proxy = new Proxy( + target, + targetType === 2 /* COLLECTION */ ? collectionHandlers : baseHandlers + ); + proxyMap.set(target, proxy); + return proxy; +} +function isReactive(value) { + if (isReadonly(value)) { + return isReactive(value["__v_raw"]); + } + return !!(value && value["__v_isReactive"]); +} +function isReadonly(value) { + return !!(value && value["__v_isReadonly"]); +} +function isShallow(value) { + return !!(value && value["__v_isShallow"]); +} +function isProxy(value) { + return value ? !!value["__v_raw"] : false; +} +function toRaw(observed) { + const raw = observed && observed["__v_raw"]; + return raw ? toRaw(raw) : observed; +} +function markRaw(value) { + if (Object.isExtensible(value)) { + def(value, "__v_skip", true); + } + return value; +} +const toReactive = (value) => isObject(value) ? reactive(value) : value; +const toReadonly = (value) => isObject(value) ? readonly(value) : value; + +const COMPUTED_SIDE_EFFECT_WARN = `Computed is still dirty after getter evaluation, likely because a computed is mutating its own dependency in its getter. State mutations in computed getters should be avoided. Check the docs for more details: https://vuejs.org/guide/essentials/computed.html#getters-should-be-side-effect-free`; +class ComputedRefImpl { + constructor(getter, _setter, isReadonly, isSSR) { + this.getter = getter; + this._setter = _setter; + this.dep = void 0; + this.__v_isRef = true; + this["__v_isReadonly"] = false; + this.effect = new ReactiveEffect( + () => getter(this._value), + () => triggerRefValue( + this, + this.effect._dirtyLevel === 2 ? 2 : 3 + ) + ); + this.effect.computed = this; + this.effect.active = this._cacheable = !isSSR; + this["__v_isReadonly"] = isReadonly; + } + get value() { + const self = toRaw(this); + if ((!self._cacheable || self.effect.dirty) && hasChanged(self._value, self._value = self.effect.run())) { + triggerRefValue(self, 4); + } + trackRefValue(self); + if (self.effect._dirtyLevel >= 2) { + if (this._warnRecursive) { + warn$2(COMPUTED_SIDE_EFFECT_WARN, ` + +getter: `, this.getter); + } + triggerRefValue(self, 2); + } + return self._value; + } + set value(newValue) { + this._setter(newValue); + } + // #region polyfill _dirty for backward compatibility third party code for Vue <= 3.3.x + get _dirty() { + return this.effect.dirty; + } + set _dirty(v) { + this.effect.dirty = v; + } + // #endregion +} +function computed$1(getterOrOptions, debugOptions, isSSR = false) { + let getter; + let setter; + const onlyGetter = isFunction(getterOrOptions); + if (onlyGetter) { + getter = getterOrOptions; + setter = () => { + warn$2("Write operation failed: computed value is readonly"); + } ; + } else { + getter = getterOrOptions.get; + setter = getterOrOptions.set; + } + const cRef = new ComputedRefImpl(getter, setter, onlyGetter || !setter, isSSR); + if (debugOptions && !isSSR) { + cRef.effect.onTrack = debugOptions.onTrack; + cRef.effect.onTrigger = debugOptions.onTrigger; + } + return cRef; +} + +function trackRefValue(ref2) { + var _a; + if (shouldTrack && activeEffect) { + ref2 = toRaw(ref2); + trackEffect( + activeEffect, + (_a = ref2.dep) != null ? _a : ref2.dep = createDep( + () => ref2.dep = void 0, + ref2 instanceof ComputedRefImpl ? ref2 : void 0 + ), + { + target: ref2, + type: "get", + key: "value" + } + ); + } +} +function triggerRefValue(ref2, dirtyLevel = 4, newVal, oldVal) { + ref2 = toRaw(ref2); + const dep = ref2.dep; + if (dep) { + triggerEffects( + dep, + dirtyLevel, + { + target: ref2, + type: "set", + key: "value", + newValue: newVal, + oldValue: oldVal + } + ); + } +} +function isRef(r) { + return !!(r && r.__v_isRef === true); +} +function ref(value) { + return createRef(value, false); +} +function shallowRef(value) { + return createRef(value, true); +} +function createRef(rawValue, shallow) { + if (isRef(rawValue)) { + return rawValue; + } + return new RefImpl(rawValue, shallow); +} +class RefImpl { + constructor(value, __v_isShallow) { + this.__v_isShallow = __v_isShallow; + this.dep = void 0; + this.__v_isRef = true; + this._rawValue = __v_isShallow ? value : toRaw(value); + this._value = __v_isShallow ? value : toReactive(value); + } + get value() { + trackRefValue(this); + return this._value; + } + set value(newVal) { + const useDirectValue = this.__v_isShallow || isShallow(newVal) || isReadonly(newVal); + newVal = useDirectValue ? newVal : toRaw(newVal); + if (hasChanged(newVal, this._rawValue)) { + const oldVal = this._rawValue; + this._rawValue = newVal; + this._value = useDirectValue ? newVal : toReactive(newVal); + triggerRefValue(this, 4, newVal, oldVal); + } + } +} +function triggerRef(ref2) { + triggerRefValue(ref2, 4, ref2.value ); +} +function unref(ref2) { + return isRef(ref2) ? ref2.value : ref2; +} +function toValue(source) { + return isFunction(source) ? source() : unref(source); +} +const shallowUnwrapHandlers = { + get: (target, key, receiver) => unref(Reflect.get(target, key, receiver)), + set: (target, key, value, receiver) => { + const oldValue = target[key]; + if (isRef(oldValue) && !isRef(value)) { + oldValue.value = value; + return true; + } else { + return Reflect.set(target, key, value, receiver); + } + } +}; +function proxyRefs(objectWithRefs) { + return isReactive(objectWithRefs) ? objectWithRefs : new Proxy(objectWithRefs, shallowUnwrapHandlers); +} +class CustomRefImpl { + constructor(factory) { + this.dep = void 0; + this.__v_isRef = true; + const { get, set } = factory( + () => trackRefValue(this), + () => triggerRefValue(this) + ); + this._get = get; + this._set = set; + } + get value() { + return this._get(); + } + set value(newVal) { + this._set(newVal); + } +} +function customRef(factory) { + return new CustomRefImpl(factory); +} +function toRefs(object) { + if (!isProxy(object)) { + warn$2(`toRefs() expects a reactive object but received a plain one.`); + } + const ret = isArray(object) ? new Array(object.length) : {}; + for (const key in object) { + ret[key] = propertyToRef(object, key); + } + return ret; +} +class ObjectRefImpl { + constructor(_object, _key, _defaultValue) { + this._object = _object; + this._key = _key; + this._defaultValue = _defaultValue; + this.__v_isRef = true; + } + get value() { + const val = this._object[this._key]; + return val === void 0 ? this._defaultValue : val; + } + set value(newVal) { + this._object[this._key] = newVal; + } + get dep() { + return getDepFromReactive(toRaw(this._object), this._key); + } +} +class GetterRefImpl { + constructor(_getter) { + this._getter = _getter; + this.__v_isRef = true; + this.__v_isReadonly = true; + } + get value() { + return this._getter(); + } +} +function toRef(source, key, defaultValue) { + if (isRef(source)) { + return source; + } else if (isFunction(source)) { + return new GetterRefImpl(source); + } else if (isObject(source) && arguments.length > 1) { + return propertyToRef(source, key, defaultValue); + } else { + return ref(source); + } +} +function propertyToRef(source, key, defaultValue) { + const val = source[key]; + return isRef(val) ? val : new ObjectRefImpl(source, key, defaultValue); +} + +const TrackOpTypes = { + "GET": "get", + "HAS": "has", + "ITERATE": "iterate" +}; +const TriggerOpTypes = { + "SET": "set", + "ADD": "add", + "DELETE": "delete", + "CLEAR": "clear" +}; + +const stack$1 = []; +function pushWarningContext(vnode) { + stack$1.push(vnode); +} +function popWarningContext() { + stack$1.pop(); +} +let isWarning = false; +function warn$1(msg, ...args) { + if (isWarning) return; + isWarning = true; + pauseTracking(); + const instance = stack$1.length ? stack$1[stack$1.length - 1].component : null; + const appWarnHandler = instance && instance.appContext.config.warnHandler; + const trace = getComponentTrace(); + if (appWarnHandler) { + callWithErrorHandling( + appWarnHandler, + instance, + 11, + [ + // eslint-disable-next-line no-restricted-syntax + msg + args.map((a) => { + var _a, _b; + return (_b = (_a = a.toString) == null ? void 0 : _a.call(a)) != null ? _b : JSON.stringify(a); + }).join(""), + instance && instance.proxy, + trace.map( + ({ vnode }) => `at <${formatComponentName(instance, vnode.type)}>` + ).join("\n"), + trace + ] + ); + } else { + const warnArgs = [`[Vue warn]: ${msg}`, ...args]; + if (trace.length && // avoid spamming console during tests + true) { + warnArgs.push(` +`, ...formatTrace(trace)); + } + console.warn(...warnArgs); + } + resetTracking(); + isWarning = false; +} +function getComponentTrace() { + let currentVNode = stack$1[stack$1.length - 1]; + if (!currentVNode) { + return []; + } + const normalizedStack = []; + while (currentVNode) { + const last = normalizedStack[0]; + if (last && last.vnode === currentVNode) { + last.recurseCount++; + } else { + normalizedStack.push({ + vnode: currentVNode, + recurseCount: 0 + }); + } + const parentInstance = currentVNode.component && currentVNode.component.parent; + currentVNode = parentInstance && parentInstance.vnode; + } + return normalizedStack; +} +function formatTrace(trace) { + const logs = []; + trace.forEach((entry, i) => { + logs.push(...i === 0 ? [] : [` +`], ...formatTraceEntry(entry)); + }); + return logs; +} +function formatTraceEntry({ vnode, recurseCount }) { + const postfix = recurseCount > 0 ? `... (${recurseCount} recursive calls)` : ``; + const isRoot = vnode.component ? vnode.component.parent == null : false; + const open = ` at <${formatComponentName( + vnode.component, + vnode.type, + isRoot + )}`; + const close = `>` + postfix; + return vnode.props ? [open, ...formatProps(vnode.props), close] : [open + close]; +} +function formatProps(props) { + const res = []; + const keys = Object.keys(props); + keys.slice(0, 3).forEach((key) => { + res.push(...formatProp(key, props[key])); + }); + if (keys.length > 3) { + res.push(` ...`); + } + return res; +} +function formatProp(key, value, raw) { + if (isString(value)) { + value = JSON.stringify(value); + return raw ? value : [`${key}=${value}`]; + } else if (typeof value === "number" || typeof value === "boolean" || value == null) { + return raw ? value : [`${key}=${value}`]; + } else if (isRef(value)) { + value = formatProp(key, toRaw(value.value), true); + return raw ? value : [`${key}=Ref<`, value, `>`]; + } else if (isFunction(value)) { + return [`${key}=fn${value.name ? `<${value.name}>` : ``}`]; + } else { + value = toRaw(value); + return raw ? value : [`${key}=`, value]; + } +} +function assertNumber(val, type) { + if (val === void 0) { + return; + } else if (typeof val !== "number") { + warn$1(`${type} is not a valid number - got ${JSON.stringify(val)}.`); + } else if (isNaN(val)) { + warn$1(`${type} is NaN - the duration expression might be incorrect.`); + } +} + +const ErrorCodes = { + "SETUP_FUNCTION": 0, + "0": "SETUP_FUNCTION", + "RENDER_FUNCTION": 1, + "1": "RENDER_FUNCTION", + "WATCH_GETTER": 2, + "2": "WATCH_GETTER", + "WATCH_CALLBACK": 3, + "3": "WATCH_CALLBACK", + "WATCH_CLEANUP": 4, + "4": "WATCH_CLEANUP", + "NATIVE_EVENT_HANDLER": 5, + "5": "NATIVE_EVENT_HANDLER", + "COMPONENT_EVENT_HANDLER": 6, + "6": "COMPONENT_EVENT_HANDLER", + "VNODE_HOOK": 7, + "7": "VNODE_HOOK", + "DIRECTIVE_HOOK": 8, + "8": "DIRECTIVE_HOOK", + "TRANSITION_HOOK": 9, + "9": "TRANSITION_HOOK", + "APP_ERROR_HANDLER": 10, + "10": "APP_ERROR_HANDLER", + "APP_WARN_HANDLER": 11, + "11": "APP_WARN_HANDLER", + "FUNCTION_REF": 12, + "12": "FUNCTION_REF", + "ASYNC_COMPONENT_LOADER": 13, + "13": "ASYNC_COMPONENT_LOADER", + "SCHEDULER": 14, + "14": "SCHEDULER", + "COMPONENT_UPDATE": 15, + "15": "COMPONENT_UPDATE" +}; +const ErrorTypeStrings$1 = { + ["sp"]: "serverPrefetch hook", + ["bc"]: "beforeCreate hook", + ["c"]: "created hook", + ["bm"]: "beforeMount hook", + ["m"]: "mounted hook", + ["bu"]: "beforeUpdate hook", + ["u"]: "updated", + ["bum"]: "beforeUnmount hook", + ["um"]: "unmounted hook", + ["a"]: "activated hook", + ["da"]: "deactivated hook", + ["ec"]: "errorCaptured hook", + ["rtc"]: "renderTracked hook", + ["rtg"]: "renderTriggered hook", + [0]: "setup function", + [1]: "render function", + [2]: "watcher getter", + [3]: "watcher callback", + [4]: "watcher cleanup function", + [5]: "native event handler", + [6]: "component event handler", + [7]: "vnode hook", + [8]: "directive hook", + [9]: "transition hook", + [10]: "app errorHandler", + [11]: "app warnHandler", + [12]: "ref function", + [13]: "async component loader", + [14]: "scheduler flush", + [15]: "component update" +}; +function callWithErrorHandling(fn, instance, type, args) { + try { + return args ? fn(...args) : fn(); + } catch (err) { + handleError(err, instance, type); + } +} +function callWithAsyncErrorHandling(fn, instance, type, args) { + if (isFunction(fn)) { + const res = callWithErrorHandling(fn, instance, type, args); + if (res && isPromise(res)) { + res.catch((err) => { + handleError(err, instance, type); + }); + } + return res; + } + if (isArray(fn)) { + const values = []; + for (let i = 0; i < fn.length; i++) { + values.push(callWithAsyncErrorHandling(fn[i], instance, type, args)); + } + return values; + } else { + warn$1( + `Invalid value type passed to callWithAsyncErrorHandling(): ${typeof fn}` + ); + } +} +function handleError(err, instance, type, throwInDev = true) { + const contextVNode = instance ? instance.vnode : null; + if (instance) { + let cur = instance.parent; + const exposedInstance = instance.proxy; + const errorInfo = ErrorTypeStrings$1[type] ; + while (cur) { + const errorCapturedHooks = cur.ec; + if (errorCapturedHooks) { + for (let i = 0; i < errorCapturedHooks.length; i++) { + if (errorCapturedHooks[i](err, exposedInstance, errorInfo) === false) { + return; + } + } + } + cur = cur.parent; + } + const appErrorHandler = instance.appContext.config.errorHandler; + if (appErrorHandler) { + pauseTracking(); + callWithErrorHandling( + appErrorHandler, + null, + 10, + [err, exposedInstance, errorInfo] + ); + resetTracking(); + return; + } + } + logError(err, type, contextVNode, throwInDev); +} +function logError(err, type, contextVNode, throwInDev = true) { + { + const info = ErrorTypeStrings$1[type]; + if (contextVNode) { + pushWarningContext(contextVNode); + } + warn$1(`Unhandled error${info ? ` during execution of ${info}` : ``}`); + if (contextVNode) { + popWarningContext(); + } + if (throwInDev) { + throw err; + } else { + console.error(err); + } + } +} + +let isFlushing = false; +let isFlushPending = false; +const queue = []; +let flushIndex = 0; +const pendingPostFlushCbs = []; +let activePostFlushCbs = null; +let postFlushIndex = 0; +const resolvedPromise = /* @__PURE__ */ Promise.resolve(); +let currentFlushPromise = null; +const RECURSION_LIMIT = 100; +function nextTick(fn) { + const p = currentFlushPromise || resolvedPromise; + return fn ? p.then(this ? fn.bind(this) : fn) : p; +} +function findInsertionIndex(id) { + let start = flushIndex + 1; + let end = queue.length; + while (start < end) { + const middle = start + end >>> 1; + const middleJob = queue[middle]; + const middleJobId = getId(middleJob); + if (middleJobId < id || middleJobId === id && middleJob.pre) { + start = middle + 1; + } else { + end = middle; + } + } + return start; +} +function queueJob(job) { + if (!queue.length || !queue.includes( + job, + isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex + )) { + if (job.id == null) { + queue.push(job); + } else { + queue.splice(findInsertionIndex(job.id), 0, job); + } + queueFlush(); + } +} +function queueFlush() { + if (!isFlushing && !isFlushPending) { + isFlushPending = true; + currentFlushPromise = resolvedPromise.then(flushJobs); + } +} +function invalidateJob(job) { + const i = queue.indexOf(job); + if (i > flushIndex) { + queue.splice(i, 1); + } +} +function queuePostFlushCb(cb) { + if (!isArray(cb)) { + if (!activePostFlushCbs || !activePostFlushCbs.includes( + cb, + cb.allowRecurse ? postFlushIndex + 1 : postFlushIndex + )) { + pendingPostFlushCbs.push(cb); + } + } else { + pendingPostFlushCbs.push(...cb); + } + queueFlush(); +} +function flushPreFlushCbs(instance, seen, i = isFlushing ? flushIndex + 1 : 0) { + { + seen = seen || /* @__PURE__ */ new Map(); + } + for (; i < queue.length; i++) { + const cb = queue[i]; + if (cb && cb.pre) { + if (instance && cb.id !== instance.uid) { + continue; + } + if (checkRecursiveUpdates(seen, cb)) { + continue; + } + queue.splice(i, 1); + i--; + cb(); + } + } +} +function flushPostFlushCbs(seen) { + if (pendingPostFlushCbs.length) { + const deduped = [...new Set(pendingPostFlushCbs)].sort( + (a, b) => getId(a) - getId(b) + ); + pendingPostFlushCbs.length = 0; + if (activePostFlushCbs) { + activePostFlushCbs.push(...deduped); + return; + } + activePostFlushCbs = deduped; + { + seen = seen || /* @__PURE__ */ new Map(); + } + for (postFlushIndex = 0; postFlushIndex < activePostFlushCbs.length; postFlushIndex++) { + const cb = activePostFlushCbs[postFlushIndex]; + if (checkRecursiveUpdates(seen, cb)) { + continue; + } + if (cb.active !== false) cb(); + } + activePostFlushCbs = null; + postFlushIndex = 0; + } +} +const getId = (job) => job.id == null ? Infinity : job.id; +const comparator = (a, b) => { + const diff = getId(a) - getId(b); + if (diff === 0) { + if (a.pre && !b.pre) return -1; + if (b.pre && !a.pre) return 1; + } + return diff; +}; +function flushJobs(seen) { + isFlushPending = false; + isFlushing = true; + { + seen = seen || /* @__PURE__ */ new Map(); + } + queue.sort(comparator); + const check = (job) => checkRecursiveUpdates(seen, job) ; + try { + for (flushIndex = 0; flushIndex < queue.length; flushIndex++) { + const job = queue[flushIndex]; + if (job && job.active !== false) { + if (check(job)) { + continue; + } + callWithErrorHandling( + job, + job.i, + job.i ? 15 : 14 + ); + } + } + } finally { + flushIndex = 0; + queue.length = 0; + flushPostFlushCbs(seen); + isFlushing = false; + currentFlushPromise = null; + if (queue.length || pendingPostFlushCbs.length) { + flushJobs(seen); + } + } +} +function checkRecursiveUpdates(seen, fn) { + if (!seen.has(fn)) { + seen.set(fn, 1); + } else { + const count = seen.get(fn); + if (count > RECURSION_LIMIT) { + const instance = fn.i; + const componentName = instance && getComponentName(instance.type); + handleError( + `Maximum recursive updates exceeded${componentName ? ` in component <${componentName}>` : ``}. This means you have a reactive effect that is mutating its own dependencies and thus recursively triggering itself. Possible sources include component template, render function, updated hook or watcher source function.`, + null, + 10 + ); + return true; + } else { + seen.set(fn, count + 1); + } + } +} + +let isHmrUpdating = false; +const hmrDirtyComponents = /* @__PURE__ */ new Map(); +{ + getGlobalThis().__VUE_HMR_RUNTIME__ = { + createRecord: tryWrap(createRecord), + rerender: tryWrap(rerender), + reload: tryWrap(reload) + }; +} +const map = /* @__PURE__ */ new Map(); +function registerHMR(instance) { + const id = instance.type.__hmrId; + let record = map.get(id); + if (!record) { + createRecord(id, instance.type); + record = map.get(id); + } + record.instances.add(instance); +} +function unregisterHMR(instance) { + map.get(instance.type.__hmrId).instances.delete(instance); +} +function createRecord(id, initialDef) { + if (map.has(id)) { + return false; + } + map.set(id, { + initialDef: normalizeClassComponent(initialDef), + instances: /* @__PURE__ */ new Set() + }); + return true; +} +function normalizeClassComponent(component) { + return isClassComponent(component) ? component.__vccOpts : component; +} +function rerender(id, newRender) { + const record = map.get(id); + if (!record) { + return; + } + record.initialDef.render = newRender; + [...record.instances].forEach((instance) => { + if (newRender) { + instance.render = newRender; + normalizeClassComponent(instance.type).render = newRender; + } + instance.renderCache = []; + isHmrUpdating = true; + instance.effect.dirty = true; + instance.update(); + isHmrUpdating = false; + }); +} +function reload(id, newComp) { + const record = map.get(id); + if (!record) return; + newComp = normalizeClassComponent(newComp); + updateComponentDef(record.initialDef, newComp); + const instances = [...record.instances]; + for (let i = 0; i < instances.length; i++) { + const instance = instances[i]; + const oldComp = normalizeClassComponent(instance.type); + let dirtyInstances = hmrDirtyComponents.get(oldComp); + if (!dirtyInstances) { + if (oldComp !== record.initialDef) { + updateComponentDef(oldComp, newComp); + } + hmrDirtyComponents.set(oldComp, dirtyInstances = /* @__PURE__ */ new Set()); + } + dirtyInstances.add(instance); + instance.appContext.propsCache.delete(instance.type); + instance.appContext.emitsCache.delete(instance.type); + instance.appContext.optionsCache.delete(instance.type); + if (instance.ceReload) { + dirtyInstances.add(instance); + instance.ceReload(newComp.styles); + dirtyInstances.delete(instance); + } else if (instance.parent) { + instance.parent.effect.dirty = true; + queueJob(() => { + instance.parent.update(); + dirtyInstances.delete(instance); + }); + } else if (instance.appContext.reload) { + instance.appContext.reload(); + } else if (typeof window !== "undefined") { + window.location.reload(); + } else { + console.warn( + "[HMR] Root or manually mounted instance modified. Full reload required." + ); + } + } + queuePostFlushCb(() => { + hmrDirtyComponents.clear(); + }); +} +function updateComponentDef(oldComp, newComp) { + extend(oldComp, newComp); + for (const key in oldComp) { + if (key !== "__file" && !(key in newComp)) { + delete oldComp[key]; + } + } +} +function tryWrap(fn) { + return (id, arg) => { + try { + return fn(id, arg); + } catch (e) { + console.error(e); + console.warn( + `[HMR] Something went wrong during Vue component hot-reload. Full reload required.` + ); + } + }; +} + +let devtools$1; +let buffer = []; +let devtoolsNotInstalled = false; +function emit$1(event, ...args) { + if (devtools$1) { + devtools$1.emit(event, ...args); + } else if (!devtoolsNotInstalled) { + buffer.push({ event, args }); + } +} +function setDevtoolsHook$1(hook, target) { + var _a, _b; + devtools$1 = hook; + if (devtools$1) { + devtools$1.enabled = true; + buffer.forEach(({ event, args }) => devtools$1.emit(event, ...args)); + buffer = []; + } else if ( + // handle late devtools injection - only do this if we are in an actual + // browser environment to avoid the timer handle stalling test runner exit + // (#4815) + typeof window !== "undefined" && // some envs mock window but not fully + window.HTMLElement && // also exclude jsdom + // eslint-disable-next-line no-restricted-syntax + !((_b = (_a = window.navigator) == null ? void 0 : _a.userAgent) == null ? void 0 : _b.includes("jsdom")) + ) { + const replay = target.__VUE_DEVTOOLS_HOOK_REPLAY__ = target.__VUE_DEVTOOLS_HOOK_REPLAY__ || []; + replay.push((newHook) => { + setDevtoolsHook$1(newHook, target); + }); + setTimeout(() => { + if (!devtools$1) { + target.__VUE_DEVTOOLS_HOOK_REPLAY__ = null; + devtoolsNotInstalled = true; + buffer = []; + } + }, 3e3); + } else { + devtoolsNotInstalled = true; + buffer = []; + } +} +function devtoolsInitApp(app, version) { + emit$1("app:init" /* APP_INIT */, app, version, { + Fragment, + Text, + Comment, + Static + }); +} +function devtoolsUnmountApp(app) { + emit$1("app:unmount" /* APP_UNMOUNT */, app); +} +const devtoolsComponentAdded = /* @__PURE__ */ createDevtoolsComponentHook( + "component:added" /* COMPONENT_ADDED */ +); +const devtoolsComponentUpdated = /* @__PURE__ */ createDevtoolsComponentHook("component:updated" /* COMPONENT_UPDATED */); +const _devtoolsComponentRemoved = /* @__PURE__ */ createDevtoolsComponentHook( + "component:removed" /* COMPONENT_REMOVED */ +); +const devtoolsComponentRemoved = (component) => { + if (devtools$1 && typeof devtools$1.cleanupBuffer === "function" && // remove the component if it wasn't buffered + !devtools$1.cleanupBuffer(component)) { + _devtoolsComponentRemoved(component); + } +}; +/*! #__NO_SIDE_EFFECTS__ */ +// @__NO_SIDE_EFFECTS__ +function createDevtoolsComponentHook(hook) { + return (component) => { + emit$1( + hook, + component.appContext.app, + component.uid, + component.parent ? component.parent.uid : void 0, + component + ); + }; +} +const devtoolsPerfStart = /* @__PURE__ */ createDevtoolsPerformanceHook( + "perf:start" /* PERFORMANCE_START */ +); +const devtoolsPerfEnd = /* @__PURE__ */ createDevtoolsPerformanceHook( + "perf:end" /* PERFORMANCE_END */ +); +function createDevtoolsPerformanceHook(hook) { + return (component, type, time) => { + emit$1(hook, component.appContext.app, component.uid, component, type, time); + }; +} +function devtoolsComponentEmit(component, event, params) { + emit$1( + "component:emit" /* COMPONENT_EMIT */, + component.appContext.app, + component, + event, + params + ); +} + +let currentRenderingInstance = null; +let currentScopeId = null; +function setCurrentRenderingInstance(instance) { + const prev = currentRenderingInstance; + currentRenderingInstance = instance; + currentScopeId = instance && instance.type.__scopeId || null; + return prev; +} +function pushScopeId(id) { + currentScopeId = id; +} +function popScopeId() { + currentScopeId = null; +} +const withScopeId = (_id) => withCtx; +function withCtx(fn, ctx = currentRenderingInstance, isNonScopedSlot) { + if (!ctx) return fn; + if (fn._n) { + return fn; + } + const renderFnWithContext = (...args) => { + if (renderFnWithContext._d) { + setBlockTracking(-1); + } + const prevInstance = setCurrentRenderingInstance(ctx); + let res; + try { + res = fn(...args); + } finally { + setCurrentRenderingInstance(prevInstance); + if (renderFnWithContext._d) { + setBlockTracking(1); + } + } + { + devtoolsComponentUpdated(ctx); + } + return res; + }; + renderFnWithContext._n = true; + renderFnWithContext._c = true; + renderFnWithContext._d = true; + return renderFnWithContext; +} + +function validateDirectiveName(name) { + if (isBuiltInDirective(name)) { + warn$1("Do not use built-in directive ids as custom directive id: " + name); + } +} +function withDirectives(vnode, directives) { + if (currentRenderingInstance === null) { + warn$1(`withDirectives can only be used inside render functions.`); + return vnode; + } + const instance = getComponentPublicInstance(currentRenderingInstance); + const bindings = vnode.dirs || (vnode.dirs = []); + for (let i = 0; i < directives.length; i++) { + let [dir, value, arg, modifiers = EMPTY_OBJ] = directives[i]; + if (dir) { + if (isFunction(dir)) { + dir = { + mounted: dir, + updated: dir + }; + } + if (dir.deep) { + traverse(value); + } + bindings.push({ + dir, + instance, + value, + oldValue: void 0, + arg, + modifiers + }); + } + } + return vnode; +} +function invokeDirectiveHook(vnode, prevVNode, instance, name) { + const bindings = vnode.dirs; + const oldBindings = prevVNode && prevVNode.dirs; + for (let i = 0; i < bindings.length; i++) { + const binding = bindings[i]; + if (oldBindings) { + binding.oldValue = oldBindings[i].value; + } + let hook = binding.dir[name]; + if (hook) { + pauseTracking(); + callWithAsyncErrorHandling(hook, instance, 8, [ + vnode.el, + binding, + vnode, + prevVNode + ]); + resetTracking(); + } + } +} + +const leaveCbKey = Symbol("_leaveCb"); +const enterCbKey$1 = Symbol("_enterCb"); +function useTransitionState() { + const state = { + isMounted: false, + isLeaving: false, + isUnmounting: false, + leavingVNodes: /* @__PURE__ */ new Map() + }; + onMounted(() => { + state.isMounted = true; + }); + onBeforeUnmount(() => { + state.isUnmounting = true; + }); + return state; +} +const TransitionHookValidator = [Function, Array]; +const BaseTransitionPropsValidators = { + mode: String, + appear: Boolean, + persisted: Boolean, + // enter + onBeforeEnter: TransitionHookValidator, + onEnter: TransitionHookValidator, + onAfterEnter: TransitionHookValidator, + onEnterCancelled: TransitionHookValidator, + // leave + onBeforeLeave: TransitionHookValidator, + onLeave: TransitionHookValidator, + onAfterLeave: TransitionHookValidator, + onLeaveCancelled: TransitionHookValidator, + // appear + onBeforeAppear: TransitionHookValidator, + onAppear: TransitionHookValidator, + onAfterAppear: TransitionHookValidator, + onAppearCancelled: TransitionHookValidator +}; +const recursiveGetSubtree = (instance) => { + const subTree = instance.subTree; + return subTree.component ? recursiveGetSubtree(subTree.component) : subTree; +}; +const BaseTransitionImpl = { + name: `BaseTransition`, + props: BaseTransitionPropsValidators, + setup(props, { slots }) { + const instance = getCurrentInstance(); + const state = useTransitionState(); + return () => { + const children = slots.default && getTransitionRawChildren(slots.default(), true); + if (!children || !children.length) { + return; + } + let child = children[0]; + if (children.length > 1) { + let hasFound = false; + for (const c of children) { + if (c.type !== Comment) { + if (hasFound) { + warn$1( + " can only be used on a single element or component. Use for lists." + ); + break; + } + child = c; + hasFound = true; + } + } + } + const rawProps = toRaw(props); + const { mode } = rawProps; + if (mode && mode !== "in-out" && mode !== "out-in" && mode !== "default") { + warn$1(`invalid mode: ${mode}`); + } + if (state.isLeaving) { + return emptyPlaceholder(child); + } + const innerChild = getKeepAliveChild(child); + if (!innerChild) { + return emptyPlaceholder(child); + } + let enterHooks = resolveTransitionHooks( + innerChild, + rawProps, + state, + instance, + // #11061, ensure enterHooks is fresh after clone + (hooks) => enterHooks = hooks + ); + setTransitionHooks(innerChild, enterHooks); + const oldChild = instance.subTree; + const oldInnerChild = oldChild && getKeepAliveChild(oldChild); + if (oldInnerChild && oldInnerChild.type !== Comment && !isSameVNodeType(innerChild, oldInnerChild) && recursiveGetSubtree(instance).type !== Comment) { + const leavingHooks = resolveTransitionHooks( + oldInnerChild, + rawProps, + state, + instance + ); + setTransitionHooks(oldInnerChild, leavingHooks); + if (mode === "out-in" && innerChild.type !== Comment) { + state.isLeaving = true; + leavingHooks.afterLeave = () => { + state.isLeaving = false; + if (instance.update.active !== false) { + instance.effect.dirty = true; + instance.update(); + } + }; + return emptyPlaceholder(child); + } else if (mode === "in-out" && innerChild.type !== Comment) { + leavingHooks.delayLeave = (el, earlyRemove, delayedLeave) => { + const leavingVNodesCache = getLeavingNodesForType( + state, + oldInnerChild + ); + leavingVNodesCache[String(oldInnerChild.key)] = oldInnerChild; + el[leaveCbKey] = () => { + earlyRemove(); + el[leaveCbKey] = void 0; + delete enterHooks.delayedLeave; + }; + enterHooks.delayedLeave = delayedLeave; + }; + } + } + return child; + }; + } +}; +const BaseTransition = BaseTransitionImpl; +function getLeavingNodesForType(state, vnode) { + const { leavingVNodes } = state; + let leavingVNodesCache = leavingVNodes.get(vnode.type); + if (!leavingVNodesCache) { + leavingVNodesCache = /* @__PURE__ */ Object.create(null); + leavingVNodes.set(vnode.type, leavingVNodesCache); + } + return leavingVNodesCache; +} +function resolveTransitionHooks(vnode, props, state, instance, postClone) { + const { + appear, + mode, + persisted = false, + onBeforeEnter, + onEnter, + onAfterEnter, + onEnterCancelled, + onBeforeLeave, + onLeave, + onAfterLeave, + onLeaveCancelled, + onBeforeAppear, + onAppear, + onAfterAppear, + onAppearCancelled + } = props; + const key = String(vnode.key); + const leavingVNodesCache = getLeavingNodesForType(state, vnode); + const callHook = (hook, args) => { + hook && callWithAsyncErrorHandling( + hook, + instance, + 9, + args + ); + }; + const callAsyncHook = (hook, args) => { + const done = args[1]; + callHook(hook, args); + if (isArray(hook)) { + if (hook.every((hook2) => hook2.length <= 1)) done(); + } else if (hook.length <= 1) { + done(); + } + }; + const hooks = { + mode, + persisted, + beforeEnter(el) { + let hook = onBeforeEnter; + if (!state.isMounted) { + if (appear) { + hook = onBeforeAppear || onBeforeEnter; + } else { + return; + } + } + if (el[leaveCbKey]) { + el[leaveCbKey]( + true + /* cancelled */ + ); + } + const leavingVNode = leavingVNodesCache[key]; + if (leavingVNode && isSameVNodeType(vnode, leavingVNode) && leavingVNode.el[leaveCbKey]) { + leavingVNode.el[leaveCbKey](); + } + callHook(hook, [el]); + }, + enter(el) { + let hook = onEnter; + let afterHook = onAfterEnter; + let cancelHook = onEnterCancelled; + if (!state.isMounted) { + if (appear) { + hook = onAppear || onEnter; + afterHook = onAfterAppear || onAfterEnter; + cancelHook = onAppearCancelled || onEnterCancelled; + } else { + return; + } + } + let called = false; + const done = el[enterCbKey$1] = (cancelled) => { + if (called) return; + called = true; + if (cancelled) { + callHook(cancelHook, [el]); + } else { + callHook(afterHook, [el]); + } + if (hooks.delayedLeave) { + hooks.delayedLeave(); + } + el[enterCbKey$1] = void 0; + }; + if (hook) { + callAsyncHook(hook, [el, done]); + } else { + done(); + } + }, + leave(el, remove) { + const key2 = String(vnode.key); + if (el[enterCbKey$1]) { + el[enterCbKey$1]( + true + /* cancelled */ + ); + } + if (state.isUnmounting) { + return remove(); + } + callHook(onBeforeLeave, [el]); + let called = false; + const done = el[leaveCbKey] = (cancelled) => { + if (called) return; + called = true; + remove(); + if (cancelled) { + callHook(onLeaveCancelled, [el]); + } else { + callHook(onAfterLeave, [el]); + } + el[leaveCbKey] = void 0; + if (leavingVNodesCache[key2] === vnode) { + delete leavingVNodesCache[key2]; + } + }; + leavingVNodesCache[key2] = vnode; + if (onLeave) { + callAsyncHook(onLeave, [el, done]); + } else { + done(); + } + }, + clone(vnode2) { + const hooks2 = resolveTransitionHooks( + vnode2, + props, + state, + instance, + postClone + ); + if (postClone) postClone(hooks2); + return hooks2; + } + }; + return hooks; +} +function emptyPlaceholder(vnode) { + if (isKeepAlive(vnode)) { + vnode = cloneVNode(vnode); + vnode.children = null; + return vnode; + } +} +function getKeepAliveChild(vnode) { + if (!isKeepAlive(vnode)) { + return vnode; + } + if (vnode.component) { + return vnode.component.subTree; + } + const { shapeFlag, children } = vnode; + if (children) { + if (shapeFlag & 16) { + return children[0]; + } + if (shapeFlag & 32 && isFunction(children.default)) { + return children.default(); + } + } +} +function setTransitionHooks(vnode, hooks) { + if (vnode.shapeFlag & 6 && vnode.component) { + setTransitionHooks(vnode.component.subTree, hooks); + } else if (vnode.shapeFlag & 128) { + vnode.ssContent.transition = hooks.clone(vnode.ssContent); + vnode.ssFallback.transition = hooks.clone(vnode.ssFallback); + } else { + vnode.transition = hooks; + } +} +function getTransitionRawChildren(children, keepComment = false, parentKey) { + let ret = []; + let keyedFragmentCount = 0; + for (let i = 0; i < children.length; i++) { + let child = children[i]; + const key = parentKey == null ? child.key : String(parentKey) + String(child.key != null ? child.key : i); + if (child.type === Fragment) { + if (child.patchFlag & 128) keyedFragmentCount++; + ret = ret.concat( + getTransitionRawChildren(child.children, keepComment, key) + ); + } else if (keepComment || child.type !== Comment) { + ret.push(key != null ? cloneVNode(child, { key }) : child); + } + } + if (keyedFragmentCount > 1) { + for (let i = 0; i < ret.length; i++) { + ret[i].patchFlag = -2; + } + } + return ret; +} + +/*! #__NO_SIDE_EFFECTS__ */ +// @__NO_SIDE_EFFECTS__ +function defineComponent(options, extraOptions) { + return isFunction(options) ? ( + // #8326: extend call and options.name access are considered side-effects + // by Rollup, so we have to wrap it in a pure-annotated IIFE. + /* @__PURE__ */ (() => extend({ name: options.name }, extraOptions, { setup: options }))() + ) : options; +} + +const isAsyncWrapper = (i) => !!i.type.__asyncLoader; +/*! #__NO_SIDE_EFFECTS__ */ +// @__NO_SIDE_EFFECTS__ +function defineAsyncComponent(source) { + if (isFunction(source)) { + source = { loader: source }; + } + const { + loader, + loadingComponent, + errorComponent, + delay = 200, + timeout, + // undefined = never times out + suspensible = true, + onError: userOnError + } = source; + let pendingRequest = null; + let resolvedComp; + let retries = 0; + const retry = () => { + retries++; + pendingRequest = null; + return load(); + }; + const load = () => { + let thisRequest; + return pendingRequest || (thisRequest = pendingRequest = loader().catch((err) => { + err = err instanceof Error ? err : new Error(String(err)); + if (userOnError) { + return new Promise((resolve, reject) => { + const userRetry = () => resolve(retry()); + const userFail = () => reject(err); + userOnError(err, userRetry, userFail, retries + 1); + }); + } else { + throw err; + } + }).then((comp) => { + if (thisRequest !== pendingRequest && pendingRequest) { + return pendingRequest; + } + if (!comp) { + warn$1( + `Async component loader resolved to undefined. If you are using retry(), make sure to return its return value.` + ); + } + if (comp && (comp.__esModule || comp[Symbol.toStringTag] === "Module")) { + comp = comp.default; + } + if (comp && !isObject(comp) && !isFunction(comp)) { + throw new Error(`Invalid async component load result: ${comp}`); + } + resolvedComp = comp; + return comp; + })); + }; + return defineComponent({ + name: "AsyncComponentWrapper", + __asyncLoader: load, + get __asyncResolved() { + return resolvedComp; + }, + setup() { + const instance = currentInstance; + if (resolvedComp) { + return () => createInnerComp(resolvedComp, instance); + } + const onError = (err) => { + pendingRequest = null; + handleError( + err, + instance, + 13, + !errorComponent + ); + }; + if (suspensible && instance.suspense || false) { + return load().then((comp) => { + return () => createInnerComp(comp, instance); + }).catch((err) => { + onError(err); + return () => errorComponent ? createVNode(errorComponent, { + error: err + }) : null; + }); + } + const loaded = ref(false); + const error = ref(); + const delayed = ref(!!delay); + if (delay) { + setTimeout(() => { + delayed.value = false; + }, delay); + } + if (timeout != null) { + setTimeout(() => { + if (!loaded.value && !error.value) { + const err = new Error( + `Async component timed out after ${timeout}ms.` + ); + onError(err); + error.value = err; + } + }, timeout); + } + load().then(() => { + loaded.value = true; + if (instance.parent && isKeepAlive(instance.parent.vnode)) { + instance.parent.effect.dirty = true; + queueJob(instance.parent.update); + } + }).catch((err) => { + onError(err); + error.value = err; + }); + return () => { + if (loaded.value && resolvedComp) { + return createInnerComp(resolvedComp, instance); + } else if (error.value && errorComponent) { + return createVNode(errorComponent, { + error: error.value + }); + } else if (loadingComponent && !delayed.value) { + return createVNode(loadingComponent); + } + }; + } + }); +} +function createInnerComp(comp, parent) { + const { ref: ref2, props, children, ce } = parent.vnode; + const vnode = createVNode(comp, props, children); + vnode.ref = ref2; + vnode.ce = ce; + delete parent.vnode.ce; + return vnode; +} + +const isKeepAlive = (vnode) => vnode.type.__isKeepAlive; +const KeepAliveImpl = { + name: `KeepAlive`, + // Marker for special handling inside the renderer. We are not using a === + // check directly on KeepAlive in the renderer, because importing it directly + // would prevent it from being tree-shaken. + __isKeepAlive: true, + props: { + include: [String, RegExp, Array], + exclude: [String, RegExp, Array], + max: [String, Number] + }, + setup(props, { slots }) { + const instance = getCurrentInstance(); + const sharedContext = instance.ctx; + const cache = /* @__PURE__ */ new Map(); + const keys = /* @__PURE__ */ new Set(); + let current = null; + { + instance.__v_cache = cache; + } + const parentSuspense = instance.suspense; + const { + renderer: { + p: patch, + m: move, + um: _unmount, + o: { createElement } + } + } = sharedContext; + const storageContainer = createElement("div"); + sharedContext.activate = (vnode, container, anchor, namespace, optimized) => { + const instance2 = vnode.component; + move(vnode, container, anchor, 0, parentSuspense); + patch( + instance2.vnode, + vnode, + container, + anchor, + instance2, + parentSuspense, + namespace, + vnode.slotScopeIds, + optimized + ); + queuePostRenderEffect(() => { + instance2.isDeactivated = false; + if (instance2.a) { + invokeArrayFns(instance2.a); + } + const vnodeHook = vnode.props && vnode.props.onVnodeMounted; + if (vnodeHook) { + invokeVNodeHook(vnodeHook, instance2.parent, vnode); + } + }, parentSuspense); + { + devtoolsComponentAdded(instance2); + } + }; + sharedContext.deactivate = (vnode) => { + const instance2 = vnode.component; + invalidateMount(instance2.m); + invalidateMount(instance2.a); + move(vnode, storageContainer, null, 1, parentSuspense); + queuePostRenderEffect(() => { + if (instance2.da) { + invokeArrayFns(instance2.da); + } + const vnodeHook = vnode.props && vnode.props.onVnodeUnmounted; + if (vnodeHook) { + invokeVNodeHook(vnodeHook, instance2.parent, vnode); + } + instance2.isDeactivated = true; + }, parentSuspense); + { + devtoolsComponentAdded(instance2); + } + }; + function unmount(vnode) { + resetShapeFlag(vnode); + _unmount(vnode, instance, parentSuspense, true); + } + function pruneCache(filter) { + cache.forEach((vnode, key) => { + const name = getComponentName(vnode.type); + if (name && (!filter || !filter(name))) { + pruneCacheEntry(key); + } + }); + } + function pruneCacheEntry(key) { + const cached = cache.get(key); + if (cached && (!current || !isSameVNodeType(cached, current))) { + unmount(cached); + } else if (current) { + resetShapeFlag(current); + } + cache.delete(key); + keys.delete(key); + } + watch( + () => [props.include, props.exclude], + ([include, exclude]) => { + include && pruneCache((name) => matches(include, name)); + exclude && pruneCache((name) => !matches(exclude, name)); + }, + // prune post-render after `current` has been updated + { flush: "post", deep: true } + ); + let pendingCacheKey = null; + const cacheSubtree = () => { + if (pendingCacheKey != null) { + if (isSuspense(instance.subTree.type)) { + queuePostRenderEffect(() => { + cache.set(pendingCacheKey, getInnerChild(instance.subTree)); + }, instance.subTree.suspense); + } else { + cache.set(pendingCacheKey, getInnerChild(instance.subTree)); + } + } + }; + onMounted(cacheSubtree); + onUpdated(cacheSubtree); + onBeforeUnmount(() => { + cache.forEach((cached) => { + const { subTree, suspense } = instance; + const vnode = getInnerChild(subTree); + if (cached.type === vnode.type && cached.key === vnode.key) { + resetShapeFlag(vnode); + const da = vnode.component.da; + da && queuePostRenderEffect(da, suspense); + return; + } + unmount(cached); + }); + }); + return () => { + pendingCacheKey = null; + if (!slots.default) { + return null; + } + const children = slots.default(); + const rawVNode = children[0]; + if (children.length > 1) { + { + warn$1(`KeepAlive should contain exactly one component child.`); + } + current = null; + return children; + } else if (!isVNode(rawVNode) || !(rawVNode.shapeFlag & 4) && !(rawVNode.shapeFlag & 128)) { + current = null; + return rawVNode; + } + let vnode = getInnerChild(rawVNode); + if (vnode.type === Comment) { + current = null; + return vnode; + } + const comp = vnode.type; + const name = getComponentName( + isAsyncWrapper(vnode) ? vnode.type.__asyncResolved || {} : comp + ); + const { include, exclude, max } = props; + if (include && (!name || !matches(include, name)) || exclude && name && matches(exclude, name)) { + current = vnode; + return rawVNode; + } + const key = vnode.key == null ? comp : vnode.key; + const cachedVNode = cache.get(key); + if (vnode.el) { + vnode = cloneVNode(vnode); + if (rawVNode.shapeFlag & 128) { + rawVNode.ssContent = vnode; + } + } + pendingCacheKey = key; + if (cachedVNode) { + vnode.el = cachedVNode.el; + vnode.component = cachedVNode.component; + if (vnode.transition) { + setTransitionHooks(vnode, vnode.transition); + } + vnode.shapeFlag |= 512; + keys.delete(key); + keys.add(key); + } else { + keys.add(key); + if (max && keys.size > parseInt(max, 10)) { + pruneCacheEntry(keys.values().next().value); + } + } + vnode.shapeFlag |= 256; + current = vnode; + return isSuspense(rawVNode.type) ? rawVNode : vnode; + }; + } +}; +const KeepAlive = KeepAliveImpl; +function matches(pattern, name) { + if (isArray(pattern)) { + return pattern.some((p) => matches(p, name)); + } else if (isString(pattern)) { + return pattern.split(",").includes(name); + } else if (isRegExp(pattern)) { + return pattern.test(name); + } + return false; +} +function onActivated(hook, target) { + registerKeepAliveHook(hook, "a", target); +} +function onDeactivated(hook, target) { + registerKeepAliveHook(hook, "da", target); +} +function registerKeepAliveHook(hook, type, target = currentInstance) { + const wrappedHook = hook.__wdc || (hook.__wdc = () => { + let current = target; + while (current) { + if (current.isDeactivated) { + return; + } + current = current.parent; + } + return hook(); + }); + injectHook(type, wrappedHook, target); + if (target) { + let current = target.parent; + while (current && current.parent) { + if (isKeepAlive(current.parent.vnode)) { + injectToKeepAliveRoot(wrappedHook, type, target, current); + } + current = current.parent; + } + } +} +function injectToKeepAliveRoot(hook, type, target, keepAliveRoot) { + const injected = injectHook( + type, + hook, + keepAliveRoot, + true + /* prepend */ + ); + onUnmounted(() => { + remove(keepAliveRoot[type], injected); + }, target); +} +function resetShapeFlag(vnode) { + vnode.shapeFlag &= ~256; + vnode.shapeFlag &= ~512; +} +function getInnerChild(vnode) { + return vnode.shapeFlag & 128 ? vnode.ssContent : vnode; +} + +function injectHook(type, hook, target = currentInstance, prepend = false) { + if (target) { + const hooks = target[type] || (target[type] = []); + const wrappedHook = hook.__weh || (hook.__weh = (...args) => { + pauseTracking(); + const reset = setCurrentInstance(target); + const res = callWithAsyncErrorHandling(hook, target, type, args); + reset(); + resetTracking(); + return res; + }); + if (prepend) { + hooks.unshift(wrappedHook); + } else { + hooks.push(wrappedHook); + } + return wrappedHook; + } else { + const apiName = toHandlerKey(ErrorTypeStrings$1[type].replace(/ hook$/, "")); + warn$1( + `${apiName} is called when there is no active component instance to be associated with. Lifecycle injection APIs can only be used during execution of setup().` + (` If you are using async setup(), make sure to register lifecycle hooks before the first await statement.` ) + ); + } +} +const createHook = (lifecycle) => (hook, target = currentInstance) => { + if (!isInSSRComponentSetup || lifecycle === "sp") { + injectHook(lifecycle, (...args) => hook(...args), target); + } +}; +const onBeforeMount = createHook("bm"); +const onMounted = createHook("m"); +const onBeforeUpdate = createHook("bu"); +const onUpdated = createHook("u"); +const onBeforeUnmount = createHook("bum"); +const onUnmounted = createHook("um"); +const onServerPrefetch = createHook("sp"); +const onRenderTriggered = createHook( + "rtg" +); +const onRenderTracked = createHook( + "rtc" +); +function onErrorCaptured(hook, target = currentInstance) { + injectHook("ec", hook, target); +} + +const COMPONENTS = "components"; +const DIRECTIVES = "directives"; +function resolveComponent(name, maybeSelfReference) { + return resolveAsset(COMPONENTS, name, true, maybeSelfReference) || name; +} +const NULL_DYNAMIC_COMPONENT = Symbol.for("v-ndc"); +function resolveDynamicComponent(component) { + if (isString(component)) { + return resolveAsset(COMPONENTS, component, false) || component; + } else { + return component || NULL_DYNAMIC_COMPONENT; + } +} +function resolveDirective(name) { + return resolveAsset(DIRECTIVES, name); +} +function resolveAsset(type, name, warnMissing = true, maybeSelfReference = false) { + const instance = currentRenderingInstance || currentInstance; + if (instance) { + const Component = instance.type; + if (type === COMPONENTS) { + const selfName = getComponentName( + Component, + false + ); + if (selfName && (selfName === name || selfName === camelize(name) || selfName === capitalize(camelize(name)))) { + return Component; + } + } + const res = ( + // local registration + // check instance[type] first which is resolved for options API + resolve(instance[type] || Component[type], name) || // global registration + resolve(instance.appContext[type], name) + ); + if (!res && maybeSelfReference) { + return Component; + } + if (warnMissing && !res) { + const extra = type === COMPONENTS ? ` +If this is a native custom element, make sure to exclude it from component resolution via compilerOptions.isCustomElement.` : ``; + warn$1(`Failed to resolve ${type.slice(0, -1)}: ${name}${extra}`); + } + return res; + } else { + warn$1( + `resolve${capitalize(type.slice(0, -1))} can only be used in render() or setup().` + ); + } +} +function resolve(registry, name) { + return registry && (registry[name] || registry[camelize(name)] || registry[capitalize(camelize(name))]); +} + +function renderList(source, renderItem, cache, index) { + let ret; + const cached = cache && cache[index]; + if (isArray(source) || isString(source)) { + ret = new Array(source.length); + for (let i = 0, l = source.length; i < l; i++) { + ret[i] = renderItem(source[i], i, void 0, cached && cached[i]); + } + } else if (typeof source === "number") { + if (!Number.isInteger(source)) { + warn$1(`The v-for range expect an integer value but got ${source}.`); + } + ret = new Array(source); + for (let i = 0; i < source; i++) { + ret[i] = renderItem(i + 1, i, void 0, cached && cached[i]); + } + } else if (isObject(source)) { + if (source[Symbol.iterator]) { + ret = Array.from( + source, + (item, i) => renderItem(item, i, void 0, cached && cached[i]) + ); + } else { + const keys = Object.keys(source); + ret = new Array(keys.length); + for (let i = 0, l = keys.length; i < l; i++) { + const key = keys[i]; + ret[i] = renderItem(source[key], key, i, cached && cached[i]); + } + } + } else { + ret = []; + } + if (cache) { + cache[index] = ret; + } + return ret; +} + +function createSlots(slots, dynamicSlots) { + for (let i = 0; i < dynamicSlots.length; i++) { + const slot = dynamicSlots[i]; + if (isArray(slot)) { + for (let j = 0; j < slot.length; j++) { + slots[slot[j].name] = slot[j].fn; + } + } else if (slot) { + slots[slot.name] = slot.key ? (...args) => { + const res = slot.fn(...args); + if (res) res.key = slot.key; + return res; + } : slot.fn; + } + } + return slots; +} + +function renderSlot(slots, name, props = {}, fallback, noSlotted) { + if (currentRenderingInstance.isCE || currentRenderingInstance.parent && isAsyncWrapper(currentRenderingInstance.parent) && currentRenderingInstance.parent.isCE) { + if (name !== "default") props.name = name; + return createVNode("slot", props, fallback && fallback()); + } + let slot = slots[name]; + if (slot && slot.length > 1) { + warn$1( + `SSR-optimized slot function detected in a non-SSR-optimized render function. You need to mark this component with $dynamic-slots in the parent template.` + ); + slot = () => []; + } + if (slot && slot._c) { + slot._d = false; + } + openBlock(); + const validSlotContent = slot && ensureValidVNode(slot(props)); + const rendered = createBlock( + Fragment, + { + key: (props.key || // slot content array of a dynamic conditional slot may have a branch + // key attached in the `createSlots` helper, respect that + validSlotContent && validSlotContent.key || `_${name}`) + // #7256 force differentiate fallback content from actual content + (!validSlotContent && fallback ? "_fb" : "") + }, + validSlotContent || (fallback ? fallback() : []), + validSlotContent && slots._ === 1 ? 64 : -2 + ); + if (!noSlotted && rendered.scopeId) { + rendered.slotScopeIds = [rendered.scopeId + "-s"]; + } + if (slot && slot._c) { + slot._d = true; + } + return rendered; +} +function ensureValidVNode(vnodes) { + return vnodes.some((child) => { + if (!isVNode(child)) return true; + if (child.type === Comment) return false; + if (child.type === Fragment && !ensureValidVNode(child.children)) + return false; + return true; + }) ? vnodes : null; +} + +function toHandlers(obj, preserveCaseIfNecessary) { + const ret = {}; + if (!isObject(obj)) { + warn$1(`v-on with no argument expects an object value.`); + return ret; + } + for (const key in obj) { + ret[preserveCaseIfNecessary && /[A-Z]/.test(key) ? `on:${key}` : toHandlerKey(key)] = obj[key]; + } + return ret; +} + +const getPublicInstance = (i) => { + if (!i) return null; + if (isStatefulComponent(i)) return getComponentPublicInstance(i); + return getPublicInstance(i.parent); +}; +const publicPropertiesMap = ( + // Move PURE marker to new line to workaround compiler discarding it + // due to type annotation + /* @__PURE__ */ extend(/* @__PURE__ */ Object.create(null), { + $: (i) => i, + $el: (i) => i.vnode.el, + $data: (i) => i.data, + $props: (i) => shallowReadonly(i.props) , + $attrs: (i) => shallowReadonly(i.attrs) , + $slots: (i) => shallowReadonly(i.slots) , + $refs: (i) => shallowReadonly(i.refs) , + $parent: (i) => getPublicInstance(i.parent), + $root: (i) => getPublicInstance(i.root), + $emit: (i) => i.emit, + $options: (i) => resolveMergedOptions(i) , + $forceUpdate: (i) => i.f || (i.f = () => { + i.effect.dirty = true; + queueJob(i.update); + }), + $nextTick: (i) => i.n || (i.n = nextTick.bind(i.proxy)), + $watch: (i) => instanceWatch.bind(i) + }) +); +const isReservedPrefix = (key) => key === "_" || key === "$"; +const hasSetupBinding = (state, key) => state !== EMPTY_OBJ && !state.__isScriptSetup && hasOwn(state, key); +const PublicInstanceProxyHandlers = { + get({ _: instance }, key) { + if (key === "__v_skip") { + return true; + } + const { ctx, setupState, data, props, accessCache, type, appContext } = instance; + if (key === "__isVue") { + return true; + } + let normalizedProps; + if (key[0] !== "$") { + const n = accessCache[key]; + if (n !== void 0) { + switch (n) { + case 1 /* SETUP */: + return setupState[key]; + case 2 /* DATA */: + return data[key]; + case 4 /* CONTEXT */: + return ctx[key]; + case 3 /* PROPS */: + return props[key]; + } + } else if (hasSetupBinding(setupState, key)) { + accessCache[key] = 1 /* SETUP */; + return setupState[key]; + } else if (data !== EMPTY_OBJ && hasOwn(data, key)) { + accessCache[key] = 2 /* DATA */; + return data[key]; + } else if ( + // only cache other properties when instance has declared (thus stable) + // props + (normalizedProps = instance.propsOptions[0]) && hasOwn(normalizedProps, key) + ) { + accessCache[key] = 3 /* PROPS */; + return props[key]; + } else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) { + accessCache[key] = 4 /* CONTEXT */; + return ctx[key]; + } else if (shouldCacheAccess) { + accessCache[key] = 0 /* OTHER */; + } + } + const publicGetter = publicPropertiesMap[key]; + let cssModule, globalProperties; + if (publicGetter) { + if (key === "$attrs") { + track(instance.attrs, "get", ""); + markAttrsAccessed(); + } else if (key === "$slots") { + track(instance, "get", key); + } + return publicGetter(instance); + } else if ( + // css module (injected by vue-loader) + (cssModule = type.__cssModules) && (cssModule = cssModule[key]) + ) { + return cssModule; + } else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) { + accessCache[key] = 4 /* CONTEXT */; + return ctx[key]; + } else if ( + // global properties + globalProperties = appContext.config.globalProperties, hasOwn(globalProperties, key) + ) { + { + return globalProperties[key]; + } + } else if (currentRenderingInstance && (!isString(key) || // #1091 avoid internal isRef/isVNode checks on component instance leading + // to infinite warning loop + key.indexOf("__v") !== 0)) { + if (data !== EMPTY_OBJ && isReservedPrefix(key[0]) && hasOwn(data, key)) { + warn$1( + `Property ${JSON.stringify( + key + )} must be accessed via $data because it starts with a reserved character ("$" or "_") and is not proxied on the render context.` + ); + } else if (instance === currentRenderingInstance) { + warn$1( + `Property ${JSON.stringify(key)} was accessed during render but is not defined on instance.` + ); + } + } + }, + set({ _: instance }, key, value) { + const { data, setupState, ctx } = instance; + if (hasSetupBinding(setupState, key)) { + setupState[key] = value; + return true; + } else if (setupState.__isScriptSetup && hasOwn(setupState, key)) { + warn$1(`Cannot mutate