文件
chengduzhongchuliang/public/screen/js/xishui_app.js

1084 行
37 KiB
JavaScript

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

import { fetchSiteData, showSiteDataState } from './site_data.js?v=20261028_demo3';
import {
calcScreenScale,
normalizeGranaryList,
formatGranaryParams,
aggregateApiGranaryData,
DESIGN_THEME,
XISHUI_GRANARY_DATA,
XISHUI_HOTSPOT_COORDS,
resolveHotspotCoord,
buildQualityHtml,
buildQualityHudHtml,
mapVarietyToForecastType,
generateForecastSeries,
buildForecastTrendChartOption,
buildForecastCompareChartOption,
fetchGranaryForecast,
getGranaryQualityReport,
generateDepotAlerts,
syncAlertMarqueeSpeed
} from './grain_monitor_core.js?v=20261028_demo3';
// 全局状态
const STATE = {
activeId: 101, // 默认 1号浅圆仓
filterType: 'all', // 'all' | 'silo' | 'warehouse'
qualityCategory: 'all', // 'all' | 'core' | 'physical' | 'safety'
isCenterChartOpen: false, // 居中大图表看板状态
centerChartTab: 'forecast', // 默认质量智能预测 'forecast' | 'macro'
autoPlay: false,
playTimer: null,
list: [],
envData: null,
customCoords: null, // 从后台同步的自定义3D热点坐标
summary: null,
charts: {
varietyPie: null,
granaryBar: null,
forecastTrend: null,
forecastCompare: null
},
forecastCache: {}
};
// 1. 电视大屏自适应缩放 (1920x1080)
function resizeScreen() {
const wrapper = document.getElementById('screen-wrapper');
if (!wrapper) return;
const winW = window.innerWidth;
const winH = window.innerHeight;
const scaleInfo = calcScreenScale(winW, winH, 1920, 1080);
wrapper.style.transform = `translate(${scaleInfo.offsetX}px, ${scaleInfo.offsetY}px) scale(${scaleInfo.scale})`;
if (STATE.charts.varietyPie) {
STATE.charts.varietyPie.resize();
}
if (STATE.charts.granaryBar) {
STATE.charts.granaryBar.resize();
}
if (STATE.charts.forecastTrend) {
STATE.charts.forecastTrend.resize();
}
if (STATE.charts.forecastCompare) {
STATE.charts.forecastCompare.resize();
}
}
// 2. 实时时钟
function updateClock() {
const clockEl = document.getElementById('header-clock');
const dateEl = document.getElementById('header-date');
if (!clockEl) return;
const now = new Date();
const timeStr = now.toLocaleTimeString('zh-CN', { hour12: false });
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const date = String(now.getDate()).padStart(2, '0');
const weekDays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
const dayStr = weekDays[now.getDay()];
clockEl.textContent = timeStr;
if (dateEl) {
dateEl.textContent = `${year}-${month}-${date} ${dayStr}`;
}
}
// 3. 加载习水子库标准数据集
async function loadGranaryData() {
try {
const data = await fetchSiteData('xishui');
STATE.list = normalizeGranaryList({ data });
STATE.customCoords = data.coords || {};
STATE.activeId = STATE.list.some(item => item.id === STATE.activeId) ? STATE.activeId : (STATE.list[0]?.id || null);
showSiteDataState('xishui', STATE.list.length ? 'live' : 'empty', STATE.list.length, STATE.list.some(item => item.isDemo));
} catch (_) {
STATE.list = [];
STATE.customCoords = {};
STATE.activeId = null;
showSiteDataState('xishui', 'unavailable', 0);
}
}
// 3.1 加载习水子库微气候环境工况
function loadEnvData() {
if (typeof fetch !== 'undefined') {
const urls = [
'/api/index/env?site=xishui',
'https://grain.gynhc.com/api/index/env?site=xishui',
'/api/granary/env?site=xishui'
];
(async () => {
for (const u of urls) {
try {
const res = await fetch(u, { cache: 'no-cache', signal: AbortSignal.timeout(2000) });
if (!res.ok) continue;
const json = await res.json();
if (json && (json.code === 1 || json.code === 0) && json.data) {
const d = json.data;
STATE.envData = d;
const inpTmI = document.getElementById('input-forecast-tm-i');
const inpTmO = document.getElementById('input-forecast-tm-o');
const inpHmI = document.getElementById('input-forecast-hm-i');
const inpHmO = document.getElementById('input-forecast-hm-o');
if (inpTmI && d.tm_i !== undefined) inpTmI.value = d.tm_i;
if (inpTmO && d.tm_o !== undefined) inpTmO.value = d.tm_o;
if (inpHmI && d.hm_i !== undefined) inpHmI.value = d.hm_i;
if (inpHmO && d.hm_o !== undefined) inpHmO.value = d.hm_o;
const pill = document.getElementById('forecast-trend-env-pill');
if (pill) {
pill.textContent = `工况: 仓内 ${d.tm_i}℃ / ${d.hm_i}% | 仓外 ${d.tm_o}℃ / ${d.hm_o}%`;
}
renderDepotAlerts();
return;
}
} catch (e) {}
}
})();
}
}
// 3.2 渲染全库储粮安全感知与预警走马灯
function renderDepotAlerts() {
const marqueeTrack = document.getElementById('alert-marquee-track');
if (!marqueeTrack) return;
const alerts = generateDepotAlerts(STATE.list, STATE.envData);
const signature = alerts.map(a => `${a.id}|${a.text}`).join('\n');
if (marqueeTrack.dataset.alertSignature === signature && marqueeTrack.childElementCount > 0) return;
marqueeTrack.dataset.alertSignature = signature;
marqueeTrack.innerHTML = '';
const displayAlerts = alerts.length > 0 ? (alerts.length === 1 ? alerts : alerts.concat(alerts)) : [];
displayAlerts.forEach(alert => {
const pill = document.createElement('div');
pill.className = `alert-item-pill ${alert.level || 'normal'}`;
pill.innerHTML = `
<span class="pill-dot"></span>
<span class="pill-name">${alert.granaryName || '安全监控'}</span>
<span class="pill-text">${alert.text}</span>
`;
if (alert.granaryId) {
pill.style.cursor = 'pointer';
pill.title = '点击聚焦该仓房';
pill.onclick = (e) => {
e.stopPropagation();
selectGranary(alert.granaryId, true);
};
}
marqueeTrack.appendChild(pill);
});
requestAnimationFrame(() => syncAlertMarqueeSpeed(marqueeTrack));
}
// 4. 渲染顶部关键 KPIs
function renderKPIs() {
STATE.summary = aggregateApiGranaryData(STATE.list);
const s = STATE.summary;
document.getElementById('kpi-total-capacity').textContent = (s.totalCapacity / 10000).toFixed(2);
document.getElementById('kpi-total-stock').textContent = (s.totalStock / 10000).toFixed(2);
document.getElementById('kpi-utilization').textContent = `${s.utilizationRate}%`;
document.getElementById('kpi-silo-count').textContent = `${s.siloCount} 座`;
document.getElementById('kpi-warehouse-count').textContent = `${s.warehouseCount} 栋`;
}
// 5. 渲染 3D 建筑物热点图钉 (基于 assets/xishui.png 实景)
function renderHotspots() {
const container = document.getElementById('aerial-hotspots');
if (!container) return;
container.innerHTML = '';
STATE.list.forEach(item => {
const rawCoord = resolveHotspotCoord(item, STATE.customCoords, XISHUI_HOTSPOT_COORDS);
if (!rawCoord) return;
const coord = {
x: rawCoord.x !== undefined ? rawCoord.x : (rawCoord.left !== undefined ? rawCoord.left : 50),
y: rawCoord.y !== undefined ? rawCoord.y : (rawCoord.top !== undefined ? rawCoord.top : 50)
};
const isSilo = item.grainTypeCategory === 'silo';
const isVisible = STATE.filterType === 'all' ||
(STATE.filterType === 'silo' && isSilo) ||
(STATE.filterType === 'warehouse' && !isSilo);
if (!isVisible) return;
const isActive = item.id === STATE.activeId;
const typeClass = item.grainName === '稻谷' ? 'paddy' : 'wheat';
const pin = document.createElement('div');
pin.className = `hotspot-pin ${typeClass} ${isActive ? 'active' : ''}`;
pin.style.left = `${coord.x}%`;
pin.style.top = `${coord.y}%`;
const shortName = item.name.replace('号浅圆仓', '号圆仓').replace('号仓房', '号平仓');
pin.innerHTML = `
<div class="bubble-inner">
<span class="bubble-name">${shortName}</span>
<span class="bubble-tag">${item.grainName}</span>
</div>
<div class="bubble-arrow"></div>
`;
pin.onclick = (e) => {
e.stopPropagation();
selectGranary(item.id, true);
};
container.appendChild(pin);
});
}
// 6. 渲染底部仓房快捷导航条 (纯净 11 仓紧凑点阵)
function renderMiniGrid() {
const container = document.getElementById('granary-mini-grid');
if (!container) return;
container.innerHTML = '';
// 仓房按钮列表
STATE.list.forEach(item => {
const isSilo = item.grainTypeCategory === 'silo';
const isVisible = STATE.filterType === 'all' ||
(STATE.filterType === 'silo' && isSilo) ||
(STATE.filterType === 'warehouse' && !isSilo);
if (!isVisible) return;
const btn = document.createElement('div');
const typeClass = item.grainName === '稻谷' ? 'paddy' : 'wheat';
const isActive = item.id === STATE.activeId;
btn.className = `mini-btn ${typeClass} ${isActive ? 'active' : ''}`;
const shortName = item.name.replace('号浅圆仓', '号圆仓').replace('号仓房', '号平仓');
btn.innerHTML = `<span class="btn-name">${shortName}</span><span class="btn-grain">${item.grainName}</span>`;
btn.title = `${item.name} (${item.grainName}) - 实存 ${item.storageNum} 吨`;
btn.onclick = () => selectGranary(item.id, true);
container.appendChild(btn);
});
}
// 7. 切换选中的仓房 (支持点击直接呼出居中大盘图表)
function selectGranary(id, openChart = false) {
STATE.activeId = id;
if (openChart) {
STATE.isCenterChartOpen = true;
STATE.centerChartTab = 'forecast';
const board = document.getElementById('center-chart-board');
if (board) {
board.style.display = 'flex';
switchCenterChartTab('forecast');
}
const closeBtn = document.getElementById('btn-close-center-chart');
if (closeBtn) {
closeBtn.onclick = () => toggleCenterChartBoard(STATE.centerChartTab, false);
}
bindCenterChartTabs();
} else if (STATE.isCenterChartOpen) {
if (STATE.centerChartTab === 'forecast') {
initOrUpdateCenterCharts();
}
}
renderMiniGrid();
renderHotspots();
renderDetailPanels();
}
// 居中大图表看板切换控制器
function toggleCenterChartBoard(targetTab = 'forecast', forceState) {
const reqTab = typeof targetTab === 'string' ? targetTab : (STATE.centerChartTab || 'forecast');
if (typeof forceState === 'boolean') {
STATE.isCenterChartOpen = forceState;
} else if (typeof targetTab === 'string') {
if (STATE.isCenterChartOpen && STATE.centerChartTab === targetTab) {
STATE.isCenterChartOpen = false;
} else {
STATE.isCenterChartOpen = true;
STATE.centerChartTab = targetTab;
}
} else {
STATE.isCenterChartOpen = !STATE.isCenterChartOpen;
}
const board = document.getElementById('center-chart-board');
if (board) {
board.style.display = STATE.isCenterChartOpen ? 'flex' : 'none';
if (STATE.isCenterChartOpen) {
switchCenterChartTab(STATE.centerChartTab);
}
}
// 同步绑定关闭按钮与模式切换 Tab
const closeBtn = document.getElementById('btn-close-center-chart');
if (closeBtn) {
closeBtn.onclick = () => toggleCenterChartBoard(STATE.centerChartTab, false);
}
bindCenterChartTabs();
renderMiniGrid();
}
// 绑定大盘顶部模式切换 Tab
function bindCenterChartTabs() {
const macroTab = document.getElementById('tab-chart-macro');
const forecastTab = document.getElementById('tab-chart-forecast');
if (macroTab) {
macroTab.onclick = () => switchCenterChartTab('macro');
}
if (forecastTab) {
forecastTab.onclick = () => switchCenterChartTab('forecast');
}
}
// 切换大盘模式 (宏观仓容品种 vs 粮食质量智能预测)
function switchCenterChartTab(tabName) {
STATE.centerChartTab = tabName;
const macroTabBtn = document.getElementById('tab-chart-macro');
const forecastTabBtn = document.getElementById('tab-chart-forecast');
const macroBody = document.getElementById('board-body-macro');
const forecastBody = document.getElementById('board-body-forecast');
const titleEl = document.getElementById('center-chart-header-title');
if (macroTabBtn && forecastTabBtn && macroBody && forecastBody) {
if (tabName === 'macro') {
macroTabBtn.classList.add('active');
forecastTabBtn.classList.remove('active');
macroBody.style.display = 'grid';
forecastBody.style.display = 'none';
if (titleEl) {
titleEl.textContent = '习水分公司 · 全库储粮品种与仓容负荷综合大盘';
}
requestAnimationFrame(() => {
initOrUpdateVarietyPie();
initOrUpdateGranaryBar();
});
} else {
macroTabBtn.classList.remove('active');
forecastTabBtn.classList.add('active');
macroBody.style.display = 'none';
forecastBody.style.display = 'flex';
const currentItem = STATE.list.find(i => i.id === STATE.activeId) || STATE.list[0];
requestAnimationFrame(() => {
initOrUpdateForecastCharts(currentItem);
});
}
}
renderMiniGrid();
}
// 8. 渲染左侧面板:基础档案 + 仓容负荷 + 空间物理尺寸
function renderLeftPanel(item, params) {
if (!item || !params) return;
const b = params.basic;
const s = params.size;
document.getElementById('param-granary-name').textContent = item.name;
document.getElementById('param-granary-type').textContent = b.warehouseType;
document.getElementById('param-granary-variety').textContent = b.varieties;
document.getElementById('param-granary-nature').textContent = b.natureName;
document.getElementById('param-granary-addr').textContent = item.isOilTank ? b.productArea : b.addr;
document.getElementById('param-granary-year').textContent = item.isOilTank ? b.machiningYear : b.harvestYear;
document.getElementById('param-granary-enter').textContent = b.enterTime || '2023-08';
document.getElementById('param-granary-custodian').textContent = b.custodian || '张卫国';
document.getElementById('param-granary-mgmt').textContent = b.managementForm || '直接管理';
// 仓容负荷
document.getElementById('param-capacity-val').textContent = b.capacity;
document.getElementById('param-storage-val').textContent = b.storageNum;
document.getElementById('param-util-percent').textContent = `${item.utilization}%`;
const fillBar = document.getElementById('param-util-bar');
if (fillBar) {
fillBar.style.width = `${Math.min(item.utilization, 100)}%`;
}
// 建筑工程物理尺寸
const sizeGrid = document.getElementById('param-size-grid');
if (!sizeGrid) return;
if (params.type === 'grainSilo') {
sizeGrid.innerHTML = `
<div class="param-item">
<span class="param-label">筒仓直径</span>
<span class="param-value highlight">${s.diameter}</span>
</div>
<div class="param-item">
<span class="param-label">装粮线高</span>
<span class="param-value highlight">${s.height}</span>
</div>
<div class="param-item">
<span class="param-label">仓容体积</span>
<span class="param-value">${s.volume}</span>
</div>
<div class="param-item">
<span class="param-label">扣除体积</span>
<span class="param-value">${s.decVolume}</span>
</div>
<div class="param-item">
<span class="param-label">建筑形式</span>
<span class="param-value highlight">钢筋混凝土浅圆仓</span>
</div>
<div class="param-item">
<span class="param-label">储粮方式</span>
<span class="param-value">${b.storageMethod || '仓内散储'}</span>
</div>
`;
} else {
sizeGrid.innerHTML = `
<div class="param-item">
<span class="param-label">仓房长度</span>
<span class="param-value highlight">${s.length}</span>
</div>
<div class="param-item">
<span class="param-label">仓房跨度(宽)</span>
<span class="param-value highlight">${s.width}</span>
</div>
<div class="param-item">
<span class="param-label">装粮线高</span>
<span class="param-value highlight">${s.height}</span>
</div>
<div class="param-item">
<span class="param-label">粮堆体积</span>
<span class="param-value">${s.volume}</span>
</div>
<div class="param-item">
<span class="param-label">扣除体积</span>
<span class="param-value">${s.decVolume}</span>
</div>
<div class="param-item">
<span class="param-label">储粮方式</span>
<span class="param-value">${b.storageMethod || '散装储粮'}</span>
</div>
`;
}
}
// 绑定右侧全量条形卡片分类 Tab 切换事件
function bindQualityTabs(container, report) {
if (!container || !report) return;
container.querySelectorAll('.quality-tab-btn').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const cat = btn.dataset.cat;
STATE.qualityCategory = cat;
container.innerHTML = buildQualityHudHtml(report, cat);
bindQualityTabs(container, report);
};
});
}
// 9. 渲染右侧面板:方案A 智能质检与品质全参数孪生舱 (100%全高长条卡片)
function renderRightPanel(item, params) {
if (!item || !params) return;
const qualityGrid = document.getElementById('param-quality-grid');
const qualityTitle = document.getElementById('param-quality-title');
const qualityExtra = qualityTitle ? qualityTitle.closest('.panel-title-bar')?.querySelector('.panel-extra') : null;
if (!qualityGrid) return;
const report = params.qualityReport;
if (!report) return;
// 标题与总体状态指示徽章 (短小精悍,杜绝截断)
if (qualityTitle) {
let statusBadge = '';
if (report.empty) {
statusBadge = '<span class="quality-badge status-unknown" style="margin-left: 5px;">暂无数据</span>';
} else if (report.summary.dangerCount > 0) {
statusBadge = `<span class="quality-badge status-danger" style="margin-left: 5px;">⚠️ ${report.summary.dangerCount}项超标</span>`;
} else if (report.summary.warnCount > 0) {
statusBadge = `<span class="quality-badge status-warn" style="margin-left: 5px;">⚠️ ${report.summary.warnCount}项预警</span>`;
} else if (!report.summary.unknownCount) {
statusBadge = `<span class="quality-badge status-normal" style="margin-left: 5px;">✓ 健康达标</span>`;
}
if (report.summary.unknownCount > 0) {
statusBadge += `<span class="quality-badge status-unknown" style="margin-left: 5px;">${report.summary.unknownCount}项待核实</span>`;
}
qualityTitle.innerHTML = `<span>${report.variety}品质全参数舱</span>${statusBadge}`;
}
// 标题栏右侧显示批次阶段与检验日期 (精炼格式)
if (qualityExtra) {
const stageShort = (report.inspectStage || '').split('/')[0].trim();
if (report.empty) qualityExtra.textContent = stageShort;
if (!report.empty) qualityExtra.innerHTML = `<span style="font-size: 9px; color: var(--text-sub); white-space: nowrap;">${stageShort} · ${report.inspectDate}</span>`;
}
// 渲染方案A专属品质全参数舱 (全量水平条形卡片 + 分类Tab)
const curCat = STATE.qualityCategory || 'all';
qualityGrid.innerHTML = buildQualityHudHtml(report, curCat);
bindQualityTabs(qualityGrid, report);
}
// 初始化与更新中央大图表看板 (包含宏观大盘与质量预测推演)
function initOrUpdateCenterCharts() {
bindCenterChartTabs();
if (STATE.centerChartTab === 'forecast') {
const currentItem = STATE.list.find(i => i.id === STATE.activeId) || STATE.list[0];
initOrUpdateForecastCharts(currentItem);
} else {
initOrUpdateVarietyPie();
initOrUpdateGranaryBar();
}
}
// 渲染粮食质量智能预测趋势图与偏离对比图
async function initOrUpdateForecastCharts(granaryItem) {
const currentItem = granaryItem || STATE.list.find(i => i.id === STATE.activeId) || STATE.list[0];
if (!currentItem) return;
const titleEl = document.getElementById('center-chart-header-title');
const trendTitleEl = document.getElementById('forecast-trend-title');
const compareTitleEl = document.getElementById('forecast-compare-title');
const grainName = currentItem.grainName || currentItem.varieties?.name || '粮食';
const grainType = mapVarietyToForecastType(grainName);
if (titleEl) {
titleEl.textContent = `习水分公司 · ${currentItem.name} (${grainName}) 质量指标演变智能预测推演`;
}
const trendDom = document.getElementById('chart-forecast-trend');
const compareDom = document.getElementById('chart-forecast-compare');
if (!trendDom || !compareDom || typeof echarts === 'undefined') return;
if (!STATE.charts.forecastTrend) {
STATE.charts.forecastTrend = echarts.init(trendDom);
}
if (!STATE.charts.forecastCompare) {
STATE.charts.forecastCompare = echarts.init(compareDom);
}
const currentReport = getGranaryQualityReport(grainName, currentItem.rawContent || currentItem.content || {}, currentItem.qualityTest, { requireRecordedData: true });
for (const id of ['btn-forecast-recalc', 'btn-forecast-reset']) {
const button = document.getElementById(id);
if (button) {
button.disabled = currentReport.empty;
button.title = currentReport.empty ? '请先录入本仓化验数据' : '';
}
}
if (currentReport.empty) {
const emptyOption = {
title: { text: '暂无化验数据\n录入本仓实测数据后可查看质量预测', left: 'center', top: '40%',
textStyle: { color: '#94a3b8', fontSize: 13, lineHeight: 24 } }
};
STATE.charts.forecastTrend.setOption(emptyOption, true);
STATE.charts.forecastCompare.setOption(emptyOption, true);
if (trendTitleEl) trendTitleEl.textContent = `${currentItem.name} · 暂无实测数据`;
if (compareTitleEl) compareTitleEl.textContent = '待录入化验记录';
delete STATE.forecastCache[currentItem.id];
return;
}
if (!grainType) {
const emptyOption = {
title: {
text: '当前仓房品类理化指标稳定\n无需进行真菌毒素推演',
left: 'center',
top: '42%',
textStyle: { color: '#94a3b8', fontSize: 13, lineHeight: 22 }
}
};
STATE.charts.forecastTrend.setOption(emptyOption, true);
STATE.charts.forecastCompare.setOption(emptyOption, true);
return;
}
// 仓内外温湿度环境工况初始化与同步
if (!STATE.forecastEnv) {
STATE.forecastEnv = {
tm_i: 18.5,
tm_o: 32.5,
hm_i: 72.4,
hm_o: 54.2,
month: 7,
day: 13
};
}
const inputTmI = document.getElementById('input-forecast-tm-i');
const inputHmI = document.getElementById('input-forecast-hm-i');
const inputTmO = document.getElementById('input-forecast-tm-o');
const inputHmO = document.getElementById('input-forecast-hm-o');
const inputMonth = document.getElementById('input-forecast-month');
const inputDay = document.getElementById('input-forecast-day');
const pillEl = document.getElementById('forecast-trend-env-pill');
if (inputTmI) inputTmI.value = STATE.forecastEnv.tm_i;
if (inputHmI) inputHmI.value = STATE.forecastEnv.hm_i;
if (inputTmO) inputTmO.value = STATE.forecastEnv.tm_o;
if (inputHmO) inputHmO.value = STATE.forecastEnv.hm_o;
if (inputMonth) inputMonth.value = STATE.forecastEnv.month;
if (inputDay) inputDay.value = STATE.forecastEnv.day;
if (pillEl) {
pillEl.textContent = `工况: 仓内 ${STATE.forecastEnv.tm_i}℃ / ${STATE.forecastEnv.hm_i}% · 仓外 ${STATE.forecastEnv.tm_o}℃ / ${STATE.forecastEnv.hm_o}%`;
}
setupForecastEnvControls();
// 1. 0 毫秒即时秒出基础图表 (杜绝任何白屏与残亮点)
const baselineList = [
{
day: 7,
targets: grainType === 'wheat'
? { wheat_moisture: 11.85, wheat_GWAC: 200.6, wheat_DON: 136.0, wheat_ZEN: 7.08 }
: { paddy_moisture: 11.66, paddy_fatacid: 25.9, paddy_AFB1: 2.45 }
},
{
day: 15,
targets: grainType === 'wheat'
? { wheat_moisture: 11.81, wheat_GWAC: 200.0, wheat_DON: 137.2, wheat_ZEN: 7.18 }
: { paddy_moisture: 11.62, paddy_fatacid: 26.4, paddy_AFB1: 2.52 }
},
{
day: 30,
targets: grainType === 'wheat'
? { wheat_moisture: 11.72, wheat_GWAC: 198.8, wheat_DON: 139.5, wheat_ZEN: 7.36 }
: { paddy_moisture: 11.55, paddy_fatacid: 27.3, paddy_AFB1: 2.64 }
},
{
day: 60,
targets: grainType === 'wheat'
? { wheat_moisture: 11.54, wheat_GWAC: 196.4, wheat_DON: 144.0, wheat_ZEN: 7.72 }
: { paddy_moisture: 11.40, paddy_fatacid: 29.1, paddy_AFB1: 2.88 }
}
];
let forecastList = STATE.forecastCache[currentItem.id];
if (!forecastList || forecastList.length === 0) {
const initialSeries = generateForecastSeries(grainType, currentReport, baselineList, STATE.forecastEnv);
const initialTrendOption = buildForecastTrendChartOption(initialSeries);
if (initialTrendOption) {
STATE.charts.forecastTrend.setOption(initialTrendOption, true);
}
const initialFuture30 = initialSeries.metrics.map(m => ({
key: m.key,
name: m.name,
unit: m.unit,
warn: m.warn,
alert: m.alert,
direction: m.direction,
predictedValue: m.values[3] !== undefined ? m.values[3] : m.values[m.values.length - 1]
}));
const initialCompareOption = buildForecastCompareChartOption(grainType, currentReport?.items || [], initialFuture30);
if (initialCompareOption) {
STATE.charts.forecastCompare.setOption(initialCompareOption, true);
}
STATE.charts.forecastTrend.resize();
STATE.charts.forecastCompare.resize();
// 并发快速拉取算法数据 (携带仓内外温湿度与时间参数)
try {
forecastList = await fetchGranaryForecast(currentItem, [7, 15, 30, 60], STATE.forecastEnv);
} catch {
forecastList = baselineList;
}
if (!forecastList || forecastList.length === 0) {
forecastList = baselineList;
}
STATE.forecastCache[currentItem.id] = forecastList;
}
// 2. 真实数据回传后平滑更新 (显式使用 notMerge=true 并携带环境工况)
const series = generateForecastSeries(grainType, currentReport, forecastList, STATE.forecastEnv);
if (trendTitleEl) {
trendTitleEl.textContent = `${currentItem.name} (${grainName}) 多周期趋势`;
}
const trendOption = buildForecastTrendChartOption(series);
if (trendOption) {
STATE.charts.forecastTrend.setOption(trendOption, true);
}
const future30Targets = series.metrics.map(m => ({
key: m.key,
name: m.name,
unit: m.unit,
warn: m.warn,
alert: m.alert,
direction: m.direction,
predictedValue: m.values[3] !== undefined ? m.values[3] : m.values[m.values.length - 1]
}));
if (compareTitleEl) {
compareTitleEl.textContent = `${currentItem.name} 未来30天 · 变化对比`;
}
const compareOption = buildForecastCompareChartOption(grainType, currentReport?.items || [], future30Targets);
if (compareOption) {
STATE.charts.forecastCompare.setOption(compareOption, true);
}
requestAnimationFrame(() => {
STATE.charts.forecastTrend?.resize();
STATE.charts.forecastCompare?.resize();
});
}
// 算法推演工况与温湿度输入交互控制器
function setupForecastEnvControls() {
const btnRecalc = document.getElementById('btn-forecast-recalc');
const btnReset = document.getElementById('btn-forecast-reset');
if (btnRecalc && !btnRecalc.dataset.bound) {
btnRecalc.dataset.bound = 'true';
btnRecalc.addEventListener('click', async () => {
const tm_i = parseFloat(document.getElementById('input-forecast-tm-i')?.value) || 18.5;
const hm_i = parseFloat(document.getElementById('input-forecast-hm-i')?.value) || 72.4;
const tm_o = parseFloat(document.getElementById('input-forecast-tm-o')?.value) || 32.5;
const hm_o = parseFloat(document.getElementById('input-forecast-hm-o')?.value) || 54.2;
const month = parseInt(document.getElementById('input-forecast-month')?.value, 10) || 7;
const day = parseInt(document.getElementById('input-forecast-day')?.value, 10) || 13;
STATE.forecastEnv = { tm_i, hm_i, tm_o, hm_o, month, day };
delete STATE.forecastCache[STATE.activeId];
const currentItem = STATE.list.find(i => i.id === STATE.activeId) || STATE.list[0];
await initOrUpdateForecastCharts(currentItem);
});
}
if (btnReset && !btnReset.dataset.bound) {
btnReset.dataset.bound = 'true';
btnReset.addEventListener('click', async () => {
STATE.forecastEnv = {
tm_i: 18.5,
tm_o: 32.5,
hm_i: 72.4,
hm_o: 54.2,
month: 7,
day: 13
};
delete STATE.forecastCache[STATE.activeId];
const currentItem = STATE.list.find(i => i.id === STATE.activeId) || STATE.list[0];
await initOrUpdateForecastCharts(currentItem);
});
}
}
// 10. 渲染居中全库品种分布图表
function initOrUpdateVarietyPie() {
const dom = document.getElementById('chart-variety-pie');
if (!dom || typeof echarts === 'undefined' || !STATE.summary) return;
if (!STATE.charts.varietyPie) {
STATE.charts.varietyPie = echarts.init(dom);
}
const dist = STATE.summary.distribution;
const pieData = Object.entries(dist).map(([name, val]) => {
const vTheme = DESIGN_THEME.getVarietyTheme(name);
return {
name,
value: Number((val / 10000).toFixed(2)),
itemStyle: {
color: vTheme.color
}
};
});
const totalStockWan = (STATE.summary.totalStock / 10000).toFixed(2);
const option = {
tooltip: {
trigger: 'item',
formatter: '{b}: <strong>{c} 万吨</strong> ({d}%)',
backgroundColor: 'rgba(11, 22, 44, 0.94)',
borderColor: 'rgba(56, 189, 248, 0.4)',
borderWidth: 1,
padding: [8, 12],
textStyle: { color: '#f8fafc', fontSize: 12 }
},
title: {
text: totalStockWan,
subtext: '习水总实储 (万吨)',
left: 'center',
top: '38%',
textStyle: {
color: '#f8fafc',
fontSize: 22,
fontWeight: 'bold',
fontFamily: 'DIN Alternate, JetBrains Mono, sans-serif'
},
subtextStyle: {
color: '#94a3b8',
fontSize: 11,
fontWeight: 'normal'
}
},
legend: {
bottom: '4%',
itemGap: 16,
textStyle: { color: '#cbd5e1', fontSize: 11 },
itemWidth: 12,
itemHeight: 12
},
series: [
{
name: '储粮品种结构',
type: 'pie',
radius: ['42%', '70%'],
center: ['50%', '46%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 6,
borderColor: '#060c20',
borderWidth: 3
},
label: {
show: true,
position: 'outside',
color: '#e2e8f0',
fontSize: 11,
formatter: '{b}\n{d}%'
},
labelLine: {
show: true,
length: 10,
length2: 12,
lineStyle: {
color: 'rgba(56, 189, 248, 0.45)'
}
},
data: pieData
}
]
};
STATE.charts.varietyPie.setOption(option);
STATE.charts.varietyPie.resize();
}
// 渲染居中习水各仓储量与设计容量对比柱状图
function initOrUpdateGranaryBar() {
const dom = document.getElementById('chart-granary-bar');
if (!dom || typeof echarts === 'undefined' || !STATE.list || STATE.list.length === 0) return;
if (!STATE.charts.granaryBar) {
STATE.charts.granaryBar = echarts.init(dom);
}
const names = STATE.list.map(i => i.name.replace('号浅圆仓', '#圆').replace('号仓房', '#平'));
const storageData = STATE.list.map(i => i.storageNum);
const capacityData = STATE.list.map(i => i.capacityNum);
const option = {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
backgroundColor: 'rgba(11, 22, 44, 0.94)',
borderColor: 'rgba(56, 189, 248, 0.4)',
textStyle: { color: '#f8fafc', fontSize: 11 }
},
grid: {
top: '12%',
left: '3%',
right: '4%',
bottom: '12%',
containLabel: true
},
legend: {
top: '0%',
right: '2%',
textStyle: { color: '#94a3b8', fontSize: 10.5 },
data: ['实存储量', '设计容量']
},
xAxis: {
type: 'category',
data: names,
axisLabel: {
color: '#94a3b8',
fontSize: 10,
interval: 0,
rotate: 0
},
axisLine: { lineStyle: { color: 'rgba(56, 189, 248, 0.2)' } }
},
yAxis: {
type: 'value',
name: '吨',
nameTextStyle: { color: '#64748b', fontSize: 10 },
axisLabel: { color: '#94a3b8', fontSize: 10 },
splitLine: { lineStyle: { color: 'rgba(56, 189, 248, 0.08)' } }
},
series: [
{
name: '实存储量',
type: 'bar',
barWidth: 12,
itemStyle: {
borderRadius: [3, 3, 0, 0],
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#34d399' },
{ offset: 1, color: '#059669' }
])
},
data: storageData
},
{
name: '设计容量',
type: 'bar',
barWidth: 12,
itemStyle: {
borderRadius: [3, 3, 0, 0],
color: 'rgba(255, 255, 255, 0.12)'
},
data: capacityData
}
]
};
STATE.charts.granaryBar.setOption(option);
STATE.charts.granaryBar.resize();
}
// 刷新全部详情
function renderDetailPanels() {
const currentItem = STATE.list.find(i => i.id === STATE.activeId) || STATE.list[0];
if (!currentItem) return;
const params = formatGranaryParams(currentItem);
renderLeftPanel(currentItem, params);
renderRightPanel(currentItem, params);
}
// 11. 自动轮播控制器 (每 5 秒切换下一仓)
function setupAutoPlay() {
const toggleBtn = document.getElementById('btn-autoplay');
const toggleText = document.getElementById('btn-autoplay-text');
if (!toggleBtn) return;
function start() {
stop();
STATE.playTimer = setInterval(() => {
if (!STATE.autoPlay || STATE.list.length === 0) return;
// 当居中大盘处于打开检视状态时,暂停自动轮播跳仓,保障人员专注于图表
if (STATE.isCenterChartOpen) return;
const curIdx = STATE.list.findIndex(i => i.id === STATE.activeId);
const nextIdx = (curIdx + 1) % STATE.list.length;
selectGranary(STATE.list[nextIdx].id);
}, 5000);
}
function stop() {
if (STATE.playTimer) {
clearInterval(STATE.playTimer);
STATE.playTimer = null;
}
}
toggleBtn.onclick = () => {
STATE.autoPlay = !STATE.autoPlay;
if (toggleText) {
toggleText.textContent = STATE.autoPlay ? '自动轮播: 开启' : '自动轮播: 关闭';
}
toggleBtn.classList.toggle('active', STATE.autoPlay);
if (STATE.autoPlay) {
start();
} else {
stop();
}
};
// 同步初始状态
if (toggleText) {
toggleText.textContent = STATE.autoPlay ? '自动轮播: 开启' : '自动轮播: 关闭';
}
toggleBtn.classList.toggle('active', STATE.autoPlay);
if (STATE.autoPlay) {
start();
}
}
// 12. 全屏与快捷键支持
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(err => {
console.warn('全屏被阻止:', err);
});
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
}
}
}
// 初始化入口
window.addEventListener('DOMContentLoaded', async () => {
resizeScreen();
window.addEventListener('resize', resizeScreen);
updateClock();
setInterval(updateClock, 1000);
await loadGranaryData();
loadEnvData();
const urlParams = new URLSearchParams(window.location.search);
const paramId = parseInt(urlParams.get('id'));
if (paramId && STATE.list.some(i => i.id === paramId)) {
STATE.activeId = paramId;
}
if (urlParams.get('autoPlay') === 'true') {
STATE.autoPlay = true;
} else if (urlParams.get('autoPlay') === 'false') {
STATE.autoPlay = false;
}
renderKPIs();
renderHotspots();
renderMiniGrid();
renderDetailPanels();
renderDepotAlerts();
// 电视大屏 60 秒自动静默轮询同步最新台账与微气候
setInterval(async () => {
try {
await loadGranaryData();
loadEnvData();
renderKPIs();
renderHotspots();
renderMiniGrid();
renderDetailPanels();
renderDepotAlerts();
} catch (_) {}
}, 60000);
setupAutoPlay();
if (urlParams.get('view') === 'forecast') selectGranary(STATE.activeId, true);
const fsBtn = document.getElementById('btn-fullscreen');
if (fsBtn) fsBtn.addEventListener('click', toggleFullscreen);
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
STATE.filterType = btn.getAttribute('data-filter');
renderHotspots();
renderMiniGrid();
});
});
window.addEventListener('keydown', (e) => {
if (e.target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
if (e.key === 'Escape' && STATE.isCenterChartOpen) {
toggleCenterChartBoard(STATE.centerChartTab, false);
return;
}
const curIdx = STATE.list.findIndex(w => w.id === STATE.activeId);
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
const nextIdx = (curIdx - 1 + STATE.list.length) % STATE.list.length;
selectGranary(STATE.list[nextIdx].id);
} else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
const nextIdx = (curIdx + 1) % STATE.list.length;
selectGranary(STATE.list[nextIdx].id);
} else if (e.code === 'Space') {
toggleFullscreen();
}
});
});