monitoring-distribution-demo.html 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  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. .meta { display:flex; gap:14px; color:var(--muted); flex-wrap:wrap; }
  14. .meta b { color:var(--ink); font-weight:600; }
  15. button { cursor:pointer; border:1px solid #b9c8d5; background:var(--panel); padding:7px 12px; border-radius:4px; }
  16. button:hover { border-color:var(--blue); color:var(--blue); }
  17. select, input[type=text] { border:1px solid #b9c8d5; border-radius:4px; padding:6px 8px; background:#fff; }
  18. main { display:grid; grid-template-columns:minmax(360px,440px) minmax(0,1fr); min-height:calc(100vh - 57px); }
  19. aside { padding:16px 18px; background:var(--panel); border-right:1px solid var(--line); overflow:auto; }
  20. section { display:grid; grid-template-rows:auto minmax(0,1fr) auto; min-width:0; }
  21. h2 { margin:0 0 10px; font-size:14px; }
  22. .filters { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-bottom:12px; }
  23. .filters label { display:flex; flex-direction:column; gap:4px; color:var(--muted); font-size:12px; }
  24. table { width:100%; border-collapse:collapse; font-size:12px; }
  25. th,td { text-align:left; padding:6px 7px; border-bottom:1px solid #edf1f5; }
  26. th { color:var(--muted); font-weight:600; }
  27. .dot { display:inline-block; width:10px; height:10px; border-radius:50%; margin-right:5px; vertical-align:-1px; }
  28. .s-ALARM { background:#d0342c; } .s-WARNING { background:#f0a500; }
  29. .s-WORK_ORDER { background:#1470ef; } .s-NORMAL { background:#0f8a4d; }
  30. .s-OFFLINE { background:#8a94a0; } .s-EMPTY { background:#b8c0ca; }
  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. .map { position:relative; background:#e2e9f0; background-image:linear-gradient(#d3dce5 1px,transparent 1px),linear-gradient(90deg,#d3dce5 1px,transparent 1px); background-size:56px 56px; }
  33. .marker { position:absolute; width:16px; height:16px; margin:-8px 0 0 -8px; border-radius:50% 50% 50% 2px; transform:rotate(-45deg); border:2px solid #fff; box-shadow:0 1px 4px rgba(16,24,40,.35); cursor:pointer; }
  34. .marker.s-EMPTY { border-radius:50%; transform:none; }
  35. .label { position:absolute; font-size:11px; transform:translate(10px,-6px); background:rgba(255,255,255,.92); padding:1px 5px; border-radius:3px; border:1px solid var(--line); pointer-events:none; font-size:11px; }
  36. .caption { padding:10px 18px; background:var(--panel); border-top:1px solid var(--line); color:var(--muted); }
  37. .panel { margin-top:14px; border:1px solid var(--line); border-radius:5px; padding:10px 12px; min-height:130px; }
  38. .panel h3 { margin:0 0 8px; font-size:13px; }
  39. .panel ul { margin:0; padding-left:18px; }
  40. .panel li { margin:2px 0; }
  41. .legend { display:flex; flex-wrap:wrap; gap:12px; color:var(--muted); font-size:12px; }
  42. @media (max-width:940px){ main{grid-template-columns:1fr;} .map{min-height:380px;} }
  43. </style>
  44. </head>
  45. <body>
  46. <header>
  47. <h1>运行监测 / 监测分布</h1>
  48. <div class="meta"><span>标记总数 <b id="total">0</b></span><span>监测点 <b id="points">0</b></span><span>设备 <b id="devices">0</b></span><span>筛选范围 <b id="scope">全部</b></span><span>更新 <b id="updated">--</b></span></div>
  49. </header>
  50. <main>
  51. <aside>
  52. <h2>筛选(即时作用于中央一张图)</h2>
  53. <div class="filters">
  54. <label>行业
  55. <select id="fIndustry">
  56. <option value="">全部行业</option>
  57. <option value="GAS">燃气</option>
  58. <option value="WATER_SUPPLY">供水</option>
  59. <option value="DRAINAGE">排水</option>
  60. </select>
  61. </label>
  62. <label>设备类型
  63. <select id="fType">
  64. <option value="">全部类型</option>
  65. <option value="GAS_SENSOR">燃气浓度计</option>
  66. <option value="WATER_SUPPLY_SENSOR">供水压力计</option>
  67. <option value="SEWAGE_ENV">污水环境监测</option>
  68. </select>
  69. </label>
  70. <label>点位状态
  71. <select id="fStatus">
  72. <option value="">全部状态</option>
  73. <option value="ALARM">报警</option>
  74. <option value="WARNING">预警</option>
  75. <option value="WORK_ORDER">工单处置</option>
  76. <option value="NORMAL">正常</option>
  77. <option value="OFFLINE">离线</option>
  78. <option value="EMPTY">无设备</option>
  79. </select>
  80. </label>
  81. <label>区域
  82. <input type="text" id="fRegion" placeholder="如:城区A">
  83. </label>
  84. </div>
  85. <div class="toolbar" style="padding:0 0 10px; border-bottom:none">
  86. <button id="apply" type="button">应用筛选</button>
  87. <button id="reset" type="button">重置</button>
  88. <button id="refresh" type="button">手动刷新</button>
  89. <button id="toggle" type="button">GAS-003 在线/离线</button>
  90. </div>
  91. <h2>六态分布</h2>
  92. <table><thead><tr><th>状态</th><th>标记数</th></tr></thead>
  93. <tbody id="statusRows"></tbody></table>
  94. <h2 style="margin-top:14px">区域分布</h2>
  95. <table><thead><tr><th>区域</th><th>点</th><th>设备</th><th>异常(报/警/单)</th></tr></thead>
  96. <tbody id="regionRows"></tbody></table>
  97. <h2 style="margin-top:14px">行业分布</h2>
  98. <table><thead><tr><th>行业</th><th>点</th><th>设备</th></tr></thead>
  99. <tbody id="industryRows"></tbody></table>
  100. <h2 style="margin-top:14px">类型分布</h2>
  101. <table><thead><tr><th>类型</th><th>点</th><th>设备</th></tr></thead>
  102. <tbody id="typeRows"></tbody></table>
  103. <div class="panel" id="detail"><h3>详情</h3><p style="color:var(--muted)">点击地图标记查看监测点或未关联设备详情。</p></div>
  104. </aside>
  105. <section>
  106. <div class="toolbar">
  107. <strong>中央一张图</strong>
  108. <div class="legend">
  109. <span><i class="dot s-ALARM"></i>报警</span>
  110. <span><i class="dot s-WARNING"></i>预警</span>
  111. <span><i class="dot s-WORK_ORDER"></i>工单</span>
  112. <span><i class="dot s-NORMAL"></i>正常</span>
  113. <span><i class="dot s-OFFLINE"></i>离线</span>
  114. <span><i class="dot s-EMPTY"></i>无设备</span>
  115. </div>
  116. </div>
  117. <div class="map" id="map"></div>
  118. <div class="caption" id="caption">全部监测点与独立设备</div>
  119. </section>
  120. </main>
  121. <script>
  122. const STATUS_ORDER = ["ALARM", "WARNING", "WORK_ORDER", "NORMAL", "OFFLINE", "EMPTY"];
  123. const INDUSTRY_NAME = { GAS: "燃气", WATER_SUPPLY: "供水", DRAINAGE: "排水" };
  124. const TYPE_NAME = { GAS_SENSOR: "燃气浓度计", WATER_SUPPLY_SENSOR: "供水压力计", SEWAGE_ENV: "污水环境监测" };
  125. const fixture = () => ({
  126. points: [
  127. { code: "P-GAS-1", name: "燃气监测点-1", region: "城区A", industry: "GAS", type: "GAS_SENSOR", lon: 110.130, lat: 28.440,
  128. devices: [
  129. { code: "GAS-001", alarm: false, warning: false, workOrder: false, online: true, reasons: [] },
  130. { code: "GAS-002", alarm: true, warning: false, workOrder: false, online: true, reasons: ["浓度超限"] } ] },
  131. { code: "P-GAS-2", name: "燃气监测点-2", region: "城区A", industry: "GAS", type: "GAS_SENSOR", lon: 110.131, lat: 28.441,
  132. devices: [ { code: "GAS-003", alarm: false, warning: false, workOrder: false, online: false, reasons: [] } ] },
  133. { code: "P-WS-1", name: "供水监测点-1", region: "城区B", industry: "WATER_SUPPLY", type: "WATER_SUPPLY_SENSOR", lon: 110.150, lat: 28.460,
  134. devices: [ { code: "WS-001", alarm: false, warning: false, workOrder: true, online: true, reasons: [] } ] },
  135. { code: "P-WS-2", name: "供水监测点-2", region: "城区B", industry: "WATER_SUPPLY", type: "WATER_SUPPLY_SENSOR", lon: 110.151, lat: 28.461, devices: [] },
  136. { code: "P-DR-1", name: "污水监测点-1", region: "城区C", industry: "DRAINAGE", type: "SEWAGE_ENV", lon: 110.170, lat: 28.480,
  137. devices: [ { code: "DRAIN-001", alarm: false, warning: true, workOrder: false, online: true, reasons: ["浓度预警"] } ] }
  138. ],
  139. standalone: [
  140. { code: "EQ-FREE-001", name: "井盖外燃气计", region: "城区A", industry: "GAS", type: "GAS_SENSOR", lon: 110.132, lat: 28.442,
  141. alarm: false, warning: false, workOrder: false, online: true, reasons: [] },
  142. { code: "EQ-FREE-002", name: "污水环境监测-独立", region: "城区B", industry: "DRAINAGE", type: "SEWAGE_ENV", lon: 110.152, lat: 28.462,
  143. alarm: false, warning: true, workOrder: false, online: true, reasons: ["设备预警"] },
  144. { code: "EQ-FREE-003", name: "压力计-独立", region: "城区C", industry: "WATER_SUPPLY", type: "WATER_SUPPLY_SENSOR", lon: 110.171, lat: 28.481,
  145. alarm: false, warning: false, workOrder: false, online: false, reasons: ["离线"] }
  146. ]
  147. });
  148. const deviceStatus = d => d.alarm ? "ALARM" : d.warning ? "WARNING" : d.workOrder ? "WORK_ORDER" : d.online ? "NORMAL" : "OFFLINE";
  149. const pointStatus = p => p.devices.length ? STATUS_ORDER[Math.min(...p.devices.map(d => STATUS_ORDER.indexOf(deviceStatus(d))))] : "EMPTY";
  150. let data = fixture();
  151. const filters = { industry: "", deviceType: "", pointStatus: "", region: "" };
  152. function inScope(marker) {
  153. if (filters.industry && marker.industry !== filters.industry) return false;
  154. if (filters.deviceType && marker.type !== filters.deviceType) return false;
  155. if (filters.pointStatus && marker.status !== filters.pointStatus) return false;
  156. if (filters.region && marker.region !== filters.region) return false;
  157. return true;
  158. }
  159. function buildMarkers() {
  160. return data.points.filter(inScope).map(p => ({ kind: "point", ...p, status: pointStatus(p) }))
  161. .concat(data.standalone.filter(inScope).map(d => ({ kind: "device", ...d, status: deviceStatus(d) })));
  162. }
  163. const statusCounts = markers => {
  164. const counts = {}; STATUS_ORDER.forEach(s => counts[s] = 0);
  165. markers.forEach(m => counts[m.status]++);
  166. return counts;
  167. };
  168. function render() {
  169. const markers = buildMarkers();
  170. const counts = statusCounts(markers);
  171. const byRegion = {}, byIndustry = {}, byType = {};
  172. markers.forEach(m => {
  173. const acc = (b, k) => (b[k] = b[k] || { point: 0, device: 0, status: statusCounts([]) });
  174. const a = acc(byRegion, m.region); a.point += m.kind === "point" ? 1 : 0; a.device += m.kind === "point" ? m.devices.length : 1; a.status[m.status]++;
  175. const b = acc(byIndustry, m.industry); b.point += m.kind === "point" ? 1 : 0; b.device += m.kind === "point" ? m.devices.length : 1; b.status[m.status]++;
  176. const c = acc(byType, m.type); c.point += m.kind === "point" ? 1 : 0; c.device += m.kind === "point" ? m.devices.length : 1; c.status[m.status]++;
  177. });
  178. document.getElementById("total").textContent = markers.length;
  179. document.getElementById("points").textContent = markers.filter(m => m.kind === "point").length;
  180. document.getElementById("devices").textContent = markers.reduce((n, m) => n + (m.kind === "point" ? m.devices.length : 1), 0);
  181. document.getElementById("scope").textContent = [filters.industry || "", filters.deviceType || "", filters.pointStatus || "", filters.region || ""].filter(Boolean).join(" / ") || "全部";
  182. document.getElementById("updated").textContent = new Date().toLocaleTimeString("zh-CN");
  183. document.getElementById("statusRows").innerHTML = STATUS_ORDER.map(s =>
  184. `<tr><td><i class="dot s-${s}"></i>${s}</td><td>${counts[s]}</td></tr>`).join("");
  185. document.getElementById("regionRows").innerHTML = Object.keys(byRegion).sort().map(r => {
  186. const x = byRegion[r];
  187. return `<tr><td>${r}</td><td>${x.point}</td><td>${x.device}</td><td>${x.status.ALARM + x.status.WARNING + x.status.WORK_ORDER}</td></tr>`;
  188. }).join("");
  189. document.getElementById("industryRows").innerHTML = Object.keys(byIndustry).sort().map(k => {
  190. const x = byIndustry[k];
  191. return `<tr><td>${INDUSTRY_NAME[k] || k}</td><td>${x.point}</td><td>${x.device}</td></tr>`;
  192. }).join("");
  193. document.getElementById("typeRows").innerHTML = Object.entries(byType)
  194. .sort((a, b) => b[1].device - a[1].device)
  195. .map(([k, x]) => `<tr><td>${TYPE_NAME[k] || k}</td><td>${x.point}</td><td>${x.device}</td></tr>`).join("");
  196. const map = document.getElementById("map");
  197. map.innerHTML = "";
  198. const lons = markers.map(m => m.lon), lats = markers.map(m => m.lat);
  199. const minLon = Math.min(...lons), maxLon = Math.max(...lons), minLat = Math.min(...lats), maxLat = Math.max(...lats);
  200. const xScale = (lon) => 12 + (lon - minLon) / Math.max(0.001, maxLon - minLon) * 76;
  201. const yScale = (lat) => 12 + (maxLat - lat) / Math.max(0.001, maxLat - minLat) * 76;
  202. markers.forEach(m => {
  203. const el = document.createElement("div");
  204. el.className = "marker s-" + m.status;
  205. el.style.left = xScale(m.lon) + "%"; el.style.top = yScale(m.lat) + "%";
  206. el.title = (m.kind === "point" ? m.name : m.name) + " / " + m.status;
  207. el.addEventListener("click", () => showDetail(m));
  208. map.appendChild(el);
  209. const l = document.createElement("div");
  210. l.className = "label"; l.style.left = xScale(m.lon) + "%"; l.style.top = yScale(m.lat) + "%";
  211. l.textContent = m.code + " · " + m.status;
  212. map.appendChild(l);
  213. });
  214. document.getElementById("caption").textContent =
  215. `共 ${markers.length} 个标记 · ` + STATUS_ORDER.map(s => `${s} ${counts[s]}`).join(" · ");
  216. }
  217. function showDetail(m) {
  218. const box = document.getElementById("detail");
  219. if (m.kind === "point") {
  220. box.innerHTML = `<h3>监测点 ${m.code}</h3><ul>
  221. <li>名称:${m.name} 区域:${m.region} 行业:${INDUSTRY_NAME[m.industry]}</li>
  222. <li>类型:${TYPE_NAME[m.type] || m.type} 状态:${m.status}</li>
  223. <li>关联设备(${m.devices.length}):</li>
  224. ${m.devices.map(d => `<li>${d.code} — ${deviceStatus(d)}${d.reasons.length ? "(" + d.reasons.join("、") + ")" : ""}</li>`).join("")}
  225. </ul>`;
  226. } else {
  227. box.innerHTML = `<h3>未关联设备 ${m.code}</h3><ul>
  228. <li>名称:${m.name} 区域:${m.region} 行业:${INDUSTRY_NAME[m.industry]}</li>
  229. <li>类型:${TYPE_NAME[m.type] || m.type} 状态:${m.status}</li>
  230. ${m.reasons.length ? `<li>原因:${m.reasons.join("、")}</li>` : ""}
  231. </ul>`;
  232. }
  233. }
  234. document.getElementById("apply").addEventListener("click", () => {
  235. filters.industry = document.getElementById("fIndustry").value;
  236. filters.deviceType = document.getElementById("fType").value;
  237. filters.pointStatus = document.getElementById("fStatus").value;
  238. filters.region = document.getElementById("fRegion").value.trim();
  239. render();
  240. });
  241. document.getElementById("reset").addEventListener("click", () => {
  242. Object.keys(filters).forEach(k => filters[k] = "");
  243. document.getElementById("fIndustry").value = "";
  244. document.getElementById("fType").value = "";
  245. document.getElementById("fStatus").value = "";
  246. document.getElementById("fRegion").value = "";
  247. render();
  248. });
  249. document.getElementById("refresh").addEventListener("click", render);
  250. document.getElementById("toggle").addEventListener("click", () => {
  251. const d = data.points.find(p => p.code === "P-GAS-2").devices[0];
  252. d.online = !d.online;
  253. render();
  254. });
  255. render();
  256. </script>
  257. </body>
  258. </html>