multidimensional-analysis-demo.html 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  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. main { display:grid; grid-template-columns:minmax(380px,470px) minmax(0,1fr); min-height:calc(100vh - 57px); }
  20. aside { padding:16px 18px; background:var(--panel); border-right:1px solid var(--line); overflow:auto; }
  21. section { display:grid; grid-template-rows:auto minmax(0,1fr) auto; min-width:0; }
  22. h2 { margin:0 0 8px; font-size:14px; }
  23. .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); }
  24. .dim { margin-bottom:14px; }
  25. .dim h3 { margin:0 0 6px; font-size:13px; font-weight:600; }
  26. .dim h3 small { color:var(--muted); font-weight:400; margin-left:6px; }
  27. .buckets { display:grid; gap:4px; }
  28. .bucket { display:grid; grid-template-columns:minmax(90px,150px) 1fr 54px 54px; gap:8px; align-items:center; cursor:pointer; padding:2px 4px; border-radius:3px; }
  29. .bucket:hover { background:#f4f7fa; }
  30. .bucket.on { background:#e8f0fe; font-weight:600; }
  31. .bucket .bar { height:11px; background:#e6ecf2; border-radius:3px; overflow:hidden; }
  32. .bucket .bar i { display:block; height:100%; background:var(--blue); }
  33. .bucket.on .bar i { background:#0f8a4d; }
  34. .bucket .num { text-align:right; color:var(--muted); font-variant-numeric:tabular-nums; }
  35. .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:200px; }
  36. .marker { position:absolute; width:14px; height:14px; margin:-7px 0 0 -7px; border-radius:50%; background:#d0342c; border:2px solid #fff; }
  37. .label { position:absolute; font-size:11px; transform:translate(9px,-6px); background:rgba(255,255,255,.92); padding:1px 5px; border-radius:3px; border:1px solid var(--line); pointer-events:none; }
  38. .caption { padding:10px 18px; background:var(--panel); border-top:1px solid var(--line); color:var(--muted); }
  39. .empty { color:var(--muted); padding:12px; border:1px dashed var(--line); border-radius:5px; }
  40. @media (max-width:980px){ main{grid-template-columns:1fr;} }
  41. </style>
  42. </head>
  43. <body>
  44. <header>
  45. <h1>预警处置 / 预警分析</h1>
  46. <div class="tabs">
  47. <button id="tabWARNING" class="on" type="button">预警</button>
  48. <button id="tabALARM" type="button">报警</button>
  49. </div>
  50. <div class="meta"><span>事件族 <b id="familyName">预警</b></span><span>命中 <b id="totalCount">0</b></span><span>筛选 <b id="filterText">无</b></span><span>更新 <b id="updated">--</b></span></div>
  51. </header>
  52. <main>
  53. <aside>
  54. <div class="toolbar" style="padding:0 0 10px; border-bottom:none">
  55. <button id="clearFilter" type="button">清除全部筛选</button>
  56. <button id="refresh" type="button">手动刷新</button>
  57. </div>
  58. <div id="dimensions"></div>
  59. <div class="empty" id="empty" style="display:none">当前筛选下没有事件。</div>
  60. </aside>
  61. <section>
  62. <div class="toolbar">
  63. <strong>中央一张图(当前筛选事件点位)</strong>
  64. <span id="mapHint" style="color:var(--muted)">点击左侧任一桶即可交叉筛选</span>
  65. </div>
  66. <div class="map" id="map"></div>
  67. <div class="caption" id="caption">点击桶后所有图表与地图同步更新</div>
  68. </section>
  69. </main>
  70. <script>
  71. const LEVEL_LABEL = { 1: "RED", 2: "YELLOW", 3: "BLUE", 4: "GREEN" };
  72. const INDUSTRY_LABEL = { GAS: "燃气", WATER_SUPPLY: "供水", DRAINAGE: "排水" };
  73. const DEVICE_TYPE_LABEL = { GAS_SENSOR: "燃气浓度计", WATER_SUPPLY_SENSOR: "供水压力计", SEWAGE_ENV: "污水环境监测" };
  74. // Deterministic fixture: type, cause (null = 缺失成因), level, region (null = 未分区), deviceType, day/hour.
  75. const fixture = {
  76. WARNING: [
  77. { no: "MW-001", type: "GAS_CONCENTRATION", cause: "浓度超限", level: 1, region: "城区A", deviceType: "GAS_SENSOR", day: "2026-09-01", hour: 9 },
  78. { no: "MW-002", type: "GAS_CONCENTRATION", cause: "浓度超限", level: 1, region: "城区A", deviceType: "GAS_SENSOR", day: "2026-09-01", hour: 10 },
  79. { no: "MW-003", type: "WATER_PRESSURE", cause: null, level: 2, region: "城区B", deviceType: "WATER_SUPPLY_SENSOR", day: "2026-09-01", hour: 11 },
  80. { no: "MW-004", type: "WATER_PRESSURE", cause: "压力波动", level: 2, region: "城区B", deviceType: "WATER_SUPPLY_SENSOR", day: "2026-09-02", hour: 9 },
  81. { no: "MW-005", type: "SEWAGE_CONCENTRATION", cause: "浓度超限", level: 2, region: "城区C", deviceType: "SEWAGE_ENV", day: "2026-09-02", hour: 10 },
  82. { no: "MW-006", type: "SEWAGE_CONCENTRATION", cause: "设备故障", level: 3, region: "城区C", deviceType: "SEWAGE_ENV", day: "2026-09-02", hour: 11 },
  83. { no: "MW-007", type: "WATER_PRESSURE", cause: null, level: 4, region: null, deviceType: "WATER_SUPPLY_SENSOR", day: "2026-09-02", hour: 12 },
  84. { no: "MW-008", type: "GAS_CONCENTRATION", cause: "设备故障", level: 9, region: "城区A", deviceType: "GAS_SENSOR", day: "2026-09-02", hour: 13 }
  85. ],
  86. ALARM: [
  87. { no: "MA-001", type: "GAS_CONCENTRATION", cause: "浓度超限", level: 1, region: "城区A", deviceType: "GAS_SENSOR", day: "2026-09-02", hour: 14 },
  88. { no: "MA-002", type: "WATER_PRESSURE", cause: null, level: 2, region: "城区B", deviceType: "WATER_SUPPLY_SENSOR", day: "2026-09-02", hour: 15 }
  89. ]
  90. };
  91. const START = "2026-09-01T00:00:00+08:00";
  92. const END = "2026-09-02T23:59:00+08:00";
  93. let family = "WARNING";
  94. const filters = { industry: null, level: null, eventType: null, cause: null, deviceType: null, region: null };
  95. const industryOf = e => e.deviceType === "GAS_SENSOR" ? "GAS" : e.deviceType === "WATER_SUPPLY_SENSOR" ? "WATER_SUPPLY" : "DRAINAGE";
  96. const levelKeyOf = e => (e.level >= 1 && e.level <= 4) ? String(e.level) : "UNKNOWN";
  97. const causeKeyOf = e => e.cause || "UNKNOWN_CAUSE";
  98. const regionKeyOf = e => e.region || "UNASSIGNED_REGION";
  99. function scoped() {
  100. return fixture[family].filter(e =>
  101. (!filters.industry || industryOf(e) === filters.industry) &&
  102. (!filters.level || levelKeyOf(e) === String(filters.level)) &&
  103. (!filters.eventType || e.type === filters.eventType) &&
  104. (!filters.cause || causeKeyOf(e) === filters.cause) &&
  105. (!filters.deviceType || e.deviceType === filters.deviceType) &&
  106. (!filters.region || regionKeyOf(e) === filters.region));
  107. }
  108. function group(list, keyFn, labelFn) {
  109. const counts = new Map();
  110. list.forEach(e => {
  111. const key = keyFn(e);
  112. const row = counts.get(key) || { count: 0, label: labelFn(e, key) };
  113. row.count++;
  114. counts.set(key, row);
  115. });
  116. return counts;
  117. }
  118. function renderBuckets(container, title, hint, list, order, keyFn, labelFn, onPick, activeKey) {
  119. const counts = group(list, keyFn, labelFn);
  120. const keys = order.filter(k => counts.has(k) || !counts.size).concat(
  121. [...counts.keys()].filter(k => !order.includes(k)));
  122. const max = Math.max(1, ...[...counts.values()].map(v => v.count));
  123. const total = list.length;
  124. const html = keys.map(k => {
  125. const row = counts.get(k) || { count: 0, label: labelFn(null, k) };
  126. const pct = total ? (row.count / total * 100).toFixed(2) : "0.00";
  127. return `<div class="bucket ${activeKey === k ? "on" : ""}" data-key="${k}">
  128. <span>${row.label}</span>
  129. <span class="bar"><i style="width:${row.count / max * 100}%"></i></span>
  130. <span class="num">${row.count}</span>
  131. <span class="num">${pct}%</span>
  132. </div>`;
  133. }).join("");
  134. container.insertAdjacentHTML("beforeend",
  135. `<div class="dim"><h3>${title}<small>${hint}</small></h3><div class="buckets">${html}</div></div>`);
  136. container.querySelectorAll(".dim:last-child .bucket").forEach(el =>
  137. el.addEventListener("click", () => onPick(el.dataset.key)));
  138. }
  139. function render() {
  140. const list = scoped();
  141. const box = document.getElementById("dimensions");
  142. box.innerHTML = "";
  143. document.getElementById("empty").style.display = list.length ? "none" : "block";
  144. document.getElementById("familyName").textContent = family === "WARNING" ? "预警" : "报警";
  145. document.getElementById("totalCount").textContent = list.length;
  146. document.getElementById("updated").textContent = new Date().toLocaleTimeString("zh-CN");
  147. document.getElementById("filterText").textContent =
  148. Object.entries(filters).filter(([, v]) => v).map(([k, v]) => k + "=" + v).join(", ") || "无";
  149. renderBuckets(box, "事件类型", "按 warningType 聚合", list, [],
  150. e => e.type, (e, k) => (e ? e.type : k),
  151. key => { filters.eventType = filters.eventType === key ? null : key; render(); }, filters.eventType);
  152. renderBuckets(box, "成因", "缺失成因保留为 未知/缺失成因", list, ["UNKNOWN_CAUSE"],
  153. e => causeKeyOf(e), (e, k) => (k === "UNKNOWN_CAUSE" ? "未知/缺失成因" : (e ? e.cause : k)),
  154. key => { filters.cause = filters.cause === key ? null : key; render(); }, filters.cause);
  155. renderBuckets(box, "行业", "仅正式行业", list, ["GAS", "WATER_SUPPLY", "DRAINAGE"],
  156. e => industryOf(e), (e, k) => INDUSTRY_LABEL[k] || k,
  157. key => { filters.industry = filters.industry === key ? null : key; render(); }, filters.industry);
  158. renderBuckets(box, "设备类型", "智能设备类型", list, Object.keys(DEVICE_TYPE_LABEL),
  159. e => e.deviceType, (e, k) => DEVICE_TYPE_LABEL[k] || k,
  160. key => { filters.deviceType = filters.deviceType === key ? null : key; render(); }, filters.deviceType);
  161. renderBuckets(box, "等级", "1红 2黄 3蓝 4绿", list, ["1", "2", "3", "4", "UNKNOWN"],
  162. e => levelKeyOf(e), (e, k) => LEVEL_LABEL[k] || "UNKNOWN",
  163. key => { filters.level = String(filters.level) === key ? null : Number(key); render(); },
  164. filters.level === null ? null : String(filters.level));
  165. renderBuckets(box, "区域", "缺失区域保留为 未分区", list, ["UNASSIGNED_REGION"],
  166. e => regionKeyOf(e), (e, k) => (k === "UNASSIGNED_REGION" ? "未分区" : (e ? e.region : k)),
  167. key => { filters.region = filters.region === key ? null : key; render(); }, filters.region);
  168. renderBuckets(box, "时间", "Asia/Shanghai 日桶", list, ["2026-09-01", "2026-09-02"],
  169. e => e.day, (e, k) => k,
  170. () => {}, null);
  171. const map = document.getElementById("map");
  172. map.innerHTML = "";
  173. if (list.length) {
  174. const lons = list.map((e, i) => 110.13 + i * 0.006), lats = list.map((e, i) => 28.44 + (i % 4) * 0.009);
  175. const minLon = Math.min(...lons), maxLon = Math.max(...lons), minLat = Math.min(...lats), maxLat = Math.max(...lats);
  176. list.forEach((e, i) => {
  177. const x = 12 + (lons[i] - minLon) / Math.max(0.001, maxLon - minLon) * 76;
  178. const y = 12 + (maxLat - lats[i]) / Math.max(0.001, maxLat - minLat) * 76;
  179. const el = document.createElement("div");
  180. el.className = "marker";
  181. el.style.left = x + "%"; el.style.top = y + "%";
  182. el.title = e.no;
  183. map.appendChild(el);
  184. const l = document.createElement("div");
  185. l.className = "label"; l.style.left = x + "%"; l.style.top = y + "%";
  186. l.textContent = e.no;
  187. map.appendChild(l);
  188. });
  189. }
  190. document.getElementById("caption").textContent =
  191. `${list.length} 个事件点位 · 窗口 ${START} ~ ${END}`;
  192. }
  193. document.getElementById("tabWARNING").addEventListener("click", () => setFamily("WARNING"));
  194. document.getElementById("tabALARM").addEventListener("click", () => setFamily("ALARM"));
  195. function setFamily(next) {
  196. family = next;
  197. Object.keys(filters).forEach(k => filters[k] = null);
  198. document.getElementById("tabWARNING").classList.toggle("on", family === "WARNING");
  199. document.getElementById("tabALARM").classList.toggle("on", family === "ALARM");
  200. render();
  201. }
  202. document.getElementById("clearFilter").addEventListener("click", () => {
  203. Object.keys(filters).forEach(k => filters[k] = null);
  204. render();
  205. });
  206. document.getElementById("refresh").addEventListener("click", render);
  207. render();
  208. </script>
  209. </body>
  210. </html>