打标签
上一章回顾: 三维中的点
你了解 Five SDK 的事件系统,并且尝试开发了小应用,通过点击事件获取到点的三维位置。
本章你可以学习到
在三维空间中设置标签。
准备工作
我们新建一个目录(src/5.tagging)以及对应的 html 文件 以及 jsx 或 tsx 文件。
携带着上一章的 State 代码,太过于繁琐,那我们从 展示三维空间 章的内容的基础开发。
src/5.tagging/index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <link rel="icon" href="data:;base64,iVBORw0KGgo=">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>打标签 | Tagging</title>
  <style>
    * { margin: 0; padding: 0; }
    html, body #app { width: 100%; height: 100%; overflow: hidden; }
  </style>
</head>
<body>
  <div id="app"></div>
  <script type="module" src="./index"></script>
</body>
</html>
- JavaScript
 - TypeScript
 
src/5.tagging/withFetchWork.jsx
import React, { Component } from "react";
import { parseWork } from "@realsee/five";
/**
 * React HOC 获取 work
 * @param url work.json 的地址
 */
function withFetchWork(url) {
  return function(Compnent) {
    return class extends Component {
      state = { work: null };
      componentDidMount() {
        fetch(url).then(res => res.json()).then(json => {
          this.setState({ work: parseWork(json) });
        });
      }
      render() {
        if (this.state.work === null) return null;
        return <Compnent work={this.state.work} {...this.props}/>;
      }
    }
  }
}
export { withFetchWork };
src/5.tagging/withWindowDimensions.jsx
import React, { Component } from "react";
/**
 * React HOC: 获取当前窗口的尺寸
 */
function withWindowDimensions() {
  return function(Compnent) {
    return class extends Component {
      state = this.getWindowDimensions();
      resizeListener = () => {
        this.setState(this.getWindowDimensions());
      };
      getWindowDimensions() {
        return { width: window.innerWidth, height: window.innerHeight };
      }
      componentDidMount() {
        window.addEventListener("resize", this.resizeListener, false);
      }
      componentWillUnmount() {
        window.removeEventListener("resize", this.resizeListener, false);
      }
      render() {
        const dimensions = { width: this.state.width, height: this.state.height };
        return <Compnent windowDimensions={dimensions} {...this.props}/>;
      }
    }
  }
}
export { withWindowDimensions };
src/5.tagging/ModeController.jsx
import React, { Component } from "react";
import { Five } from "@realsee/five";
import { withFive, createFiveFeature } from "@realsee/five/react";
import { compose } from "@wordpress/compose";
import BottomNavigation from "@mui/material/BottomNavigation";
import BottomNavigationAction from "@mui/material/BottomNavigationAction";
import Paper from "@mui/material/Paper";
import DirectionsWalkIcon from "@mui/icons-material/DirectionsWalk";
import ViewInArIcon from "@mui/icons-material/ViewInAr";
const FEATURES = createFiveFeature("currentState", "setState");
/**
 * React Component: 模态控制
 */
const ModeController = compose(
  withFive(FEATURES)
)(class extends Component {
  render() {
    return <Paper sx={{ position: "fixed", bottom: 0, left: 0, right: 0 }}>
      <BottomNavigation
        showLabels
        value={this.props.$five.currentState.mode}
        onChange={(_, newValue) => {
          this.props.$five.setState({ mode: newValue });
        }}
      >
        <BottomNavigationAction label="全景漫游" icon={<DirectionsWalkIcon/>} value={Five.Mode.Panorama}/>
        <BottomNavigationAction label="空间总览" icon={<ViewInArIcon/>} value={Five.Mode.Floorplan}/>
      </BottomNavigation>
    </Paper>;
  }
})
export { ModeController };
src/5.tagging/App.jsx
import React, { Component } from "react";
import { compose } from "@wordpress/compose";
import { createFiveProvider, FiveCanvas } from "@realsee/five/react";
import { withFetchWork } from "./withFetchWork";
import { withWindowDimensions } from "./withWindowDimensions";
import { ModeController } from "./ModeController";
/** work.json 的数据 URL */
const workURL = "https://vrlab-public.ljcdn.com/release/static/image/release/five/work-sample/07bdc58f413bc5494f05c7cbb5cbdce4/work.json";
const FiveProvider = createFiveProvider();
const App = compose(
  withFetchWork(workURL),
  withWindowDimensions()
)(class extends Component {
  render() {
    const { work, windowDimensions } = this.props;
    return <FiveProvider initialWork={work}>
      <FiveCanvas width={windowDimensions.width} height={windowDimensions.height}/>
      <ModeController/>;
    </FiveProvider>;
  }
});
export { App };
src/5.tagging/index.jsx
import React from "react";
import ReactDOM from "react-dom";
import { App } from "./App";
ReactDOM.render(<App/>, document.querySelector("#app"));
export {};
src/5.tagging/withFetchWork.tsx
import React, { Component, ComponentClass } from "react";
import { Work, parseWork } from "@realsee/five";
/**
 * React HOC 获取 work
 * @param url work.json 的地址
 */
