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 0000000000..62972b9778 Binary files /dev/null and b/openpilot/starpilot/galaxy/web/assets/galaxy-icon-180.png differ 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 0000000000..8547af3969 Binary files /dev/null and b/openpilot/starpilot/galaxy/web/assets/galaxy-icon-192.png differ 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 0000000000..3b9dea092f Binary files /dev/null and b/openpilot/starpilot/galaxy/web/assets/galaxy-icon-512.png differ diff --git a/openpilot/starpilot/galaxy/web/assets/galaxy-icon.svg b/openpilot/starpilot/galaxy/web/assets/galaxy-icon.svg new file mode 100644 index 0000000000..e18661d786 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/assets/galaxy-icon.svg @@ -0,0 +1,6 @@ + + + + + + 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 0000000000..51204d27de Binary files /dev/null and b/openpilot/starpilot/galaxy/web/vendor/bootstrap-icons/fonts/bootstrap-icons.woff differ 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 0000000000..92c4830216 Binary files /dev/null and b/openpilot/starpilot/galaxy/web/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 differ diff --git a/openpilot/starpilot/galaxy/web/vendor/notices/Bootstrap-Icons-MIT.txt b/openpilot/starpilot/galaxy/web/vendor/notices/Bootstrap-Icons-MIT.txt new file mode 100644 index 0000000000..2165a59605 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/vendor/notices/Bootstrap-Icons-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019-2024 The Bootstrap Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/openpilot/starpilot/galaxy/web/vendor/notices/Vue-MIT.txt b/openpilot/starpilot/galaxy/web/vendor/notices/Vue-MIT.txt new file mode 100644 index 0000000000..b202669cba --- /dev/null +++ b/openpilot/starpilot/galaxy/web/vendor/notices/Vue-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018-present Yuxi (Evan) You and Vue contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/openpilot/starpilot/galaxy/web/vendor/vue/vue.esm-browser.js b/openpilot/starpilot/galaxy/web/vendor/vue/vue.esm-browser.js new file mode 100644 index 0000000000..c2fd80a85d --- /dev/null +++ b/openpilot/starpilot/galaxy/web/vendor/vue/vue.esm-browser.js @@ -0,0 +1,16774 @@ +/** +* vue v3.4.38 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/ +/*! #__NO_SIDE_EFFECTS__ */ +// @__NO_SIDE_EFFECTS__ +function makeMap(str, expectsLowerCase) { + const set = new Set(str.split(",")); + return expectsLowerCase ? (val) => 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