mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-18 13:33:53 +08:00
mario
This commit is contained in:
@@ -195,10 +195,12 @@ class HudRenderer(Widget):
|
||||
controls_state = sm['controlsState']
|
||||
car_state = sm['carState']
|
||||
rivian_lateral_mode.update()
|
||||
starpilot_car_state = sm['starpilotCarState'] if sm.valid.get('starpilotCarState', False) else None
|
||||
self._wheel_tint = get_wheel_tint(
|
||||
getattr(car_state, "brakePressed", False),
|
||||
rivian_lateral_mode.wheel_tint,
|
||||
ui_state.ui_params.get_bool("ShowBrakeStatus"),
|
||||
getattr(starpilot_car_state, "brakeLights", False),
|
||||
)
|
||||
|
||||
v_cruise_cluster = car_state.vCruiseCluster
|
||||
|
||||
@@ -16,8 +16,9 @@ from openpilot.starpilot.common.experimental_state import (
|
||||
BRAKE_WHEEL_COLOR = rl.Color(255, 0, 0, 255)
|
||||
|
||||
|
||||
def get_wheel_tint(brake_pressed: bool, mode_tint: rl.Color | None, brake_status_enabled: bool) -> rl.Color | None:
|
||||
return BRAKE_WHEEL_COLOR if brake_status_enabled and brake_pressed else mode_tint
|
||||
def get_wheel_tint(brake_pressed: bool, mode_tint: rl.Color | None, brake_status_enabled: bool,
|
||||
brake_lights: bool = False) -> rl.Color | None:
|
||||
return BRAKE_WHEEL_COLOR if brake_status_enabled and (brake_pressed or brake_lights) else mode_tint
|
||||
|
||||
|
||||
class ExpButton(Widget):
|
||||
@@ -111,10 +112,12 @@ class ExpButton(Widget):
|
||||
texture = self._txt_exp if exp_mode else self._txt_wheel
|
||||
color = self._white_color
|
||||
tint = None
|
||||
starpilot_car_state = ui_state.sm["starpilotCarState"] if getattr(ui_state.sm, "valid", {}).get("starpilotCarState", False) else None
|
||||
wheel_tint = get_wheel_tint(
|
||||
getattr(ui_state.sm["carState"], "brakePressed", False),
|
||||
self.wheel_tint,
|
||||
self._params.get_bool("ShowBrakeStatus"),
|
||||
getattr(starpilot_car_state, "brakeLights", False),
|
||||
)
|
||||
if wheel_tint is not None:
|
||||
tint = rl.Color(wheel_tint.r, wheel_tint.g, wheel_tint.b, self._white_color.a)
|
||||
|
||||
@@ -311,6 +311,26 @@ def test_non_mici_wheel_icon_turns_red_when_brakes_are_pressed(monkeypatch):
|
||||
assert (texture_color.r, texture_color.g, texture_color.b, texture_color.a) == (255, 0, 0, 255)
|
||||
|
||||
|
||||
def test_non_mici_wheel_icon_uses_reported_brake_lights(monkeypatch):
|
||||
module, draws = load_exp_button(monkeypatch)
|
||||
button = module.ExpButton(192, 144)
|
||||
button.wheel_tint = FakeColor(0x4D, 0x9D, 0xFF, 255)
|
||||
class FakeUiSubMaster(dict):
|
||||
pass
|
||||
|
||||
module.ui_state.sm = FakeUiSubMaster(module.ui_state.sm)
|
||||
module.ui_state.sm.valid = {"starpilotCarState": True}
|
||||
module.ui_state.sm["starpilotCarState"] = SimpleNamespace(brakeLights=True)
|
||||
module.ui_state.ui_params.get_bool = lambda key, *args, **kwargs: key == "ShowBrakeStatus"
|
||||
button._update_state()
|
||||
|
||||
button._render(FakeRectangle(0, 0, 192, 192))
|
||||
|
||||
assert len(draws["textures"]) == 1
|
||||
texture_color = draws["textures"][0][-1]
|
||||
assert (texture_color.r, texture_color.g, texture_color.b, texture_color.a) == (255, 0, 0, 255)
|
||||
|
||||
|
||||
def test_non_mici_wheel_icon_brake_tint_is_disabled_by_default(monkeypatch):
|
||||
module, draws = load_exp_button(monkeypatch)
|
||||
button = module.ExpButton(192, 144)
|
||||
|
||||
@@ -52,6 +52,12 @@ MACH_E_DIRECTION_CHANGE_MIN_PREVIEW_CURVATURE = 0.0005
|
||||
MACH_E_DIRECTION_CHANGE_FULL_PREVIEW_CURVATURE = 0.002
|
||||
MACH_E_DIRECTION_CHANGE_MIN_LAG_CURVATURE = 0.0008
|
||||
MACH_E_DIRECTION_CHANGE_FULL_LAG_CURVATURE = 0.0015
|
||||
MACH_E_LOW_SPEED_DIRECTION_CHANGE_START_SPEED = 1.8
|
||||
MACH_E_LOW_SPEED_DIRECTION_CHANGE_FULL_SPEED = 2.0
|
||||
MACH_E_LOW_SPEED_DIRECTION_CHANGE_HOLD_SPEED = 2.8
|
||||
MACH_E_LOW_SPEED_DIRECTION_CHANGE_FADE_SPEED = 3.5
|
||||
MACH_E_LOW_SPEED_DIRECTION_CHANGE_MIN_CURVATURE = 0.0004
|
||||
MACH_E_LOW_SPEED_DIRECTION_CHANGE_FULL_CURVATURE = 0.0006
|
||||
FORD_CURVATURE_LOOKAHEAD = {
|
||||
CAR.FORD_EXPLORER_MK6: 0.20,
|
||||
}
|
||||
@@ -261,6 +267,21 @@ class FordLateralController:
|
||||
))
|
||||
return preview_weight * lag_weight
|
||||
|
||||
@staticmethod
|
||||
def _low_speed_direction_change_weight(v_ego: float, desired: float) -> float:
|
||||
speed_weight = float(np.interp(
|
||||
v_ego,
|
||||
[MACH_E_LOW_SPEED_DIRECTION_CHANGE_START_SPEED, MACH_E_LOW_SPEED_DIRECTION_CHANGE_FULL_SPEED,
|
||||
MACH_E_LOW_SPEED_DIRECTION_CHANGE_HOLD_SPEED, MACH_E_LOW_SPEED_DIRECTION_CHANGE_FADE_SPEED],
|
||||
[0.0, 1.0, 1.0, 0.0],
|
||||
))
|
||||
curvature_weight = float(np.interp(
|
||||
abs(desired),
|
||||
[MACH_E_LOW_SPEED_DIRECTION_CHANGE_MIN_CURVATURE, MACH_E_LOW_SPEED_DIRECTION_CHANGE_FULL_CURVATURE],
|
||||
[0.0, 1.0],
|
||||
))
|
||||
return speed_weight * curvature_weight
|
||||
|
||||
def _manual_turn(self, CC, CS) -> bool:
|
||||
if not CC.latActive:
|
||||
self.human_turn.reset()
|
||||
@@ -329,11 +350,15 @@ class FordLateralController:
|
||||
turn_in_predicted = self._predicted_curvature(v_ego, lookahead + MACH_E_TURN_IN_LOOKAHEAD_EXTRA)
|
||||
direction_change_predicted = turn_in_predicted
|
||||
direction_change_weight = 0.0
|
||||
if v_ego > MACH_E_DIRECTION_CHANGE_MIN_SPEED and not CS.out.steeringPressed and not self._lane_change()[0]:
|
||||
direction_change_speed_weight = float(v_ego > MACH_E_DIRECTION_CHANGE_MIN_SPEED)
|
||||
if direction_change_speed_weight == 0.0:
|
||||
direction_change_speed_weight = self._low_speed_direction_change_weight(v_ego, desired)
|
||||
if direction_change_speed_weight > 0.0 and not CS.out.steeringPressed and not self._lane_change()[0]:
|
||||
direction_change_lookahead_extra = self._direction_change_lookahead_extra(v_ego)
|
||||
if direction_change_lookahead_extra > MACH_E_TURN_IN_LOOKAHEAD_EXTRA:
|
||||
direction_change_predicted = self._predicted_curvature(v_ego, lookahead + direction_change_lookahead_extra)
|
||||
direction_change_weight = self._direction_change_preview_weight(desired, direction_change_predicted, current)
|
||||
direction_change_weight *= direction_change_speed_weight
|
||||
if direction_change_weight > 0.0:
|
||||
predicted = float(np.interp(direction_change_weight, [0.0, 1.0], [predicted, direction_change_predicted]))
|
||||
allow_opposite_preview = True
|
||||
|
||||
@@ -199,6 +199,21 @@ def test_mach_e_direction_change_lookahead_extra_fades_by_speed(controller, spee
|
||||
assert controller._direction_change_lookahead_extra(speed) == pytest.approx(expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("speed,desired,expected", (
|
||||
(1.8, 0.0010, 0.0),
|
||||
(1.9, 0.0010, 0.5),
|
||||
(2.0, 0.0010, 1.0),
|
||||
(2.8, 0.0010, 1.0),
|
||||
(3.15, 0.0010, 0.5),
|
||||
(3.5, 0.0010, 0.0),
|
||||
(2.5, 0.0004, 0.0),
|
||||
(2.5, 0.0005, 0.5),
|
||||
(2.5, 0.0006, 1.0),
|
||||
))
|
||||
def test_mach_e_low_speed_direction_change_weight(controller, speed, desired, expected):
|
||||
assert controller._low_speed_direction_change_weight(speed, desired) == pytest.approx(expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sign", (1.0, -1.0))
|
||||
def test_mach_e_direction_change_preview_leads_a_lagging_unwind(controller, sign):
|
||||
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
|
||||
@@ -257,6 +272,30 @@ def test_mach_e_direction_change_preview_uses_far_path_when_unwind_lags(controll
|
||||
pytest.approx(0.003), True)]
|
||||
|
||||
|
||||
def test_mach_e_direction_change_preview_leads_low_speed_handoff(controller, monkeypatch):
|
||||
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
|
||||
controller.sm["liveDelay"].lateralDelay = 0.4
|
||||
controller.desired_curvature_last = 0.002
|
||||
blend_inputs = []
|
||||
monkeypatch.setattr(
|
||||
controller, "_predicted_curvature",
|
||||
lambda _v_ego, lookahead: 0.002 if lookahead < 1.0 else -0.002,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
controller, "_blend_and_scale",
|
||||
lambda desired, predicted, v_ego, current, allow_opposite_preview=False:
|
||||
blend_inputs.append((desired, predicted, v_ego, current, allow_opposite_preview)) or (0.0, 1),
|
||||
)
|
||||
|
||||
controller.update(
|
||||
SimpleNamespace(latActive=True), car_state(speed=2.5, curvature=0.003),
|
||||
SimpleNamespace(curvature=0.0015),
|
||||
)
|
||||
|
||||
assert blend_inputs == [(pytest.approx(0.0015), pytest.approx(-0.002), pytest.approx(2.5),
|
||||
pytest.approx(0.003), True)]
|
||||
|
||||
|
||||
def test_mach_e_direction_change_preview_uses_extended_horizon_at_medium_speed(controller, monkeypatch):
|
||||
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
|
||||
controller.sm["liveDelay"].lateralDelay = 0.4
|
||||
@@ -310,8 +349,12 @@ def test_mach_e_extended_direction_horizon_does_not_replace_turn_in_preview(cont
|
||||
|
||||
|
||||
@pytest.mark.parametrize("speed,steering_pressed,lane_change", (
|
||||
(1.8, False, False),
|
||||
(3.5, False, False),
|
||||
(8.0, False, False),
|
||||
(9.0, False, False),
|
||||
(2.5, True, False),
|
||||
(2.5, False, True),
|
||||
(15.0, True, False),
|
||||
(15.0, False, True),
|
||||
))
|
||||
@@ -340,6 +383,26 @@ def test_non_mach_e_direction_change_preview_is_unchanged(controller):
|
||||
desired=0.0015, preview=-0.002, current=0.003) == 0.0
|
||||
|
||||
|
||||
def test_non_mach_e_bypasses_low_speed_direction_change_preview(controller, monkeypatch):
|
||||
controller.desired_curvature_last = 0.002
|
||||
lookaheads = []
|
||||
monkeypatch.setattr(
|
||||
controller, "_predicted_curvature",
|
||||
lambda _v_ego, lookahead: lookaheads.append(lookahead) or -0.002,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
controller, "_low_speed_direction_change_weight",
|
||||
lambda *_args: pytest.fail("low-speed direction-change preview must remain Mach-E-only"),
|
||||
)
|
||||
|
||||
controller.update(
|
||||
SimpleNamespace(latActive=True), car_state(speed=2.5, curvature=0.003),
|
||||
SimpleNamespace(curvature=0.0015),
|
||||
)
|
||||
|
||||
assert lookaheads == [pytest.approx(0.2)]
|
||||
|
||||
|
||||
def test_mach_e_turn_in_preview_uses_extra_model_horizon(controller, monkeypatch):
|
||||
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
|
||||
controller.sm["liveDelay"].lateralDelay = 0.4
|
||||
|
||||
@@ -2842,8 +2842,8 @@
|
||||
{
|
||||
"key": "ShowBrakeStatus",
|
||||
"label": "Show Brake Status",
|
||||
"description": "Tint the on-screen steering-wheel icon red while the car reports that the brake pedal is pressed.",
|
||||
"picker_description": "Tints the on-screen steering-wheel icon red while braking.",
|
||||
"description": "Tint the on-screen steering-wheel icon red while the car reports that its brake lights are on.",
|
||||
"picker_description": "Tints the on-screen steering-wheel icon red while the brake lights are on.",
|
||||
"data_type": "bool",
|
||||
"ui_type": "toggle",
|
||||
"galaxy_only": true,
|
||||
|
||||
@@ -360,7 +360,8 @@ ul { list-style: none; margin: 0; padding: 0; }
|
||||
}
|
||||
|
||||
.gx-appbar .gx-appbar__back,
|
||||
.gx-appbar .gx-theme-toggle {
|
||||
.gx-appbar .gx-theme-toggle,
|
||||
.gx-appbar .gx-appbar__pin {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
@@ -464,6 +465,7 @@ ul { list-style: none; margin: 0; padding: 0; }
|
||||
.gx-icon-btn i { font-size: 1.3rem; }
|
||||
.gx-back-btn { display: none; }
|
||||
.gx-menu-btn { display: inline-flex; }
|
||||
.gx-appbar .gx-appbar__pin { display: none; }
|
||||
|
||||
.gx-searchwrap {
|
||||
align-items: center;
|
||||
@@ -1297,6 +1299,336 @@ button.gx-chip:hover {
|
||||
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);
|
||||
}
|
||||
|
||||
.gx-language-card {
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
|
||||
.gx-language-card__row {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.gx-language-card__label {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 1 1 360px;
|
||||
gap: var(--sp-4);
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.gx-language-card__select {
|
||||
margin-left: var(--sp-2);
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.gx-language-card__hint {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.gx-language-card__label {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.gx-language-card__hint {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* The destination view is a map-first screen. Search and route details float
|
||||
over the map, matching the classic navigation experience. */
|
||||
.gx-navigation-view {
|
||||
margin: calc(-1 * var(--sp-4));
|
||||
min-height: calc(100dvh - var(--appbar-height));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gx-navigation-stage {
|
||||
background: var(--surface-container-low);
|
||||
min-height: calc(100dvh - var(--appbar-height));
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gx-navigation-map {
|
||||
height: calc(100dvh - var(--appbar-height));
|
||||
min-height: 520px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gx-navigation-map .mapboxgl-canvas {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Route details use a light Mapbox popup over the map. Keep every text span
|
||||
readable when the route is changed, without affecting the destination cards
|
||||
or the classic navigation view. */
|
||||
.gx-navigation-stage .route-tooltip .mapboxgl-popup-content,
|
||||
.gx-navigation-stage .route-tooltip .custom-tooltip,
|
||||
.gx-navigation-stage .route-tooltip .tooltip-row,
|
||||
.gx-navigation-stage .route-tooltip .tooltip-row .label,
|
||||
.gx-navigation-stage .route-tooltip .tooltip-row .value {
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.gx-navigation-stage .route-tooltip .mapboxgl-popup-content {
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.gx-navigation-overlay {
|
||||
inset: 0;
|
||||
padding: var(--sp-4);
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.gx-navigation-overlay > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.gx-navigation-search,
|
||||
.gx-navigation-summary,
|
||||
.gx-navigation-recent,
|
||||
.gx-navigation-error {
|
||||
max-width: min(500px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.gx-navigation-search {
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
padding: var(--sp-2);
|
||||
}
|
||||
|
||||
.gx-navigation-search__row {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.gx-navigation-search__row > i {
|
||||
color: var(--primary);
|
||||
font-size: 1.15rem;
|
||||
padding-left: var(--sp-2);
|
||||
}
|
||||
|
||||
.gx-navigation-search .gx-field {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-left: var(--sp-1);
|
||||
}
|
||||
|
||||
.gx-navigation-send {
|
||||
color: var(--primary);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.gx-navigation-status,
|
||||
.gx-navigation-recent__title {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-sm);
|
||||
padding: var(--sp-2) var(--sp-3) var(--sp-1);
|
||||
}
|
||||
|
||||
.gx-navigation-suggestions {
|
||||
background: var(--card-scroll-bg);
|
||||
border-top: 1px solid var(--outline);
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
margin: var(--sp-2) calc(-1 * var(--sp-2)) calc(-1 * var(--sp-2));
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-2);
|
||||
}
|
||||
|
||||
.gx-navigation-suggestion {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--on-surface);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: var(--sp-3);
|
||||
justify-content: space-between;
|
||||
min-height: 52px;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gx-navigation-suggestion:hover,
|
||||
.gx-navigation-suggestion:focus-visible {
|
||||
background: var(--glass-active-bg);
|
||||
}
|
||||
|
||||
.gx-navigation-suggestion > span {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gx-navigation-suggestion strong,
|
||||
.gx-navigation-suggestion small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gx-navigation-suggestion small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.gx-navigation-summary {
|
||||
margin: var(--sp-4) 0 0;
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
|
||||
.gx-navigation-summary__title {
|
||||
background: var(--surface-container-high);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: var(--fw-bold);
|
||||
overflow: hidden;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gx-navigation-summary__rows {
|
||||
display: grid;
|
||||
gap: var(--sp-1);
|
||||
padding: var(--sp-3) var(--sp-2);
|
||||
}
|
||||
|
||||
.gx-navigation-summary__rows > div {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
grid-template-columns: 28px 90px 1fr;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.gx-navigation-summary__rows strong {
|
||||
font-weight: var(--fw-bold);
|
||||
}
|
||||
|
||||
.gx-navigation-summary__icon {
|
||||
font-size: 1.15rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gx-navigation-summary__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gx-navigation-summary__actions .gx-btn {
|
||||
flex: 1 1 190px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gx-btn--success { background: var(--success); color: var(--on-secondary); }
|
||||
.gx-btn--success:hover { background: #6df3e9; }
|
||||
.gx-btn--danger { background: var(--error); color: var(--on-error); }
|
||||
.gx-btn--danger:hover { background: #ff7492; }
|
||||
.gx-btn--favorite { background: var(--accent-rose); color: #fff; }
|
||||
.gx-btn--favorite:hover,
|
||||
.gx-btn--favorite.active { background: #ed4f94; }
|
||||
|
||||
.gx-navigation-recent {
|
||||
margin: var(--sp-4) 0 0;
|
||||
padding: var(--sp-2);
|
||||
}
|
||||
|
||||
.gx-navigation-recent .gx-navigation-suggestion + .gx-navigation-suggestion {
|
||||
border-top: 1px solid var(--outline);
|
||||
}
|
||||
|
||||
.gx-navigation-error {
|
||||
color: var(--error);
|
||||
margin: var(--sp-4) 0 0;
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
|
||||
.gx-navigation-empty {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
min-height: calc(100dvh - var(--appbar-height));
|
||||
padding: var(--sp-6);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gx-navigation-empty p {
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gx-navigation-tabs {
|
||||
pointer-events: auto;
|
||||
position: absolute;
|
||||
right: var(--sp-4);
|
||||
top: var(--sp-4);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.gx-navigation-view {
|
||||
margin: calc(-1 * var(--sp-3));
|
||||
}
|
||||
|
||||
.gx-navigation-overlay {
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
|
||||
.gx-navigation-tabs {
|
||||
bottom: calc(var(--bottomnav-height) + var(--sp-3));
|
||||
left: var(--sp-3);
|
||||
right: auto;
|
||||
top: auto;
|
||||
}
|
||||
|
||||
.gx-navigation-summary__rows > div {
|
||||
grid-template-columns: 28px 82px 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.gx-nav-section { margin-bottom: var(--sp-3); }
|
||||
|
||||
.gx-nav-section__title {
|
||||
@@ -1868,6 +2200,7 @@ button.gx-chip:hover {
|
||||
.gx-back-btn { display: inline-flex; }
|
||||
.gx-appbar__search { flex: 1; min-width: 0; }
|
||||
.gx-status-pill { display: none; }
|
||||
.gx-appbar .gx-appbar__pin { display: inline-flex; }
|
||||
.gx-content { padding-left: var(--sp-3); padding-right: var(--sp-3); }
|
||||
}
|
||||
|
||||
|
||||
@@ -185,6 +185,7 @@ export const api = {
|
||||
|
||||
getNavigation() { return request("/api/navigation") },
|
||||
setNavigation(body) { return request("/api/navigation", { method: "POST", data: body }) },
|
||||
clearNavigation() { return request("/api/navigation", { method: "DELETE" }) },
|
||||
getNavigationFavorites() { return request("/api/navigation/favorite", { cache: "no-store" }) },
|
||||
mapboxSuggest(query, accessToken, sessionToken, context = {}) {
|
||||
const params = new URLSearchParams({ access_token: accessToken, session_token: sessionToken, q: query, limit: "4", ...context })
|
||||
@@ -207,6 +208,7 @@ export const api = {
|
||||
getNavigationKeys() { return request("/api/navigation_key") },
|
||||
setNavigationKey(body) { return request("/api/navigation_key", { method: "POST", data: body }) },
|
||||
navigationFavorite(body) { return request("/api/navigation/favorite", { method: "POST", data: body }) },
|
||||
deleteNavigationFavorite(body) { return request("/api/navigation/favorite", { method: "DELETE", data: body }) },
|
||||
deleteNavigationKey(type) { return request(`/api/navigation_key?type=${encodeURIComponent(type)}`, { method: "DELETE" }) },
|
||||
|
||||
async systemMonitor(signal) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Tools } from "./views/Tools.js"
|
||||
import { Recordings } from "./views/Recordings.js"
|
||||
import { Logs } from "./views/Logs.js"
|
||||
import { Tuning } from "./views/Tuning.js"
|
||||
import { Navigation } from "./views/Navigation.js?v=nav-destination-2"
|
||||
import { Navigation } from "./views/Navigation.js?v=nav-destination-3"
|
||||
import { Vehicle } from "./views/Vehicle.js"
|
||||
import { Bluetooth } from "./views/Bluetooth.js"
|
||||
import { SystemTools } from "./views/SystemTools.js"
|
||||
@@ -22,6 +22,7 @@ import { ModelLaboratory } from "./views/ModelLaboratory.js"
|
||||
import { Cameras } from "./views/Cameras.js"
|
||||
import { store, initRouter, navigate } from "./store.js"
|
||||
import { showSnackbar } from "./api.js"
|
||||
import { installDomTranslator } from "./i18n.js"
|
||||
|
||||
window.__galaxyVue = { createApp, h }
|
||||
|
||||
@@ -87,6 +88,7 @@ const app = createApp({
|
||||
})
|
||||
|
||||
app.mount("#galaxy-app")
|
||||
installDomTranslator(document.getElementById("galaxy-app"))
|
||||
|
||||
initRouter()
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { store, navigate, goBack, toolHref, toggleTheme } from "../store.js"
|
||||
import { store, navigate, goBack, toolHref, toggleTheme, toggleNavPinned } from "../store.js"
|
||||
import { api } from "../api.js"
|
||||
import { usePolling } from "../composables.js"
|
||||
import { languageState, setLanguage, t } from "../i18n.js"
|
||||
@@ -40,6 +40,7 @@ export const AppShell = {
|
||||
online() { return store.online },
|
||||
statusLabel() { return store.online ? t(store.deviceStatus, store.deviceStatus) : t("Offline") },
|
||||
isLight() { return store.theme === "light" },
|
||||
navPinned() { return store.navPinned },
|
||||
drawerOpen: {
|
||||
get() { return store.drawerOpen },
|
||||
set(v) { store.drawerOpen = v },
|
||||
@@ -59,7 +60,7 @@ export const AppShell = {
|
||||
},
|
||||
methods: {
|
||||
tr(key, fallback = key) { return t(key, fallback) },
|
||||
closeDrawer() { store.drawerOpen = false },
|
||||
closeDrawer() { if (!store.navPinned) store.drawerOpen = false },
|
||||
back() { goBack() },
|
||||
async refreshStatus() {
|
||||
try {
|
||||
@@ -84,6 +85,7 @@ export const AppShell = {
|
||||
this.$nextTick(() => { const el = this.$refs.searchInput; if (el) el.focus() })
|
||||
},
|
||||
themeToggle() { toggleTheme() },
|
||||
toggleNavPin() { toggleNavPinned() },
|
||||
navTo(link) {
|
||||
this.closeDrawer()
|
||||
navigate(toolHref(link))
|
||||
@@ -107,7 +109,7 @@ export const AppShell = {
|
||||
this.statusPoll?.destroy()
|
||||
},
|
||||
template: `
|
||||
<div class="gx-app">
|
||||
<div class="gx-app" :class="{ 'gx-nav-pinned': navPinned }">
|
||||
<header class="gx-appbar">
|
||||
<button type="button" class="gx-icon-btn gx-appbar__back gx-back-btn" :aria-label="tr('Back')" @click="back">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
@@ -138,15 +140,25 @@ export const AppShell = {
|
||||
:title="isLight ? tr('Dark mode') : tr('Light mode')" @click="themeToggle">
|
||||
<i class="bi" :class="isLight ? 'bi-moon-stars-fill' : 'bi-sun-fill'"></i>
|
||||
</button>
|
||||
<button type="button" class="gx-icon-btn gx-appbar__pin" :aria-pressed="navPinned"
|
||||
:aria-label="navPinned ? tr('Unpin navigation') : tr('Pin navigation')"
|
||||
:title="navPinned ? tr('Unpin navigation') : tr('Pin navigation')" @click="toggleNavPin">
|
||||
<i class="bi" :class="navPinned ? 'bi-pin-angle-fill' : 'bi-pin-angle'"></i>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<transition name="gx-fade">
|
||||
<div v-if="store.drawerOpen" class="gx-underlay" @click="closeDrawer"></div>
|
||||
<div v-if="store.drawerOpen && !navPinned" class="gx-underlay" @click="closeDrawer"></div>
|
||||
</transition>
|
||||
<aside class="gx-drawer" :class="{ open: store.drawerOpen }">
|
||||
<aside class="gx-drawer" :class="{ open: store.drawerOpen || navPinned }">
|
||||
<div class="gx-drawer__header">
|
||||
<img class="gx-logo" src="/assets/images/main_logo.png" alt="Galaxy logo" />
|
||||
<span class="gx-drawer-title">{{ tr("Galaxy") }}</span>
|
||||
<button type="button" class="gx-icon-btn gx-drawer__pin" :aria-pressed="navPinned"
|
||||
:aria-label="navPinned ? tr('Unpin navigation') : tr('Pin navigation')"
|
||||
:title="navPinned ? tr('Unpin navigation') : tr('Pin navigation')" @click.stop="toggleNavPin">
|
||||
<i class="bi" :class="navPinned ? 'bi-pin-angle-fill' : 'bi-pin-angle'"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="gx-nav-section">
|
||||
<div class="gx-nav-section__title">{{ tr("Main") }}</div>
|
||||
|
||||
@@ -45,19 +45,19 @@ export const LanguageSelector = {
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div class="gx-card" style="margin-bottom:16px;">
|
||||
<div class="gx-card gx-language-card">
|
||||
<div class="gx-section__header">
|
||||
<i class="bi bi-translate"></i>
|
||||
<span class="gx-section__title">{{ tr("Language") }}</span>
|
||||
</div>
|
||||
<div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
|
||||
<label style="display:flex; align-items:center; gap:10px; flex:1; min-width:220px;">
|
||||
<div class="gx-language-card__row">
|
||||
<label class="gx-language-card__label">
|
||||
<span>{{ tr("Select language") }}</span>
|
||||
<select class="gx-field" style="max-width:220px;" :value="selected" :disabled="saving" @change="change">
|
||||
<select class="gx-field gx-language-card__select" :value="selected" :disabled="saving" @change="change">
|
||||
<option v-for="option in languages" :key="option.value" :value="option.value">{{ tr(option.label, option.label) }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<small class="gx-row__desc">{{ tr("Galaxy uses English when no language is selected.") }}</small>
|
||||
<small class="gx-row__desc gx-language-card__hint">{{ tr("Galaxy uses English when no language is selected.") }}</small>
|
||||
</div>
|
||||
<p v-if="error" class="gx-row__desc" style="color:var(--danger); margin:8px 0 0;">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
+135
-29
@@ -1,5 +1,12 @@
|
||||
import { api, showSnackbar } from "../api.js"
|
||||
import { getMapboxSearchContext, addRouteToMap, removeRouteFromMap } from "../../../components/navigation/navigation_utilities.js?v=nav-search-context-2"
|
||||
import {
|
||||
getMapboxSearchContext,
|
||||
addRouteToMap,
|
||||
removeRouteFromMap,
|
||||
formatSecondsToHuman,
|
||||
formatMetersToHuman,
|
||||
formatMetersToMiles,
|
||||
} from "../../../components/navigation/navigation_utilities.js?v=nav-search-context-2"
|
||||
|
||||
const MAPBOX_STYLE = "mapbox://styles/frogsgomoo/cmcfv151j000o01rcdxebhl76"
|
||||
|
||||
@@ -83,6 +90,9 @@ export const NavigationDestinationPanel = {
|
||||
recentDestinations: [],
|
||||
favorites: [],
|
||||
destination: null,
|
||||
routeSummary: null,
|
||||
navigationStarted: false,
|
||||
isMetric: false,
|
||||
mapboxPublic: "",
|
||||
language: "",
|
||||
lastPosition: null,
|
||||
@@ -107,6 +117,15 @@ export const NavigationDestinationPanel = {
|
||||
return true
|
||||
}).slice(0, 10)
|
||||
},
|
||||
favoriteDestination() {
|
||||
const destination = coordinates(this.destination)
|
||||
if (!destination) return null
|
||||
return this.favorites.find((favorite) => {
|
||||
const favoriteCoordinates = coordinates(favorite)
|
||||
return favoriteCoordinates && Math.abs(favoriteCoordinates.latitude - destination.latitude) < 0.00001 && Math.abs(favoriteCoordinates.longitude - destination.longitude) < 0.00001
|
||||
}) || null
|
||||
},
|
||||
isFavorite() { return !!this.favoriteDestination },
|
||||
},
|
||||
async mounted() {
|
||||
await this.load()
|
||||
@@ -121,6 +140,13 @@ export const NavigationDestinationPanel = {
|
||||
},
|
||||
methods: {
|
||||
secondaryLabel,
|
||||
isPlaceFavorite(place) {
|
||||
const placeCoordinates = coordinates(place)
|
||||
return !!placeCoordinates && this.favorites.some((favorite) => {
|
||||
const favoriteCoordinates = coordinates(favorite)
|
||||
return favoriteCoordinates && Math.abs(favoriteCoordinates.latitude - placeCoordinates.latitude) < 0.00001 && Math.abs(favoriteCoordinates.longitude - placeCoordinates.longitude) < 0.00001
|
||||
})
|
||||
},
|
||||
async load() {
|
||||
try {
|
||||
const [nav, favoritePayload] = await Promise.all([
|
||||
@@ -129,6 +155,7 @@ export const NavigationDestinationPanel = {
|
||||
])
|
||||
this.mapboxPublic = String(nav?.mapboxPublic || "").trim()
|
||||
this.language = String(nav?.language || "").trim()
|
||||
this.isMetric = !!nav?.isMetric
|
||||
this.lastPosition = coordinates(nav?.lastPosition)
|
||||
this.favorites = Array.isArray(favoritePayload?.favorites) ? favoritePayload.favorites : []
|
||||
this.recentDestinations = parseJson(nav?.previousDestinations, [])
|
||||
@@ -136,8 +163,10 @@ export const NavigationDestinationPanel = {
|
||||
const savedDestination = coordinates(nav?.destination) || coordinates(saved)
|
||||
if (savedDestination) {
|
||||
const raw = saved || nav?.destination || {}
|
||||
this.destination = { ...raw, ...savedDestination, name: labelFor(raw) || "Current destination" }
|
||||
const savedName = String(raw?.name || raw?.text || "").trim()
|
||||
this.destination = { ...raw, ...savedDestination, name: savedName || labelFor(raw) || "Current destination" }
|
||||
this.query = this.destination.name
|
||||
this.navigationStarted = true
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = e?.message || "Failed to load navigation."
|
||||
@@ -179,6 +208,9 @@ export const NavigationDestinationPanel = {
|
||||
},
|
||||
onInput(event) {
|
||||
this.destination = null
|
||||
this.routeSummary = null
|
||||
this.navigationStarted = false
|
||||
if (this.map) removeRouteFromMap(this.map)
|
||||
this.searchRequest += 1
|
||||
this.searching = false
|
||||
this.error = ""
|
||||
@@ -205,7 +237,8 @@ export const NavigationDestinationPanel = {
|
||||
}
|
||||
},
|
||||
async resolvePlace(place) {
|
||||
const placeLabel = labelFor(place) || this.query.trim()
|
||||
const primaryLabel = String(place?.name || place?.text || "").trim()
|
||||
const placeLabel = primaryLabel || labelFor(place) || this.query.trim()
|
||||
let coords = coordinates(place?.geometry?.coordinates) || coordinates(place)
|
||||
if (!coords && place?.mapbox_id) {
|
||||
const payload = await api.mapboxRetrieve(place.mapbox_id, this.mapboxPublic, this.sessionToken)
|
||||
@@ -216,7 +249,7 @@ export const NavigationDestinationPanel = {
|
||||
coords = coordinates(payload?.features?.[0]?.geometry?.coordinates)
|
||||
}
|
||||
if (!coords) throw new Error("Could not determine that location.")
|
||||
return { ...coords, name: placeLabel, place_name: placeLabel }
|
||||
return { ...coords, name: primaryLabel || placeLabel, place_name: labelFor(place) || placeLabel }
|
||||
},
|
||||
async chooseSuggestion(place) {
|
||||
this.searching = true
|
||||
@@ -248,6 +281,7 @@ export const NavigationDestinationPanel = {
|
||||
this.destination = place || await this.resolveQuery()
|
||||
if (!this.destination) throw new Error("Enter a destination first.")
|
||||
await api.setNavigation(this.destination)
|
||||
this.navigationStarted = true
|
||||
this.query = this.destination.name
|
||||
this.suggestions = []
|
||||
await this.previewDestination(this.destination)
|
||||
@@ -259,12 +293,60 @@ export const NavigationDestinationPanel = {
|
||||
this.loadingRoute = false
|
||||
}
|
||||
},
|
||||
async cancelNavigation() {
|
||||
try {
|
||||
await api.clearNavigation()
|
||||
this.navigationStarted = false
|
||||
this.destination = null
|
||||
this.routeSummary = null
|
||||
this.query = ""
|
||||
this.suggestions = []
|
||||
if (this.map) removeRouteFromMap(this.map)
|
||||
this.destinationMarker?.remove()
|
||||
this.destinationMarker = null
|
||||
showSnackbar("Navigation cancelled.")
|
||||
} catch (e) {
|
||||
showSnackbar(e?.message || "Could not cancel navigation.", "error")
|
||||
}
|
||||
},
|
||||
async toggleFavorite() {
|
||||
const destination = coordinates(this.destination)
|
||||
if (!destination) return
|
||||
const favorite = this.favoriteDestination
|
||||
try {
|
||||
if (favorite) {
|
||||
await api.deleteNavigationFavorite(favorite)
|
||||
showSnackbar("Removed from favorites.")
|
||||
} else {
|
||||
await api.navigationFavorite({
|
||||
name: this.destination.name || this.query || "Favorite destination",
|
||||
longitude: destination.longitude,
|
||||
latitude: destination.latitude,
|
||||
routeId: this.routeSummary?.routeId || null,
|
||||
})
|
||||
showSnackbar("Added to favorites.")
|
||||
}
|
||||
const payload = await api.getNavigationFavorites()
|
||||
this.favorites = Array.isArray(payload?.favorites) ? payload.favorites : []
|
||||
} catch (e) {
|
||||
showSnackbar(e?.message || "Could not update favorites.", "error")
|
||||
}
|
||||
},
|
||||
formatDistance(value) {
|
||||
return this.isMetric ? formatMetersToHuman(value, true) : formatMetersToMiles(value)
|
||||
},
|
||||
formatDuration(value) { return formatSecondsToHuman(value) },
|
||||
formatEta(value) {
|
||||
const eta = new Date(Date.now() + Number(value || 0) * 1000)
|
||||
return eta.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
|
||||
},
|
||||
async previewDestination(place) {
|
||||
if (!this.mapReady || !this.map || !place) return
|
||||
const mapboxgl = window.mapboxgl
|
||||
this.destinationMarker?.remove()
|
||||
this.destinationMarker = new mapboxgl.Marker({ color: "#9d72ff" }).setLngLat([place.longitude, place.latitude]).addTo(this.map)
|
||||
if (!this.lastPosition) {
|
||||
this.routeSummary = null
|
||||
this.map.flyTo({ center: [place.longitude, place.latitude], zoom: 14 })
|
||||
return
|
||||
}
|
||||
@@ -272,48 +354,72 @@ export const NavigationDestinationPanel = {
|
||||
const payload = await api.mapboxDirections(this.lastPosition, place, this.mapboxPublic)
|
||||
const routes = Array.isArray(payload?.routes) ? payload.routes : []
|
||||
if (routes.length) {
|
||||
const route = routes[0]
|
||||
this.routeSummary = {
|
||||
distance: Number(route.distance) || 0,
|
||||
duration: Number(route.duration) || 0,
|
||||
routeId: "main",
|
||||
}
|
||||
removeRouteFromMap(this.map)
|
||||
addRouteToMap(this.map, routes, [this.lastPosition.longitude, this.lastPosition.latitude], [place.longitude, place.latitude], () => {}, true, () => "main")
|
||||
} else {
|
||||
this.routeSummary = null
|
||||
this.map.fitBounds([[this.lastPosition.longitude, this.lastPosition.latitude], [place.longitude, place.latitude]], { padding: 80, duration: 500 })
|
||||
}
|
||||
} catch (e) {
|
||||
this.routeSummary = null
|
||||
this.map.fitBounds([[this.lastPosition.longitude, this.lastPosition.latitude], [place.longitude, place.latitude]], { padding: 80, duration: 500 })
|
||||
}
|
||||
},
|
||||
usePlace(place) { this.chooseSuggestion(place) },
|
||||
},
|
||||
template: `
|
||||
<div style="display:grid; gap:12px;">
|
||||
<section class="gx-card">
|
||||
<div class="gx-section__header"><i class="bi bi-geo-alt-fill"></i><span class="gx-section__title">Navigation Destination</span></div>
|
||||
<div style="padding:var(--sp-3); display:grid; gap:8px;">
|
||||
<p v-if="!hasMapbox && !loading" class="gx-row__desc" style="margin:0;">Add a Mapbox public key in <a href="#/navigation/keys">App Keys</a> to search destinations and show the map.</p>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<input class="gx-field" style="flex:1;" v-model="query" @input="onInput" @keyup.enter="setDestination()" placeholder="Search an address or place" autocomplete="off" />
|
||||
<button type="button" class="gx-btn" :disabled="loadingRoute || searching || !query.trim()" @click="setDestination()"><i class="bi bi-send"></i> {{ loadingRoute ? 'Setting...' : 'Send' }}</button>
|
||||
<div class="gx-navigation-stage">
|
||||
<div v-if="loading || !hasMapbox" class="gx-navigation-empty gx-card">
|
||||
<div class="gx-loading">{{ loading ? 'Loading navigation...' : 'Map unavailable until a Mapbox key is configured.' }}</div>
|
||||
<p v-if="!hasMapbox && !loading">Add a Mapbox public key in <a href="#/navigation/keys">App Keys</a> to search destinations and show the map.</p>
|
||||
</div>
|
||||
<div v-else ref="map" class="gx-navigation-map"></div>
|
||||
|
||||
<div v-if="hasMapbox && !loading" class="gx-navigation-overlay">
|
||||
<section class="gx-navigation-search gx-card">
|
||||
<div class="gx-navigation-search__row">
|
||||
<i class="bi bi-search" aria-hidden="true"></i>
|
||||
<input class="gx-field" v-model="query" @input="onInput" @keyup.enter="setDestination()" placeholder="Search here" aria-label="Search for a destination" autocomplete="off" />
|
||||
<button type="button" class="gx-icon-btn gx-navigation-send" :disabled="loadingRoute || searching || !query.trim()" @click="setDestination()" aria-label="Send destination" title="Send destination"><i class="bi bi-send-fill"></i></button>
|
||||
</div>
|
||||
<div v-if="searching" class="gx-row__desc">Searching...</div>
|
||||
<div v-if="suggestions.length" style="display:grid; gap:4px;">
|
||||
<button v-for="place in suggestions" :key="place.mapbox_id || place.id || place.name" type="button" class="gx-row" style="text-align:left; cursor:pointer;" @click="chooseSuggestion(place)">
|
||||
<span class="gx-row__info"><span class="gx-row__label">{{ place.name || place.text || place.place_name || 'Unnamed location' }}</span><span class="gx-row__desc">{{ secondaryLabel(place) }}</span></span>
|
||||
<div v-if="searching" class="gx-navigation-status">Searching...</div>
|
||||
<div v-if="suggestions.length" class="gx-navigation-suggestions">
|
||||
<button v-for="place in suggestions" :key="place.mapbox_id || place.id || place.name" type="button" class="gx-navigation-suggestion" @click="chooseSuggestion(place)">
|
||||
<span><strong>{{ place.name || place.text || place.place_name || 'Unnamed location' }}</strong><small>{{ secondaryLabel(place) }}</small></span>
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="recentPlaces.length && !suggestions.length && !query" style="display:grid; gap:4px;">
|
||||
<div class="gx-row__desc">Recent and favorite destinations</div>
|
||||
<button v-for="place in recentPlaces" :key="place.id || place.name" type="button" class="gx-row" style="text-align:left; cursor:pointer;" @click="usePlace(place)">
|
||||
<span class="gx-row__info"><span class="gx-row__label">{{ place.name || place.place_name }}</span><span class="gx-row__desc">{{ secondaryLabel(place) }}</span></span>
|
||||
<i class="bi bi-clock-history"></i>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section v-if="destination" class="gx-navigation-summary gx-card">
|
||||
<div class="gx-navigation-summary__title">{{ destination.name || query || 'Destination' }}</div>
|
||||
<div v-if="routeSummary" class="gx-navigation-summary__rows">
|
||||
<div><span class="gx-navigation-summary__icon">🛣️</span><span>Distance:</span><strong>{{ formatDistance(routeSummary.distance) }}</strong></div>
|
||||
<div><span class="gx-navigation-summary__icon">⌛</span><span>Duration:</span><strong>{{ formatDuration(routeSummary.duration) }}</strong></div>
|
||||
<div><span class="gx-navigation-summary__icon">🕗</span><span>ETA:</span><strong>{{ formatEta(routeSummary.duration) }}</strong></div>
|
||||
</div>
|
||||
<p v-if="error" class="gx-row__desc" style="color:var(--error); margin:0;">{{ error }}</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="gx-card" style="overflow:hidden;">
|
||||
<div v-if="loading || !hasMapbox" class="gx-loading" style="min-height:280px; display:grid; place-items:center;">{{ loading ? 'Loading navigation...' : 'Map unavailable until a Mapbox key is configured.' }}</div>
|
||||
<div v-else ref="map" style="height:380px; width:100%; min-height:280px;"></div>
|
||||
</section>
|
||||
<div class="gx-navigation-summary__actions">
|
||||
<button v-if="navigationStarted" type="button" class="gx-btn gx-btn--danger" @click="cancelNavigation"><i class="bi bi-x-lg"></i> Cancel Navigation</button>
|
||||
<button v-else type="button" class="gx-btn gx-btn--success" :disabled="loadingRoute" @click="setDestination(destination)"><i class="bi bi-sign-turn-right"></i> {{ loadingRoute ? 'Calculating...' : 'Start Navigation' }}</button>
|
||||
<button type="button" class="gx-btn gx-btn--favorite" :class="{ active: isFavorite }" @click="toggleFavorite"><i class="bi" :class="isFavorite ? 'bi-heart-fill' : 'bi-heart'"></i> {{ isFavorite ? 'Unfavorite' : 'Favorite' }}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="recentPlaces.length && !suggestions.length && !query && !destination" class="gx-navigation-recent gx-card">
|
||||
<div class="gx-navigation-recent__title">Recent and favorite destinations</div>
|
||||
<button v-for="place in recentPlaces" :key="place.id || place.name" type="button" class="gx-navigation-suggestion" @click="usePlace(place)">
|
||||
<span><strong>{{ place.name || place.place_name }}</strong><small>{{ secondaryLabel(place) }}</small></span>
|
||||
<i class="bi" :class="isPlaceFavorite(place) ? 'bi-heart-fill' : 'bi-clock-history'"></i>
|
||||
</button>
|
||||
</section>
|
||||
<p v-if="error" class="gx-navigation-error gx-card">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
|
||||
@@ -105,6 +105,111 @@ const TRANSLATIONS = {
|
||||
},
|
||||
}
|
||||
|
||||
// The settings catalog is shared with the native UI, so most of its labels
|
||||
// arrive from the device rather than this bundle. These common section names
|
||||
// and controls keep the Galaxy settings screen translated as well.
|
||||
const MORE_TRANSLATIONS = {
|
||||
es: {
|
||||
Favorites: "Favoritos", "Lateral (Steering)": "Lateral (Dirección)",
|
||||
"Longitudinal (Speed & Following)": "Longitudinal (Velocidad y seguimiento)",
|
||||
"Vision Speed Limits": "Límites de velocidad por visión", "Visual (Display & UI)": "Visual (Pantalla e interfaz)",
|
||||
"Sounds & Alerts": "Sonidos y alertas", Vehicle: "Vehículo", "Wheel Controls": "Controles del volante",
|
||||
"Device & Data": "Dispositivo y datos", Developer: "Desarrollador", "Advanced Lateral Tuning": "Ajuste lateral avanzado",
|
||||
"Advanced steering control changes to fine-tune how openpilot drives.": "Cambios avanzados en el control de la dirección para ajustar cómo conduce openpilot.",
|
||||
"Always On Lateral": "Lateral siempre activo", "openpilot's steering remains active even when the accelerator or brake pedals are pressed.": "La dirección de openpilot permanece activa incluso cuando se pisan el acelerador o los frenos.",
|
||||
"Lane Changes": "Cambios de carril", "Allow openpilot to change lanes.": "Permitir que openpilot cambie de carril.",
|
||||
"Lateral Tuning": "Ajuste lateral", "Miscellaneous steering control changes to fine-tune how openpilot drives.": "Cambios diversos del control de la dirección para ajustar cómo conduce openpilot.",
|
||||
"Quality of Life": "Calidad de vida", "Steering control changes to fine-tune how openpilot drives.": "Cambios del control de la dirección para ajustar cómo conduce openpilot.",
|
||||
"Enable V-ASM": "Activar V-ASM", "Favorites": "Favoritos", "Device & Data": "Dispositivo y datos",
|
||||
},
|
||||
fr: {
|
||||
Favorites: "Favoris", "Lateral (Steering)": "Latéral (Direction)",
|
||||
"Longitudinal (Speed & Following)": "Longitudinal (Vitesse et suivi)",
|
||||
"Vision Speed Limits": "Limites de vitesse par vision", "Visual (Display & UI)": "Visuel (Affichage et interface)",
|
||||
"Sounds & Alerts": "Sons et alertes", Vehicle: "Véhicule", "Wheel Controls": "Commandes au volant",
|
||||
"Device & Data": "Appareil et données", Developer: "Développeur", "Advanced Lateral Tuning": "Réglage latéral avancé",
|
||||
"Advanced steering control changes to fine-tune how openpilot drives.": "Modifications avancées de la direction pour régler finement le comportement d’openpilot.",
|
||||
"Always On Lateral": "Direction latérale toujours active", "openpilot's steering remains active even when the accelerator or brake pedals are pressed.": "La direction d’openpilot reste active même lorsque l’accélérateur ou les freins sont enfoncés.",
|
||||
"Lane Changes": "Changements de voie", "Allow openpilot to change lanes.": "Autoriser openpilot à changer de voie.",
|
||||
"Lateral Tuning": "Réglage latéral", "Miscellaneous steering control changes to fine-tune how openpilot drives.": "Divers réglages de direction pour ajuster finement le comportement d’openpilot.",
|
||||
"Quality of Life": "Confort d’utilisation", "Steering control changes to fine-tune how openpilot drives.": "Réglages de direction pour ajuster finement le comportement d’openpilot.",
|
||||
"Enable V-ASM": "Activer V-ASM",
|
||||
},
|
||||
ko: {
|
||||
Favorites: "즐겨찾기", "Lateral (Steering)": "횡방향 (조향)",
|
||||
"Longitudinal (Speed & Following)": "종방향 (속도 및 추종)",
|
||||
"Vision Speed Limits": "비전 속도 제한", "Visual (Display & UI)": "시각 (디스플레이 및 UI)",
|
||||
"Sounds & Alerts": "소리 및 경고", Vehicle: "차량", "Wheel Controls": "휠 컨트롤",
|
||||
"Device & Data": "장치 및 데이터", Developer: "개발자", "Advanced Lateral Tuning": "고급 횡방향 튜닝",
|
||||
"Advanced steering control changes to fine-tune how openpilot drives.": "openpilot의 주행 방식을 세밀하게 조정하는 고급 조향 제어 변경입니다.",
|
||||
"Always On Lateral": "항상 활성화된 횡방향 제어", "openpilot's steering remains active even when the accelerator or brake pedals are pressed.": "가속 페달이나 브레이크 페달을 밟아도 openpilot 조향이 계속 활성화됩니다.",
|
||||
"Lane Changes": "차선 변경", "Allow openpilot to change lanes.": "openpilot이 차선을 변경하도록 허용합니다.",
|
||||
"Lateral Tuning": "횡방향 튜닝", "Miscellaneous steering control changes to fine-tune how openpilot drives.": "openpilot의 주행을 세밀하게 조정하는 기타 조향 제어 변경입니다.",
|
||||
"Quality of Life": "편의 기능", "Steering control changes to fine-tune how openpilot drives.": "openpilot의 주행을 세밀하게 조정하는 조향 제어 변경입니다.",
|
||||
"Enable V-ASM": "V-ASM 활성화",
|
||||
},
|
||||
"zh-CHS": {
|
||||
Favorites: "收藏", "Lateral (Steering)": "横向(转向)",
|
||||
"Longitudinal (Speed & Following)": "纵向(速度和跟车)", "Vision Speed Limits": "视觉限速",
|
||||
"Visual (Display & UI)": "视觉(显示和界面)", "Sounds & Alerts": "声音和提醒", Vehicle: "车辆",
|
||||
"Wheel Controls": "方向盘控制", "Device & Data": "设备和数据", Developer: "开发者", "Advanced Lateral Tuning": "高级横向调校",
|
||||
"Advanced steering control changes to fine-tune how openpilot drives.": "用于精细调整 openpilot 驾驶方式的高级转向控制设置。",
|
||||
"Always On Lateral": "始终启用横向控制", "openpilot's steering remains active even when the accelerator or brake pedals are pressed.": "即使踩下加速或制动踏板,openpilot 转向仍保持启用。",
|
||||
"Lane Changes": "变道", "Allow openpilot to change lanes.": "允许 openpilot 变道。", "Lateral Tuning": "横向调校",
|
||||
"Miscellaneous steering control changes to fine-tune how openpilot drives.": "用于精细调整 openpilot 驾驶方式的其他转向控制设置。",
|
||||
"Quality of Life": "使用体验", "Steering control changes to fine-tune how openpilot drives.": "用于精细调整 openpilot 驾驶方式的转向控制设置。",
|
||||
"Enable V-ASM": "启用 V-ASM",
|
||||
},
|
||||
}
|
||||
|
||||
Object.keys(MORE_TRANSLATIONS).forEach((code) => Object.assign(TRANSLATIONS[code], MORE_TRANSLATIONS[code]))
|
||||
|
||||
// A word-level fallback covers the many device-provided descriptions and the
|
||||
// older Galaxy views that still contain literal English labels. Exact phrases
|
||||
// above always win; this fallback only runs for a non-English selection.
|
||||
const TERM_TRANSLATIONS = {
|
||||
es: {
|
||||
"Advanced": "Avanzado", "Always On": "Siempre activo", "Lateral": "Lateral", "Steering": "Dirección", "Longitudinal": "Longitudinal", "Speed": "Velocidad", "Following": "Seguimiento", "Vision": "Visión", "Limits": "Límites", "Visual": "Visual", "Display": "Pantalla", "Sounds": "Sonidos", "Alerts": "Alertas", "Vehicle": "Vehículo", "Wheel": "Volante", "Controls": "Controles", "Device": "Dispositivo", "Data": "Datos", "Developer": "Desarrollador", "Favorites": "Favoritos", "Main": "Principal", "Tools": "Herramientas", "Recordings": "Grabaciones", "Cameras": "Cámaras", "Monitoring": "monitoreo", "Logs": "Registros", "Diagnostics": "diagnósticos", "Model": "Modelo", "Manager": "administrador", "Navigation": "Navegación", "Maps": "mapas", "System": "Sistema", "Laboratory": "Laboratorio", "Plots": "Gráficas", "Testing": "Pruebas", "Ground": "Área", "Theme": "Tema", "Maker": "creador", "Home": "Inicio", "Toggles": "Interruptores", "Install": "Instalar", "Update": "Actualizar", "Available": "disponible", "Loading": "Cargando", "Error": "Error", "Retry": "Reintentar", "Save": "Guardar", "Cancel": "Cancelar", "Close": "Cerrar", "Delete": "Eliminar", "All": "todo", "Search": "Buscar", "Clear": "Borrar", "Manage": "Administrar", "Connected": "Conectado", "Disconnect": "Desconectar", "Connect": "Conectar", "Pair": "Emparejar", "Refresh": "Actualizar", "Status": "Estado", "Samples": "Muestras", "Duration": "Duración", "Distance": "Distancia", "drives": "viajes", "hours": "horas", "engaged": "activado", "Onroad": "En carretera", "Offroad": "Fuera de carretera", "Enabled": "Activado", "Disabled": "Desactivado", "Default": "Predeterminado", "Working": "Procesando", "Run": "Ejecutar", "Reset": "Restablecer", "Download": "Descargar", "Network": "Red", "Current": "Actual", "Change": "Cambiar", "Changes": "Cambios", "Allow": "Permitir", "Use": "Usar", "Show": "Mostrar", "Hide": "Ocultar", "Enable": "Activar", "Disable": "Desactivar", "Automatic": "Automático", "Settings": "Configuración", "Language": "Idioma",
|
||||
},
|
||||
fr: {
|
||||
"Advanced": "Avancé", "Always On": "Toujours actif", "Lateral": "Latéral", "Steering": "Direction", "Longitudinal": "Longitudinal", "Speed": "Vitesse", "Following": "Suivi", "Vision": "Vision", "Limits": "Limites", "Visual": "Visuel", "Display": "Affichage", "Sounds": "Sons", "Alerts": "Alertes", "Vehicle": "Véhicule", "Wheel": "Volant", "Controls": "Commandes", "Device": "Appareil", "Data": "Données", "Developer": "Développeur", "Favorites": "Favoris", "Main": "Principal", "Tools": "Outils", "Recordings": "Enregistrements", "Cameras": "Caméras", "Monitoring": "surveillance", "Logs": "Journaux", "Diagnostics": "diagnostics", "Model": "Modèle", "Manager": "gestionnaire", "Navigation": "Navigation", "Maps": "cartes", "System": "Système", "Laboratory": "Laboratoire", "Plots": "Graphiques", "Testing": "Tests", "Ground": "Zone", "Theme": "Thème", "Maker": "créateur", "Home": "Accueil", "Toggles": "Options", "Install": "Installer", "Update": "Mettre à jour", "Available": "disponible", "Loading": "Chargement", "Error": "Erreur", "Retry": "Réessayer", "Save": "Enregistrer", "Cancel": "Annuler", "Close": "Fermer", "Delete": "Supprimer", "All": "tout", "Search": "Rechercher", "Clear": "Effacer", "Manage": "Gérer", "Connected": "Connecté", "Disconnect": "Déconnecter", "Connect": "Connecter", "Pair": "Associer", "Refresh": "Actualiser", "Status": "État", "Samples": "Échantillons", "Duration": "Durée", "Distance": "Distance", "drives": "trajets", "hours": "heures", "engaged": "activé", "Onroad": "En route", "Offroad": "Hors route", "Enabled": "Activé", "Disabled": "Désactivé", "Default": "Par défaut", "Working": "En cours", "Run": "Exécuter", "Reset": "Réinitialiser", "Download": "Télécharger", "Network": "Réseau", "Current": "Actuel", "Change": "Modifier", "Changes": "Modifications", "Allow": "Autoriser", "Use": "Utiliser", "Show": "Afficher", "Hide": "Masquer", "Enable": "Activer", "Disable": "Désactiver", "Automatic": "Automatique", "Settings": "Paramètres", "Language": "Langue",
|
||||
},
|
||||
ko: {
|
||||
"Advanced": "고급", "Always On": "항상 활성화", "Lateral": "횡방향", "Steering": "조향", "Longitudinal": "종방향", "Speed": "속도", "Following": "추종", "Vision": "비전", "Limits": "제한", "Visual": "시각", "Display": "디스플레이", "Sounds": "소리", "Alerts": "경고", "Vehicle": "차량", "Wheel": "휠", "Controls": "제어", "Device": "장치", "Data": "데이터", "Developer": "개발자", "Favorites": "즐겨찾기", "Main": "메인", "Tools": "도구", "Recordings": "녹화", "Cameras": "카메라", "Monitoring": "모니터링", "Logs": "로그", "Diagnostics": "진단", "Model": "모델", "Manager": "관리자", "Navigation": "내비게이션", "Maps": "지도", "System": "시스템", "Laboratory": "연구소", "Plots": "플롯", "Testing": "테스트", "Ground": "공간", "Theme": "테마", "Maker": "제작", "Home": "홈", "Toggles": "토글", "Install": "설치", "Update": "업데이트", "Available": "사용 가능", "Loading": "로드 중", "Error": "오류", "Retry": "재시도", "Save": "저장", "Cancel": "취소", "Close": "닫기", "Delete": "삭제", "All": "모두", "Search": "검색", "Clear": "지우기", "Manage": "관리", "Connected": "연결됨", "Disconnect": "연결 해제", "Connect": "연결", "Pair": "페어링", "Refresh": "새로 고침", "Status": "상태", "Samples": "샘플", "Duration": "시간", "Distance": "거리", "drives": "주행", "hours": "시간", "engaged": "활성화", "Onroad": "주행 중", "Offroad": "오프로드", "Enabled": "활성화", "Disabled": "비활성화", "Default": "기본값", "Working": "처리 중", "Run": "실행", "Reset": "초기화", "Download": "다운로드", "Network": "네트워크", "Current": "현재", "Change": "변경", "Changes": "변경 사항", "Allow": "허용", "Use": "사용", "Show": "표시", "Hide": "숨기기", "Enable": "활성화", "Disable": "비활성화", "Automatic": "자동", "Settings": "설정", "Language": "언어",
|
||||
},
|
||||
"zh-CHS": {
|
||||
"Advanced": "高级", "Always On": "始终启用", "Lateral": "横向", "Steering": "转向", "Longitudinal": "纵向", "Speed": "速度", "Following": "跟车", "Vision": "视觉", "Limits": "限制", "Visual": "视觉", "Display": "显示", "Sounds": "声音", "Alerts": "提醒", "Vehicle": "车辆", "Wheel": "方向盘", "Controls": "控制", "Device": "设备", "Data": "数据", "Developer": "开发者", "Favorites": "收藏", "Main": "主菜单", "Tools": "工具", "Recordings": "录制", "Cameras": "摄像头", "Monitoring": "监控", "Logs": "日志", "Diagnostics": "诊断", "Model": "模型", "Manager": "管理器", "Navigation": "导航", "Maps": "地图", "System": "系统", "Laboratory": "实验室", "Plots": "图表", "Testing": "测试", "Ground": "区域", "Theme": "主题", "Maker": "制作器", "Home": "主页", "Toggles": "开关", "Install": "安装", "Update": "更新", "Available": "可用", "Loading": "加载中", "Error": "错误", "Retry": "重试", "Save": "保存", "Cancel": "取消", "Close": "关闭", "Delete": "删除", "All": "全部", "Search": "搜索", "Clear": "清除", "Manage": "管理", "Connected": "已连接", "Disconnect": "断开连接", "Connect": "连接", "Pair": "配对", "Refresh": "刷新", "Status": "状态", "Samples": "样本", "Duration": "时长", "Distance": "距离", "drives": "驾驶次数", "hours": "小时", "engaged": "已启用", "Onroad": "行驶中", "Offroad": "非行驶", "Enabled": "已启用", "Disabled": "已停用", "Default": "默认", "Working": "处理中", "Run": "运行", "Reset": "重置", "Download": "下载", "Network": "网络", "Current": "当前", "Change": "更改", "Changes": "更改内容", "Allow": "允许", "Use": "使用", "Show": "显示", "Hide": "隐藏", "Enable": "启用", "Disable": "停用", "Automatic": "自动", "Settings": "设置", "Language": "语言",
|
||||
},
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
}
|
||||
|
||||
const TERM_REPLACERS = Object.fromEntries(Object.entries(TERM_TRANSLATIONS).map(([code, terms]) => [
|
||||
code,
|
||||
Object.entries(terms)
|
||||
.sort(([a], [b]) => b.length - a.length)
|
||||
.map(([source, target]) => [new RegExp(`(^|[^A-Za-z])${escapeRegExp(source)}(?=$|[^A-Za-z])`, "gi"), target, source.match(/[A-Za-z]+/g)?.length || 1]),
|
||||
]))
|
||||
|
||||
function translateText(value) {
|
||||
const source = String(value ?? "")
|
||||
const exact = TRANSLATIONS[languageState?.code]?.[source]
|
||||
if (exact) return exact
|
||||
if (!languageState || languageState.code === "en" || /https?:\/\//i.test(source)) return source
|
||||
let translated = source
|
||||
let replacedWords = 0
|
||||
for (const [pattern, replacement, wordCount] of TERM_REPLACERS[languageState.code] || []) {
|
||||
translated = translated.replace(pattern, (_, prefix) => {
|
||||
replacedWords += wordCount
|
||||
return `${prefix}${replacement}`
|
||||
})
|
||||
}
|
||||
const sourceWords = source.match(/[A-Za-z]+/g)?.length || 0
|
||||
return sourceWords >= 4 && replacedWords / sourceWords < 0.8 ? source : translated
|
||||
}
|
||||
|
||||
function storageValue() {
|
||||
try { return window.localStorage.getItem(STORAGE_KEY) || "en" } catch (e) { return "en" }
|
||||
}
|
||||
@@ -116,17 +221,92 @@ export function normalizeLanguage(value) {
|
||||
|
||||
export const languageState = reactive({ code: normalizeLanguage(storageValue()) })
|
||||
|
||||
const translatedTextNodes = new WeakMap()
|
||||
const translatedAttributes = new WeakMap()
|
||||
let domObserver = null
|
||||
const TRANSLATABLE_ATTRIBUTES = ["aria-label", "placeholder", "title"]
|
||||
|
||||
function canTranslateNode(node) {
|
||||
const parent = node?.parentElement
|
||||
return !!parent && !parent.closest("script, style, textarea, pre, [data-no-translate]")
|
||||
}
|
||||
|
||||
function translateTextNode(node) {
|
||||
if (!canTranslateNode(node)) return
|
||||
const current = node.nodeValue || ""
|
||||
if (!current.trim()) return
|
||||
let state = translatedTextNodes.get(node)
|
||||
if (!state) {
|
||||
state = { source: current, output: current }
|
||||
translatedTextNodes.set(node, state)
|
||||
} else if (current !== state.output) {
|
||||
// Vue replaced the source text (for example, a device-provided label).
|
||||
state.source = current
|
||||
}
|
||||
const output = translateText(state.source)
|
||||
if (output !== current) node.nodeValue = output
|
||||
state.output = output
|
||||
}
|
||||
|
||||
function translateElementAttributes(element) {
|
||||
if (!element || element.matches("script, style, textarea, pre, [data-no-translate]")) return
|
||||
let state = translatedAttributes.get(element)
|
||||
if (!state) {
|
||||
state = {}
|
||||
translatedAttributes.set(element, state)
|
||||
}
|
||||
for (const attribute of TRANSLATABLE_ATTRIBUTES) {
|
||||
if (!element.hasAttribute(attribute)) continue
|
||||
const current = element.getAttribute(attribute) || ""
|
||||
const previous = state[attribute]
|
||||
if (!previous) state[attribute] = { source: current, output: current }
|
||||
else if (current !== previous.output) previous.source = current
|
||||
const entry = state[attribute]
|
||||
const output = translateText(entry.source)
|
||||
if (output !== current) element.setAttribute(attribute, output)
|
||||
entry.output = output
|
||||
}
|
||||
}
|
||||
|
||||
export function translateDom(root = (typeof document !== "undefined" ? document.getElementById("galaxy-app") : null)) {
|
||||
if (!root || typeof document === "undefined") return
|
||||
translateElementAttributes(root)
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||||
let node
|
||||
while ((node = walker.nextNode())) translateTextNode(node)
|
||||
root.querySelectorAll("*").forEach(translateElementAttributes)
|
||||
}
|
||||
|
||||
export function installDomTranslator(root = (typeof document !== "undefined" ? document.getElementById("galaxy-app") : null)) {
|
||||
if (!root || typeof MutationObserver === "undefined") return
|
||||
translateDom(root)
|
||||
domObserver?.disconnect()
|
||||
domObserver = new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
if (record.type === "characterData") translateTextNode(record.target)
|
||||
else if (record.type === "attributes") translateElementAttributes(record.target)
|
||||
else record.addedNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) translateTextNode(node)
|
||||
else if (node.nodeType === Node.ELEMENT_NODE) translateDom(node)
|
||||
})
|
||||
}
|
||||
})
|
||||
domObserver.observe(root, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: TRANSLATABLE_ATTRIBUTES })
|
||||
}
|
||||
|
||||
export function setLanguage(value) {
|
||||
const code = normalizeLanguage(value)
|
||||
languageState.code = code
|
||||
try { window.localStorage.setItem(STORAGE_KEY, code) } catch (e) { /* storage can be unavailable in private webviews */ }
|
||||
if (typeof document !== "undefined") document.documentElement.lang = code === "zh-CHS" ? "zh-CN" : code
|
||||
if (typeof document !== "undefined") translateDom(document.getElementById("galaxy-app"))
|
||||
return code
|
||||
}
|
||||
|
||||
export function t(key, fallback = key) {
|
||||
const source = String(key ?? "")
|
||||
return TRANSLATIONS[languageState.code]?.[source] || fallback || source
|
||||
const translated = translateText(source)
|
||||
return translated !== source ? translated : fallback || source
|
||||
}
|
||||
|
||||
setLanguage(languageState.code)
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { reactive } from "vue"
|
||||
|
||||
const THEME_KEY = "galaxy-theme"
|
||||
const NAV_PINNED_KEY = "galaxy-nav-pinned"
|
||||
|
||||
function initialTheme() {
|
||||
return localStorage.getItem(THEME_KEY) || "dark"
|
||||
}
|
||||
|
||||
function initialNavPinned() {
|
||||
try { return localStorage.getItem(NAV_PINNED_KEY) === "true" } catch (e) { return false }
|
||||
}
|
||||
|
||||
export const store = reactive({
|
||||
route: "/",
|
||||
params: {},
|
||||
drawerOpen: false,
|
||||
navPinned: initialNavPinned(),
|
||||
search: "",
|
||||
snackbar: null,
|
||||
online: false,
|
||||
@@ -29,6 +35,16 @@ export function toggleTheme() {
|
||||
setTheme(store.theme === "dark" ? "light" : "dark")
|
||||
}
|
||||
|
||||
export function setNavPinned(pinned) {
|
||||
store.navPinned = Boolean(pinned)
|
||||
try { localStorage.setItem(NAV_PINNED_KEY, String(store.navPinned)) } catch (e) {}
|
||||
}
|
||||
|
||||
export function toggleNavPinned() {
|
||||
setNavPinned(!store.navPinned)
|
||||
if (store.navPinned) store.drawerOpen = true
|
||||
}
|
||||
|
||||
export function parseHash(hash) {
|
||||
const raw = hash.replace(/^#/, "") || "/"
|
||||
const [pathname, queryString] = raw.split("?")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NavigationDestinationPanel } from "../components/NavigationDestinationPanel.js?v=nav-destination-2"
|
||||
import { NavigationDestinationPanel } from "../components/NavigationDestinationPanel.js?v=nav-destination-3"
|
||||
import { MapsPanel } from "../components/MapsPanel.js"
|
||||
import { NavigationKeysPanel } from "../components/NavigationKeysPanel.js"
|
||||
import { SpeedLimitsPanel } from "../components/SpeedLimitsPanel.js"
|
||||
@@ -22,26 +22,18 @@ export const Navigation = {
|
||||
})
|
||||
},
|
||||
template: `
|
||||
<div class="gx-view">
|
||||
<h2 style="margin-top:0;">Navigation & Maps</h2>
|
||||
|
||||
<GalaxyTabs :items="TABS" :active="tab" @select="selectTab" />
|
||||
|
||||
<template v-if="tab === 'nav'">
|
||||
<NavigationDestinationPanel />
|
||||
<div class="gx-navigation-view">
|
||||
<NavigationDestinationPanel />
|
||||
<div class="gx-navigation-tabs"><GalaxyTabs :items="TABS" :active="tab" @select="selectTab" /></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="tab === 'maps'">
|
||||
<MapsPanel />
|
||||
</template>
|
||||
|
||||
<template v-if="tab === 'keys'">
|
||||
<NavigationKeysPanel />
|
||||
</template>
|
||||
|
||||
<template v-if="tab === 'speeds'">
|
||||
<SpeedLimitsPanel />
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="gx-view">
|
||||
<h2 style="margin-top:0;">Navigation & Maps</h2>
|
||||
<GalaxyTabs :items="TABS" :active="tab" @select="selectTab" />
|
||||
<template v-if="tab === 'maps'"><MapsPanel /></template>
|
||||
<template v-if="tab === 'keys'"><NavigationKeysPanel /></template>
|
||||
<template v-if="tab === 'speeds'"><SpeedLimitsPanel /></template>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
|
||||
@@ -142,8 +142,6 @@ export const Settings = {
|
||||
<div>
|
||||
<h2 style="margin-top:0;">{{ tr("Toggles") }}</h2>
|
||||
|
||||
<LanguageSelector :device-value="String(values.LanguageSetting || '')" />
|
||||
|
||||
<DevModeBanner :hidden-count="hiddenAdvancedCount" :dev-mode-on="devModeOn" />
|
||||
|
||||
<div v-if="loading" class="gx-loading">{{ tr("Loading configuration...") }}</div>
|
||||
@@ -191,6 +189,8 @@ export const Settings = {
|
||||
</template>
|
||||
|
||||
<div v-else class="gx-empty">{{ tr("No settings available.") }}</div>
|
||||
|
||||
<LanguageSelector v-if="route === '/settings' && !loading" :device-value="String(values.LanguageSetting || '')" />
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export const SystemTools = {
|
||||
branchLoading: true,
|
||||
branchListFallback: false,
|
||||
branchListError: "",
|
||||
advancedVersionPickerOpen: false,
|
||||
otherBranchesOpen: false,
|
||||
branchBusy: false,
|
||||
|
||||
@@ -57,7 +58,7 @@ export const SystemTools = {
|
||||
created() {
|
||||
this.poll = usePolling(() => this.loadFastStatus(), {
|
||||
interval: 1000,
|
||||
enabled: () => !this.fastStatus || !!this.fastStatus.running,
|
||||
enabled: () => this.statusPollingNeeded,
|
||||
})
|
||||
this.poll.start()
|
||||
},
|
||||
@@ -76,14 +77,17 @@ export const SystemTools = {
|
||||
return branches
|
||||
},
|
||||
branchSwitchBlocked() {
|
||||
return this.branchLoading || this.isOnroad || !!this.fastStatus?.isOnroad || !!this.fastStatus?.running || !!this.busy
|
||||
return this.branchLoading || this.isOnroad || !!this.fastStatus?.isOnroad || this.updateInProgress || !!this.busy
|
||||
},
|
||||
statusRebooting() { return String(this.fastStatus?.stage || "").trim().toLowerCase() === "rebooting" },
|
||||
updateInProgress() { return !!this.fastStatus?.running || this.statusRebooting },
|
||||
statusPollingNeeded() { return !this.fastStatus || this.updateInProgress },
|
||||
versionChoices() { return this.targetBranch === "StarPilot" ? releaseVersions(this.versionCommits) : this.versionCommits },
|
||||
installVersionBlocked() {
|
||||
return this.branchSwitchBlocked || this.branchBusy || !this.branches.includes(this.targetBranch) ||
|
||||
(this.versionMode === "earlier" && (this.versionLoading || !/^[a-f0-9]{40}$/.test(this.selectedCommit) || !this.versionChoices.some(commit => commit.sha === this.selectedCommit)))
|
||||
},
|
||||
updateAvailable() { return this.checkedForUpdates && !!this.fastStatus?.updateAvailable && !this.fastStatus?.running },
|
||||
updateAvailable() { return this.checkedForUpdates && !!this.fastStatus?.updateAvailable && !this.updateInProgress },
|
||||
factoryResetStatus() {
|
||||
const s = this.fastStatus
|
||||
if (!s || String(s?.lastMode || "").trim() !== "factory-reset") return null
|
||||
@@ -352,13 +356,13 @@ export const SystemTools = {
|
||||
}
|
||||
},
|
||||
async checkUpdates() {
|
||||
if (this.busy) return
|
||||
if (this.busy || this.updateInProgress) return
|
||||
this.busy = "check"
|
||||
try {
|
||||
await this.loadFastStatus({ throwOnError: true })
|
||||
this.checkedForUpdates = true
|
||||
const st = this.fastStatus
|
||||
if (st?.running) showSnackbar("An update is already running.")
|
||||
if (this.updateInProgress) showSnackbar("An update is already running.")
|
||||
else if (st?.updateAvailable) showSnackbar(st?.message || "Update available.")
|
||||
else showSnackbar(st?.message || "No update available — you're up to date.")
|
||||
} catch (e) {
|
||||
@@ -368,7 +372,7 @@ export const SystemTools = {
|
||||
}
|
||||
},
|
||||
async setAutomaticUpdates(enabled) {
|
||||
if (this.autoUpdateBusy || this.isOnroad || this.fastStatus?.running || !this.fastStatus) return
|
||||
if (this.autoUpdateBusy || this.isOnroad || this.updateInProgress || !this.fastStatus) return
|
||||
const previous = !!this.fastStatus.automaticUpdates
|
||||
this.autoUpdateBusy = true
|
||||
this.fastStatus = { ...this.fastStatus, automaticUpdates: !!enabled }
|
||||
@@ -384,7 +388,7 @@ export const SystemTools = {
|
||||
},
|
||||
async applyFastUpdate() {
|
||||
if (this.busy || this.isOnroad) return
|
||||
if (this.fastStatus?.running) { showSnackbar("Fast update is already running."); return }
|
||||
if (this.updateInProgress) { showSnackbar("Fast update is already running."); return }
|
||||
if (!this.checkedForUpdates || !this.updateAvailable) {
|
||||
showSnackbar("No update available. Run \"Check for Updates\" first.", "error")
|
||||
return
|
||||
@@ -400,7 +404,7 @@ export const SystemTools = {
|
||||
await this.runUpdate("fast")
|
||||
},
|
||||
async runUpdate(action) {
|
||||
if (this.busy) return
|
||||
if (this.busy || this.updateInProgress) return
|
||||
this.busy = action
|
||||
try {
|
||||
if (action === "rollback") {
|
||||
@@ -503,17 +507,17 @@ export const SystemTools = {
|
||||
<div class="gx-section__header">
|
||||
<i class="bi bi-arrow-repeat"></i>
|
||||
<span class="gx-section__title">Update Status</span>
|
||||
<span v-if="fastStatus.running" class="gx-chip" style="background:var(--primary);color:var(--on-primary);">{{ fastStatus.progressPercent }}%</span>
|
||||
<span v-if="updateInProgress" class="gx-chip" style="background:var(--primary);color:var(--on-primary);">{{ statusRebooting ? 'Reconnecting…' : fastStatus.progressPercent + '%' }}</span>
|
||||
<span v-else-if="updateAvailable" class="gx-chip" style="background:var(--warning);color:var(--black);">Update available</span>
|
||||
<span v-else-if="checkedForUpdates" class="gx-chip">Up to date</span>
|
||||
<span v-else class="gx-chip">Not checked</span>
|
||||
</div>
|
||||
<div style="padding: var(--sp-3); display:grid; gap:6px;">
|
||||
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Installed branch</span><span class="gx-row__value">{{ fastStatus.branch || currentBranch || '—' }}</span></div>
|
||||
<div v-if="fastStatus.running" class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Stage</span><span class="gx-row__value">{{ fastStatus.stage }} · {{ fastStatus.progressLabel }}</span></div>
|
||||
<div v-if="updateInProgress" class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Stage</span><span class="gx-row__value">{{ fastStatus.stage }} · {{ fastStatus.progressLabel }}</span></div>
|
||||
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Local</span><span class="gx-row__value" style="font-family:monospace;">{{ shortCommit(fastStatus.localCommit) }}</span></div>
|
||||
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Remote</span><span class="gx-row__value" style="font-family:monospace;">{{ shortCommit(fastStatus.remoteCommit) }}</span></div>
|
||||
<div v-if="fastStatus.running" class="gx-update-progress" role="progressbar" aria-label="Update progress"
|
||||
<div v-if="updateInProgress" class="gx-update-progress" role="progressbar" aria-label="Update progress"
|
||||
:aria-valuenow="Math.round(fastStatus.progressPercent || 0)" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="gx-update-progress__track">
|
||||
<div class="gx-update-progress__fill" :class="{ 'gx-update-progress__fill--error': fastStatus.stage === 'error' }"
|
||||
@@ -526,7 +530,7 @@ export const SystemTools = {
|
||||
<small v-if="fastStatus.progressDetail">{{ fastStatus.progressDetail }}</small>
|
||||
</div>
|
||||
<div v-if="fastStatus.message" class="gx-note">{{ fastStatus.message }}</div>
|
||||
<div v-if="fastStatus.warning && (fastStatus.running || fastStatus.updateAvailable)" class="gx-note gx-note--danger">{{ fastStatus.warning }}</div>
|
||||
<div v-if="fastStatus.warning && (updateInProgress || fastStatus.updateAvailable)" class="gx-note gx-note--danger">{{ fastStatus.warning }}</div>
|
||||
<div v-if="fastStatus.agnosUpdate?.available && fastStatus.agnosUpdate?.warnings?.length" style="margin-top:4px;">
|
||||
<div v-for="w in fastStatus.agnosUpdate.warnings" :key="w" class="gx-note gx-note--danger"><i class="bi bi-exclamation-triangle-fill"></i> {{ w }}</div>
|
||||
</div>
|
||||
@@ -541,7 +545,7 @@ export const SystemTools = {
|
||||
</div>
|
||||
<label class="gx-switch">
|
||||
<input type="checkbox" :checked="!!fastStatus?.automaticUpdates"
|
||||
:disabled="!fastStatus || isOnroad || autoUpdateBusy || !!fastStatus?.running"
|
||||
:disabled="!fastStatus || isOnroad || autoUpdateBusy || updateInProgress"
|
||||
@change="setAutomaticUpdates($event.target.checked)" />
|
||||
<span class="gx-switch__track"></span>
|
||||
<span class="gx-switch__thumb"></span>
|
||||
@@ -549,9 +553,15 @@ export const SystemTools = {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gx-card" style="margin-bottom:12px;">
|
||||
<div class="gx-section__header"><i class="bi bi-git-branch"></i><span class="gx-section__title">Install a Version</span></div>
|
||||
<div style="padding: var(--sp-3);">
|
||||
<details class="gx-card" style="margin-bottom:12px;" @toggle="advancedVersionPickerOpen = $event.target.open">
|
||||
<summary class="gx-section__header" style="list-style:none;">
|
||||
<i class="bi bi-sliders"></i><span class="gx-section__title">Advanced update options</span>
|
||||
<i class="bi" :class="advancedVersionPickerOpen ? 'bi-chevron-up' : 'bi-chevron-down'" aria-hidden="true"></i>
|
||||
</summary>
|
||||
<div v-if="advancedVersionPickerOpen">
|
||||
<div class="gx-section__header" style="cursor:default;"><i class="bi bi-git-branch"></i><span class="gx-section__title">Install a Version</span></div>
|
||||
<div style="padding: var(--sp-3);">
|
||||
<p class="gx-note" style="margin-top:0; overflow-wrap:anywhere;">Most people should stay on the latest version. Use this only to install a specific branch or historical version while troubleshooting.</p>
|
||||
<p class="gx-note" style="margin-top:0; overflow-wrap:anywhere;">Installed branch: <strong>{{ currentBranch || 'Unknown' }}</strong></p>
|
||||
<GalaxySelect id="gx-primary-branch" class="gx-field gx-field--full" aria-label="Target branch"
|
||||
:value="primaryBranchValue" :disabled="branchSwitchBlocked || branchBusy" @change="onPrimaryBranchSelect">
|
||||
@@ -592,23 +602,24 @@ export const SystemTools = {
|
||||
<p>Pinned version: <strong>{{ fastStatus.versionPin.branch }} · {{ shortCommit(fastStatus.versionPin.commit) }}</strong><br>Automatic updates were paused at installation.</p>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="branchSwitchBlocked || branchBusy || !branches.includes(fastStatus.versionPin.branch)" @click="returnToLatest">Return to Latest</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div style="display:flex; gap:8px; margin-top:12px; flex-wrap:wrap;">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy || isOnroad || !!fastStatus?.running" @click="checkUpdates">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy || isOnroad || updateInProgress" @click="checkUpdates">
|
||||
<i v-if="busy === 'check'" class="bi bi-arrow-repeat gx-spin"></i>
|
||||
<i v-else class="bi bi-search"></i> {{ busy === 'check' ? 'Checking...' : 'Check for Updates' }}
|
||||
</button>
|
||||
<button v-if="updateAvailable" type="button" class="gx-btn" :disabled="!!busy || isOnroad" @click="applyFastUpdate">
|
||||
<i class="bi bi-arrow-up-circle"></i> {{ busy === 'fast' ? 'Updating...' : 'Update Now' }}
|
||||
</button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy || isOnroad" @click="runUpdate('recover')">Recover</button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy || isOnroad" @click="runUpdate('rollback')">Rollback</button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy || isOnroad || updateInProgress" @click="runUpdate('recover')">Recover</button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy || isOnroad || updateInProgress" @click="runUpdate('rollback')">Rollback</button>
|
||||
</div>
|
||||
<p class="gx-note">Check for Updates scans for a newer commit. Use <strong>Update Now</strong> to install it.</p>
|
||||
<p class="gx-note"><strong>Recover</strong> continues an update that was interrupted (for example, by power loss mid-install). <strong>Rollback</strong> returns the device to the previously installed version if the current one has a problem.</p>
|
||||
<p v-if="checkedForUpdates && !updateAvailable && !fastStatus?.running" class="gx-note">
|
||||
<p v-if="checkedForUpdates && !updateAvailable && !updateInProgress" class="gx-note">
|
||||
The device is up to date. Update becomes available only after a check finds a newer commit.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user