需求分析

  • 展示仓库
    • 编号信息
    • 温度信息
    • 湿度信息
    • 能耗信息
    • 储量信息(打开/关闭仓库盖板)
    • 详情面板
  • 展示监控位置,展示监控信息并播放
  • 展示车辆信息,面板跟随车辆移动

    功能梳理

  1. 三维场景加载
    1. 3D DOM 容器;
    2. 加载场景;
    3. 摄像机飞行(角度,视角)
  2. 创建 2D UI 通栏
  3. 仓库对象:顶牌信息显隐、面板信息显隐、仓库盖板控制
  4. 监控对象:顶牌显隐、顶牌事件、面板信息显隐(播放)
  5. 车辆对象:路径移动、面板信息显隐(跟随移动)

    功能清单

    | 需求 | 功能 | API | | —- | —- | —- | | 三维场景加载 |
    - 创建 3D DOM 容器节点
    - 创建并加载园区场景
    - 初始化事件
    | new THING.App()
    app.on("load", () => {}) | | 2D UI 通栏 |
    - 创建通栏 UI
    | new THING.widget.Banner | | 仓库对象 |
    - 顶牌信息
    - 面板信息
    - 盖板控制
    | app.create({type: "UI"})
    new THING.widget.Pannel
    object.visiable/object.position/object.moveTo | | 监控对象 |
    - 顶牌信息
    - 顶牌事件
    - 面板信息
    | app.create({type: "Maker"})
    maker.on("click", () => {})
    new THING.widget.Pannel | | 车辆对象 |
    - 车辆路径移动
    - 面板信息(跟随移动)
    | object.movePath([])
    new THING.widget.Pannel |

实战:

一、三维场景加载

  1. // 1.1 加载场景
  2. const app = new THING.App({
  3. el: "div3d",
  4. url: "models/silohouse",
  5. });
  6. // 1.2 初始化事件
  7. app.on("load", (e) => {
  8. // 1.3 调整摄像机初始位置及视角
  9. app.camera.flyTo({
  10. position: [-182.16900300883736, 53.24677728392183, 72.21965470775368],
  11. target: [-68.1412926741533, -18.16319203074775, -23.30416731768694],
  12. time: 1500,
  13. });
  14. });

二、创建 2D UI 通栏

  1. /**
  2. 2.1 通过 toolbar = new THING.widget.Banner(param) 创建通栏
  3. 其中 param = {
  4. template: "default", // 模板
  5. column: "top" | "left", // 位置
  6. opaticy: 1, // 透明度
  7. visiable: true, // 可见性
  8. }
  9. 2.2 通过 toolbar.addImageBoolean(buttonCollection, buttonKey) 初始化图标按钮
  10. 2.3 通过初始化图标按钮的 .caption(title) 设置标题
  11. 2.4 通过初始化图标按钮的 .url(url) 设置图片路径
  12. 2.5 通过初始化图标按钮的 .on(eventName, callback) 设置事件监听
  13. */
  14. const toolbar = new THING.widget.Banner({
  15. column: "left"
  16. });
  17. const iconBaseUrl = "https://www.thingjs.com/static/images/sliohouse/";
  18. toolbar.btns = {
  19. status: {
  20. number: false,
  21. },
  22. info: {
  23. number: {
  24. caption: "仓库编号",
  25. url: `${btnIconBaseUrl}warehouse_code.png`
  26. },
  27. }
  28. }
  29. for (const btnKey in toolbar.btns.status) {
  30. toolbar
  31. .addImageBoolean(toolbar.btns.status, btnKey)
  32. .caption(toolbar.btns.info[btnKey]["caption"])
  33. .url(toolbar.btns.info[btnKey]["url"])
  34. .on("change", function (toolbarBtnStatus) {
  35. console.log(btnKey, toolbarBtnStatus);
  36. gui_btn_onchange(btnKey, toolbar.btns.info[btnKey]["caption"], toolbarBtnStatus);
  37. });
  38. }
  39. }
  40. function gui_btn_onchange(btnKey, status) {
  41. // TODO
  42. }

三、创建仓库控制类

