Files
carrotpilot/selfdrive/carrot/web/js/realtime/nav_hud.js
2026-06-04 20:44:17 +09:00

615 lines
23 KiB
JavaScript

(function () {
"use strict";
// Carrot Nav HUD V2 — small top-center card with rich slots.
//
// Reads window.CarrotLiveRuntimeState.services.carrotMan and renders into
// #carrotNavHud directly (no iframe, no network, no Kakao API). Independent
// of carrot_map.js by design so it runs with or without the minimap.
//
// Main card modes:
// turn — turn arrow + dist + road
// idle — current road name + speed limit only
//
// Side card:
// camera / speed-limit details, independent of the main guidance card.
//
// Attachments (conditional, mode-independent):
// ATC badge — atcType "prepare" / "go"
// Countdown chip — xTurnCountDown / xSpdCountDown <= 10s
// Speed limit — nRoadLimitSpeed (idle/turn)
// Hint badge — trafficState (idle, red signal)
// Hysteresis thresholds (enter vs stay).
const SHOW_TURN_MAX_M = 1500;
const HIDE_TURN_MAX_M = 2000;
const SHOW_SDI_MAX_M = 800;
const HIDE_SDI_MAX_M = 1100;
// Stale cache: how long to keep showing the last payload when the feed
// goes quiet. Long enough to ride out UDP drops / navi phase gaps.
const HIDE_AFTER_STALE_MS = 10000;
// Once visible, keep the card up at least this long. Prevents
// threshold-flicker and short data gaps from blinking the card.
const MIN_VISIBLE_MS = 4000;
// Countdown chip threshold (xTurnCountDown / xSpdCountDown in seconds).
const COUNTDOWN_SHOW_SEC = 10;
// Refresh throttle (DOM updates max ~5Hz).
const REFRESH_THROTTLE_MS = 200;
// Turn-info code -> glyph + label. Matches selfdrive/ui/carrot.cc
// TurnInfoDrawer::drawTurnInfoHud() switch statement.
const TURN_ICONS = {
1: "left",
2: "right",
3: "lane-left",
4: "lane-right",
5: "left",
6: "toll",
7: "uturn",
8: "finish",
};
const TURN_LABELS = {
1: "좌회전",
2: "우회전",
3: "좌 차로변경",
4: "우 차로변경",
5: "좌회전",
6: "통행료",
7: "유턴",
8: "목적지",
};
// trafficState enum (carrot_serv): 0=none, 1=stopped(red), 2=go(green).
// Conservative — only show "red" hint. "green" is noise.
const TRAFFIC_RED = 1;
const TRAFFIC_GREEN = 2;
function finiteNumber(value) {
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function normalizeBool(value) {
if (typeof value === "string") {
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}
return Boolean(value);
}
function getSetting(key, fallback) {
const settings = window.CarrotWebSettingsState || {};
return Object.prototype.hasOwnProperty.call(settings, key) ? settings[key] : fallback;
}
function isCarrotPageActive() {
return document.body?.dataset?.page === "carrot";
}
function isVisionActive() {
if (typeof window.isCarrotVisionActive === "function") return window.isCarrotVisionActive();
return Boolean(window.CarrotVisionState?.active);
}
function isLandscape() {
if (typeof window.matchMedia === "function") {
try {
return window.matchMedia("(orientation: landscape)").matches;
} catch {}
}
return Number(window.innerWidth || 0) >= Number(window.innerHeight || 0);
}
function formatDistance(meters) {
const m = finiteNumber(meters);
if (m === null || m <= 0) return "";
if (m < 950) return `${Math.round(m / 10) * 10}m`;
const km = m / 1000;
return km < 10 ? `${km.toFixed(1)}km` : `${Math.round(km)}km`;
}
function formatDuration(seconds) {
const s = finiteNumber(seconds);
if (s === null || s <= 0) return "";
const mins = Math.round(s / 60);
if (mins < 60) return `${mins}`;
const h = Math.floor(mins / 60);
const rest = mins % 60;
return rest ? `${h}시간 ${rest}` : `${h}시간`;
}
// Computes "HH:MM" arrival time from now + remaining seconds.
function formatEtaClock(remainingSec) {
const s = finiteNumber(remainingSec);
if (s === null || s <= 0) return "";
const eta = new Date(Date.now() + s * 1000);
const hh = String(eta.getHours()).padStart(2, "0");
const mm = String(eta.getMinutes()).padStart(2, "0");
return `${hh}:${mm}`;
}
function buildMeta(cm) {
const goalDist = formatDistance(cm.nGoPosDist);
const goalTime = formatDuration(cm.nGoPosTime);
const etaClock = formatEtaClock(cm.nGoPosTime);
const parts = [];
if (goalDist) parts.push(goalDist);
if (goalTime) parts.push(goalTime);
if (etaClock) parts.push(`도착 ${etaClock}`);
return parts.join(" · ");
}
function cleanGuidanceText(value) {
const text = String(value || "").trim();
if (!text) return "";
if (/^route\s*=\s*[-+]?\d+(?:\.\d+)?$/i.test(text)) return "";
return text;
}
function escapeRegex(text) {
return String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function compactSideLabel(side) {
let label = String(side?.label || "").replace(/\s+/g, " ").trim();
const sign = String(side?.sign || "").trim();
if (side?.kind === "camera" && sign) {
label = label.replace(new RegExp(`^${escapeRegex(sign)}\\s*`), "").trim();
}
return label;
}
function navIconSvg(kind) {
const attrs = 'class="carrot-nav-hud__icon-svg" viewBox="0 0 48 48" aria-hidden="true" focusable="false"';
const pathAttrs = 'fill="none" stroke="currentColor" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round"';
switch (kind) {
case "straight":
return `<svg ${attrs}><path ${pathAttrs} d="M24 39V10"/><path ${pathAttrs} d="M14 20 24 10l10 10"/></svg>`;
case "left":
return `<svg ${attrs}><path ${pathAttrs} d="M34 38V26c0-7-5-12-12-12H13"/><path ${pathAttrs} d="M21 6l-8 8 8 8"/></svg>`;
case "right":
return `<svg ${attrs}><path ${pathAttrs} d="M14 38V26c0-7 5-12 12-12h9"/><path ${pathAttrs} d="M27 6l8 8-8 8"/></svg>`;
case "lane-left":
return `<svg ${attrs}><path ${pathAttrs} d="M31 39V12"/><path ${pathAttrs} d="M18 36V24c0-6 4-10 10-10h5"/><path ${pathAttrs} d="M26 6l8 8-8 8"/></svg>`;
case "lane-right":
return `<svg ${attrs}><path ${pathAttrs} d="M17 39V12"/><path ${pathAttrs} d="M30 36V24c0-6-4-10-10-10h-5"/><path ${pathAttrs} d="M22 6l-8 8 8 8"/></svg>`;
case "uturn":
return `<svg ${attrs}><path ${pathAttrs} d="M33 39V22c0-7-5-12-12-12h-5"/><path ${pathAttrs} d="M23 3l-8 7 8 8"/></svg>`;
case "finish":
return `<svg ${attrs}><path ${pathAttrs} d="M15 39V9"/><path fill="currentColor" d="M18 9h18l-4 7 4 7H18z"/></svg>`;
default:
return "";
}
}
class CarrotNavHud {
constructor() {
this.root = document.getElementById("carrotNavHud");
this.mainEl = this.root?.querySelector("[data-nav-hud-main]") || null;
this.sideEl = this.root?.querySelector("[data-nav-hud-side]") || null;
this.sideSignEl = this.root?.querySelector("[data-nav-hud-side-sign]") || null;
this.sideDistEl = this.root?.querySelector("[data-nav-hud-side-dist]") || null;
this.sideLabelEl = this.root?.querySelector("[data-nav-hud-side-label]") || null;
this.sideCountdownEl = this.root?.querySelector("[data-nav-hud-side-countdown]") || null;
this.atcEl = this.root?.querySelector("[data-nav-hud-atc]") || null;
this.iconEl = this.root?.querySelector("[data-nav-hud-icon]") || null;
this.distEl = this.root?.querySelector("[data-nav-hud-dist]") || null;
this.adviceEl = this.root?.querySelector("[data-nav-hud-advice]") || null;
this.countdownEl = this.root?.querySelector("[data-nav-hud-countdown]") || null;
this.roadEl = this.root?.querySelector("[data-nav-hud-road]") || null;
this.limitEl = this.root?.querySelector("[data-nav-hud-limit]") || null;
this.metaEl = this.root?.querySelector("[data-nav-hud-meta]") || null;
this.hintEl = this.root?.querySelector("[data-nav-hud-hint]") || null;
this.lastSig = "";
this.lastRenderAt = 0;
this.lastDataAt = 0;
this.visible = false;
this.visibleSinceMs = 0;
this.lastMode = "";
this.hideTimer = 0;
this.resizeObserver = null;
this.viewportLayout = null;
this.sync = this.sync.bind(this);
this.tick = this.tick.bind(this);
this.handleVisibility = this.handleVisibility.bind(this);
}
init() {
if (!this.root) return;
window.addEventListener("carrot:pagechange", this.sync);
window.addEventListener("carrot:visionchange", this.sync);
window.addEventListener("carrot:websettingschange", this.sync);
window.addEventListener("carrot:render-request", this.tick);
window.addEventListener("carrot:viewportlayout", (event) => {
this.viewportLayout = event.detail || null;
this.sync();
});
window.addEventListener("resize", this.sync);
window.addEventListener("orientationchange", this.sync);
document.addEventListener("visibilitychange", this.handleVisibility);
const stage = document.getElementById("carrotStage");
if (stage && typeof ResizeObserver === "function") {
this.resizeObserver = new ResizeObserver(this.sync);
this.resizeObserver.observe(stage);
}
this.sync();
}
enabled() {
return normalizeBool(getSetting("nav_hud_enabled", true));
}
shouldRun() {
if (!this.enabled()) return false;
if (!isCarrotPageActive()) return false;
if (!isVisionActive()) return false;
if (!isLandscape()) return false;
if (document.visibilityState === "hidden") return false;
return true;
}
handleVisibility() {
if (document.visibilityState !== "visible") {
this.hide({ force: true });
return;
}
this.sync();
}
sync() {
if (!this.shouldRun()) {
this.hide({ force: true });
return;
}
this.updateLayout(!this.sideEl?.hidden);
this.tick();
}
updateLayout(sideVisible = false) {
if (!this.root) return;
const stage = document.getElementById("carrotStage");
const stageRect = stage?.getBoundingClientRect?.();
const stageWidth = stage?.clientWidth || stageRect?.width || window.innerWidth || 0;
const stageHeight = stage?.clientHeight || stageRect?.height || window.innerHeight || 0;
if (!stageWidth || !stageHeight) return;
const edge = clamp(stageWidth * 0.012, 10, 18);
const viewport = this.viewportLayout || {};
const viewportLeft = clamp(finiteNumber(viewport.left) ?? 0, 0, stageWidth);
const viewportTop = clamp(finiteNumber(viewport.top) ?? 0, 0, stageHeight);
const viewportWidth = clamp(finiteNumber(viewport.width) ?? stageWidth, 1, stageWidth);
const top = Math.round(clamp(viewportTop + (stageHeight <= 500 ? 36 : 38), edge, stageHeight - 96));
let gap = clamp(stageWidth * 0.010, 10, 14);
let sideWidth = sideVisible ? clamp(stageWidth * 0.145, 156, 204) : 0;
let mainWidth = clamp(stageWidth * 0.31, 318, 430);
const groupWidth = () => (sideVisible ? sideWidth + gap : 0) + mainWidth;
const available = Math.max(260, stageWidth - edge * 2);
if (mainWidth > available) {
mainWidth = clamp(available, 280, mainWidth);
}
const mainLeft = Math.round(clamp(
viewportLeft + (viewportWidth - mainWidth) / 2,
edge,
Math.max(edge, stageWidth - edge - mainWidth),
));
let sideLeft = Math.round(mainLeft - gap - sideWidth);
if (sideVisible && sideLeft < edge) {
sideWidth = clamp(mainLeft - gap - edge, 132, sideWidth);
sideLeft = Math.round(mainLeft - gap - sideWidth);
}
const compact = sideVisible && (sideLeft <= edge + 1 || groupWidth() > stageWidth * 0.68);
const navScale = clamp(stageHeight / 604, 0.86, 1.10);
const compactScale = compact ? clamp(navScale * 0.92, 0.82, 0.98) : navScale;
const cardHeight = Math.round((compact ? 88 : 96) * compactScale);
const setPx = (name, value) => this.root.style.setProperty(name, `${Math.round(value)}px`);
if (compact) this.root.dataset.layout = "compact";
else delete this.root.dataset.layout;
this.root.dataset.sideVisible = sideVisible ? "1" : "0";
this.root.style.setProperty("--nav-top", `${top}px`);
this.root.style.setProperty("--nav-main-width", `${Math.round(mainWidth)}px`);
this.root.style.setProperty("--nav-side-width", `${Math.round(Math.max(0, sideWidth))}px`);
this.root.style.setProperty("--nav-gap", `${Math.round(gap)}px`);
this.root.style.setProperty("--nav-card-height", `${Math.round(cardHeight)}px`);
this.root.style.setProperty("--nav-main-left", `${mainLeft}px`);
this.root.style.setProperty("--nav-side-left", `${Math.max(edge, sideLeft)}px`);
this.root.style.setProperty("--nav-scale", String(Number(compactScale.toFixed(3))));
setPx("--nav-padding-y", 12 * compactScale);
setPx("--nav-main-padding-x", 18 * compactScale);
setPx("--nav-side-padding-x", 15 * compactScale);
setPx("--nav-side-gap", 14 * compactScale);
setPx("--nav-body-gap", 5 * compactScale);
setPx("--nav-side-sign-size", 58 * compactScale);
setPx("--nav-side-sign-font", 25 * compactScale);
setPx("--nav-side-dist-font", 23 * compactScale);
setPx("--nav-side-label-font", 18 * compactScale);
setPx("--nav-icon-size", 62 * compactScale);
setPx("--nav-icon-font", 38 * compactScale);
setPx("--nav-dist-font", 33 * compactScale);
setPx("--nav-road-font", 21 * compactScale);
setPx("--nav-meta-font", 17 * compactScale);
}
readCarrotMan() {
const runtimeState = window.CarrotLiveRuntimeState;
if (!runtimeState?.ok) return null;
const services = runtimeState.services || {};
const cm = services.carrotMan;
if (!cm || typeof cm !== "object") return null;
this.lastDataAt = finiteNumber(runtimeState.fetchedAtMs) || Date.now();
return cm;
}
pickPayload(cm) {
// Active flag — match carrot.cc: activeCarrot > 1 means actively guiding.
const active = (finiteNumber(cm.activeCarrot) ?? 0) > 1;
if (!active) return null;
const sdiDist = finiteNumber(cm.xSpdDist) ?? 0;
const sdiType = finiteNumber(cm.xSpdType) ?? -1;
const sdiLimit = finiteNumber(cm.xSpdLimit) ?? 0;
const sdiDescr = String(cm.szSdiDescr || "").trim();
const sdiCountdown = finiteNumber(cm.xSpdCountDown) ?? 0;
const turnInfo = finiteNumber(cm.xTurnInfo) ?? -1;
const turnDist = finiteNumber(cm.xDistToTurn) ?? 0;
const turnCountdown = finiteNumber(cm.xTurnCountDown) ?? 0;
const roadLimit = finiteNumber(cm.nRoadLimitSpeed) ?? 0;
const roadName = cleanGuidanceText(cm.szPosRoadName);
const tbtRoad = cleanGuidanceText(cm.szTBTMainText);
const atcType = String(cm.atcType || "").trim().toLowerCase();
const trafficState = finiteNumber(cm.trafficState) ?? 0;
const meta = buildMeta(cm);
// Shared attachments — computed once, slotted per mode below.
const atcBadge = (atcType === "prepare" || atcType === "go")
? (atcType === "prepare" ? "차로변경 준비" : "차로변경 중")
: "";
const limitText = roadLimit > 0 ? String(roadLimit) : "";
// Hysteresis: if a mode is already visible, stay until the wider HIDE_*.
const sdiThreshold = (this.visible && this.root?.dataset.sideKind === "camera") ? HIDE_SDI_MAX_M : SHOW_SDI_MAX_M;
const turnThreshold = (this.visible && this.lastMode === "turn") ? HIDE_TURN_MAX_M : SHOW_TURN_MAX_M;
let side = null;
if (sdiDist > 0 && sdiDist <= sdiThreshold && sdiType > 0) {
const countdown = (sdiCountdown > 0 && sdiCountdown <= COUNTDOWN_SHOW_SEC) ? `${sdiCountdown}` : "";
side = {
kind: "camera",
sign: sdiLimit > 0 ? String(sdiLimit) : "!",
dist: formatDistance(sdiDist),
countdown,
label: sdiDescr || (sdiLimit > 0 ? `${sdiLimit} 제한 단속` : "단속 구간"),
};
} else if (limitText) {
side = {
kind: "limit",
sign: limitText,
dist: "",
countdown: "",
label: "제한속도",
};
}
// Main card: turn guidance has priority, but speed/camera no longer
// replaces the whole card. That keeps the top-center layout stable.
if (turnInfo > 0 && turnDist > 0 && turnDist <= turnThreshold) {
const countdown = (turnCountdown > 0 && turnCountdown <= COUNTDOWN_SHOW_SEC) ? `${turnCountdown}` : "";
return {
mode: "turn",
icon: turnInfo === 6 ? "TG" : "",
iconKind: TURN_ICONS[turnInfo] || "straight",
dist: formatDistance(turnDist),
advice: "",
countdown,
road: tbtRoad || TURN_LABELS[turnInfo] || "안내",
limit: "",
meta,
atc: atcBadge,
side,
hint: "",
hintTone: "",
};
}
// Idle — road name / route summary. Keep it stable while activeCarrot ≥ 2.
let hint = "";
let hintTone = "";
if (trafficState === TRAFFIC_RED) {
hint = "신호 대기";
hintTone = "red";
} else if (trafficState === TRAFFIC_GREEN) {
// Suppress green by default to reduce noise; uncomment to enable.
// hint = "출발"; hintTone = "green";
}
return {
mode: "idle",
icon: "",
iconKind: "straight",
dist: "",
advice: "",
countdown: "",
road: roadName || tbtRoad || "주행 중",
limit: "",
meta,
atc: atcBadge,
side,
hint,
hintTone,
};
}
tick() {
if (!this.shouldRun()) {
this.hide({ force: true });
return;
}
const now = Date.now();
if (now - this.lastRenderAt < REFRESH_THROTTLE_MS) return;
const cm = this.readCarrotMan();
if (!cm) {
if (this.lastDataAt && now - this.lastDataAt > HIDE_AFTER_STALE_MS) this.hide();
return;
}
const payload = this.pickPayload(cm);
if (!payload) {
this.hide();
return;
}
this.updateLayout(Boolean(payload.side));
const sig = [
payload.mode, payload.icon, payload.dist, payload.advice, payload.countdown,
payload.iconKind,
payload.road, payload.limit, payload.meta, payload.atc,
payload.side?.kind, payload.side?.sign, payload.side?.dist, payload.side?.label, payload.side?.countdown,
payload.hint, payload.hintTone,
].join("|");
if (sig === this.lastSig && this.visible) return;
this.lastSig = sig;
this.lastRenderAt = now;
this.render(payload);
}
render(payload) {
if (!this.root) return;
this.updateLayout(Boolean(payload.side));
this.root.dataset.mainMode = payload.mode;
this.root.dataset.mode = payload.mode;
this.lastMode = payload.mode;
if (payload.atc) {
// Distinguish prepare vs go via raw atcType — we already mapped to label
// text, but the CSS hook reads data-atc. Approximate: any non-empty
// means active; CSS treats "prepare"|"go" identically (green outline).
this.root.dataset.atc = payload.atc.includes("중") ? "go" : "prepare";
} else {
delete this.root.dataset.atc;
}
if (this.iconEl) {
const iconSvg = navIconSvg(payload.iconKind);
if (iconSvg) this.iconEl.innerHTML = iconSvg;
else this.iconEl.textContent = payload.icon || "";
}
if (this.distEl) this.distEl.textContent = payload.dist;
if (this.adviceEl) {
this.adviceEl.textContent = payload.advice;
this.adviceEl.hidden = !payload.advice;
}
if (this.countdownEl) {
this.countdownEl.textContent = payload.countdown;
this.countdownEl.hidden = !payload.countdown;
}
if (this.roadEl) this.roadEl.textContent = payload.road;
if (this.limitEl) {
this.limitEl.textContent = payload.limit;
this.limitEl.hidden = !payload.limit;
}
if (this.metaEl) {
this.metaEl.textContent = payload.meta;
this.metaEl.hidden = !payload.meta;
}
if (this.atcEl) {
this.atcEl.textContent = payload.atc;
this.atcEl.hidden = !payload.atc;
}
if (this.hintEl) {
this.hintEl.textContent = payload.hint;
this.hintEl.hidden = !payload.hint;
if (payload.hintTone) this.hintEl.dataset.tone = payload.hintTone;
else delete this.hintEl.dataset.tone;
}
this.renderSide(payload.side);
this.show();
}
renderSide(side) {
if (!this.root || !this.sideEl) return;
if (!side) {
this.sideEl.hidden = true;
delete this.root.dataset.sideKind;
delete this.sideEl.dataset.kind;
return;
}
this.root.dataset.sideKind = side.kind || "";
this.sideEl.dataset.kind = side.kind || "";
if (this.sideSignEl) this.sideSignEl.textContent = side.sign || "";
if (this.sideDistEl) {
this.sideDistEl.textContent = side.dist || "";
this.sideDistEl.hidden = !side.dist;
}
if (this.sideLabelEl) this.sideLabelEl.textContent = compactSideLabel(side);
if (this.sideCountdownEl) {
this.sideCountdownEl.textContent = side.countdown || "";
this.sideCountdownEl.hidden = !side.countdown;
}
this.sideEl.hidden = false;
}
show() {
if (!this.root || this.visible) return;
if (this.hideTimer) {
window.clearTimeout(this.hideTimer);
this.hideTimer = 0;
}
this.root.hidden = false;
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
this.root.classList.add("is-visible");
});
});
this.visible = true;
this.visibleSinceMs = Date.now();
}
hide({ force = false } = {}) {
if (!this.root) return;
if (!this.visible && this.root.hidden) return;
if (!force && this.visible && this.visibleSinceMs > 0) {
const aliveMs = Date.now() - this.visibleSinceMs;
if (aliveMs < MIN_VISIBLE_MS) return;
}
this.root.classList.remove("is-visible");
this.visible = false;
this.visibleSinceMs = 0;
this.lastSig = "";
delete this.root.dataset.atc;
delete this.root.dataset.sideKind;
const finalize = () => {
if (this.visible || !this.root) return;
this.root.hidden = true;
if (this.sideEl) this.sideEl.hidden = true;
this.hideTimer = 0;
};
if (force) {
finalize();
} else {
if (this.hideTimer) window.clearTimeout(this.hideTimer);
this.hideTimer = window.setTimeout(finalize, 260);
}
}
}
const instance = new CarrotNavHud();
window.CarrotNavHud = instance;
if (document.readyState === "loading") {
window.addEventListener("DOMContentLoaded", () => instance.init(), { once: true });
} else {
instance.init();
}
})();