function withFetchWork<P extends Record<string, any>>(url: string) {
  return function(Compnent: ComponentClass<P & { work: Work }>): ComponentClass<P> {
    return class extends Component<P, {work: Work | null}> {
      state = { work: null };
      componentDidMount() {
        fetch(url).then(res => res.json()).then(json => {
          this.setState({ work: parseWork(json) });
        });
      }
      render() {
        if (this.state.work === null) return null;
        return <Compnent work={this.state.work} {...this.props}/>;
      }
    }
  }
}
export { withFetchWork };
src/5.tagging/useWindowDimensions.tsx
import React, { Component, ComponentClass } from "react";
/**
 * React HOC: 获取当前窗口的尺寸
 */
function withWindowDimensions<P extends Record<string, any>>() {
  return function(Compnent: ComponentClass<P & { windowDimensions: { width: number, height: number} }>): ComponentClass<P> {
    return class extends Component<P, {width: number, height: number}> {
      state = this.getWindowDimensions();
      resizeListener = () => {
        this.setState(this.getWindowDimensions());
      };
      getWindowDimensions() {
        return { width: window.innerWidth, height: window.innerHeight };
      }
      componentDidMount() {
        window.addEventListener("resize", this.resizeListener, false);
      }
      componentWillUnmount() {
        window.removeEventListener("resize", this.resizeListener, false);
      }
      render() {
        const dimensions = { width: this.state.width, height: this.state.height };
        return <Compnent windowDimensions={dimensions} {...this.props}/>;
      }
    }
  }
}
export { withWindowDimensions };
src/5.tagging/ModeController.tsx
import React, { Component } from "react";
import { Five, Mode } from "@realsee/five";
import { withFive, createFiveFeature, PropTypeOfFiveFeatures } from "@realsee/five/react";
import { compose } from "@wordpress/compose";
import BottomNavigation from "@mui/material/BottomNavigation";
import BottomNavigationAction from "@mui/material/BottomNavigationAction";
import Paper from "@mui/material/Paper";
import DirectionsWalkIcon from "@mui/icons-material/DirectionsWalk";
import ViewInArIcon from "@mui/icons-material/ViewInAr";
const FEATURES = createFiveFeature("currentState", "setState");
type Props = PropTypeOfFiveFeatures<typeof FEATURES>;
/**
 * React Component: 模态控制
 */