1. 控制粮仓顶牌显隐

  1. // 3.1 设置粮仓控制类
  2. class SiloHouseCtrl {
  3. constructor(siloHouse) {
  4. // 保存粮仓对象
  5. this.siloHouse = siloHouse;
  6. // 保存控制对象
  7. siloHouse.ctrl = this;
  8. // 初始化(模拟)数据
  9. this.mockData();
  10. // UI 面板对象
  11. this.panel = null;
  12. this.ui = null;
  13. }
  14. // 模拟数据
  15. mockData() {
  16. const that = this;
  17. this.data = {
  18. number: this.siloHouse.name,
  19. temper: "26℃",
  20. humi: "35%",
  21. power: "15kWh",
  22. store: "70%",
  23. info: {
  24. 基本信息: {
  25. 品种: Math.ceil(Math.random() * 2) == 1 ? "小麦" : "玉米",
  26. 库存数量: Math.ceil(Math.random() * 9000) + "",
  27. 报关员: Math.ceil(Math.random() * 2) == 1 ? "张三" : "李四",
  28. 入库时间: Math.ceil(Math.random() * 2) == 1 ? "11:24" : "19:02",
  29. 用电量: Math.ceil(Math.random() * 100) + "",
  30. 单仓核算: "无",
  31. },
  32. 粮情信息: {
  33. 仓房温度: Math.ceil(Math.random() * 27 + 25) + "",
  34. 粮食温度: Math.ceil(Math.random() * 25 + 20) + "",
  35. },
  36. 报警信息: {
  37. 火灾: "无",
  38. 虫害: "无",
  39. },
  40. }
  41. }
  42. setInterval(() => {
  43. that.data.temper = `${(20 + Math.random() * 10).toFixed(2)}℃`;
  44. that.data.humi = `${(30 + Math.random() * 10).toFixed(2)}%`;
  45. that.data.power = `${(Math.random() * 20).toFixed(2)}kWh`;
  46. that.data.store = `${(Math.random() * 100).toFixed(2)}%`;
  47. }, 1000);
  48. }
  49. // 3.1.1 创建顶牌界面
  50. createUI(width = 140) {
  51. // 创建一个面板,并可向该面板中添加组件。
  52. this.panel = new THING.widget.Panel({
  53. template: "default",
  54. // 设置面板角标
  55. cornerType: "s2c5",
  56. width: `${width}px`,
  57. isClose: false,
  58. opacity: 0.8,
  59. media: true,
  60. });
  61. // 创建顶牌,顶牌内容为前面的面板
  62. this.ui = app.create({
  63. type: "UI",
  64. parent: this.siloHouse,
  65. el: this.panel.domElement,
  66. });
  67. // 返回面板实例,方便后续设置内容
  68. return this.panel;
  69. }
  70. // 3.1.2 隐藏顶牌界面
  71. hideUI() {
  72. if (this.panel) {
  73. this.panel.destroy()
  74. this.panel = null;
  75. }
  76. if (this.ui) {
  77. this.ui.destroy()
  78. this.ui = null;
  79. }
  80. }
  81. // 3.1.3 显示顶牌
  82. toggleUI(dataKey, title, show = false) {
  83. // 先隐藏前一种
  84. this.hideUI();
  85. // 如果是显示创建并显示 panel
  86. if (show) {
  87. // console.log(this, dataKey, title);
  88. // ⭐此处调用了 panel 的 add 方法创建文本控件,传入数据对象和数据键值,然后设置 panel 的标题
  89. this.createUI().addString(this.data, dataKey).name(title);
  90. }
  91. }
  92. }

2. 修改初始化事件

在场景加载完成后,先创建 2D 界面,接着获取操作物体对象集合,并将 2D UI 按钮的事件回调设置为控制物体对象。

  1. // 1.2 初始化事件
  2. app.on("load", (e) => {
  3. init_gui();
  4. init();
  5. });
  6. // 2.1 初始化 2D UI
  7. let toolbar;
  8. function init_gui() {
  9. // ...
  10. }
  11. // 2.2 通栏按钮响应回调
  12. function gui_btn_onchange(btnKey, caption, status = false) {
  13. // 关闭其他按钮
  14. for (const k in toolbar.btns.status) {
  15. if (["cloud", "location", btnKey].indexOf(k) > -1) {
  16. continue;
  17. }
  18. toolbar.btns.status[k] = false;
  19. }
  20. // 3.2.2 粮仓面板信息展示切换
  21. if (["number", "temper", "humi", "power", "store"].indexOf(btnKey) > -1) {
  22. siloHouseCtrlList.forEach((siloHouseCtrl) => {
  23. siloHouseCtrl.toggleUI(btnKey, caption, status);
  24. // 隐藏粮仓模型展示粮食储量模型
  25. // ❗不能直接设置不可见,visible 的隐藏会使子物体一起隐藏
  26. // 应使用 opacity 透明度,模拟“隐藏”
  27. // siloHouseCtrl.siloHouse.visible = !status;
  28. let opacity = 1;
  29. if (btnKey === "store") {
  30. opacity = status ? 0 : 1;
  31. }
  32. siloHouseCtrl.siloHouse.style.opacity = opacity;
  33. });
  34. }
  35. }
  36. // 3.2.1 初始化事件
  37. const siloHouseCtrlList = []; // 粮仓控制对象集合
  38. function init() {
  39. // 调整摄像机初始位置及视角
  40. app.camera.flyTo({
  41. position: [-182.16900300883736, 53.24677728392183, 72.21965470775368],
  42. target: [-68.1412926741533, -18.16319203074775, -23.30416731768694],
  43. time: 1500,
  44. });
  45. // 获取场景中粮仓对象集合,并为每一个粮仓对象创建控制类
  46. app.query("[物体类型=粮仓]").forEach(function (siloHouse) {
  47. siloHouseCtrlList.push(new SiloHouseCtrl(siloHouse));
  48. });
  49. }

