1084 行
37 KiB
JavaScript
1084 行
37 KiB
JavaScript
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();
|
||
}
|
||
});
|
||
});
|