diff --git a/package-lock.json b/package-lock.json index 7b31c0c..a850dcb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,11 +11,13 @@ "dependencies": { "@fluentui/react": "^8.56.0", "@juggle/resize-observer": "^3.3.1", + "chart.js": "^3.7.1", "detect-port": "^1.3.0", "downloadjs": "^1.4.7", "express": "^4.17.3", "gl-matrix": "^3.4.3", "react": "^17.0.2", + "react-chartjs-2": "^4.1.0", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^17.0.2", @@ -2200,6 +2202,11 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chart.js": { + "version": "3.7.1", + "resolved": "https://registry.npmmirror.com/chart.js/-/chart.js-3.7.1.tgz", + "integrity": "sha512-8knRegQLFnPQAheZV8MjxIXc5gQEfDFD897BJgv/klO/vtIyFFmgMXrNfgrXpbTr/XbTturxRgxIXx/Y+ASJBA==" + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -6210,6 +6217,15 @@ "node": ">=0.10.0" } }, + "node_modules/react-chartjs-2": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/react-chartjs-2/-/react-chartjs-2-4.1.0.tgz", + "integrity": "sha512-AsUihxEp8Jm1oBhbEovE+w50m9PVNhz1sfwEIT4hZduRC0m14gHWHd0cUaxkFDb8HNkdMIGzsNlmVqKiOpU74g==", + "peerDependencies": { + "chart.js": "^3.5.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-dnd": { "version": "16.0.1", "resolved": "https://registry.npmmirror.com/react-dnd/-/react-dnd-16.0.1.tgz", @@ -10113,6 +10129,11 @@ "supports-color": "^7.1.0" } }, + "chart.js": { + "version": "3.7.1", + "resolved": "https://registry.npmmirror.com/chart.js/-/chart.js-3.7.1.tgz", + "integrity": "sha512-8knRegQLFnPQAheZV8MjxIXc5gQEfDFD897BJgv/klO/vtIyFFmgMXrNfgrXpbTr/XbTturxRgxIXx/Y+ASJBA==" + }, "chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -13200,6 +13221,12 @@ "object-assign": "^4.1.1" } }, + "react-chartjs-2": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/react-chartjs-2/-/react-chartjs-2-4.1.0.tgz", + "integrity": "sha512-AsUihxEp8Jm1oBhbEovE+w50m9PVNhz1sfwEIT4hZduRC0m14gHWHd0cUaxkFDb8HNkdMIGzsNlmVqKiOpU74g==", + "requires": {} + }, "react-dnd": { "version": "16.0.1", "resolved": "https://registry.npmmirror.com/react-dnd/-/react-dnd-16.0.1.tgz", diff --git a/package.json b/package.json index afef7db..c919826 100644 --- a/package.json +++ b/package.json @@ -71,11 +71,13 @@ "dependencies": { "@fluentui/react": "^8.56.0", "@juggle/resize-observer": "^3.3.1", + "chart.js": "^3.7.1", "detect-port": "^1.3.0", "downloadjs": "^1.4.7", "express": "^4.17.3", "gl-matrix": "^3.4.3", "react": "^17.0.2", + "react-chartjs-2": "^4.1.0", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^17.0.2", diff --git a/source/Context/Setting.tsx b/source/Context/Setting.tsx index 5996ff4..e58d435 100644 --- a/source/Context/Setting.tsx +++ b/source/Context/Setting.tsx @@ -44,6 +44,11 @@ class Setting extends Emitter { */ public layout: Layout = new Layout(); + /** + * 是否显示线性图表 + */ + public lineChartType: boolean = false; + /** * 设置参数 */ diff --git a/source/Context/Status.tsx b/source/Context/Status.tsx index 3bcb630..74520e3 100644 --- a/source/Context/Status.tsx +++ b/source/Context/Status.tsx @@ -38,6 +38,7 @@ interface IStatusEvent { physicsLoop: number; recordLoop: number; offlineLoop: number; + modelUpdate: void; mouseModChange: void; focusObjectChange: void; focusLabelChange: void; @@ -132,6 +133,7 @@ class Status extends Emitter { this.actuator.on("loop", (t) => { this.emit("physicsLoop", t) }); this.actuator.on("record", (t) => { this.emit("recordLoop", t) }); this.actuator.on("offline", (t) => { this.emit("offlineLoop", t) }); + this.actuator.on("modelUpdate", () => { this.emit("modelUpdate") }); // 对象变化事件 this.model.on("objectChange", () => this.emit("objectChange")); diff --git a/source/Localization/EN-US.ts b/source/Localization/EN-US.ts index 4fcbd3f..d8473ee 100644 --- a/source/Localization/EN-US.ts +++ b/source/Localization/EN-US.ts @@ -62,6 +62,8 @@ const EN_US = { "Panel.Info.Behavior.Clip.Player": "Pre render recorded data", "Panel.Title.Behavior.Clip.Details": "Clip", "Panel.Info.Behavior.Clip.Details": "Edit view clip attributes", + "Panel.Info.Statistics": "View statistics", + "Panel.Title.Statistics": "Statistics", "Panel.Info.Behavior.Clip.Time.Formate": "{current} / {all} / {fps}fps", "Panel.Info.Behavior.Clip.Record.Formate": "Record: {time}", "Panel.Info.Behavior.Clip.Uname.Clip": "Waiting for recording...", @@ -168,6 +170,7 @@ const EN_US = { "Panel.Info.Behavior.Details.Parameter.Key.Vec.Z": "{key} Z", "Panel.Info.Clip.List.Error.Nodata": "There is no clip, please click the record button to record, or click the plus sign to create", "Panel.Info.Clip.Details.Error.Nodata": "Specify a clip to view an attribute", + "Panel.Info.Statistics.Nodata": "There are no groups in the model or clip", "Info.Hint.Save.After.Close": "Any unsaved progress will be lost. Are you sure you want to continue?", "Info.Hint.Load.File.Title": "Load save", "Info.Hint.Load.File.Intro": "Release to load the dragged save file", diff --git a/source/Localization/ZH-CN.ts b/source/Localization/ZH-CN.ts index fe00be8..f1661a8 100644 --- a/source/Localization/ZH-CN.ts +++ b/source/Localization/ZH-CN.ts @@ -26,10 +26,10 @@ const ZH_CN = { "Command.Bar.Camera.Info": "渲染器设置", "Command.Bar.Setting.Info": "全局设置", "Input.Error.Not.Number": "请输入数字", - "Input.Error.Max": "输入数值须小于 {number}", - "Input.Error.Min": "输入数值须大于 {number}", - "Input.Error.Length": "输入内容长度须小于 {number}", - "Input.Error.Length.Less": "输入内容长度须大于 {number}", + "Input.Error.Max": "输入数值须小于 {num}", + "Input.Error.Min": "输入数值须大于 {num}", + "Input.Error.Length": "输入内容长度须小于 {num}", + "Input.Error.Length.Less": "输入内容长度须大于 {num}", "Input.Error.Select": "选择对象 ...", "Input.Error.Combo": "选择选项 ...", "Object.List.New.Group": "群对象 {id}", @@ -62,6 +62,8 @@ const ZH_CN = { "Panel.Info.Behavior.Clip.Player": "预渲染录制数据", "Panel.Title.Behavior.Clip.Details": "剪辑", "Panel.Info.Behavior.Clip.Details": "编辑查看剪辑片段属性", + "Panel.Info.Statistics": "查看统计信息", + "Panel.Title.Statistics": "统计", "Panel.Info.Behavior.Clip.Time.Formate": "{current} / {all} / {fps} fps", "Panel.Info.Behavior.Clip.Record.Formate": "录制: {time}", "Panel.Info.Behavior.Clip.Uname.Clip": "等待录制...", @@ -168,6 +170,7 @@ const ZH_CN = { "Panel.Info.Behavior.Details.Parameter.Key.Vec.Z": "{key} Z 坐标", "Panel.Info.Clip.List.Error.Nodata": "没有剪辑片段,请点击录制按钮录制,或者点击加号创建", "Panel.Info.Clip.Details.Error.Nodata": "请指定一个剪辑片段以查看属性", + "Panel.Info.Statistics.Nodata": "模型或剪辑中不存在任何群", "Info.Hint.Save.After.Close": "任何未保存的进度都会丢失, 确定要继续吗?", "Info.Hint.Load.File.Title": "加载存档", "Info.Hint.Load.File.Intro": "释放以加载拽入的存档", diff --git a/source/Model/Actuator.ts b/source/Model/Actuator.ts index 20314c4..e59b76f 100644 --- a/source/Model/Actuator.ts +++ b/source/Model/Actuator.ts @@ -14,6 +14,7 @@ interface IActuatorEvent { record: number; loop: number; offline: number; + modelUpdate: void; } /** @@ -401,6 +402,7 @@ class Actuator extends Emitter { } this.emit("loop", this.alignTimer); + this.emit("modelUpdate"); this.alignTimer = 0; } } diff --git a/source/Page/SimulatorDesktop/SimulatorDesktop.tsx b/source/Page/SimulatorDesktop/SimulatorDesktop.tsx index 06b5639..c6f04aa 100644 --- a/source/Page/SimulatorDesktop/SimulatorDesktop.tsx +++ b/source/Page/SimulatorDesktop/SimulatorDesktop.tsx @@ -74,7 +74,7 @@ class SimulatorDesktop extends Component { }, { items: [{ - panels: ["ObjectList"] + panels: ["ObjectList", "Statistics"] }, { panels: ["GroupDetails", "RangeDetails", "LabelDetails", "BehaviorDetails", "ClipDetails"] }], diff --git a/source/Page/SimulatorWeb/SimulatorWeb.tsx b/source/Page/SimulatorWeb/SimulatorWeb.tsx index a520ff0..36058af 100644 --- a/source/Page/SimulatorWeb/SimulatorWeb.tsx +++ b/source/Page/SimulatorWeb/SimulatorWeb.tsx @@ -65,7 +65,7 @@ class SimulatorWeb extends Component { }, { items: [{ - panels: ["ObjectList"] + panels: ["ObjectList", "Statistics"] }, { panels: ["GroupDetails", "RangeDetails", "LabelDetails", "BehaviorDetails", "ClipDetails"] }], diff --git a/source/Panel/Panel.tsx b/source/Panel/Panel.tsx index b4cc8c1..4aa6644 100644 --- a/source/Panel/Panel.tsx +++ b/source/Panel/Panel.tsx @@ -13,6 +13,7 @@ import { BehaviorDetails } from "@Panel/BehaviorDetails/BehaviorDetails"; import { ClipPlayer } from "@Panel/ClipPlayer/ClipPlayer"; import { ClipRecorder } from "@Panel/ClipPlayer/ClipRecorder"; import { ClipDetails } from "@Panel/ClipDetails/ClipDetails"; +import { Statistics } from "@Panel/Statistics/Statistics"; interface IPanelInfo { nameKey: string; @@ -36,6 +37,7 @@ type PanelId = "" | "BehaviorDetails" // 行为属性 | "ClipPlayer" // 剪辑影片 | "ClipDetails" // 剪辑详情 +| "Statistics" // 统计信息 ; const PanelInfoMap = new Map(); @@ -79,6 +81,10 @@ PanelInfoMap.set("ClipDetails", { nameKey: "Panel.Title.Behavior.Clip.Details", introKay: "Panel.Info.Behavior.Clip.Details", class: ClipDetails }); +PanelInfoMap.set("Statistics", { + nameKey: "Panel.Title.Statistics", introKay: "Panel.Info.Statistics", + class: Statistics +}); function getPanelById(panelId: PanelId): ReactNode { switch (panelId) { diff --git a/source/Panel/Statistics/Statistics.scss b/source/Panel/Statistics/Statistics.scss new file mode 100644 index 0000000..692925c --- /dev/null +++ b/source/Panel/Statistics/Statistics.scss @@ -0,0 +1,63 @@ +@import "../../Component/Theme/Theme.scss"; + +div.statistics-panel { + width: 100%; + height: 100%; + min-height: 100%; + + div.statistics-chart { + box-sizing: border-box; + padding-top: 10px; + max-width: 400px; + min-height: 100%; + height: 100%; + width: 100%; + } + + div.statistics-switch { + width: 100%; + height: 0; + display: flex; + justify-content: flex-end; + + div.switch-button { + width: 24px; + height: 24px; + position: relative; + user-select: none; + right: -10px; + top: -2px; + border-radius: 3px; + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + } + } +} + +div.statistics-panel.dark { + + div.switch-button { + background-color: $lt-bg-color-lvl2-dark; + color: $lt-font-color-lvl2-dark; + } + + div.switch-button:hover { + background-color: $lt-bg-color-lvl1-dark; + color: $lt-font-color-lvl1-dark; + } +} + +div.statistics-panel.light { + + div.switch-button { + background-color: $lt-bg-color-lvl2-light; + color: $lt-font-color-lvl2-light; + } + + div.switch-button:hover { + background-color: $lt-bg-color-lvl1-light; + color: $lt-font-color-lvl1-light; + } +} \ No newline at end of file diff --git a/source/Panel/Statistics/Statistics.tsx b/source/Panel/Statistics/Statistics.tsx new file mode 100644 index 0000000..3a132c1 --- /dev/null +++ b/source/Panel/Statistics/Statistics.tsx @@ -0,0 +1,303 @@ +import { Component, ReactNode } from "react"; +import { useStatusWithEvent, IMixinStatusProps } from "@Context/Status"; +import { useSettingWithEvent, IMixinSettingProps, Themes } from "@Context/Setting"; +import { + Chart as ChartJS, CategoryScale, LinearScale, + BarElement, Tooltip, Legend, Decimation, + PointElement, LineElement, Title +} from 'chart.js'; +import { Bar, Line } from 'react-chartjs-2'; +import { Theme } from "@Component/Theme/Theme"; +import { Icon } from "@fluentui/react"; +import { IAnyObject, Model } from "@Model/Model"; +import { Group } from "@Model/Group"; +import { ActuatorModel } from "@Model/Actuator"; +import { Message } from "@Input/Message/Message"; +import { Clip, IFrame } from "@Model/Clip"; +import "./Statistics.scss"; + +ChartJS.register( + CategoryScale, + LinearScale, + BarElement, + Tooltip, + Legend, + PointElement, + LineElement, + Title, + Decimation +); + +interface IStatisticsProps { + +} + +@useSettingWithEvent("themes", "language", "lineChartType") +@useStatusWithEvent("focusClipChange", "actuatorStartChange", "fileLoad", "modelUpdate", "recordLoop", "individualChange") +class Statistics extends Component { + + public barDarkOption = { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { + position: 'bottom' as const, + labels: { boxWidth: 10, boxHeight: 10, color: 'rgba(255, 255, 255, .5)' } + }}, + scales: { + x: { grid: { color: 'rgba(255, 255, 255, .2)' }, title: { color: 'rgba(255, 255, 255, .5)'} }, + y: { grid: { color: 'rgba(255, 255, 255, .2)', borderDash: [3, 3] }, title: { color: 'rgba(255, 255, 255, .5)'} } + } + }; + + public barLightOption = { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { + position: 'bottom' as const, + labels: { boxWidth: 10, boxHeight: 10, color: 'rgba(0, 0, 0, .5)' } + }}, + scales: { + x: { grid: { color: 'rgba(0, 0, 0, .2)' }, title: { color: 'rgba(0, 0, 0, .5)'} }, + y: { grid: { color: 'rgba(0, 0, 0, .2)', borderDash: [3, 3] }, title: { color: 'rgba(0, 0, 0, .5)'} } + } + }; + + private modelBarChart(model: Model, theme: boolean) { + + const datasets: any[] = []; + const labels: any[] = ["Group"]; + + // 收集数据 + model.objectPool.forEach((obj) => { + let label = obj.displayName; + let color = `rgb(${obj.color.map((v) => Math.floor(v * 255)).join(",")})`; + + if (obj instanceof Group) { + datasets.push({label, data: [obj.individuals.size], backgroundColor: color}); + } + }); + + if (datasets.length <= 0) { + return + } + + return + } + + private clipBarChart(frame: IFrame, theme: boolean) { + + const datasets: any[] = []; + const labels: any[] = ["Group"]; + + // 收集数据 + frame.commands.forEach((command) => { + let label = command.name; + let color = `rgb(${command.parameter?.color.map((v: number) => Math.floor(v * 255)).join(",")})`; + + if (command.type === "points") { + datasets.push({label, data: [(command.data?.length ?? 0) / 3], backgroundColor: color}); + } + }); + + if (datasets.length <= 0) { + return + } + + return + } + + public lineDarkOption = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom' as const, + labels: { boxWidth: 10, boxHeight: 10, color: 'rgba(255, 255, 255, .5)' }, + decimation: { enabled: true } + }, + decimation: { enabled: true, algorithm: "lttb" as const, samples: 100 } + }, + scales: { + x: { grid: { color: 'rgba(255, 255, 255, .2)' }, type: "linear", title: { color: 'rgba(255, 255, 255, .5)'} }, + y: { grid: { color: 'rgba(255, 255, 255, .2)', borderDash: [3, 3] }, title: { color: 'rgba(255, 255, 255, .5)'} } + } + }; + + public lineLightOption = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom' as const, + labels: { boxWidth: 10, boxHeight: 10, color: 'rgba(0, 0, 0, .5)' }, + }, + decimation: { enabled: true, algorithm: "lttb" as const, samples: 100 } + }, + scales: { + x: { grid: { color: 'rgba(0, 0, 0, .2)' }, title: { color: 'rgba(0, 0, 0, .5)'} }, + y: { grid: { color: 'rgba(0, 0, 0, .2)', borderDash: [3, 3] }, title: { color: 'rgba(0, 0, 0, .5)'} } + } + }; + + private clipLineChart(clip: Clip, theme: boolean) { + + type IDataSet = {label: string, data: number[], id: string} & IAnyObject; + const datasets: IDataSet[] = []; + const labels: number[] = []; + let frameLen: number = 0; + let lastDataSet: Map | undefined; + let lastProcess: number | undefined; + + // 收集数据 + clip.frames.forEach((frame) => { + + const frameData = new Map(); + + frame.commands.forEach((command) => { + + if (command.type !== "points") return; + + let findKey: IDataSet | undefined; + for (let i = 0; i < datasets.length; i++) { + if (datasets[i].id === command.id) { + findKey = datasets[i]; + break; + } + } + + // 记录当前数据 + frameData.set(command.id, (command.data?.length ?? 0) / 3); + + // 新建数据 + if (!findKey) { + + const color = `rgb(${command.parameter?.color.map((v: number) => Math.floor(v * 255)).join(",")})`; + + findKey = {} as any; + findKey!.label = command.name ?? ""; + findKey!.backgroundColor = color; + findKey!.borderColor = color; + findKey!.id = command.id; + findKey!.pointRadius = 0; + findKey!.borderWidth = 1.5; + findKey!.borderCapStyle = "round"; + findKey!.borderJoinStyle = "round"; + findKey!.pointHitRadius = 8; + + // 补充数据 + findKey!.data = new Array(frameLen).fill(0); + + datasets.push(findKey!); + } + }); + + // 与上一帧数据进行对比 + const isSameData = datasets.every((value: IDataSet) => { + if (value.data[frameLen - 1] === frameData.get(value.id)) { + return true; + } else { + return false; + } + }); + + lastDataSet = frameData; + lastProcess = frame.process; + + // 如果是不同数据 纪录 + if (!isSameData) { + datasets.forEach((value: IDataSet) => { + value.data.push(frameData.get(value.id) ?? 0); + }); + frameLen ++; + labels.push(frame.process); + } + }); + + // 记录最后一帧数据 + if (lastDataSet && lastProcess !== labels[labels.length - 1]) { + datasets.forEach((value: IDataSet) => { + value.data.push(lastDataSet!.get(value.id) ?? 0); + }); + frameLen ++; + labels.push(lastProcess!); + } + + if (datasets.length <= 0) { + return + } + + return ; + } + + private renderChart() { + + let themes = this.props.setting?.themes === Themes.light; + let lineChartType = this.props.setting?.lineChartType; + + // 播放模式 + if (this.props.status?.focusClip) { + if (this.props.status.actuator.playClip && lineChartType) { + return this.clipLineChart(this.props.status.actuator.playClip, themes); + } + if (this.props.status.actuator.playFrame) { + return this.clipBarChart(this.props.status.actuator.playFrame, themes); + } + } + + // 正在录制中 + else if ( + this.props.status?.actuator.mod === ActuatorModel.Record || + this.props.status?.actuator.mod === ActuatorModel.Offline + ) { + if (this.props.status.actuator.recordClip && lineChartType) { + return this.clipLineChart(this.props.status.actuator.recordClip, themes); + } + return this.modelBarChart(this.props.status.model, themes); + } + + // 主时钟运行 + else if (this.props.status) { + return this.modelBarChart(this.props.status.model, themes); + } + } + + public render(): ReactNode { + return + + { + ( + this.props.status?.focusClip || + this.props.status?.actuator.mod === ActuatorModel.Record || + this.props.status?.actuator.mod === ActuatorModel.Offline + ) ? + +
+
{ + this.props.setting?.setProps("lineChartType", !this.props.setting?.lineChartType); + }}> + +
+
+ + : null + } + +
+ { this.renderChart() } +
+
; + } +} + +export { Statistics }; \ No newline at end of file