3. 单击粮仓模型事件

单击粮仓模型时,模型显示选中效果(橙色勾边)并展示详细信息。修改粮仓控制类:

  1. // 3.1 设置粮仓控制类
  2. class SiloHouseCtrl {
  3. constructor(siloHouse) {
  4. // ...
  5. // 3.3 粮仓模型单击事件
  6. this.selected = false;
  7. // this.infoPanel = null; 设置为全局单例,绑定到粮仓控制类上
  8. SiloHouseCtrl.infoPanel = null;
  9. this.initClickEvent();
  10. }
  11. // ...
  12. // 3.3.1 初始化监听单击事件
  13. initClickEvent() {
  14. this.siloHouse.on("click", (e) => {
  15. const { button } = e;
  16. // 左键单击
  17. if (button === 0) {
  18. // 根据当前是否选中然后切换选中状态
  19. this.toggleSelected();
  20. }
  21. });
  22. }
  23. // 3.3.2 切换选中状态
  24. toggleSelected() {
  25. // 切换勾边
  26. this.siloHouse.style.outlineColor = this.selected ? null : "orange";
  27. // console.log(SiloHouseCtrl.infoPanel, this.selected);
  28. // 先销毁全局粮仓信息面板
  29. if (SiloHouseCtrl.infoPanel) {
  30. SiloHouseCtrl.infoPanel.destroy();
  31. SiloHouseCtrl.infoPanel = null;
  32. }
  33. // 如果是未选中改为选中,新创建全局粮仓信息面板
  34. if (!this.selected) {
  35. SiloHouseCtrl.infoPanel = new THING.widget.Panel({
  36. width: '350px',
  37. isClose: true,
  38. isDrag: true,
  39. hasTitle: true,
  40. name: this.siloHouse.name,
  41. // 设置面板坐标位置(相对世界的绝对坐标)
  42. position: [300, 50, 9999999],
  43. });
  44. SiloHouseCtrl.infoPanel.addTab(this.data.info);
  45. // TODO: 设置 z-index 无效
  46. // SiloHouseCtrl.infoPanel.setZIndex(999999);
  47. }
  48. // 记录新选中状态
  49. this.selected = !this.selected;
  50. }
  51. }

4. 双击粮仓模型事件

