waterfallChart.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636
  1. <template>
  2. <div>
  3. <div ref="chart" class="waterfall-chart" id="waterfall-chart"></div>
  4. <div class="control-panel">
  5. <!-- 频率范围 -->
  6. <div class="box full-row">
  7. <div class="panel-block">
  8. <span class="label1">频率</span>
  9. <el-input v-model="freqMin" size="mini" placeholder="下限" />
  10. <span>~</span>
  11. <el-input v-model="freqMax" size="mini" placeholder="上限" />
  12. <el-button size="mini" type="primary" @click="handleFreqRange">
  13. 应用
  14. </el-button>
  15. </div>
  16. </div>
  17. </div>
  18. <div class="control-panel">
  19. <!-- 手动标注 -->
  20. <div class="panel-block">
  21. <span class="label1">标注</span>
  22. <el-input v-model="manualFreq" size="mini" placeholder="频率" />
  23. <el-input v-model="multiple" size="mini" placeholder="倍频" />
  24. <el-button size="mini" type="success" @click="handleMark"
  25. >标注</el-button
  26. >
  27. <el-button size="mini" type="info" @click="removeMark">清除</el-button>
  28. </div>
  29. </div>
  30. </div>
  31. </template>
  32. <script>
  33. import Plotly from "plotly.js-dist-min";
  34. export default {
  35. name: "Waterfall3D",
  36. props: {
  37. // 数据格式:[freq, amp, time]
  38. data: {
  39. type: Array,
  40. default: () => [],
  41. },
  42. },
  43. data() {
  44. return {
  45. freqMin: "",
  46. freqMax: "",
  47. manualFreq: "",
  48. multiple: 1,
  49. freqRange: null, // 当前频率范围
  50. markLines: [], // 标注线
  51. };
  52. },
  53. watch: {
  54. data: {
  55. deep: true,
  56. immediate: true,
  57. handler() {
  58. this.$nextTick(() => {
  59. this.renderChart();
  60. });
  61. },
  62. },
  63. },
  64. methods: {
  65. /** ✅ 时间解析(核心修复) */
  66. parseTime(time) {
  67. if (typeof time === "number") {
  68. return time < 1e12 ? time * 1000 : time;
  69. }
  70. if (typeof time === "string") {
  71. return new Date(time.replace(/-/g, "/")).getTime();
  72. }
  73. return new Date(time).getTime();
  74. },
  75. /** ✅ 时间格式化 */
  76. formatTime(t) {
  77. const d = new Date(t);
  78. const pad = (n) => String(n).padStart(2, "0");
  79. return `${d.getFullYear()}/${pad(d.getMonth() + 1)}/${pad(
  80. d.getDate(),
  81. )} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
  82. },
  83. renderChart() {
  84. if (!this.data.length) return;
  85. /** =========================
  86. * 1. 分组(时间 + Hz)
  87. ========================== */
  88. const groupMap = new Map();
  89. this.data.forEach(([freq, amp, time, Hz]) => {
  90. const t = this.parseTime(time);
  91. const key = `${t}_${Hz}`;
  92. if (!groupMap.has(key)) {
  93. groupMap.set(key, {
  94. time: t,
  95. Hz,
  96. points: [],
  97. });
  98. }
  99. groupMap.get(key).points.push([freq, amp]);
  100. });
  101. /** =========================
  102. * 2. 转数组 + 排序
  103. ========================== */
  104. const groups = [...groupMap.values()];
  105. // .sort((a, b) => a.time - b.time);
  106. /** =========================
  107. * 3. 同时间分组(关键!!!)
  108. ========================== */
  109. const timeGroupMap = new Map();
  110. groups.forEach((g) => {
  111. if (!timeGroupMap.has(g.time)) {
  112. timeGroupMap.set(g.time, []);
  113. }
  114. timeGroupMap.get(g.time).push(g);
  115. });
  116. /** =========================
  117. * 4. traces
  118. ========================== */
  119. const traces = [];
  120. groups.forEach((group, globalIndex) => {
  121. const { time, Hz, points } = group;
  122. const sameTimeGroups = timeGroupMap.get(time);
  123. const index = sameTimeGroups.indexOf(group);
  124. const total = sameTimeGroups.length;
  125. const spread = 200; // 👈 控制展开宽度(可调)
  126. // 👉 同时间“对称展开”
  127. const offsetX = total > 1 ? (index - (total - 1) / 2) * spread : 0;
  128. const x = [];
  129. const y = [];
  130. const z = [];
  131. const text = [];
  132. points.sort((a, b) => a[0] - b[0]);
  133. points.forEach(([freq, amp]) => {
  134. if (this.freqRange) {
  135. if (freq < this.freqRange.min || freq > this.freqRange.max) return;
  136. }
  137. x.push(time + offsetX); // 👈 关键
  138. y.push(freq);
  139. z.push(amp + globalIndex * 0.02); // 防Z重叠
  140. text.push(
  141. `时间: ${this.formatTime(time)}<br>` +
  142. `采样频率: ${Hz} Hz<br>` +
  143. `频率: ${freq.toFixed(2)} Hz<br>` +
  144. `幅值: ${amp.toFixed(3)}`,
  145. );
  146. });
  147. traces.push({
  148. x,
  149. y,
  150. z,
  151. text,
  152. type: "scatter3d",
  153. mode: "lines",
  154. opacity: 0.7,
  155. line: {
  156. width: 2,
  157. color: "#162961",
  158. },
  159. showlegend: false,
  160. hovertemplate: "%{text}<extra></extra>",
  161. });
  162. });
  163. /** =========================
  164. * 5. X轴刻度(时间 + Hz)
  165. ========================== */
  166. const tickvals = groups.map((g) => g.time);
  167. const ticktext = groups.map((g) => {
  168. return `${this.formatTime(g.time)}-${g.Hz}Hz`;
  169. });
  170. /** =========================
  171. * 6. 标注线
  172. ========================== */
  173. if (this.markLines.length) {
  174. const minTime = Math.min(...groups.map((g) => g.time));
  175. const maxTime = Math.max(...groups.map((g) => g.time));
  176. this.markLines.forEach((line) => {
  177. traces.push({
  178. x: [minTime, maxTime],
  179. y: [line.freq, line.freq],
  180. z: [0, 0],
  181. type: "scatter3d",
  182. mode: "lines+text",
  183. line: {
  184. color: "#ff0000",
  185. width: 4,
  186. dash: "dash",
  187. },
  188. text: ["", line.label],
  189. textposition: "top right",
  190. showlegend: false,
  191. hovertemplate: `${line.label}<extra></extra>`,
  192. });
  193. });
  194. }
  195. /** =========================
  196. * 7. layout
  197. ========================== */
  198. const layout = {
  199. // title: "3D 瀑布频谱图",
  200. paper_bgcolor: "#f5f7fa",
  201. margin: { l: 0, r: 0, b: 0, t: 40 },
  202. scene: {
  203. xaxis: {
  204. autorange: "reversed",
  205. title: "时间",
  206. tickvals,
  207. ticktext,
  208. tickangle: 30,
  209. gridcolor: "#fff",
  210. backgroundcolor: "#e0e7f1",
  211. showbackground: true,
  212. },
  213. yaxis: {
  214. title: "频率 (Hz)",
  215. gridcolor: "#fff",
  216. backgroundcolor: "#e0e7f1",
  217. showbackground: true,
  218. },
  219. zaxis: {
  220. title: "幅值",
  221. gridcolor: "#fff",
  222. backgroundcolor: "#e0e7f1",
  223. showbackground: true,
  224. },
  225. aspectmode: "manual",
  226. aspectratio: { x: 2.0, y: 2.8, z: 1.2 },
  227. camera: {
  228. up: {
  229. x: -0.1644947035315824,
  230. y: -0.07969781808287146,
  231. z: 0.9831529638377166,
  232. },
  233. center: {
  234. x: -0.052807476121180814,
  235. y: 0.02451796399554085,
  236. z: -0.022911006648570736,
  237. },
  238. eye: {
  239. x: 2.980700714870927,
  240. y: 1.6273671421077383,
  241. z: 0.6145682420564063,
  242. },
  243. projection: {
  244. type: "orthographic",
  245. },
  246. },
  247. },
  248. };
  249. /** =========================
  250. * 8. 渲染
  251. ========================== */
  252. Plotly.react(this.$refs.chart, traces, layout, {
  253. responsive: true,
  254. displayModeBar: false,
  255. });
  256. },
  257. // renderChart() {
  258. // if (!this.data.length) return;
  259. // /** =========================
  260. // * 1. 分组
  261. // ========================== */
  262. // // console.log(this.data, "groupMap");
  263. // const groupMap = new Map();
  264. // this.data.forEach(([freq, amp, time, Hz]) => {
  265. // const t = this.parseTime(time);
  266. // const key = `${t}_${Hz}`; // 👈 用key区分
  267. // if (!groupMap.has(key)) {
  268. // groupMap.set(key, {
  269. // time: t,
  270. // Hz,
  271. // points: [],
  272. // });
  273. // }
  274. // groupMap.get(key).points.push([freq, amp]);
  275. // });
  276. // /** =========================
  277. // * 2. 排序
  278. // ========================== */
  279. // const times = [...groupMap.keys()].sort((a, b) => a - b);
  280. // /** =========================
  281. // * 3. traces
  282. // ========================== */
  283. // const traces = [];
  284. // times.forEach((time, index) => {
  285. // const points = groupMap.get(time);
  286. // points.sort((a, b) => a[0] - b[0]);
  287. // const x = [];
  288. // const y = [];
  289. // const z = [];
  290. // const text = [];
  291. // // const offset = index * 0.02;
  292. // points.forEach(([freq, amp]) => {
  293. // if (this.freqRange) {
  294. // if (freq < this.freqRange.min || freq > this.freqRange.max) return;
  295. // }
  296. // const offsetX = index * 50; // 👈 新增
  297. // const offsetZ = index * 0.02; // 👈 你已有
  298. // x.push(time + offsetX);
  299. // y.push(freq);
  300. // z.push(amp + offsetZ);
  301. // text.push(
  302. // `时间: ${this.formatTime(time)}<br>` +
  303. // `频率: ${freq.toFixed(2)} Hz<br>` +
  304. // `幅值: ${amp.toFixed(3)}`,
  305. // );
  306. // });
  307. // traces.push({
  308. // x,
  309. // y,
  310. // z,
  311. // text,
  312. // type: "scatter3d",
  313. // mode: "lines",
  314. // opacity: 0.7,
  315. // line: {
  316. // width: 2,
  317. // color: "#162961",
  318. // },
  319. // showlegend: false,
  320. // hovertemplate: "%{text}<extra></extra>",
  321. // });
  322. // });
  323. // /** =========================
  324. // * ✅ 标注线(修复重复问题)
  325. // ========================== */
  326. // if (this.markLines.length) {
  327. // const minTime = Math.min(...times);
  328. // const maxTime = Math.max(...times);
  329. // this.markLines.forEach((line) => {
  330. // traces.push({
  331. // x: [minTime, maxTime],
  332. // y: [line.freq, line.freq],
  333. // z: [0, 0],
  334. // type: "scatter3d",
  335. // mode: "lines+text",
  336. // line: {
  337. // color: "#ff0000",
  338. // width: 4,
  339. // dash: "dash",
  340. // },
  341. // text: ["", line.label],
  342. // textposition: "top right",
  343. // showlegend: false,
  344. // hovertemplate: `${line.label}<extra></extra>`,
  345. // });
  346. // });
  347. // }
  348. // // 👉 抽稀刻度(防止重叠)
  349. // const step = Math.ceil(times.length / 8);
  350. // const tickvals = times.filter((_, i) => i % step === 0);
  351. // const ticktext = tickvals.map(this.formatTime);
  352. // /** =========================
  353. // * 5. 布局(工业风)
  354. // ========================== */
  355. // const layout = {
  356. // title: "3D 瀑布频谱图",
  357. // paper_bgcolor: "#f5f7fa",
  358. // margin: { l: 0, r: 0, b: 0, t: 40 },
  359. // scene: {
  360. // xaxis: {
  361. // autorange: "reversed", //轴方向
  362. // title: "时间",
  363. // tickvals,
  364. // ticktext,
  365. // dtick: "D3",
  366. // // showbackground: true,
  367. // // backgroundcolor: "#ffffff",
  368. // // gridcolor: "#e0e7f1",
  369. // gridcolor: "#fff",
  370. // backgroundcolor: "#e0e7f1",
  371. // showbackground: true,
  372. // linecolor: "black",
  373. // ticks: "outside",
  374. // ticklen: 10,
  375. // tickcolor: "black",
  376. // zeroline: false,
  377. // tickangle: -10,
  378. // tickangle: 30, // 👈 倾斜
  379. // margin: {
  380. // l: 20,
  381. // r: 120, // 👉 关键!!给时间留空间
  382. // t: 40,
  383. // b: 40,
  384. // },
  385. // },
  386. // yaxis: {
  387. // title: "频率 (Hz)",
  388. // gridcolor: "#fff",
  389. // backgroundcolor: "#e0e7f1",
  390. // showbackground: true,
  391. // linecolor: "black",
  392. // ticks: "outside",
  393. // ticklen: 10,
  394. // tickcolor: "black",
  395. // zeroline: false,
  396. // tickangle: -10,
  397. // tickangle: 0,
  398. // },
  399. // zaxis: {
  400. // title: "幅值",
  401. // gridcolor: "#fff",
  402. // backgroundcolor: "#e0e7f1",
  403. // showbackground: true,
  404. // linecolor: "black",
  405. // ticks: "outside",
  406. // ticklen: 10,
  407. // tickcolor: "black",
  408. // zeroline: false,
  409. // tickangle: -10,
  410. // tickangle: 0,
  411. // },
  412. // /** ✅ 核心1:比例(决定“扁不扁”) */
  413. // // bgcolor: "#e5ecf6",
  414. // // gridcolor: "#fff",
  415. // aspectmode: "manual",
  416. // aspectratio: { x: 2.0, y: 2.8, z: 1.2 },
  417. // /** ✅ 核心2:相机(决定轴在哪边) */
  418. // camera: {
  419. // up: {
  420. // x: -0.1644947035315824,
  421. // y: -0.07969781808287146,
  422. // z: 0.9831529638377166,
  423. // },
  424. // center: {
  425. // x: -0.052807476121180814,
  426. // y: 0.02451796399554085,
  427. // z: -0.022911006648570736,
  428. // },
  429. // eye: {
  430. // x: 2.980700714870927,
  431. // y: 1.6273671421077383,
  432. // z: 0.6145682420564063,
  433. // },
  434. // projection: {
  435. // type: "orthographic",
  436. // },
  437. // },
  438. // },
  439. // };
  440. // /** =========================
  441. // * 6. 渲染
  442. // ========================== */
  443. // Plotly.react(this.$refs.chart, traces, layout, {
  444. // responsive: true,
  445. // displayModeBar: false,
  446. // });
  447. // // 监听图表的 relayout 事件,获取并输出相机视角
  448. // const plotElement = document.getElementById(`waterfall-chart`);
  449. // plotElement.on("plotly_relayout", function (eventData) {
  450. // // 在每次布局变更时,打印当前相机视角
  451. // if (eventData["scene.camera"]) {
  452. // console.log(
  453. // "当前相机视角:",
  454. // eventData["scene.camera"],
  455. // eventData["scene.aspectratio"],
  456. // );
  457. // }
  458. // });
  459. // },
  460. removeMark() {
  461. this.markLines = [];
  462. this.manualFreq = "";
  463. this.multiple = 1;
  464. this.renderChart();
  465. },
  466. handleFreqRange() {
  467. const min = Number(this.freqMin);
  468. const max = Number(this.freqMax);
  469. if (isNaN(min) || isNaN(max)) return;
  470. this.freqRange = { min, max };
  471. this.renderChart();
  472. },
  473. handleMark() {
  474. const base = Number(this.manualFreq);
  475. const multi = Number(this.multiple) || 1;
  476. if (isNaN(base)) return;
  477. const newLines = [];
  478. for (let i = 1; i <= multi; i++) {
  479. newLines.push({
  480. freq: base * i,
  481. label: `${i}x (${base * i}Hz)`,
  482. });
  483. }
  484. this.markLines = [...this.markLines, ...newLines];
  485. this.renderChart();
  486. },
  487. },
  488. };
  489. </script>
  490. <style scoped>
  491. .waterfall-chart {
  492. width: 100%;
  493. height: 520px;
  494. }
  495. .control-panel {
  496. display: flex;
  497. flex-wrap: wrap;
  498. justify-content: space-between;
  499. gap: 12px;
  500. padding: 8px 12px;
  501. background: #f5f7fa;
  502. border: 1px solid #ddd;
  503. border-radius: 6px;
  504. margin-bottom: 10px;
  505. }
  506. /* 🌟 关键:独占一行 */
  507. .full-row {
  508. width: 100%;
  509. .panel-block {
  510. width: 45%;
  511. }
  512. }
  513. .panel-block {
  514. display: flex;
  515. align-items: center;
  516. gap: 6px;
  517. }
  518. .label {
  519. font-size: 12px;
  520. color: #666;
  521. }
  522. .label1 {
  523. font-size: 12px;
  524. color: #666;
  525. display: inline-block;
  526. width: 75px;
  527. }
  528. .btn-group {
  529. display: flex;
  530. gap: 6px;
  531. }
  532. .btn {
  533. padding: 2px 8px;
  534. font-size: 12px;
  535. border: 1px solid #ccc;
  536. border-radius: 3px;
  537. cursor: pointer;
  538. }
  539. .btn.active {
  540. background: #409eff;
  541. color: #fff;
  542. }
  543. .full-width {
  544. width: 100%;
  545. }
  546. .el-cascader {
  547. font-size: 12px;
  548. }
  549. .el-cascader__tags {
  550. max-width: 240px;
  551. overflow: hidden;
  552. }
  553. .feature-grid {
  554. display: grid;
  555. grid-template-columns: repeat(4, 1fr);
  556. gap: 10px;
  557. width: 100%;
  558. }
  559. .feature-item {
  560. display: flex;
  561. align-items: center;
  562. gap: 6px;
  563. }
  564. .el-select {
  565. flex: 1;
  566. }
  567. </style>