feat: refine vehicle console and self-repair workflow

This commit is contained in:
vee
2026-08-13 17:38:04 +08:00
parent fd3c219c39
commit 0ec43052f3
20 changed files with 3925 additions and 552 deletions
+711 -18
View File
@@ -137,18 +137,475 @@ function initJointTwinLayout() {
if (seatFeedback) coverage.prepend(seatFeedback);
domainGrid.prepend(driverCard);
domainGrid.prepend(passengerCard);
const updateDomainGridScrollState = () => {
const isScrollable = domainGrid.scrollHeight > domainGrid.clientHeight + 2;
const isAtBottom = domainGrid.scrollTop + domainGrid.clientHeight >= domainGrid.scrollHeight - 2;
domainGrid.dataset.scrollable = String(isScrollable);
domainGrid.dataset.atBottom = String(!isScrollable || isAtBottom);
const vehicleStatusPanel = $('[data-domain-panel="vehicle_status"]', domainGrid);
if (vehicleStatusPanel) {
vehicleStatusPanel.classList.add("cabin-telemetry-source");
vehicleStatusPanel.hidden = true;
cabinBand.append(vehicleStatusPanel);
}
cabinBand.classList.add("cabin-compact-board");
const cabinHeading = $(".cabin-heading", cabinBand);
const cabinHeadingActions = document.createElement("div");
cabinHeadingActions.className = "cabin-heading-actions";
cabinHeadingActions.innerHTML = `
<span class="cabin-live-status" id="cabinLiveStatus"><i></i><b>通信正常 · 7 个设备在线</b></span>
<button class="cabin-expand-button" id="cabinExpandButton" type="button" aria-expanded="false">
<i data-lucide="maximize-2"></i><span>展开查看</span>
</button>`;
cabinHeading?.append(cabinHeadingActions);
const cabinToolbar = document.createElement("div");
cabinToolbar.className = "cabin-board-toolbar";
cabinToolbar.innerHTML = `
<dl class="cabin-vehicle-summary" aria-label="车辆实时概况">
<div><dt>当前档位</dt><dd id="cabinSummaryGear">--</dd></div>
<div><dt>实时车速</dt><dd id="cabinSummarySpeed">-- km/h</dd></div>
<div><dt>门锁状态</dt><dd id="cabinSummaryDoors">状态未知</dd></div>
<div><dt>系统状态</dt><dd id="cabinSummarySystem">无故障</dd></div>
</dl>
<div class="cabin-carousel-controls" aria-label="设备卡片浏览">
<span class="cabin-carousel-count" aria-live="polite"><b id="cabinCarouselRange">1–3</b><i> / 7</i></span>
<div role="group" aria-label="左右浏览设备卡片">
<button id="cabinCarouselPrev" type="button" aria-label="查看上一组设备" disabled>
<i data-lucide="chevron-left"></i>
</button>
<button id="cabinCarouselNext" type="button" aria-label="查看下一组设备">
<i data-lucide="chevron-right"></i>
</button>
</div>
</div>`;
cabinBand.insertBefore(cabinToolbar, domainGrid);
const powerEntryDevices = [
{
card: passengerCard,
key: "passenger_seat",
name: "副驾座椅",
measurable: true,
components: [
["longitudinal_motor", "水平电机"],
["cushion_motor", "坐盆电机"],
["backrest_motor", "座椅靠背电机"],
["legrest_motor", "腿托电机"],
["ventilation_control", "通风控制"],
["heating_control", "加热控制"],
],
},
{ card: driverCard, key: "driver_seat", name: "主驾座椅", measurable: true, components: [] },
{
card: $('[data-domain-panel="climate"]', domainGrid),
key: "climate",
name: "空调",
measurable: true,
components: [
["compressor", "压缩机"],
["electronic_fan", "电子风扇"],
["blower", "鼓风机"],
["thermal_management_module", "空调热管理集成模块"],
["compressor_controller", "压缩机电控"],
["electronic_cooling_pump", "电控冷却水泵"],
],
},
{ card: $('[data-domain-panel="roof"]', domainGrid), key: "roof", name: "遮阳帘", measurable: true, components: [] },
{ card: $('[data-domain-panel="ambient_light"]', domainGrid), key: "ambient_light", name: "车内灯光", measurable: false, components: [] },
{
card: $('[data-domain-panel="window"]', domainGrid),
key: "window",
name: "车窗",
measurable: true,
components: [
["front_left_window", "左前车窗"],
["front_right_window", "右前车窗"],
["rear_left_window", "左后车窗"],
["rear_right_window", "右后车窗"],
],
},
{
card: $('[data-domain-panel="front_light"]', domainGrid),
key: "front_light",
name: "前大灯",
measurable: true,
components: [
["front_left_combination_lamp", "左前组合灯"],
["front_right_combination_lamp", "右前组合灯"],
],
},
];
const powerDeviceByKey = new Map(powerEntryDevices.map((device) => [device.key, device]));
const powerTelemetryByDevice = new Map();
let activePowerDevice = null;
let activePowerTrendRange = "trip";
let powerDrawerReturnFocus = null;
const powerBackdrop = document.createElement("div");
powerBackdrop.className = "device-power-backdrop";
powerBackdrop.id = "devicePowerBackdrop";
powerBackdrop.hidden = true;
const powerDrawer = document.createElement("aside");
powerDrawer.className = "device-power-drawer";
powerDrawer.id = "devicePowerDrawer";
powerDrawer.hidden = true;
powerDrawer.setAttribute("role", "dialog");
powerDrawer.setAttribute("aria-modal", "true");
powerDrawer.setAttribute("aria-labelledby", "devicePowerTitle");
powerDrawer.innerHTML = `
<header class="device-power-drawer-heading">
<div>
<span class="section-kicker">DEVICE POWER INSIGHT</span>
<h2 id="devicePowerTitle">设备功率详情</h2>
<p><i></i><span id="devicePowerFreshness">实时更新 · 等待功率数据</span></p>
</div>
<button id="devicePowerClose" type="button" aria-label="关闭功率详情"><i data-lucide="x"></i></button>
</header>
<div class="device-power-drawer-body">
<section class="device-power-overview" aria-label="设备功率摘要">
<div class="device-power-total">
<small>当前总功率</small>
<strong><b id="devicePowerCurrent">--</b><span id="devicePowerCurrentUnit">W</span></strong>
<em id="devicePowerDataState">等待测量数据</em>
</div>
<dl class="device-power-energy-summary">
<div><dt>本次行程</dt><dd id="devicePowerTripEnergy">-- kWh</dd></div>
<div><dt>今日累计</dt><dd id="devicePowerTodayEnergy">-- kWh</dd></div>
</dl>
</section>
<section class="device-power-components" aria-labelledby="devicePowerComponentsTitle">
<header>
<div><span class="section-kicker">POWER BREAKDOWN</span><h3 id="devicePowerComponentsTitle">子部件当前功率</h3></div>
<span id="devicePowerComponentTotal">合计 -- W</span>
</header>
<div class="device-power-component-list" id="devicePowerComponentList"></div>
</section>
<section class="device-power-trend" aria-labelledby="devicePowerTrendTitle">
<header>
<div><span class="section-kicker">POWER TREND</span><h3 id="devicePowerTrendTitle">功率趋势</h3></div>
<div class="device-power-trend-tabs" role="group" aria-label="功率趋势时间范围">
<button type="button" class="active" data-power-trend-range="trip">本次行程</button>
<button type="button" data-power-trend-range="today">今日</button>
<button type="button" data-power-trend-range="week">近7天</button>
</div>
</header>
<div class="device-power-chart" id="devicePowerChart">
<svg viewBox="0 0 520 180" preserveAspectRatio="none" aria-hidden="true">
<g class="device-power-chart-grid">
<line x1="36" y1="24" x2="504" y2="24"></line>
<line x1="36" y1="74" x2="504" y2="74"></line>
<line x1="36" y1="124" x2="504" y2="124"></line>
<line x1="36" y1="160" x2="504" y2="160"></line>
</g>
<path class="device-power-chart-area" id="devicePowerChartArea" d=""></path>
<polyline class="device-power-chart-line" id="devicePowerChartLine" points=""></polyline>
</svg>
<span class="device-power-chart-y top" id="devicePowerChartMax">-- W</span>
<span class="device-power-chart-y bottom">0</span>
<div class="device-power-chart-empty" id="devicePowerChartEmpty"><i data-lucide="chart-no-axes-column-increasing"></i><span>暂无功率趋势数据</span></div>
<div class="device-power-chart-axis"><span id="devicePowerChartStart">行程开始</span><span id="devicePowerChartEnd">当前</span></div>
</div>
</section>
<footer class="device-power-note"><i data-lucide="info"></i><span id="devicePowerNote">总功率为设备级实时测量值。</span></footer>
</div>`;
document.body.append(powerBackdrop, powerDrawer);
const finitePowerNumber = (value) => {
if (value === null || value === undefined || value === "") return null;
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? number : null;
};
domainGrid.addEventListener("scroll", updateDomainGridScrollState, { passive: true });
const formatPower = (value) => {
const watts = finitePowerNumber(value);
if (watts === null) return "-- W";
if (watts >= 1000) return `${(watts / 1000).toFixed(watts >= 10000 ? 1 : 2)} kW`;
return `${Math.round(watts)} W`;
};
const formatEnergy = (value) => {
const energy = finitePowerNumber(value);
return energy === null ? "-- kWh" : `${energy.toFixed(2)} kWh`;
};
const componentPower = (telemetry, key) => {
const value = telemetry?.components?.[key];
return finitePowerNumber(value?.power_w ?? value);
};
const historyForRange = (telemetry, range) => {
const source = Array.isArray(telemetry?.history)
? telemetry.history
: telemetry?.history?.[range];
if (!Array.isArray(source)) return [];
return source
.map((item) => finitePowerNumber(item?.power_w ?? item?.value ?? item))
.filter((item) => item !== null);
};
const renderPowerChart = (telemetry) => {
const history = historyForRange(telemetry, activePowerTrendRange);
const empty = $("#devicePowerChartEmpty", powerDrawer);
const line = $("#devicePowerChartLine", powerDrawer);
const area = $("#devicePowerChartArea", powerDrawer);
const maxLabel = $("#devicePowerChartMax", powerDrawer);
const labels = {
trip: ["行程开始", "当前"],
today: ["00:00", "当前"],
week: ["7天前", "今日"],
};
$("#devicePowerChartStart", powerDrawer).textContent = labels[activePowerTrendRange][0];
$("#devicePowerChartEnd", powerDrawer).textContent = labels[activePowerTrendRange][1];
if (history.length < 2) {
line.setAttribute("points", "");
area.setAttribute("d", "");
maxLabel.textContent = "-- W";
empty.hidden = false;
return;
}
const maxPower = Math.max(...history, 1);
const points = history.map((value, index) => {
const x = 36 + (468 * index) / (history.length - 1);
const y = 160 - (136 * value) / maxPower;
return [x, y];
});
line.setAttribute("points", points.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" "));
area.setAttribute("d", `M ${points[0][0]} 160 L ${points.map(([x, y]) => `${x.toFixed(1)} ${y.toFixed(1)}`).join(" L ")} L ${points.at(-1)[0]} 160 Z`);
maxLabel.textContent = formatPower(maxPower);
empty.hidden = true;
};
const renderPowerDrawer = () => {
if (!activePowerDevice) return;
const telemetry = powerTelemetryByDevice.get(activePowerDevice.key) || {};
const totalPower = finitePowerNumber(telemetry.power_w ?? telemetry.total_power_w);
const title = $("#devicePowerTitle", powerDrawer);
title.textContent = `${activePowerDevice.name}功率详情`;
const current = $("#devicePowerCurrent", powerDrawer);
const currentUnit = $("#devicePowerCurrentUnit", powerDrawer);
if (totalPower !== null && totalPower >= 1000) {
current.textContent = (totalPower / 1000).toFixed(totalPower >= 10000 ? 1 : 2);
currentUnit.textContent = "kW";
} else {
current.textContent = totalPower === null ? "--" : String(Math.round(totalPower));
currentUnit.textContent = "W";
}
$("#devicePowerTripEnergy", powerDrawer).textContent = formatEnergy(telemetry.trip_energy_kwh);
$("#devicePowerTodayEnergy", powerDrawer).textContent = formatEnergy(telemetry.today_energy_kwh);
$("#devicePowerDataState", powerDrawer).textContent = totalPower === null ? "等待测量数据" : "设备总功率";
const updatedAt = telemetry.updated_at ? new Date(telemetry.updated_at) : null;
const validUpdatedAt = updatedAt && !Number.isNaN(updatedAt.getTime());
$("#devicePowerFreshness", powerDrawer).textContent = validUpdatedAt
? `实时更新 · 最近更新 ${updatedAt.toLocaleTimeString("zh-CN", { hour12: false })}`
: "实时更新 · 等待功率数据";
const componentList = $("#devicePowerComponentList", powerDrawer);
componentList.replaceChildren();
const componentValues = activePowerDevice.components
.map(([key, label]) => ({ key, label, value: componentPower(telemetry, key) }));
const availableValues = componentValues.map((item) => item.value).filter((value) => value !== null);
const maxComponentPower = Math.max(...availableValues, 1);
if (!componentValues.length) {
const empty = document.createElement("div");
empty.className = "device-power-components-empty";
empty.innerHTML = `
<span><i data-lucide="gauge"></i></span>
<div><strong>仅支持设备总功率</strong><p>${activePowerDevice.name}暂无可读的子部件功率通道,顶部总功率仍可独立查看。</p></div>`;
componentList.append(empty);
$("#devicePowerComponentTotal", powerDrawer).textContent = "设备级测量";
} else {
componentValues.forEach(({ label, value }) => {
const row = document.createElement("div");
row.className = "device-power-component-row";
row.innerHTML = `
<span>${label}</span>
<i><b style="--power-share: ${value === null ? 0 : (value / maxComponentPower) * 100}%"></b></i>
<strong>${formatPower(value)}</strong>`;
componentList.append(row);
});
const componentTotal = availableValues.length
? availableValues.reduce((sum, value) => sum + value, 0)
: null;
$("#devicePowerComponentTotal", powerDrawer).textContent = `合计 ${formatPower(componentTotal)}`;
}
$("#devicePowerNote", powerDrawer).textContent = componentValues.length
? "总功率为设备级实时测量值;子部件功率由各测量通道独立读取。"
: "该设备仅提供设备级总功率,暂无子部件功率测量通道。";
renderPowerChart(telemetry);
refreshIcons();
};
const closePowerDrawer = () => {
if (powerDrawer.hidden) return;
powerDrawer.hidden = true;
powerBackdrop.hidden = true;
document.body.classList.remove("device-power-drawer-open");
powerDrawerReturnFocus?.focus();
powerDrawerReturnFocus = null;
};
const openPowerDrawer = (device, trigger) => {
activePowerDevice = device;
activePowerTrendRange = "trip";
powerDrawerReturnFocus = trigger;
$$('[data-power-trend-range]', powerDrawer).forEach((button) => {
button.classList.toggle("active", button.dataset.powerTrendRange === activePowerTrendRange);
});
renderPowerDrawer();
powerBackdrop.hidden = false;
powerDrawer.hidden = false;
document.body.classList.add("device-power-drawer-open");
$("#devicePowerClose", powerDrawer)?.focus();
};
$("#devicePowerClose", powerDrawer)?.addEventListener("click", closePowerDrawer);
powerBackdrop.addEventListener("click", closePowerDrawer);
$$('[data-power-trend-range]', powerDrawer).forEach((button) => {
button.addEventListener("click", () => {
activePowerTrendRange = button.dataset.powerTrendRange;
$$('[data-power-trend-range]', powerDrawer).forEach((tab) => {
tab.classList.toggle("active", tab === button);
});
renderPowerChart(powerTelemetryByDevice.get(activePowerDevice?.key) || {});
});
});
const updatePowerTelemetry = (payload) => {
const updates = payload?.devices && typeof payload.devices === "object"
? Object.entries(payload.devices)
: payload?.device ? [[payload.device, payload]] : [];
updates.forEach(([key, telemetry]) => {
if (!powerDeviceByKey.has(key) || !telemetry) return;
powerTelemetryByDevice.set(key, telemetry);
const cardValue = $(`[data-power-device="${key}"] [data-device-power-value]`, domainGrid);
if (cardValue) cardValue.textContent = formatPower(telemetry.power_w ?? telemetry.total_power_w);
});
if (activePowerDevice && !powerDrawer.hidden) renderPowerDrawer();
};
window.addEventListener("cognitive-agent:power-telemetry", (event) => {
updatePowerTelemetry(event.detail || {});
});
window.updateDevicePowerTelemetry = updatePowerTelemetry;
powerEntryDevices.forEach((device) => {
const { card, key, name, measurable } = device;
if (!card) return;
card.classList.add("has-device-power-entry");
const entry = document.createElement(measurable ? "button" : "div");
entry.className = `device-power-entry${measurable ? "" : " unavailable"}`;
entry.dataset.powerDevice = key;
if (measurable) {
entry.type = "button";
entry.setAttribute("aria-label", `查看${name}功率详情`);
entry.innerHTML = `
<span class="device-power-copy"><i data-lucide="zap"></i><span><small>实时功率</small><b data-device-power-value>-- W</b></span></span>
<span class="device-power-action">详情<i data-lucide="chevron-right"></i></span>`;
entry.addEventListener("click", () => openPowerDrawer(device, entry));
} else {
entry.setAttribute("aria-label", `${name}暂不支持功率测量`);
entry.innerHTML = `
<span class="device-power-copy"><i data-lucide="zap-off"></i><span><small>功率测量</small><b>暂不支持</b></span></span>
<span class="device-power-unavailable">无测量通道</span>`;
}
card.append(entry);
});
const cabinCards = $$(":scope > .domain-panel", domainGrid);
let cabinCarouselIndex = 0;
const cabinCardsPerView = () => 2;
const scrollToCabinCard = (index, smooth = true) => {
const visibleCount = cabinCardsPerView();
const maxIndex = Math.max(0, cabinCards.length - visibleCount);
cabinCarouselIndex = Math.max(0, Math.min(index, maxIndex));
const firstOffset = cabinCards[0]?.offsetLeft || 0;
const targetOffset = cabinCards[cabinCarouselIndex]?.offsetLeft || firstOffset;
domainGrid.scrollTo({
left: Math.max(0, targetOffset - firstOffset),
behavior: smooth ? "smooth" : "auto",
});
};
const updateCabinCarouselState = () => {
const visibleCount = cabinCardsPerView();
domainGrid.style.setProperty("--cabin-cards-per-view", String(visibleCount));
const maxIndex = Math.max(0, cabinCards.length - visibleCount);
const firstCard = cabinCards[0];
const secondCard = cabinCards[1];
const cardStep = firstCard && secondCard
? Math.max(1, secondCard.offsetLeft - firstCard.offsetLeft)
: Math.max(1, firstCard?.offsetWidth || domainGrid.clientWidth);
cabinCarouselIndex = Math.max(0, Math.min(Math.round(domainGrid.scrollLeft / cardStep), maxIndex));
const range = $("#cabinCarouselRange", cabinToolbar);
if (range) {
range.textContent = `${cabinCarouselIndex + 1}–${Math.min(cabinCards.length, cabinCarouselIndex + visibleCount)}`;
}
const previous = $("#cabinCarouselPrev", cabinToolbar);
const next = $("#cabinCarouselNext", cabinToolbar);
if (previous) previous.disabled = cabinCarouselIndex <= 0;
if (next) next.disabled = cabinCarouselIndex >= maxIndex;
};
const moveCabinCarousel = (direction) => {
scrollToCabinCard(cabinCarouselIndex + direction * cabinCardsPerView());
};
const setCabinExpanded = (expanded) => {
shell.classList.toggle("cabin-expanded", expanded);
const button = $("#cabinExpandButton", cabinBand);
if (!button) return;
button.setAttribute("aria-expanded", String(expanded));
const icon = $("svg", button);
if (icon) icon.outerHTML = `<i data-lucide="${expanded ? "minimize-2" : "maximize-2"}"></i>`;
$("span", button).textContent = expanded ? "返回孪生" : "展开查看";
refreshIcons();
window.dispatchEvent(new Event("resize"));
requestAnimationFrame(() => {
updateCabinCarouselState();
scrollToCabinCard(cabinCarouselIndex, false);
});
};
$("#cabinCarouselPrev", cabinToolbar)?.addEventListener("click", () => moveCabinCarousel(-1));
$("#cabinCarouselNext", cabinToolbar)?.addEventListener("click", () => moveCabinCarousel(1));
$("#cabinExpandButton", cabinBand)?.addEventListener("click", (event) => {
setCabinExpanded(event.currentTarget.getAttribute("aria-expanded") !== "true");
});
window.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
if (!powerDrawer.hidden) {
closePowerDrawer();
} else if (shell.classList.contains("cabin-expanded")) {
setCabinExpanded(false);
}
});
const updateCabinConnection = (status = state.connectionStatus) => {
const indicator = $("#cabinLiveStatus", cabinBand);
const label = $("b", indicator);
if (!indicator || !label) return;
const online = status === "online";
indicator.dataset.state = online ? "online" : status === "connecting" ? "connecting" : "offline";
label.textContent = online
? "通信正常 · 7 个设备在线"
: status === "connecting" ? "正在连接车辆设备" : "车辆设备通信离线";
};
window.addEventListener("cognitive-agent:connection", (event) => {
updateCabinConnection(event.detail?.status || "offline");
});
updateCabinConnection();
domainGrid.addEventListener("scroll", updateCabinCarouselState, { passive: true });
if ("ResizeObserver" in window) {
const domainGridObserver = new ResizeObserver(updateDomainGridScrollState);
const domainGridObserver = new ResizeObserver(() => {
updateCabinCarouselState();
scrollToCabinCard(cabinCarouselIndex, false);
});
domainGridObserver.observe(domainGrid);
}
requestAnimationFrame(updateDomainGridScrollState);
requestAnimationFrame(() => {
updateCabinCarouselState();
scrollToCabinCard(0, false);
});
coverage.classList.add("joint-hardware-boundary");
shell.append(coverage);
stateBand.remove();
@@ -1064,6 +1521,23 @@ function createTwinCabin(THREE, scene) {
const TWIN_THEME_PALETTES = {
user: {
accent: 0x0e7773,
accentStrong: 0x095d5a,
accentSoft: 0xd8f4f1,
sceneBackground: 0xe8f2ef,
floor: 0xdceae6,
gridStrong: 0x69a49e,
gridSoft: 0xb9cfca,
fillLight: 0x9fd8cf,
vehicleOutline: 0x0e7773,
vehicleOccluded: 0x91b9b4,
seatShell: 0x0e7773,
seatInsert: 0xbfe9e4,
seatThermal: 0x79ccc4,
seatBolster: 0x095d5a,
seatStructure: 0x183f3d,
seatEdge: 0x62d2c8,
seatSeam: 0xd8f4f1,
glass: 0x258f89,
glassEmissive: 0x124c49,
glassEdge: 0x086861,
@@ -1075,6 +1549,23 @@ const TWIN_THEME_PALETTES = {
lampEdge: 0x096d67,
},
admin: {
accent: 0x315eaa,
accentStrong: 0x234785,
accentSoft: 0xe1e9fa,
sceneBackground: 0xe8eef8,
floor: 0xdfe6f3,
gridStrong: 0x7190c2,
gridSoft: 0xbdc9df,
fillLight: 0xa9bfe4,
vehicleOutline: 0x315eaa,
vehicleOccluded: 0x9eafd0,
seatShell: 0x315eaa,
seatInsert: 0xc9d8f2,
seatThermal: 0x91b2e4,
seatBolster: 0x234785,
seatStructure: 0x1d2f52,
seatEdge: 0x88a9df,
seatSeam: 0xe1e9fa,
glass: 0x477bc4,
glassEmissive: 0x1f427c,
glassEdge: 0x234f91,
@@ -1088,12 +1579,107 @@ const TWIN_THEME_PALETTES = {
};
function currentTwinThemeRole() {
if (document.body.dataset.module === "self-extension") return "user";
return document.body.dataset.demoRole === "admin"
|| sessionStorage.getItem("cognitive-agent.demo-role") === "admin"
? "admin"
: "user";
}
function createTwinGroundTexture(THREE, palette, size = 768) {
const textureCanvas = document.createElement("canvas");
textureCanvas.width = size;
textureCanvas.height = size;
const context = textureCanvas.getContext("2d");
if (!context) return null;
const accent = new THREE.Color(palette.accent);
const gridColor = accent.clone().lerp(new THREE.Color(0xffffff), 0.52);
const rgba = (color, alpha) => {
const red = Math.round(color.r * 255);
const green = Math.round(color.g * 255);
const blue = Math.round(color.b * 255);
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
};
const center = size / 2;
// The white base is fully opaque beneath the vehicle and fades to completely
// transparent at the outer edge. Theme colors are reserved for the grid.
const outerRadius = size * 0.48;
context.clearRect(0, 0, size, size);
const surfaceGradient = context.createRadialGradient(
center,
center,
0,
center,
center,
outerRadius,
);
surfaceGradient.addColorStop(0, "rgba(255, 255, 255, 1)");
surfaceGradient.addColorStop(0.18, "rgba(255, 255, 255, 1)");
surfaceGradient.addColorStop(0.48, "rgba(255, 255, 255, 0.72)");
surfaceGradient.addColorStop(0.75, "rgba(255, 255, 255, 0.28)");
surfaceGradient.addColorStop(1, "rgba(255, 255, 255, 0)");
context.fillStyle = surfaceGradient;
context.fillRect(0, 0, size, size);
const gridCanvas = document.createElement("canvas");
gridCanvas.width = size;
gridCanvas.height = size;
const gridContext = gridCanvas.getContext("2d");
const gridStep = size / 40;
for (let index = 0; index <= 40; index += 1) {
const position = index * gridStep;
const major = index % 5 === 0;
gridContext.strokeStyle = rgba(gridColor, major ? 0.32 : 0.16);
gridContext.lineWidth = major ? 0.9 : 0.45;
gridContext.beginPath();
gridContext.moveTo(position, 0);
gridContext.lineTo(position, size);
gridContext.stroke();
gridContext.beginPath();
gridContext.moveTo(0, position);
gridContext.lineTo(size, position);
gridContext.stroke();
}
const gridMask = gridContext.createRadialGradient(
center,
center,
0,
center,
center,
outerRadius,
);
gridMask.addColorStop(0, "rgba(255, 255, 255, 0.64)");
gridMask.addColorStop(0.22, "rgba(255, 255, 255, 0.56)");
gridMask.addColorStop(0.52, "rgba(255, 255, 255, 0.36)");
gridMask.addColorStop(0.8, "rgba(255, 255, 255, 0.1)");
gridMask.addColorStop(1, "rgba(255, 255, 255, 0)");
gridContext.globalCompositeOperation = "destination-in";
gridContext.fillStyle = gridMask;
gridContext.fillRect(0, 0, size, size);
context.drawImage(gridCanvas, 0, 0);
const texture = new THREE.CanvasTexture(textureCanvas);
if ("colorSpace" in texture) texture.colorSpace = THREE.SRGBColorSpace;
else texture.encoding = THREE.sRGBEncoding;
texture.generateMipmaps = true;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.needsUpdate = true;
return texture;
}
function applyTwinGroundTheme(twin, palette) {
if (!twin?.floor?.material || !window.THREE) return;
const nextTexture = createTwinGroundTexture(window.THREE, palette);
const previousTexture = twin.floor.material.map;
twin.floor.material.map = nextTexture;
twin.floor.material.color.setHex(0xffffff);
twin.floor.material.needsUpdate = true;
if (previousTexture && previousTexture !== nextTexture) previousTexture.dispose();
}
function applyTwinVehicleTheme(systems, role = currentTwinThemeRole()) {
if (!systems) return;
const themeRole = role === "admin" ? "admin" : "user";
@@ -1134,6 +1720,87 @@ function applyTwinVehicleTheme(systems, role = currentTwinThemeRole()) {
});
}
function applyTwinSeatTheme(model, palette) {
if (!model?.materials) return;
const {
shell,
insert,
thermalInsert,
bolster,
structure,
edge,
seam,
} = model.materials;
shell.color.setHex(palette.seatShell);
shell.emissive.setHex(palette.accentStrong);
shell.emissiveIntensity = 0.14;
insert.color.setHex(palette.seatInsert);
insert.emissive.setHex(palette.accentStrong);
insert.emissiveIntensity = 0.1;
thermalInsert.color.setHex(palette.seatThermal);
thermalInsert.emissive.setHex(palette.accentStrong);
thermalInsert.emissiveIntensity = 0.12;
bolster.color.setHex(palette.seatBolster);
bolster.emissive.setHex(palette.accentStrong);
bolster.emissiveIntensity = 0.12;
structure.color.setHex(palette.seatStructure);
structure.emissive.setHex(palette.seatStructure);
structure.emissiveIntensity = 0.08;
edge.color.setHex(palette.seatEdge);
seam.color.setHex(palette.seatSeam);
model.thermalColors.base.setHex(palette.seatThermal);
Object.values(model.angleGuides || {}).forEach((guide) => {
guide.group.traverse((object) => {
if (object.material?.color) object.material.color.setHex(palette.accent);
});
});
model.root.traverse((object) => {
if (object.userData.twinOutline?.material?.color) {
object.userData.twinOutline.material.color.setHex(palette.seatEdge);
}
if (object.userData.twinOccludedOutline?.material?.color) {
object.userData.twinOccludedOutline.material.color.setHex(palette.accent);
}
const ghost = object.userData.twinOccludedGhost;
if (ghost) {
const materials = Array.isArray(ghost.material) ? ghost.material : [ghost.material];
materials.filter(Boolean).forEach((material) => material.color.setHex(palette.accent));
}
});
}
function applyTwinSceneTheme(twin, role = currentTwinThemeRole()) {
if (!twin) return;
const themeRole = document.body.dataset.module === "self-extension"
? "user"
: role === "admin" ? "admin" : "user";
const palette = TWIN_THEME_PALETTES[themeRole];
applyTwinVehicleTheme(twin.vehicleSystems, themeRole);
twin.scene.background.setHex(palette.sceneBackground);
if (twin.scene.fog?.color) twin.scene.fog.color.setHex(palette.sceneBackground);
applyTwinGroundTheme(twin, palette);
if (twin.fillLight?.color) twin.fillLight.color.setHex(palette.fillLight);
Object.values(twin.models || {}).forEach((model) => applyTwinSeatTheme(model, palette));
[twin.vehicleSystems?.root, twin.referenceVehicle, twin.fixedModels?.rear]
.filter(Boolean)
.forEach((root) => root.traverse((object) => {
if (object.userData.twinOutline?.material?.color) {
object.userData.twinOutline.material.color.setHex(palette.vehicleOutline);
}
if (object.userData.twinOccludedOutline?.material?.color) {
object.userData.twinOccludedOutline.material.color.setHex(palette.vehicleOccluded);
}
}));
// Controllable glass and lamp entities use stronger semantic variants from
// the same role palette after the neutral vehicle outline pass above.
applyTwinVehicleTheme(twin.vehicleSystems, themeRole);
const canvas = twin.renderer?.domElement;
if (canvas) {
canvas.dataset.themeRole = themeRole;
canvas.dataset.themeAccent = `#${palette.accent.toString(16).padStart(6, "0")}`;
}
}
function updateTwinVehicleVisual(systems, timestamp) {
if (!systems) return;
const ease = (current, target, factor = 0.08) => (
@@ -3099,18 +3766,20 @@ function initSeatTwin() {
scene.add(fillLight);
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(12, 9),
new THREE.MeshStandardMaterial({ color: 0xdfe7e4, roughness: 0.86 }),
new THREE.PlaneGeometry(30, 30),
new THREE.MeshBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 1,
depthWrite: false,
side: THREE.DoubleSide,
toneMapped: false,
}),
);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -0.16;
floor.receiveShadow = true;
floor.renderOrder = 1;
scene.add(floor);
const grid = new THREE.GridHelper(10, 20, 0xaebdb9, 0xcbd6d2);
grid.position.y = -0.155;
grid.material.transparent = true;
grid.material.opacity = 0.16;
scene.add(grid);
const vehicleSystems = createTwinCabin(THREE, scene);
applyTwinOutlineStyle(THREE, vehicleSystems.root, {
color: 0x4f747c,
@@ -3197,6 +3866,8 @@ function initSeatTwin() {
models,
fixedModels,
vehicleSystems,
floor,
fillLight,
selected: "passenger",
actuatorState: { passenger: initialDevice(), driver: initialDevice() },
explicitAngleTargets: { passenger: {}, driver: {} },
@@ -3204,9 +3875,12 @@ function initSeatTwin() {
dragging: false,
pointer: { x: 0, y: 0 },
};
applyTwinVehicleTheme(vehicleSystems);
applyTwinSceneTheme(state.seatTwin);
window.addEventListener("cognitive-agent:demo-role-change", (event) => {
applyTwinVehicleTheme(vehicleSystems, event.detail?.role);
applyTwinSceneTheme(state.seatTwin, event.detail?.role);
});
window.addEventListener("cognitive-agent:module-change", () => {
applyTwinSceneTheme(state.seatTwin);
});
loadReferenceVehicleModel(THREE, scene, vehicleSystems, fallback)
@@ -3215,6 +3889,7 @@ function initSeatTwin() {
state.seatTwin.referenceVehicle = vehicle;
state.seatTwin.referenceReport = report;
state.seatTwin.referenceBounds = normalized.bounds;
applyTwinSceneTheme(state.seatTwin);
fixedModels.rear.visible = false;
[["passenger", passengerCushion], ["driver", driverCushion]].forEach(([seat, referenceCushion]) => {
if (!referenceCushion) return;
@@ -4856,9 +5531,11 @@ function renderDomainState(domainState) {
rear_right: "rearRightDoorState",
};
let knownDoorCount = 0;
let lockedDoorCount = 0;
Object.entries(doorIds).forEach(([name, id]) => {
const lockState = String(door?.locks?.[name]?.state || "unknown").toLowerCase();
if (lockState !== "unknown") knownDoorCount += 1;
if (lockState === "lock") lockedDoorCount += 1;
$(`#${id}`).textContent = doorLabels[lockState] || "未知";
const row = panel?.querySelector(`[data-door-state="${name}"]`);
if (row) row.dataset.state = lockState;
@@ -4869,6 +5546,22 @@ function renderDomainState(domainState) {
$("#vehicleTelemetryState").textContent = hasPhysicalState
? (hasGear && hasSpeed && knownDoorCount === 4 ? "实时" : "部分")
: "只读";
const summaryGear = $("#cabinSummaryGear");
const summarySpeed = $("#cabinSummarySpeed");
const summaryDoors = $("#cabinSummaryDoors");
const summarySystem = $("#cabinSummarySystem");
if (summaryGear) summaryGear.textContent = hasGear ? `${gear} 挡` : "--";
if (summarySpeed) summarySpeed.textContent = hasSpeed
? `${speed < 10 ? speed.toFixed(1) : speed.toFixed(0)} km/h`
: "-- km/h";
if (summaryDoors) summaryDoors.textContent = knownDoorCount < 4
? "状态未知"
: lockedDoorCount === 4 ? "四门已锁" : `${lockedDoorCount} 门已锁`;
if (summarySystem) {
const faulted = Boolean(vehicleStatus?.fault || door?.fault);
summarySystem.textContent = faulted ? "需检查" : "无故障";
summarySystem.dataset.state = faulted ? "warning" : "healthy";
}
}
const ambient = domainState.ambient_light;