双击粮仓模型时,模型屋顶切换打开/关闭(本质是移动模型位置),修改粮仓控制类:

  1. // 3.1 设置粮仓控制类
  2. class SiloHouseCtrl {
  3. constructor(siloHouse) {
  4. // ...
  5. // 3.3 粮仓模型点击事件
  6. this.initClickEvent();
  7. // 单击
  8. this.selected = false;
  9. // this.infoPanel = null; 设置为全局单例,绑定到粮仓控制类上,保证只打开一个面板
  10. SiloHouseCtrl.infoPanel = null;
  11. // 双击
  12. // 全局单例,保证只打开一个屋顶
  13. SiloHouseCtrl.opendRoof = null;
  14. // 保存屋顶方便后续操作
  15. this.roof = this.siloHouse.query("/gaizi")[0];
  16. // 记录屋顶原始位置
  17. this.roof.originPos = [].concat(this.roof.position);
  18. }
  19. // 模拟数据
  20. mockData() {
  21. // ...
  22. }
  23. // 3.1.1 创建顶牌界面
  24. createUI(width = 140) {
  25. // ...
  26. }
  27. // 3.1.2 隐藏顶牌界面
  28. hideUI() {
  29. // ...
  30. }
  31. // 3.1.3 显示顶牌
  32. toggleUI(dataKey, title, show = false) {
  33. // ...
  34. }
  35. // 3.3.1 初始化监听事件
  36. initClickEvent() {
  37. /**
  38. * ⭐如果用到双击事件是需要严格区分 click 与 singleClick
  39. * 否则 click 会响应 dblclick
  40. */
  41. // 单击事件
  42. this.siloHouse.on("singleClick", (e) => {
  43. const { button } = e;
  44. // 左键单击
  45. if (button === 0) {
  46. // 根据当前是否选中然后切换选中状态
  47. this.toggleSelected();
  48. }
  49. });
  50. // 双击事件
  51. this.siloHouse.on("dblclick", (e) => {
  52. const { button, object } = e;
  53. // 左键双击
  54. if (button === 0) {
  55. // 切换屋顶打开状态
  56. this.toggleRoof();
  57. }
  58. })
  59. }
  60. // 3.3.2 单击切换选中状态
  61. toggleSelected() {
  62. // ...
  63. }
  64. // 3.4.1 双击切换屋顶打开/关闭
  65. toggleRoof() {
  66. // 如果有已经打开的先关闭
  67. if (SiloHouseCtrl.opendRoof) {
  68. SiloHouseCtrl.opendRoof.moveTo({
  69. position: SiloHouseCtrl.opendRoof.originPos,
  70. time: 400,
  71. });
  72. // 如果本来就是当前双击的已打开,关闭后不再打开新的
  73. if (SiloHouseCtrl.opendRoof === this.roof) {
  74. SiloHouseCtrl.opendRoof = null;
  75. return;
  76. }
  77. }
  78. // 记录新打开的屋顶
  79. SiloHouseCtrl.opendRoof = this.roof;
  80. // 获取原始位置
  81. const [x, y, z] = SiloHouseCtrl.opendRoof.originPos;
  82. // 移动到新位置
  83. SiloHouseCtrl.opendRoof.moveTo({
  84. position: [x, y + 20, z],
  85. time: 400,
  86. });
  87. }
  88. }

四、监控摄像头控制类