const ModeController = compose(
  withFive(FEATURES)
)(class extends Component<Props> {
  render() {
    return <Paper sx={{ position: "fixed", bottom: 0, left: 0, right: 0 }}>
      <BottomNavigation
        showLabels
        value={this.props.$five.currentState.mode}
        onChange={(_, newValue: Mode) => {
          this.props.$five.setState({ mode: newValue });
        }}
      >
        <BottomNavigationAction label="全景漫游" icon={<DirectionsWalkIcon/>} value={Five.Mode.Panorama}/>
        <BottomNavigationAction label="空间总览" icon={<ViewInArIcon/>} value={Five.Mode.Floorplan}/>
      </BottomNavigation>
    </Paper>;
  }
})
export { ModeController };
src/5.tagging/App.tsx
import React, { Component } from "react";
import { Work } from "@realsee/five";
import { createFiveProvider, FiveCanvas } from "@realsee/five/react";
import { compose } from "@wordpress/compose";
import { withFetchWork } from "./withFetchWork";
import { withWindowDimensions } from "./withWindowDimensions";
import { ModeController } from "./ModeController";
/** work.json 的数据 URL */
const workURL = "https://vrlab-public.ljcdn.com/release/static/image/release/five/work-sample/07bdc58f413bc5494f05c7cbb5cbdce4/work.json";
const FiveProvider = createFiveProvider();
const App = compose(
  withFetchWork(workURL),
  withWindowDimensions()
)(class extends Component<{work: Work, windowDimensions: { width: number, height: number }}> {
  render() {
    const { work, windowDimensions } = this.props;
    return <FiveProvider initialWork={work}>
      <FiveCanvas width={windowDimensions.width} height={windowDimensions.height}/>
      <ModeController/>
    </FiveProvider>;
  }
});
export { App };
src/5.tagging/index.tsx
import React from "react";
import ReactDOM from "react-dom";
import { App } from "./App";
ReactDOM.render(<App/>, document.querySelector("#app"));
export {};
启动服务 npm run dev。 并跳转到当前页面 "http://localhost:3000/src/5.tagging/index.html"。
信息
请查看你的控制台,端口号会因为你的配置以及当前端口占用情况变更,请已控制台输出的为准。 如果你使用其他开发构建工具,请按照自己的开发构建工具的要求启动服务。
开发打标签功能
添加标签样式
在 html 文件中添加标签样式。
样式并非是必须的,只是为了标签好看一些。
src/5.tagging/index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <link rel="icon" href="data:;base64,iVBORw0KGgo=">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>打标签 | Tagging</title>
  <style>
    * { margin: 0; padding: 0; }
    html, body #app { width: 100%; height: 100%; overflow: hidden; }
    .tag { position: absolute; width: 0; height: 0; transform: translateZ(0); }
    .tag-pannel { position: absolute; width: 100px; min-height: 20px; transform: translate(-50%, 0); left: 50%; bottom: 10px; background: #333; color: #fff; border-radius: 2px; text-align: center; line-height: 20px; padding: 8px; font-size: 14px;}
    .tag-pannel:after { content: ""; display: block; position: absolute; width: 10px; height: 10px; left: 50%; bottom: -5px; transform: translate(-50%, 0) rotate(45deg); background: #333; pointer-events: none; }
  </style>
</head>
<body>
  <div id="app"></div>
  <script type="module" src="./index"></script>
</body>
</html>
useFiveProject2d 说明
本章会使用到 useFiveProject2d 方法。他可以将三维的坐标对应到而为屏幕。
$five.project2d(vector: THREE.Vector3, testModel: boolean): THREE.Vector2 | null
- 将一个三维坐标传入可以得到一个屏幕二维坐标, 原点在左上, 单位为像素。可以作为 
{ left: returnValue.x + "px", top: returnValue.y + "px" }等方式使用。 - 如果三维坐标无法计算到屏幕中(比如在背后或者被遮挡),则返回 
null。 - 第二个参数 testModel 为是否计算模型碰撞,即被模型遮挡的坐标是否为 
null。 
编写 TaggingController
- 添加一个 TaggingController 文件,用于编写组件。
 - 通过 
tagsReact state 存储标签位置和文字。 - 通过 
newTagReact state 存储当前新建的标签。 - 监听 five 的 
intersectionOnModelUpdate事件,将新建中的标签放在在鼠标的位置。 - 通过 标签调用 
$five.project2d方法(tagElement方法中)获取屏幕画布坐标,然后通过改变样式,渲染出来。 
- JavaScript
 - TypeScript
 
src/5.tagging/TaggingController.jsx
import React, { Component } from "react";
import { compose } from "@wordpress/compose";
import { withFive, createFiveFeature } from "@realsee/five/react";
import Button from "@mui/material/Button";
import Paper from "@mui/material/Paper";
const FEATURES = createFiveFeature("project2d", "currentState", "on", "off");
/**
 * React Component: 打标签
 */
const TaggingController = compose(
  withFive(FEATURES)
)(class extends Component {
  state = { tags: [], newTag: null };
  addTag = () => {
    this.setState({ newTag: { label: window.prompt("添加标签", "") || "未命名" } });
  }
  tagElement(tag, key) {
    const position = tag.position && this.props.$five.project2d(tag.position, true);
    const style = position ? { left: position.x, top: position.y } : { display: "none" };
    return <div className="tag" style={style} key={key}>
      <div className="tag-pannel"><span className="tag-content">{tag.label}</span></div>
    </div>
  }
  onIntersectionUpdate = intersect => {
    if (this.state.newTag) this.setState({ newTag: { position: intersect.point, label: this.state.newTag.label } });
  };
  onTapGesture = () => {
    if (this.state.newTag && this.state.newTag.position) {
      this.setState({
        tags: this.state.tags.concat(this.state.newTag),
        newTag: null
      });
      return false;
    }
  };
  componentDidMount() {
    this.props.$five.on("intersectionOnModelUpdate", this.onIntersectionUpdate);
    this.props.$five.on("wantsTapGesture", this.onTapGesture);
  }
  componentWillUnmount() {
    this.props.$five.off("intersectionOnModelUpdate", this.onIntersectionUpdate);
    this.props.$five.off("wantsTapGesture", this.onTapGesture);
  }
  render() {
    return <React.Fragment>
      <Paper sx={{ position: "fixed", top: 10, left: 10 }}>
        <Button onClick={this.addTag}>打标签</Button>
      </Paper>
        {this.state.newTag && this.tagElement(this.state.newTag)}
        {this.state.tags.map((tag, index) => this.tagElement(tag, index))}
    </React.Fragment>;
  }
});
export { TaggingController };
src/5.tagging/TaggingController.tsx
import * as THREE from "three";
import React, { Component } from "react";
import { compose } from "@wordpress/compose";
import { EventCallback } from "@realsee/five";
import { withFive, createFiveFeature, PropTypeOfFiveFeatures } from "@realsee/five/react";
import Button from "@mui/material/Button";
import Paper from "@mui/material/Paper";
type Tag = { position?: THREE.Vector3, label: string };
const FEATURES = createFiveFeature("project2d", "currentState", "on", "off");
type Props = PropTypeOfFiveFeatures<typeof FEATURES>;
type State = {
  tags: Tag[],
  newTag: Tag | null
};
/**
 * React Component: 打标签
 */
const TaggingController = compose(
  withFive(FEATURES)
)(class extends Component<Props, State> {
  state: State = { tags: [], newTag: null };
  addTag = () => {
    this.setState({ newTag: { label: window.prompt("添加标签", "") || "未命名" } });
  }
  tagElement(tag: Tag, key?: number | string) {
    const position = tag.position && this.props.$five.project2d(tag.position, true);
    const style = position ? { left: position.x, top: position.y } : { display: "none" };
    return <div className="tag" style={style} key={key}>
      <div className="tag-pannel"><span className="tag-content">{tag.label}</span></div>
    </div>
  }
  onIntersectionUpdate: EventCallback["intersectionOnModelUpdate"] = intersect => {
    if (this.state.newTag) this.setState({ newTag: { position: intersect.point, label: this.state.newTag.label } });
  };
  onTapGesture: EventCallback["wantsTapGesture"] = () => {
    if (this.state.newTag && this.state.newTag.position) {
      this.setState({
        tags: this.state.tags.concat(this.state.newTag),
        newTag: null
      });
      return false;
    }
  };
  componentDidMount() {
    this.props.$five.on("intersectionOnModelUpdate", this.onIntersectionUpdate);
    this.props.$five.on("wantsTapGesture", this.onTapGesture);
  }
  componentWillUnmount() {
    this.props.$five.off("intersectionOnModelUpdate", this.onIntersectionUpdate);
    this.props.$five.off("wantsTapGesture", this.onTapGesture);
  }
  render() {
    return <React.Fragment>
      <Paper sx={{ position: "fixed", top: 10, left: 10 }}>
        <Button onClick={this.addTag}>打标签</Button>
      </Paper>
        {this.state.newTag && this.tagElement(this.state.newTag)}
        {this.state.tags.map((tag, index) => this.tagElement(tag, index))}
    </React.Fragment>;
  }
});
export { TaggingController };
使用打标签组件
插入到 App 文件的 FiveProvider 中
- JavaScript
 - TypeScript
 
src/5.tagging/App.jsx
import React, { Component } from "react";
import { compose } from "@wordpress/compose";
import { createFiveProvider, FiveCanvas } from "@realsee/five/react";
import { withFetchWork } from "./withFetchWork";
import { withWindowDimensions } from "./withWindowDimensions";
import { ModeController } from "./ModeController";
import { TaggingController } from "./TaggingController";
/** work.json 的数据 URL */
const workURL = "https://vrlab-public.ljcdn.com/release/static/image/release/five/work-sample/07bdc58f413bc5494f05c7cbb5cbdce4/work.json";
const FiveProvider = createFiveProvider();
const App = compose(
  withFetchWork(workURL),
  withWindowDimensions()
)(class extends Component {
  render() {
    const { work, windowDimensions } = this.props;
    return <FiveProvider initialWork={work}>
      <FiveCanvas width={windowDimensions.width} height={windowDimensions.height}/>
      <ModeController/>;
      <TaggingController/>;
    </FiveProvider>;
  }
});
export { App };
src/5.tagging/App.tsx
import React, { Component } from "react";
import { Work } from "@realsee/five";
import { createFiveProvider, FiveCanvas } from "@realsee/five/react";
import { compose } from "@wordpress/compose";
import { withFetchWork } from "./withFetchWork";
import { withWindowDimensions } from "./withWindowDimensions";
import { ModeController } from "./ModeController";
import { TaggingController } from "./TaggingController";
/** work.json 的数据 URL */
const workURL = "https://vrlab-public.ljcdn.com/release/static/image/release/five/work-sample/07bdc58f413bc5494f05c7cbb5cbdce4/work.json";
const FiveProvider = createFiveProvider();
const App = compose(
  withFetchWork(workURL),
  withWindowDimensions()
)(class extends Component<{work: Work, windowDimensions: { width: number, height: number }}> {
  render() {
    const { work, windowDimensions } = this.props;
    return <FiveProvider initialWork={work}>
      <FiveCanvas width={windowDimensions.width} height={windowDimensions.height}/>
      <ModeController/>
      <TaggingController/>
    </FiveProvider>;
  }
});
export { App };
回到你的浏览器查看,会发现你的页面左上角出现打标签按钮,点击,填写标签名称后,后移动鼠标,在你需要的位置点击。便可放置标签。
嗯,确实是一个实用的功能🥳 。