waterfallChart.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628
  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-select
  10. v-model="axisMode"
  11. size="mini"
  12. style="width: 120px"
  13. @change="handleAxisModeChange"
  14. >
  15. <el-option label="频率(Hz)" value="hz" />
  16. <el-option label="阶次(Order)" value="order" />
  17. </el-select>
  18. <span class="label1">{{ axisModeLabel }}</span>
  19. <el-input v-model="freqMin" size="mini" placeholder="下限" />
  20. <span>~</span>
  21. <el-input v-model="freqMax" size="mini" placeholder="上限" />
  22. <el-button size="mini" type="primary" @click="handleFreqRange">
  23. 应用
  24. </el-button>
  25. </div>
  26. <!-- 手动标注 -->
  27. <div class="panel-block">
  28. <span class="label1">标注</span>
  29. <el-input v-model="manualFreq" size="mini" placeholder="频率/阶次" />
  30. <el-input v-model="multiple" size="mini" placeholder="倍频" />
  31. <el-button size="mini" type="success" @click="handleMark"
  32. >标注</el-button
  33. >
  34. <el-button size="mini" type="info" @click="removeMark"
  35. >清除</el-button
  36. >
  37. </div>
  38. <p class="hint-text">
  39. 提示:点击某一采样时刻对应的谱线可高亮该时间(红色);再点同一条谱线取消高亮。
  40. </p>
  41. </div>
  42. </div>
  43. </div>
  44. </template>
  45. <script>
  46. import Plotly from "plotly.js-dist-min";
  47. export default {
  48. name: "Waterfall3D",
  49. props: {
  50. // 数据格式:[freq, amp, time]
  51. data: {
  52. type: Array,
  53. default: () => [],
  54. },
  55. },
  56. data() {
  57. return {
  58. freqMin: "",
  59. freqMax: "",
  60. manualFreq: "",
  61. multiple: 1,
  62. axisMode: "hz", // hz | order
  63. freqRange: null, // 当前频率范围
  64. markLines: [], // 标注线
  65. /** 点击高亮的采样时间(时间戳 ms),null 表示未选中 */
  66. highlightedTime: null,
  67. };
  68. },
  69. computed: {
  70. axisModeLabel() {
  71. return this.axisMode === "order" ? "阶次" : "频率";
  72. },
  73. },
  74. watch: {
  75. data: {
  76. deep: true,
  77. immediate: true,
  78. handler() {
  79. this.highlightedTime = null;
  80. this.$nextTick(() => {
  81. this.renderChart();
  82. });
  83. },
  84. },
  85. },
  86. beforeDestroy() {
  87. const el = this.$refs.chart;
  88. if (el && typeof el.removeAllListeners === "function") {
  89. el.removeAllListeners("plotly_click");
  90. el.removeAllListeners("plotly_relayout");
  91. }
  92. if (el && typeof Plotly.purge === "function") {
  93. Plotly.purge(el);
  94. }
  95. },
  96. methods: {
  97. calcOrder(freq, rpm) {
  98. const f = Number(freq);
  99. const r = Number(rpm);
  100. if (!Number.isFinite(f) || !Number.isFinite(r) || r <= 0) return null;
  101. return (60 * f) / r;
  102. },
  103. toAxisValue(freq, rpm) {
  104. if (this.axisMode === "order") {
  105. return this.calcOrder(freq, rpm);
  106. }
  107. const f = Number(freq);
  108. return Number.isFinite(f) ? f : null;
  109. },
  110. handleAxisModeChange() {
  111. // 单位切换后,旧范围/标注的单位语义失效,清空避免误读
  112. this.freqRange = null;
  113. this.freqMin = "";
  114. this.freqMax = "";
  115. this.markLines = [];
  116. this.manualFreq = "";
  117. this.multiple = 1;
  118. this.highlightedTime = null;
  119. this.renderChart();
  120. },
  121. /** ✅ 时间解析(核心修复) */
  122. parseTime(time) {
  123. if (typeof time === "number") {
  124. return time < 1e12 ? time * 1000 : time;
  125. }
  126. if (typeof time === "string") {
  127. return new Date(time.replace(/-/g, "/")).getTime();
  128. }
  129. return new Date(time).getTime();
  130. },
  131. /** ✅ 时间格式化 */
  132. formatTime(t) {
  133. const d = new Date(t);
  134. const pad = (n) => String(n).padStart(2, "0");
  135. return `${d.getFullYear()}/${pad(d.getMonth() + 1)}/${pad(
  136. d.getDate(),
  137. )} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
  138. },
  139. /** ✅ 轴刻度:仅显示 月/日 时:分 */
  140. formatTimeTick(t) {
  141. const d = new Date(t);
  142. const pad = (n) => String(n).padStart(2, "0");
  143. const md = `${pad(d.getMonth() + 1)}/${pad(d.getDate())}`;
  144. const hh = pad(d.getHours());
  145. const mm = pad(d.getMinutes());
  146. // 00:00 不展示时间(避免“0点”刷屏)
  147. if (hh === "00" && mm === "00") return md;
  148. return `${md} ${hh}:${mm}`;
  149. },
  150. renderChart() {
  151. if (!this.data.length) return;
  152. /** =========================
  153. * 1. 分组(时间 + Hz)
  154. ========================== */
  155. const groupMap = new Map();
  156. this.data.forEach(([freq, amp, time, Hz, RPM]) => {
  157. const t = this.parseTime(time);
  158. const key = `${t}_${Hz}`;
  159. if (!groupMap.has(key)) {
  160. groupMap.set(key, {
  161. time: t,
  162. Hz,
  163. points: [],
  164. });
  165. }
  166. groupMap.get(key).points.push([freq, amp, RPM]);
  167. });
  168. /** =========================
  169. * 2. 转数组 + 排序
  170. ========================== */
  171. const groups = [...groupMap.values()];
  172. // .sort((a, b) => a.time - b.time);
  173. if (
  174. this.highlightedTime != null &&
  175. !groups.some((g) => g.time === this.highlightedTime)
  176. ) {
  177. this.highlightedTime = null;
  178. }
  179. /** =========================
  180. * 3. 同时间分组(关键!!!)
  181. ========================== */
  182. const timeGroupMap = new Map();
  183. groups.forEach((g) => {
  184. if (!timeGroupMap.has(g.time)) {
  185. timeGroupMap.set(g.time, []);
  186. }
  187. timeGroupMap.get(g.time).push(g);
  188. });
  189. /** =========================
  190. * 4. traces
  191. ========================== */
  192. const traces = [];
  193. let maxAmp = 0;
  194. // ✅ 时间轴等间隔:用索引轴 + ticktext(避免时间戳间隔不均导致视觉“挤/拉”)
  195. const uniqueTimes = Array.from(new Set(groups.map((g) => g.time))).sort(
  196. (a, b) => a - b,
  197. );
  198. const timeToIndex = new Map(uniqueTimes.map((t, i) => [t, i]));
  199. groups.forEach((group, globalIndex) => {
  200. const { time, Hz, points } = group;
  201. const sameTimeGroups = timeGroupMap.get(time);
  202. const index = sameTimeGroups.indexOf(group);
  203. const total = sameTimeGroups.length;
  204. // 同一时间点若存在多组(不同采样频率 Hz),用极小偏移避免完全重叠(不影响刻度显示)
  205. const offsetX = total > 1 ? (index - (total - 1) / 2) * 0.08 : 0;
  206. const xBase = timeToIndex.get(time) ?? 0;
  207. const x = [];
  208. const y = [];
  209. const z = [];
  210. const text = [];
  211. const lineColor = [];
  212. points.sort((a, b) => a[0] - b[0]);
  213. points.forEach(([freq, amp, rpm]) => {
  214. const axisY = this.toAxisValue(freq, rpm);
  215. if (axisY == null) return;
  216. if (this.freqRange) {
  217. if (axisY < this.freqRange.min || axisY > this.freqRange.max)
  218. return;
  219. }
  220. // 轴映射:X=采样时间(等间隔),Y=频率(Hz),Z=幅值
  221. x.push(xBase + offsetX);
  222. y.push(axisY);
  223. z.push(amp);
  224. if (Number.isFinite(amp)) {
  225. maxAmp = Math.max(maxAmp, Number(amp));
  226. }
  227. lineColor.push(amp);
  228. const order = this.calcOrder(freq, rpm);
  229. text.push(
  230. `时间: ${this.formatTime(time)}<br>` +
  231. `采样频率: ${Hz} Hz<br>` +
  232. `频率: ${freq.toFixed(2)} Hz<br>` +
  233. `阶次: ${order == null ? "-" : order.toFixed(3)}<br>` +
  234. `加速度(m/s^2): ${amp.toFixed(3)}`,
  235. );
  236. });
  237. traces.push({
  238. x,
  239. y,
  240. z,
  241. text,
  242. type: "scatter3d",
  243. mode: "lines",
  244. opacity: 0.78,
  245. line:
  246. this.highlightedTime != null && time === this.highlightedTime
  247. ? {
  248. width: 3,
  249. color: "#ff0000",
  250. }
  251. : {
  252. width: 2,
  253. color: lineColor,
  254. colorscale: "Viridis",
  255. cauto: true,
  256. },
  257. showlegend: false,
  258. hovertemplate: "%{text}<extra></extra>",
  259. });
  260. });
  261. this._dataTraceCount = groups.length;
  262. this._traceIndexToTime = groups.map((g) => g.time);
  263. /** =========================
  264. * 5. 采样时间刻度(仅 月/日 时:分)
  265. ========================== */
  266. const tickvals = uniqueTimes.map((_, i) => i);
  267. const ticktext = uniqueTimes.map((t) => this.formatTimeTick(t));
  268. // 仅用于 z 轴标签:去掉 0,避免原点出现两个“0”标签
  269. const zTickVals =
  270. maxAmp > 0
  271. ? Array.from({ length: 5 }, (_, i) => ((i + 1) * maxAmp) / 5)
  272. : [];
  273. /** =========================
  274. * 6. 标注线
  275. ========================== */
  276. if (this.markLines.length) {
  277. const minX = 0;
  278. const maxX = Math.max(0, uniqueTimes.length - 1);
  279. this.markLines.forEach((line) => {
  280. traces.push({
  281. x: [minX, maxX],
  282. y: [line.freq, line.freq],
  283. z: [0, 0],
  284. type: "scatter3d",
  285. mode: "lines+text",
  286. line: {
  287. color: "#ff0000",
  288. width: 4,
  289. dash: "dash",
  290. },
  291. text: ["", line.label],
  292. textposition: "top right",
  293. showlegend: false,
  294. hovertemplate: `${line.label}<extra></extra>`,
  295. });
  296. });
  297. }
  298. /** =========================
  299. * 7. layout
  300. ========================== */
  301. const layout = {
  302. // title: "3D 瀑布频谱图",
  303. paper_bgcolor: "#ffffff",
  304. plot_bgcolor: "#ffffff",
  305. margin: { l: 0, r: 0, b: 10, t: 26 },
  306. scene: {
  307. xaxis: {
  308. // title: "采样时间",
  309. title: "",
  310. tickvals,
  311. ticktext,
  312. tickangle: 45,
  313. gridcolor: "#e5e5e5",
  314. zeroline: false,
  315. showspikes: false,
  316. ticks: "outside",
  317. tickfont: { size: 10, color: "#606266" },
  318. titlefont: { size: 10, color: "#606266" },
  319. backgroundcolor: "#ffffff",
  320. showbackground: true,
  321. showline: true,
  322. // linecolor: "#dcdfe6",
  323. linecolor: "#e5e5e5",
  324. linewidth: 1,
  325. },
  326. yaxis: {
  327. title: this.axisMode === "order" ? "阶次 (Order)" : "频率 (Hz)",
  328. gridcolor: "#e5e5e5",
  329. zeroline: false,
  330. showspikes: false,
  331. ticks: "outside",
  332. tickfont: { size: 9, color: "#606266" },
  333. titlefont: { size: 10, color: "#606266" },
  334. backgroundcolor: "#fafafa",
  335. showbackground: true,
  336. showline: true,
  337. // linecolor: "#dcdfe6",
  338. linecolor: "#e5e5e5",
  339. linewidth: 1,
  340. // tickangle: 90,
  341. },
  342. zaxis: {
  343. title: "",
  344. gridcolor: "#e5e5e5",
  345. zeroline: false,
  346. showspikes: false,
  347. ticks: "outside",
  348. tickangle: 90,
  349. tickmode: "array",
  350. tickvals: zTickVals,
  351. tickformat: ".2f",
  352. tickfont: { size: 10, color: "#606266" },
  353. titlefont: {
  354. size: 10,
  355. color: "#606266",
  356. },
  357. backgroundcolor: "#fbfbfb",
  358. showbackground: true,
  359. showline: true,
  360. // linecolor: "#dcdfe6",
  361. linecolor: "#e5e5e5",
  362. linewidth: 1,
  363. },
  364. aspectmode: "manual",
  365. // X 采用等间隔索引后,整体更均衡
  366. aspectratio: { x: 2.4, y: 3.2, z: 1.1 },
  367. // annotations: [
  368. // {
  369. // text: "加速度(m/s^2)",
  370. // x: 0.92, // 右上角位置可微调
  371. // y: 0.98,
  372. // xref: "paper",
  373. // yref: "paper",
  374. // showarrow: false,
  375. // font: { size: 10, color: "#606266" },
  376. // align: "right",
  377. // },
  378. // ],
  379. camera: {
  380. up: { x: 0, y: 0, z: 1 },
  381. center: { x: 0, y: 0, z: 0 },
  382. // 让时间轴视觉更贴近底边
  383. // eye: { x: 2.6, y: 1.2, z: 0.8 },
  384. eye: {
  385. x: 2.8547815750982184,
  386. y: 0.3847735823828088,
  387. z: 0.7363229242526919,
  388. },
  389. projection: {
  390. type: "orthographic",
  391. },
  392. },
  393. },
  394. };
  395. /** =========================
  396. * 8. 渲染(先卸监听再 react,避免重绘过程中 plotly_click 重入 renderChart 栈溢出)
  397. ========================== */
  398. const plotEl = this.$refs.chart;
  399. if (!plotEl) return;
  400. if (typeof plotEl.removeAllListeners === "function") {
  401. plotEl.removeAllListeners("plotly_relayout");
  402. plotEl.removeAllListeners("plotly_click");
  403. }
  404. const plotOpts = {
  405. responsive: true,
  406. displayModeBar: true,
  407. displaylogo: false,
  408. modeBarButtonsToRemove: [
  409. "zoom3d",
  410. "pan3d",
  411. "orbitRotation",
  412. "tableRotation",
  413. "resetCameraDefault3d",
  414. "resetCameraLastSave3d",
  415. "hoverClosest3d",
  416. ],
  417. };
  418. const done = Plotly.react(plotEl, traces, layout, plotOpts);
  419. const bind = () => this._bindPlotlyEvents();
  420. if (done && typeof done.then === "function") {
  421. done.then(bind).catch(bind);
  422. } else {
  423. this.$nextTick(bind);
  424. }
  425. },
  426. _bindPlotlyEvents() {
  427. const plotElement = this.$refs.chart;
  428. if (!plotElement || typeof plotElement.on !== "function") return;
  429. plotElement.on("plotly_relayout", (eventData) => {
  430. if (eventData && eventData["scene.camera"]) {
  431. console.log("当前相机视角:", eventData["scene.camera"]);
  432. }
  433. });
  434. plotElement.on("plotly_click", (ev) => {
  435. const pt = ev.points && ev.points[0];
  436. if (!pt || pt.curveNumber == null) return;
  437. const n = this._dataTraceCount || 0;
  438. if (pt.curveNumber >= n) return;
  439. const t =
  440. this._traceIndexToTime && this._traceIndexToTime[pt.curveNumber];
  441. if (t == null) return;
  442. this.highlightedTime = this.highlightedTime === t ? null : t;
  443. this.$nextTick(() => {
  444. this.renderChart();
  445. });
  446. });
  447. },
  448. removeMark() {
  449. this.markLines = [];
  450. this.manualFreq = "";
  451. this.multiple = 1;
  452. this.renderChart();
  453. },
  454. handleFreqRange() {
  455. const min = Number(this.freqMin);
  456. const max = Number(this.freqMax);
  457. if (isNaN(min) || isNaN(max)) return;
  458. this.freqRange = { min, max };
  459. this.renderChart();
  460. },
  461. handleMark() {
  462. const base = Number(this.manualFreq);
  463. const multi = Number(this.multiple) || 1;
  464. if (isNaN(base)) return;
  465. const newLines = [];
  466. for (let i = 1; i <= multi; i++) {
  467. const val = base * i;
  468. newLines.push({
  469. freq: val,
  470. label:
  471. this.axisMode === "order"
  472. ? `${i}x (${val}Order)`
  473. : `${i}x (${val}Hz)`,
  474. });
  475. }
  476. this.markLines = [...this.markLines, ...newLines];
  477. this.renderChart();
  478. },
  479. },
  480. };
  481. </script>
  482. <style scoped>
  483. .waterfall-chart {
  484. width: 100%;
  485. height: 520px;
  486. }
  487. .control-panel {
  488. display: flex;
  489. flex-wrap: wrap;
  490. justify-content: space-between;
  491. gap: 12px;
  492. padding: 8px 12px;
  493. background: #f5f7fa;
  494. border: 1px solid #ddd;
  495. border-radius: 6px;
  496. margin-bottom: 10px;
  497. }
  498. /* 🌟 关键:独占一行 */
  499. .full-row {
  500. width: 100%;
  501. .panel-block {
  502. width: 65%;
  503. margin: 5px 0;
  504. }
  505. }
  506. .hint-text {
  507. width: 100%;
  508. margin: 4px 0 0;
  509. font-size: 12px;
  510. color: #909399;
  511. line-height: 1.45;
  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. /* 父级是 flex:仅写 width 会被 flex-shrink 压缩,需固定 flex 基准且不收缩 */
  526. flex: 0 0 40px;
  527. box-sizing: border-box;
  528. }
  529. .btn-group {
  530. display: flex;
  531. gap: 6px;
  532. }
  533. .btn {
  534. padding: 2px 8px;
  535. font-size: 12px;
  536. border: 1px solid #ccc;
  537. border-radius: 3px;
  538. cursor: pointer;
  539. }
  540. .btn.active {
  541. background: #409eff;
  542. color: #fff;
  543. }
  544. .full-width {
  545. width: 100%;
  546. }
  547. .el-cascader {
  548. font-size: 12px;
  549. }
  550. .el-cascader__tags {
  551. max-width: 240px;
  552. overflow: hidden;
  553. }
  554. .feature-grid {
  555. display: grid;
  556. grid-template-columns: repeat(4, 1fr);
  557. gap: 10px;
  558. width: 100%;
  559. }
  560. .feature-item {
  561. display: flex;
  562. align-items: center;
  563. gap: 6px;
  564. }
  565. /* 勿用 flex:1,否则会拉伸子项,内联 width 无法按预期生效 */
  566. .el-select {
  567. flex: 0 0 auto;
  568. }
  569. </style>