history-analysis-demo.html 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. <!doctype html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>预警处置 / 历史分析</title>
  7. <style>
  8. :root { --ink:#16324f; --muted:#64748b; --line:#d9e1ea; --panel:#fff; --page:#edf2f7; --blue:#1470ef; }
  9. * { box-sizing:border-box; }
  10. body { margin:0; min-height:100vh; background:var(--page); color:var(--ink); font-family:"Microsoft YaHei",Arial,sans-serif; font-size:13px; }
  11. header { display:flex; flex-wrap:wrap; gap:12px; justify-content:space-between; align-items:center; padding:14px 20px; background:var(--panel); border-bottom:1px solid var(--line); }
  12. h1 { margin:0; font-size:18px; }
  13. .tabs { display:inline-flex; border:1px solid var(--line); border-radius:5px; overflow:hidden; }
  14. .tabs button { border:0; border-right:1px solid var(--line); padding:8px 16px; background:#f4f7fa; }
  15. .tabs button.on { background:var(--blue); color:#fff; }
  16. .meta { display:flex; gap:14px; color:var(--muted); flex-wrap:wrap; }
  17. .meta b { color:var(--ink); font-weight:600; }
  18. button { cursor:pointer; border:1px solid #b9c8d5; background:var(--panel); padding:7px 12px; border-radius:4px; }
  19. select { border:1px solid #b9c8d5; border-radius:4px; padding:6px 8px; background:#fff; }
  20. main { display:grid; grid-template-columns:minmax(340px,420px) minmax(0,1fr); min-height:calc(100vh - 57px); }
  21. aside { padding:16px 18px; background:var(--panel); border-right:1px solid var(--line); overflow:auto; }
  22. section { display:grid; grid-template-rows:auto minmax(0,1fr) auto; min-width:0; }
  23. h2 { margin:0 0 10px; font-size:14px; }
  24. .kpis { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin-bottom:14px; }
  25. .kpi { border:1px solid var(--line); border-radius:5px; padding:8px 10px; }
  26. .kpi span { display:block; color:var(--muted); font-size:11px; }
  27. .kpi b { display:block; margin-top:2px; font-size:18px; }
  28. table { width:100%; border-collapse:collapse; font-size:12px; }
  29. th,td { text-align:left; padding:6px 7px; border-bottom:1px solid #edf1f5; }
  30. th { color:var(--muted); font-weight:600; }
  31. .toolbar { display:flex; flex-wrap:wrap; gap:10px; justify-content:space-between; align-items:center; padding:10px 18px; background:var(--panel); border-bottom:1px solid var(--line); }
  32. .chart { position:relative; padding:10px 18px; background:#fff; overflow:auto; }
  33. .columns { display:flex; align-items:flex-end; gap:10px; height:220px; border-bottom:1px solid var(--line); }
  34. .col { flex:1; min-width:44px; display:flex; flex-direction:column; justify-content:flex-end; align-items:center; gap:5px; cursor:pointer; }
  35. .col .bars { display:flex; align-items:flex-end; gap:3px; width:100%; height:150px; }
  36. .col .bars i { display:block; width:100%; border-radius:3px 3px 0 0; }
  37. .col .bars .ev { background:var(--blue); opacity:.85; }
  38. .col .bars .hd { background:#0f8a4d; }
  39. .col small { font-size:11px; color:var(--muted); }
  40. .col .eff { font-size:11px; color:#b65c08; }
  41. .col.selected .bars { outline:2px solid var(--ink); }
  42. .map { position:relative; background:#e2e9f0; background-image:linear-gradient(#d3dce5 1px,transparent 1px),linear-gradient(90deg,#d3dce5 1px,transparent 1px); background-size:56px 56px; min-height:150px; }
  43. .marker { position:absolute; width:13px; height:13px; margin:-6px 0 0 -6px; border-radius:50%; background:#d0342c; border:2px solid #fff; }
  44. .marker.hot { outline:3px solid var(--ink); outline-offset:1px; }
  45. .label { position:absolute; font-size:11px; transform:translate(8px,-6px); background:rgba(255,255,255,.92); padding:1px 5px; border-radius:3px; border:1px solid var(--line); pointer-events:none; }
  46. .caption { padding:10px 18px; background:var(--panel); border-top:1px solid var(--line); color:var(--muted); }
  47. .empty { color:var(--muted); padding:12px; border:1px dashed var(--line); border-radius:5px; margin-top:10px; }
  48. .comparison { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-bottom:10px; }
  49. .comparison .kpi b.mom-up { color:#0f8a4d; } .comparison .kpi b.mom-down { color:#d0342c; }
  50. @media (max-width:940px){ main{grid-template-columns:1fr;} }
  51. </style>
  52. </head>
  53. <body>
  54. <header>
  55. <h1>预警处置 / 历史分析</h1>
  56. <div class="tabs">
  57. <button id="tabWARNING" class="on" type="button">预警</button>
  58. <button id="tabALARM" type="button">报警</button>
  59. </div>
  60. <div class="meta"><span>事件族 <b id="familyName">预警</b></span><span>范围 <b id="rangeText">--</b></span><span>更新 <b id="updated">--</b></span></div>
  61. </header>
  62. <main>
  63. <aside>
  64. <h2>筛选与摘要</h2>
  65. <div class="comparison">
  66. <label style="color:var(--muted);font-size:12px">等级
  67. <select id="fLevel"><option value="">全部</option><option value="1">1 级</option><option value="2">2 级</option><option value="3">3 级</option><option value="4">4 级</option></select>
  68. </label>
  69. <label style="color:var(--muted);font-size:12px">行业
  70. <select id="fIndustry"><option value="">全部</option><option value="GAS">燃气</option><option value="WATER_SUPPLY">供水</option><option value="DRAINAGE">排水</option></select>
  71. </label>
  72. <label style="color:var(--muted);font-size:12px">天数
  73. <select id="fDays"><option value="3">近 3 天</option><option value="5" selected>近 5 天</option><option value="7">近 7 天</option></select>
  74. </label>
  75. </div>
  76. <div class="toolbar" style="padding:0 0 12px; border-bottom:none">
  77. <button id="apply" type="button">应用</button>
  78. <button id="refresh" type="button">手动刷新</button>
  79. </div>
  80. <div class="kpis">
  81. <div class="kpi"><span>事件总数</span><b id="totalEvents">0</b></div>
  82. <div class="kpi"><span>已处置(结案)</span><b id="totalHandled">0</b></div>
  83. <div class="kpi"><span>处置效率</span><b id="totalEff">0%</b></div>
  84. </div>
  85. <h2>同比/环比摘要</h2>
  86. <div class="comparison">
  87. <div class="kpi"><span>近段 vs 前段(事件)</span><b id="momEvents" class="mom-up">--</b></div>
  88. <div class="kpi"><span>近段 vs 前段(效率)</span><b id="momEff" class="mom-up">--</b></div>
  89. </div>
  90. <h2>每日明细</h2>
  91. <table><thead><tr><th>日期</th><th>事件</th><th>处置</th><th>效率</th></tr></thead>
  92. <tbody id="dayRows"></tbody></table>
  93. <div class="empty" id="empty" style="display:none">所选时间段内没有事件(空时间段状态)。</div>
  94. </aside>
  95. <section>
  96. <div class="toolbar">
  97. <strong>趋势(蓝=事件数 绿=已处置数)</strong>
  98. <span style="color:var(--muted)">点击某一列:筛选该时间桶并联动下方中央一张图</span>
  99. </div>
  100. <div class="chart"><div class="columns" id="columns"></div></div>
  101. <div class="toolbar"><strong>中央一张图(当前选中桶的设备)</strong><span id="bucketLabel" style="color:var(--muted)">未选中</span></div>
  102. <div class="map" id="map"></div>
  103. <div class="caption" id="caption">事件按 hourly 分桶落入 Asia/Shanghai;点击列查看该桶设备点位</div>
  104. </section>
  105. </main>
  106. <script>
  107. const INDUSTRY = { GAS_SENSOR: "GAS", WATER_SUPPLY_SENSOR: "WATER_SUPPLY", SEWAGE_ENV: "DRAINAGE" };
  108. const HANDLED = 6;
  109. const days = day => {
  110. const d = new Date(Date.UTC(2026, 8, day));
  111. return d.toISOString().slice(0, 10);
  112. };
  113. // Multi-day fixture, deterministic. Each event: day, hour, level, type, work order state (null = 无工单).
  114. const buildFixture = () => {
  115. const warnings = [], alarms = [];
  116. const specs = [
  117. { day: 1, hour: 8, level: 1, type: "GAS_SENSOR", precision: "w" },
  118. { day: 1, hour: 12, level: 2, type: "WATER_SUPPLY_SENSOR", precision: "w" },
  119. { day: 1, hour: 20, level: 3, type: "SEWAGE_ENV", precision: "a" },
  120. { day: 2, hour: 7, level: 1, type: "GAS_SENSOR", precision: "w" },
  121. { day: 2, hour: 10, level: 2, type: "GAS_SENSOR", precision: "a" },
  122. { day: 2, hour: 15, level: 4, type: "WATER_SUPPLY_SENSOR", precision: "w" },
  123. { day: 3, hour: 9, level: 1, type: "GAS_SENSOR", precision: "w" },
  124. { day: 3, hour: 9, level: 1, type: "GAS_SENSOR", precision: "a" },
  125. { day: 3, hour: 18, level: 2, type: "SEWAGE_ENV", precision: "w" },
  126. { day: 4, hour: 6, level: 3, type: "WATER_SUPPLY_SENSOR", precision: "w" },
  127. { day: 4, hour: 13, level: 1, type: "GAS_SENSOR", precision: "a" },
  128. { day: 5, hour: 8, level: 2, type: "SEWAGE_ENV", precision: "w" },
  129. { day: 5, hour: 11, level: 1, type: "GAS_SENSOR", precision: "w" }
  130. ];
  131. specs.forEach((s, i) => {
  132. const device = `D-${s.type === "GAS_SENSOR" ? "GAS" : s.type === "WATER_SUPPLY_SENSOR" ? "WS" : "DR"}-${i + 1}`;
  133. const event = {
  134. no: (s.precision === "w" ? "H" : "A") + String(i + 1).padStart(3, "0"),
  135. level: s.level, hour: s.hour, day: days(s.day), type: s.type,
  136. device, lon: 110.13 + i * 0.004, lat: 28.44 + (i % 4) * 0.01,
  137. wo: i % 3 === 0 ? HANDLED : (i % 3 === 1 ? 4 : null)
  138. };
  139. (s.precision === "w" ? warnings : alarms).push(event);
  140. });
  141. return { warnings, alarms };
  142. };
  143. const fixture = buildFixture();
  144. let family = "WARNING";
  145. const filters = { level: "", industry: "", days: 5 };
  146. function events() { return family === "WARNING" ? fixture.warnings : fixture.alarms; }
  147. function render() {
  148. const startDay = "2026-09-" + String(Math.max(1, 6 - filters.days)).padStart(2, "0");
  149. const endDay = "2026-09-06";
  150. const inWindow = e => e.day >= startDay && e.day <= endDay;
  151. const list = events().filter(e =>
  152. inWindow(e) &&
  153. (!filters.level || String(e.level) === filters.level) &&
  154. (!filters.industry || INDUSTRY[e.type] === filters.industry));
  155. const daily = {};
  156. let totalEvents = 0, totalHandled = 0;
  157. const buckets = {};
  158. list.forEach(e => {
  159. const day = e.day, key = e.day + "T" + String(e.hour).padStart(2, "0") + ":00:00+08:00";
  160. totalEvents++;
  161. if (e.wo === HANDLED) totalHandled++;
  162. (daily[day] = daily[day] || { ev: 0, hd: 0, eff: 0, devices: new Set() });
  163. daily[day].ev++;
  164. if (e.wo === HANDLED) daily[day].hd++;
  165. daily[day].devices.add(e.device);
  166. (buckets[key] = buckets[key] || []).push(e);
  167. });
  168. document.getElementById("familyName").textContent = family === "WARNING" ? "预警" : "报警";
  169. document.getElementById("rangeText").textContent = startDay + " ~ " + endDay;
  170. document.getElementById("updated").textContent = new Date().toLocaleTimeString("zh-CN");
  171. document.getElementById("totalEvents").textContent = totalEvents;
  172. document.getElementById("totalHandled").textContent = totalHandled;
  173. document.getElementById("totalEff").textContent =
  174. totalEvents ? (totalHandled / totalEvents * 100).toFixed(1) + "%" : "--";
  175. document.getElementById("empty").style.display = totalEvents ? "none" : "block";
  176. // MoM-style comparison: last half vs previous half of the window.
  177. const half = Math.floor(list.length / 2);
  178. if (list.length >= 2) {
  179. const evA = list.slice(0, half).length, evB = list.length - half;
  180. const hdA = list.slice(0, half).filter(e => e.wo === HANDLED).length;
  181. const hdB = list.slice(half).filter(e => e.wo === HANDLED).length;
  182. const rateA = evA ? hdA / evA : 0, rateB = evB ? hdB / evB : 0;
  183. setMom("momEvents", evB - evA, evA);
  184. setMom("momEff", rateB - rateA, rateA);
  185. } else {
  186. document.getElementById("momEvents").textContent = "--";
  187. document.getElementById("momEff").textContent = "--";
  188. }
  189. document.getElementById("dayRows").innerHTML = Object.keys(daily).sort().map(d => {
  190. const row = daily[d];
  191. return `<tr><td>${d}</td><td>${row.ev}</td><td>${row.hd}</td><td>${row.ev ? (row.hd / row.ev * 100).toFixed(0) + "%" : "--"}</td></tr>`;
  192. }).join("");
  193. const maxEv = Math.max(1, ...Object.values(daily).map(d => d.ev));
  194. document.getElementById("columns").innerHTML = Object.keys(daily).sort().map(d => {
  195. const row = daily[d];
  196. const pct = row.ev ? (row.hd / row.ev * 100).toFixed(0) : 0;
  197. return `<div class="col" data-day="${d}"><span class="eff">${pct}%</span><div class="bars">
  198. <i class="ev" style="height:${row.ev / maxEv * 130}px"></i>
  199. <i class="hd" style="height:${row.hd / maxEv * 130}px"></i>
  200. </div><small>${d.slice(5)}</small></div>`;
  201. }).join("");
  202. document.querySelectorAll(".col").forEach(col =>
  203. col.addEventListener("click", () => selectBucket(daily[col.dataset.day], col.dataset.day)));
  204. }
  205. function setMom(id, delta, base) {
  206. const el = document.getElementById(id);
  207. if (!base) { el.textContent = delta > 0 ? "+" + delta : String(delta); return; }
  208. const pct = delta / base * 100;
  209. el.textContent = (pct >= 0 ? "+" : "") + pct.toFixed(1) + "%";
  210. el.className = pct > 0 ? "mom-up" : pct < 0 ? "mom-down" : "";
  211. }
  212. function selectBucket(day, label) {
  213. const map = document.getElementById("map");
  214. map.innerHTML = "";
  215. const selected = events().filter(e => e.day === label);
  216. document.getElementById("bucketLabel").textContent = label + " 选中(" + selected.length + " 个事件)";
  217. if (!selected.length) { document.getElementById("caption").textContent = "该桶无事件"; return; }
  218. const lons = selected.map(e => e.lon), lats = selected.map(e => e.lat);
  219. const minLon = Math.min(...lons), maxLon = Math.max(...lons), minLat = Math.min(...lats), maxLat = Math.max(...lats);
  220. const xs = e => 12 + (e.lon - minLon) / Math.max(0.001, maxLon - minLon) * 76;
  221. const ys = e => 12 + (maxLat - e.lat) / Math.max(0.001, maxLat - minLat) * 76;
  222. selected.forEach(e => {
  223. const el = document.createElement("div");
  224. el.className = "marker hot";
  225. el.style.left = xs(e) + "%"; el.style.top = ys(e) + "%";
  226. map.appendChild(el);
  227. const l = document.createElement("div");
  228. l.className = "label"; l.style.left = xs(e) + "%"; l.style.top = ys(e) + "%";
  229. l.textContent = e.no + " · " + e.device;
  230. map.appendChild(l);
  231. });
  232. document.getElementById("caption").textContent = `${label} 桶内 ${selected.length} 个事件的点位(联动中央一张图)`;
  233. }
  234. document.getElementById("tabWARNING").addEventListener("click", () => setFamily("WARNING"));
  235. document.getElementById("tabALARM").addEventListener("click", () => setFamily("ALARM"));
  236. function setFamily(next) {
  237. family = next;
  238. document.getElementById("tabWARNING").classList.toggle("on", family === "WARNING");
  239. document.getElementById("tabALARM").classList.toggle("on", family === "ALARM");
  240. render();
  241. }
  242. document.getElementById("apply").addEventListener("click", () => {
  243. filters.level = document.getElementById("fLevel").value;
  244. filters.industry = document.getElementById("fIndustry").value;
  245. filters.days = Number(document.getElementById("fDays").value);
  246. render();
  247. });
  248. document.getElementById("refresh").addEventListener("click", render);
  249. render();
  250. </script>
  251. </body>
  252. </html>