需求:

  • 点击 2D UI 通栏的按钮,所有摄像头展示顶牌,再次点击可隐藏;
  • 点击摄像头的顶牌,显示播放面板; ```javascript

// 2.2 通栏按钮响应回调 function gui_btn_onchange(btnKey, caption, status = false) { // … // 4.2 切换云台监控 Maker 展示 if (btnKey === “video”) { videoCtrlList.forEach((videoCtrl) => { videoCtrl.toggleMaker(status); }); } }

// 3.2.1 初始化事件 // 粮仓控制对象集合 const siloHouseCtrlList = []; const videoCtrlList = []; function init() { // … // 获取场景中云台监控对象集合,并为每一个云台监控对像创建控制类 app.query(“[物体类型=摄像头]”).forEach(function (video) { videoCtrlList.push(new VideoCtrl(video)); }); }

// 4.1 创建云台监控控制类 class VideoCtrl { constructor(video) { // 保存云台监控对象 this.video = video; // 保存云台控制对象 this.video.ctrl = this; // 创建顶牌 this.marker = app.create({ type: “Marker”, offset: [0, 3.5, 0], size: 10, url: “https://www.thingjs.com/static/images/sliohouse/videocamera3.png“, parent: this.video, }); // 初始化记录播放视频面板 VideoCtrl.videoPanelList = []; // 点击顶牌时,切换监控视频面板 this.marker.on(“singleClick”, () => { this.togglePanel(); }); // 默认隐藏顶牌 this.toggleMaker(); } // 切换顶牌显隐 toggleMaker(vis = false) { this.marker.visible = vis; this.video.style.color = vis ? “orange” : null; this.video.style.glow = vis ? true : false; // 隐藏摄像头时顶牌时,销毁所有视频播放 if (!vis) { VideoCtrl.videoPanelList.forEach((videoPanel) => { videoPanel.close(); }); } } // 销毁监控视频播放面板 destroyPanel() { const videoPanelList = [].concat(VideoCtrl.videoPanelList || []); const index = videoPanelList.indexOf(this.videoPanel); // 已经创建,销毁 if (this.videoPanel && index > -1) { videoPanelList.splice(index, 1); this.videoPanel.destroy(); this.videoPanel = null; } VideoCtrl.videoPanelList = videoPanelList; } // 创建监控视频播放面板 createPanel() { const videoPanelList = [].concat(VideoCtrl.videoPanelList || []); // 创建一个新空 Panel const panel = new THING.widget.Panel({ template: “default2”, // width: “450px”, isClose: true, isDrag: true, media: true, hasTitle: true, name: this.video.name, }); // 给新面板创建视频播放 panel .addIframe({ iframe: true }, ‘iframe’) .caption(‘’) // Panel 已经有标题了, iframe 无需重新设置标题 .iframeUrl(“https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4“); // ⭐position 的更改必须设置在 panel 创建之后 panel.setPosition({ top: 0, left: app.domElement.offsetWidth - panel.domElement.offsetWidth, }); // ⭐绑定面板关闭事件,关闭时销毁播放器面板 panel.on(“close”, () => { this.destroyPanel(); }); // 保存已创建的新面板 this.videoPanel = panel; videoPanelList.push(panel); VideoCtrl.videoPanelList = videoPanelList; } // 切换监控视频播放面板 togglePanel() { // 已存在,销毁 if (this.videoPanel) { this.destroyPanel(); } else { // 每超出线路数,创建 if (VideoCtrl.videoPanelList.length === 9) { // 创建提示 alert(性能不足!最多支持9路摄像头播放。); return; } this.createPanel(); } } }

  1. <a name="qe5aT"></a>
  2. ### 五、人车对象控制类
  3. ```javascript
  4. // 5.1 创建人车定位控制类
  5. class LocationCtrl {
  6. constructor(type) {
  7. this.ui = null;
  8. this.panel = null;
  9. this.obj = null;
  10. this.createObject(type);
  11. }
  12. createObject(type) {
  13. if (type === "worker") {
  14. // 创建工人
  15. this.obj = app.create({
  16. type: 'Thing',
  17. name: '工人',
  18. url: '/api/models/3a1f327991084d25a1dd362917f0b347/0/gltf/',
  19. position: [0, 0, 0],
  20. angle: 180,
  21. scale: [0.1, 0.1, 0.1],
  22. userData: {
  23. 物体类型: type,
  24. info: {
  25. 编号: 9527,
  26. 名称: '张三',
  27. 职位: '巡检员',
  28. 年龄: 25,
  29. }
  30. },
  31. complete: function () {
  32. console.log(`${type} thing created: ${this.id}`);
  33. }
  34. });
  35. }
  36. if (type === "truck") {
  37. // 创建卡车
  38. this.obj = app.create({
  39. type: 'Thing',
  40. name: 'truck',
  41. url: 'https://www.thingjs.com/static/models/truck',
  42. position: [0, 0, 0],
  43. angle: 0,
  44. userData: {
  45. 物体类型: type,
  46. info: {
  47. 编号: 89757,
  48. 车牌: '渝N.B74110',
  49. 种类: '货车',
  50. 使用年限: 2,
  51. }
  52. },
  53. complete: function () {
  54. console.log(`${type} thing created: ${this.id}`);
  55. }
  56. });
  57. }
  58. if (this.obj) {
  59. this.obj.ctrl = this;
  60. this.moveObj();
  61. }
  62. }
  63. moveObj() {
  64. const path = [
  65. "L109",
  66. "L110",
  67. "L104",
  68. "L103",
  69. "L102",
  70. "L108",
  71. "L109",
  72. "L118",
  73. "L119",
  74. "L112",
  75. "L111",
  76. "L117",
  77. "L118",
  78. ].map((point) => {
  79. const pObj = app.query(point)[0];
  80. return pObj && pObj.position;
  81. }).filter((pos) => pos && pos.length === 3);
  82. this.obj.movePath({
  83. path,
  84. orientToPath: true,
  85. orientToPathDegree: this.obj.name === "truck" ? 180 : 0,
  86. speed: this.obj.name === "truck" ? 20 : 5,
  87. delayTime: 500,
  88. lerp: false,
  89. loop: true,
  90. });
  91. }
  92. createUI() {
  93. this.panel = new THING.widget.Panel({
  94. template: 'default',
  95. width: '180px',
  96. // cornerType: "polyline",
  97. offset: [0, 0, 0],
  98. pivot: [0, 0],
  99. isClose: false,
  100. opacity: 0.8,
  101. });
  102. for (const dataKey in this.obj.userData["info"]) {
  103. this.panel.addString(this.obj.userData["info"], dataKey);
  104. }
  105. this.ui = app.create({
  106. type: "UI",
  107. el: this.panel.domElement,
  108. parent: this.obj,
  109. });
  110. }
  111. destroyUI() {
  112. if (this.panel) {
  113. this.panel.destroy();
  114. this.panel = null;
  115. }
  116. if (this.ui) {
  117. this.ui.destroy();
  118. this.ui = null;
  119. }
  120. }
  121. toggleUI() {
  122. if (this.panel || this.ui) {
  123. this.destroyUI();
  124. } else {
  125. this.createUI();
  126. }
  127. }
  128. }