Browse Source

feat: 制作动画模块

bill 5 months ago
parent
commit
767cf2b8e9

+ 6 - 1
package.json

@@ -11,17 +11,22 @@
   },
   "dependencies": {
     "@ant-design/icons-vue": "^7.0.1",
+    "@types/three": "^0.169.0",
     "ant-design-vue": "^4.2.6",
     "axios": "^0.27.2",
     "coordtransform": "^2.1.2",
+    "konva": "^9.3.18",
     "less": "^4.1.3",
     "mitt": "^3.0.0",
     "simaqcore": "^1.2.0",
     "swiper": "^11.1.15",
+    "three": "^0.169.0",
+    "uuid": "^11.0.2",
     "vite-plugin-mkcert": "^1.10.1",
     "vue": "3.2.47",
     "vue-cropper": "1.0.2",
-    "vue-router": "^4.1.3",
+    "vue-konva": "^3.2.0",
+    "vue-router": "^4.5.0",
     "vuedraggable": "^4.1.0"
   },
   "devDependencies": {

+ 76 - 7
pnpm-lock.yaml

@@ -3,37 +3,47 @@ lockfileVersion: 5.4
 specifiers:
   '@ant-design/icons-vue': ^7.0.1
   '@types/node': ^18.6.5
+  '@types/three': ^0.169.0
   '@vitejs/plugin-vue': ^3.0.0
   ant-design-vue: ^4.2.6
   axios: ^0.27.2
   coordtransform: ^2.1.2
+  konva: ^9.3.18
   less: ^4.1.3
   mitt: ^3.0.0
   sass: ^1.54.3
   simaqcore: ^1.2.0
   swiper: ^11.1.15
+  three: ^0.169.0
   typescript: ^4.6.4
+  uuid: ^11.0.2
   vite: ^3.0.0
   vite-plugin-mkcert: ^1.10.1
   vue: 3.2.47
   vue-cropper: 1.0.2
-  vue-router: ^4.1.3
+  vue-konva: ^3.2.0
+  vue-router: ^4.5.0
   vue-tsc: ^0.38.4
   vuedraggable: ^4.1.0
 
 dependencies:
   '@ant-design/icons-vue': 7.0.1_vue@3.2.47
+  '@types/three': 0.169.0
   ant-design-vue: 4.2.6_vue@3.2.47
   axios: 0.27.2
   coordtransform: 2.1.2
+  konva: 9.3.18
   less: 4.1.3
   mitt: 3.0.0
   simaqcore: 1.2.0
   swiper: 11.1.15
+  three: 0.169.0
+  uuid: 11.1.0
   vite-plugin-mkcert: 1.10.1_vite@3.0.4
   vue: 3.2.47
   vue-cropper: 1.0.2
-  vue-router: 4.1.3_vue@3.2.47
+  vue-konva: 3.2.0_konva@9.3.18+vue@3.2.47
+  vue-router: 4.5.0_vue@3.2.47
   vuedraggable: 4.1.0_vue@3.2.47
 
 devDependencies:
@@ -243,10 +253,33 @@ packages:
       nanopop: 2.1.0
     dev: false
 
+  /@tweenjs/tween.js/23.1.3:
+    resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
+    dev: false
+
   /@types/node/18.6.5:
     resolution: {integrity: sha512-Xjt5ZGUa5WusGZJ4WJPbOT8QOqp6nDynVFRKcUt32bOgvXEoc6o085WNkYTMO7ifAj2isEfQQ2cseE+wT6jsRw==}
     dev: true
 
+  /@types/stats.js/0.17.3:
+    resolution: {integrity: sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==}
+    dev: false
+
+  /@types/three/0.169.0:
+    resolution: {integrity: sha512-oan7qCgJBt03wIaK+4xPWclYRPG9wzcg7Z2f5T8xYTNEF95kh0t0lklxLLYBDo7gQiGLYzE6iF4ta7nXF2bcsw==}
+    dependencies:
+      '@tweenjs/tween.js': 23.1.3
+      '@types/stats.js': 0.17.3
+      '@types/webxr': 0.5.21
+      '@webgpu/types': 0.1.55
+      fflate: 0.8.2
+      meshoptimizer: 0.18.1
+    dev: false
+
+  /@types/webxr/0.5.21:
+    resolution: {integrity: sha512-geZIAtLzjGmgY2JUi6VxXdCrTb99A7yP49lxLr2Nm/uIK0PkkxcEi4OGhoGDO4pxCf3JwGz2GiJL2Ej4K2bKaA==}
+    dev: false
+
   /@vitejs/plugin-vue/3.0.1_vite@3.0.4+vue@3.2.47:
     resolution: {integrity: sha512-Ll9JgxG7ONIz/XZv3dssfoMUDu9qAnlJ+km+pBA0teYSXzwPCIzS/e1bmwNYl5dcQGs677D21amgfYAnzMl17A==}
     engines: {node: ^14.18.0 || >=16.0.0}
@@ -360,8 +393,8 @@ packages:
       '@vue/shared': 3.5.13
     dev: true
 
-  /@vue/devtools-api/6.2.1:
-    resolution: {integrity: sha512-OEgAMeQXvCoJ+1x8WyQuVZzFo0wcyCmUR3baRVLmKBo1LmYZWMlRiXlux5jd0fqVJu6PfDbOrZItVqUEzLobeQ==}
+  /@vue/devtools-api/6.6.4:
+    resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
     dev: false
 
   /@vue/reactivity-transform/3.2.47:
@@ -417,6 +450,10 @@ packages:
     resolution: {integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==}
     dev: true
 
+  /@webgpu/types/0.1.55:
+    resolution: {integrity: sha512-p97I8XEC1h04esklFqyIH+UhFrUcj8/1/vBWgc6lAK4jMJc+KbhUy8D4dquHYztFj6pHLqGcp/P1xvBBF4r3DA==}
+    dev: false
+
   /ant-design-vue/4.2.6_vue@3.2.47:
     resolution: {integrity: sha512-t7eX13Yj3i9+i5g9lqFyYneoIb3OzTvQjq9Tts1i+eiOd3Eva/6GagxBSXM1fOCjqemIu0FYVE1ByZ/38epR3Q==}
     engines: {node: '>=12.22.0'}
@@ -796,6 +833,10 @@ packages:
     resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
     dev: false
 
+  /fflate/0.8.2:
+    resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
+    dev: false
+
   /fill-range/7.0.1:
     resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==}
     engines: {node: '>=8'}
@@ -909,6 +950,10 @@ packages:
     resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
     dev: false
 
+  /konva/9.3.18:
+    resolution: {integrity: sha512-ad5h0Y9phUrinBrKXyIISbURRHQO7Rx5cz7mAEEfdVCs45gDqRD8Y0I0nJRk8S6iqEbiRE87CEZu5GVSnU8oow==}
+    dev: false
+
   /less/4.1.3:
     resolution: {integrity: sha512-w16Xk/Ta9Hhyei0Gpz9m7VS8F28nieJaL/VyShID7cYvP6IL5oHeL6p4TXSDJqZE/lNv0oJ2pGVjJsRkfwm5FA==}
     engines: {node: '>=6'}
@@ -963,6 +1008,10 @@ packages:
       semver: 5.7.1
     optional: true
 
+  /meshoptimizer/0.18.1:
+    resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==}
+    dev: false
+
   /mime-db/1.52.0:
     resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
     engines: {node: '>= 0.6'}
@@ -1188,6 +1237,10 @@ packages:
     engines: {node: '>= 4.7.0'}
     dev: false
 
+  /three/0.169.0:
+    resolution: {integrity: sha512-Ed906MA3dR4TS5riErd4QBsRGPcx+HBDX2O5yYE5GqJeFQTPU+M56Va/f/Oph9X7uZo3W3o4l2ZhBZ6f6qUv0w==}
+    dev: false
+
   /throttle-debounce/5.0.2:
     resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
     engines: {node: '>=12.22'}
@@ -1216,6 +1269,11 @@ packages:
     resolution: {integrity: sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w==}
     dev: false
 
+  /uuid/11.1.0:
+    resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
+    hasBin: true
+    dev: false
+
   /vite-plugin-mkcert/1.10.1_vite@3.0.4:
     resolution: {integrity: sha512-fNNC0z+AcBZExKedjWC7bWlDMf4+WZJqO/4aYf7C/vYY1dqYVOM+zowwTYV0xSx5ZQgplfangPkZk+RwdUlpBg==}
     engines: {node: '>=v16.0.0'}
@@ -1264,12 +1322,23 @@ packages:
     resolution: {integrity: sha512-ZD1kl8OMMrDXJOS1ZRdnohh2BFfYjTeq+r7+yAahydQvrVOKbzXixx6f8LCoMjB+AgAf5BXnlWsZxmu964wJYA==}
     dev: false
 
-  /vue-router/4.1.3_vue@3.2.47:
-    resolution: {integrity: sha512-XvK81bcYglKiayT7/vYAg/f36ExPC4t90R/HIpzrZ5x+17BOWptXLCrEPufGgZeuq68ww4ekSIMBZY1qdUdfjA==}
+  /vue-konva/3.2.0_konva@9.3.18+vue@3.2.47:
+    resolution: {integrity: sha512-n1KcOJDvTsgBRy/9HNAEm+5mNgvIxatImIjeuietH5Qt3yHbIK8mp1sP6TQL+a3Pne45UiMO9W+Gwrq1cjptkw==}
+    engines: {node: '>= 4.0.0', npm: '>= 3.0.0'}
+    peerDependencies:
+      konva: '>7'
+      vue: ^3
+    dependencies:
+      konva: 9.3.18
+      vue: 3.2.47
+    dev: false
+
+  /vue-router/4.5.0_vue@3.2.47:
+    resolution: {integrity: sha512-HDuk+PuH5monfNuY+ct49mNmkCRK4xJAV9Ts4z9UFc4rzdDnxQLyCMGGc8pKhZhHTVzfanpNwB/lwqevcBwI4w==}
     peerDependencies:
       vue: ^3.2.0
     dependencies:
-      '@vue/devtools-api': 6.2.1
+      '@vue/devtools-api': 6.6.4
       vue: 3.2.47
     dev: false
 

+ 76 - 0
src/api/animation.ts

@@ -0,0 +1,76 @@
+import axios from './instance'
+import { params } from '@/env'
+import { 
+  AM_MODEL_LIST,
+  INSERT_AM_MODEL,
+  UPDATE_AM_MODEL,
+  DELETE_AM_MODEL,
+} from './constant'
+
+type ServiceAnimationModel = AnimationModel
+
+export interface AnimationAction  {
+  id: string,
+  title: string,
+  url: string,
+
+}
+export interface AnimationModel {
+  id: string,
+  title: string,
+  url: string,
+  showTitle: boolean
+  fontSize: number
+  globalVisibility: boolean
+  visibilityRange: number
+}
+
+export type AnimationModels = AnimationModel[]
+
+const serviceToLocal = (serviceAM: ServiceAnimationModel): AnimationModel => ({
+  ...serviceAM,
+})
+
+const localToService = (am: AnimationModel): ServiceAnimationModel => ({
+  ...am,
+})
+
+export const fetchAnimationModels = async () => {
+  return [
+    { id: '1', title: '模型1', url: '' },
+    { id: '2', title: '模型2', url: '' },
+    { id: '3', title: '模型3', url: '' },
+  ] as AnimationModel[]
+  
+  
+  const ams = await axios.get<ServiceAnimationModel[]>(AM_MODEL_LIST, { params: { caseId: params.caseId } })
+  return ams.map(serviceToLocal)
+}
+
+export const fetchAnimationActions = async () => {
+  return [
+    { id: '1', title: '模型1', url: '' },
+    { id: '2', title: '模型2', url: '' },
+    { id: '3', title: '模型3', url: '' },
+    { id: '2', title: '模型2', url: '' },
+    { id: '3', title: '模型3', url: '' },
+    { id: '2', title: '模型2', url: '' },
+    { id: '3', title: '模型3', url: '' },
+  ]
+}
+
+export const postInsertAnimationModel = async (am: AnimationModel) => {
+  const addData = { ...localToService(am), caseId: params.caseId, id: undefined }
+   const serviceData = await axios.post<ServiceAnimationModel>(INSERT_AM_MODEL, addData)
+   return serviceToLocal(serviceData)
+}
+
+export const postUpdateAnimationModel = async (guide: AnimationModel) => {
+  return axios.post<undefined>(UPDATE_AM_MODEL, { ...localToService(guide)})
+}
+
+export const postDeleteAnimationModel = (id: AnimationModel['id']) => {
+  return axios.post<undefined>(DELETE_AM_MODEL, { id: Number(id) })
+}
+
+  

+ 8 - 1
src/api/constant.ts

@@ -114,4 +114,11 @@ export const UPLOAD_FILE = `${namespace}/upload/file`
 export const MATERIAL_PAG = `/service/manage/dictFile/pageList/media-library`
 export const ADD_MATERIAL = `/service/manage/common/upload/fileNew`
 export const DEL_MATERIAL = `/service/manage/dictFile/del/media-library`
-export const MATERIAL_GROUP_LIST = `/service/manage/dict/getByKey/media-library`
+export const MATERIAL_GROUP_LIST = `/service/manage/dict/getByKey/media-library`
+
+
+// 动画模块
+export const AM_MODEL_LIST = `${namespace}/fusionGuide/allList`
+export const INSERT_AM_MODEL = `${namespace}/fusionGuide/add`
+export const UPDATE_AM_MODEL = `${namespace}/fusionGuide/update`
+export const DELETE_AM_MODEL = `${namespace}/fusionGuide/delete`

+ 2 - 1
src/api/index.ts

@@ -35,4 +35,5 @@ export * from './view'
 export * from './folder-type'
 export * from './floder'
 export * from './setting'
-export * from './path'
+export * from './path'
+export * from './animation'

+ 90 - 0
src/components/drawing-time-line/action.vue

@@ -0,0 +1,90 @@
+<template>
+  <template v-for="(rect, i) in rects">
+    <v-rect :config="rect" :ref="(s: any) => actionShapes[i] = s" />
+    <v-text :config="getTextConfig(i)" />
+  </template>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref } from "vue";
+import {
+  useGlobalVar,
+  useViewerInvertTransformConfig,
+  useViewerTransform,
+} from "../drawing/hook";
+import { DC } from "../drawing/dec";
+import { Rect } from "konva/lib/shapes/Rect";
+import { Transform } from "konva/lib/Util";
+import { lineLen } from "../drawing/math";
+
+const { misPixel } = useGlobalVar();
+
+const props = defineProps<{
+  items: { time: number; duration: number; name: string }[];
+  top: number;
+}>();
+
+const fontSize = 14;
+const viewerMat = useViewerTransform();
+const getTextConfig = (ndx: number) => {
+  const rect = rects.value[ndx];
+  let name = props.items[ndx].name;
+  const rectWidth = lineLen(
+    viewerMat.value.point(rect),
+    viewerMat.value.point({ x: rect.x + rect.width, y: rect.y })
+  );
+  const nameWidth = name.length * fontSize;
+
+  if (rectWidth > 14) {
+    if (nameWidth > rectWidth) {
+      const len = Math.floor(rectWidth / fontSize) - 4;
+      name = name.substring(0, len) + "...";
+    }
+  } else {
+    name = "";
+  }
+
+  const dec = new Transform()
+    .translate(rect.x, rect.y)
+    .scale(invConfig.value.scaleX, 1)
+    .decompose();
+  return {
+    ...dec,
+    width: rect.width / invConfig.value.scaleX,
+    height: rect.height / invConfig.value.scaleY,
+    fill: "#fff",
+    fontSize: 14,
+    listening: false,
+    align: "center",
+    verticalAlign: "middle",
+    text: name,
+  };
+};
+
+const invConfig = useViewerInvertTransformConfig();
+const rects = computed(() => {
+  return props.items.map((item) => {
+    const origin = { x: item.time * misPixel, y: props.top };
+    const end = {
+      x: (item.time + item.duration) * misPixel,
+      y: props.top + 30,
+    };
+
+    return {
+      ...origin,
+      cornerRadius: 5 * invConfig.value.scaleX,
+      width: end.x - origin.x,
+      fill: "#fff",
+      opacity: 0.16,
+      height: end.y - origin.y,
+    };
+  });
+});
+
+const actionShapes = ref<DC<Rect>[]>([]);
+defineExpose({
+  get shapes() {
+    return actionShapes.value;
+  },
+});
+</script>

+ 53 - 0
src/components/drawing-time-line/frame.vue

@@ -0,0 +1,53 @@
+<template>
+  <template v-for="(l, i) in lines">
+    <v-line :config="l" :ref="(s: any) => frameShapes[i] = s" />
+  </template>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref } from "vue";
+import {
+  flatPositions,
+  useGlobalVar,
+  useViewerInvertTransformConfig,
+} from "../drawing/hook";
+import { Transform } from "konva/lib/Util";
+import { Line } from "konva/lib/shapes/Line";
+import { DC } from "../drawing/dec";
+
+const { misPixel } = useGlobalVar();
+
+const props = defineProps<{ items: { time: number }[], top: number }>();
+const s = 6;
+const invConfig = useViewerInvertTransformConfig();
+const lines = computed(() => {
+  return props.items.map((item) => {
+    const origin = { x: item.time * misPixel, y: props.top + 10 };
+    const points = [
+      { x: origin.x - s / 2, y: origin.y },
+      { x: origin.x + s / 2, y: origin.y },
+      { x: origin.x + s / 2, y: origin.y + s * 1.5 },
+      { x: origin.x, y: origin.y + s * 2 },
+      { x: origin.x - s / 2, y: origin.y + s * 1.5 },
+    ];
+
+    return {
+      points: flatPositions(points),
+      closed: true,
+      fill: "#fff",
+      ...new Transform()
+        .translate(origin.x, 0)
+        .scale(invConfig.value.scaleX, 1)
+        .translate(-origin.x, 0)
+        .decompose(),
+    };
+  });
+});
+
+const frameShapes = ref<DC<Line>[]>([]);
+defineExpose({
+  get shapes() {
+    return frameShapes.value;
+  },
+});
+</script>

+ 118 - 0
src/components/drawing-time-line/index.vue

@@ -0,0 +1,118 @@
+<template>
+  <v-rect
+    :config="{
+      width: size?.width,
+      height: height,
+      fill: background ? background : '#000',
+      opacity: background ? 1 : 0,
+      ...bgConfig,
+    }"
+  />
+
+  <component
+    :is="itemsRenderer"
+    :items="items"
+    :top="top"
+    :ref="({ shapes }: any) => itemShapes = shapes"
+  />
+  <template v-for="(itemShape, i) in itemShapes">
+    <Operate
+      v-if="itemShape"
+      :target="itemShape"
+      :menus="[
+        {
+          label: '复制',
+          handler: () => copyHandler(i),
+        },
+        {
+          label: '删除',
+          handler: () => delHandler(i),
+        },
+      ]"
+    />
+  </template>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref, watch, watchEffect } from "vue";
+import {
+  useDrag,
+  useGlobalResize,
+  useGlobalVar,
+  useViewerInvertTransform,
+} from "../drawing/hook";
+import { Transform } from "konva/lib/Util";
+import { DC, EntityShape } from "../drawing/dec";
+import Operate from "../drawing/operate.vue";
+
+const { misPixel } = useGlobalVar();
+const { size } = useGlobalResize();
+
+const props = defineProps<{
+  items: { time: number; duration?: number }[];
+  itemsRenderer: any;
+  background?: string;
+  height: number;
+  top: number;
+}>();
+const emit = defineEmits<{
+  (e: "update", data: { ndx: number; time: number }): void;
+  (e: "add", data: any): void;
+  (e: "del", ndx: number): void;
+}>();
+
+const invMat = useViewerInvertTransform();
+const bgConfig = computed(() => {
+  return new Transform()
+    .multiply(invMat.value.copy())
+    .translate(0, props.top)
+    .decompose();
+});
+
+const itemShapes = ref<DC<EntityShape>[]>([]);
+const { drag } = useDrag(itemShapes);
+let total = { x: 0, y: 0 };
+watch(drag, (drag) => {
+  if (!drag) {
+    total = { x: 0, y: 0 };
+    return;
+  }
+  const cur = props.items[drag.ndx];
+  const curX = cur.time * misPixel + total.x + drag.x;
+  const exRects = props.items
+    .filter((_, ndx) => ndx !== drag.ndx)
+    .map((item) => ({
+      x: item.time * misPixel,
+      xe: (item.time + (item.duration || 0.001)) * misPixel,
+    }));
+
+  const curRect = { x: curX, xe: curX + (cur.duration || 0.001) * misPixel };
+  const joinNdx = exRects.findIndex(
+    (exRect) =>
+      (curRect.x > exRect.x && curRect.x < exRect.xe) ||
+      (curRect.xe > exRect.x && curRect.xe < exRect.xe) ||
+      (exRect.x > curRect.x && exRect.x < curRect.xe) ||
+      (exRect.xe > curRect.x && exRect.xe < curRect.xe)
+  );
+
+  if (!~joinNdx) {
+    emit("update", { ndx: drag.ndx, time: curX / misPixel });
+    total = { x: 0, y: 0 };
+  } else {
+    total.x += drag.x;
+    total.y += drag.y;
+  }
+});
+
+const copyHandler = (ndx: number) => {
+  const newFrame = {
+    ...props.items[ndx],
+    time: props.items[ndx].time + 3,
+  };
+  emit("add", newFrame);
+};
+
+const delHandler = (ndx: number) => {
+  emit("del", ndx);
+};
+</script>

+ 83 - 0
src/components/drawing-time/current.vue

@@ -0,0 +1,83 @@
+<template>
+  <v-arrow
+    ref="arrow"
+    :config="{
+      points: [currentX, 0, currentX, 10],
+      fill: currentColor,
+      stroke: currentColor,
+      strokeWidth: 1,
+      pointerLength: 10,
+      hitStrokeWidth: 10,
+      pointerWidth: 10,
+      ...currentMat,
+    }"
+  />
+  <v-line
+    :config="{
+        points: [currentX, size!.height, currentX, 10],
+        stroke: currentColor,
+        strokeWidth: 2,
+        listening: false,
+        ...currentMat
+      }"
+  />
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch, watchEffect } from "vue";
+import {
+  useDrag,
+  useGlobalResize,
+  useGlobalVar,
+  useViewer,
+  useViewerInvertTransformConfig,
+  useViewerTransform,
+} from "../drawing/hook";
+import { Transform } from "konva/lib/Util";
+import { Arrow } from "konva/lib/shapes/Arrow";
+import { DC } from "../drawing/dec";
+
+const props = withDefaults(defineProps<{ currentTime: number; follow?: boolean }>(), {
+  follow: false,
+});
+
+const currentColor = "#fff";
+const { misPixel } = useGlobalVar();
+const emit = defineEmits<{ (e: "update:currentTime", num: number): void }>();
+
+const arrow = ref<DC<Arrow>>();
+const { drag } = useDrag(arrow);
+watch(drag, (drag) => {
+  if (!drag) return;
+  const offsetX = drag.x;
+
+  const currentX = props.currentTime * misPixel + offsetX;
+  emit("update:currentTime", currentX / misPixel);
+});
+
+const invConfig = useViewerInvertTransformConfig();
+const currentX = computed(() => props.currentTime * misPixel);
+const currentMat = computed(() => {
+  return new Transform()
+    .translate(currentX.value, 0)
+    .scale(invConfig.value.scaleX, 1)
+    .translate(-currentX.value, 0)
+    .decompose();
+});
+
+const { viewer } = useViewer();
+const { size } = useGlobalResize();
+const viewerMat = useViewerTransform();
+watch(
+  () => {
+    if (!props.follow || !size.value) return;
+    return currentX.value;
+  },
+  (x) => {
+    if (!x) return;
+    const currentPixel = viewerMat.value.point({ x: currentX.value, y: 0 }).x;
+    const offsetX = size.value!.width / 2 - currentPixel;
+    viewer.movePixel({ x: offsetX, y: 0 });
+  }
+);
+</script>

+ 116 - 0
src/components/drawing-time/time.vue

@@ -0,0 +1,116 @@
+<template>
+  <template v-if="shapeConfig">
+    <v-line v-for="line in shapeConfig.lines" :config="line" />
+    <v-line
+      :config="{
+        points: [0, 0, size?.width, 0],
+        strokeWidth: 25,
+        stroke: '#fff',
+        opacity: 0,
+        ...invConfig,
+      }"
+      @click="clickHandler"
+      ref="line"
+    />
+    <v-text v-for="texConfig in shapeConfig.texts" :config="{ ...texConfig }" />
+    <slot />
+  </template>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from "vue";
+import {
+  useGlobalResize,
+  useGlobalVar,
+  useHoverPointer,
+  useStage,
+  useViewerInvertTransform,
+  useViewerInvertTransformConfig,
+} from "../drawing/hook";
+import { formatDate } from "@/utils";
+import { TextConfig } from "konva/lib/shapes/Text";
+import { Transform } from "konva/lib/Util";
+import { Line, LineConfig } from "konva/lib/shapes/Line";
+import { DC } from "../drawing/dec";
+
+const { misPixel } = useGlobalVar();
+const emit = defineEmits<{ (e: "updateCurrentTime", num: number): void }>();
+
+const getText = (mis: number) => {
+  const date = new Date(0);
+  date.setHours(0);
+  date.setSeconds(mis);
+  return formatDate(date, "mm:ss");
+};
+
+const invMat = useViewerInvertTransform();
+const invConfig = useViewerInvertTransformConfig();
+const { size } = useGlobalResize();
+
+const timeRange = computed(() => {
+  if (!size.value) return;
+
+  const lt = { x: 0, y: 0 };
+  const rt = { x: size.value!.width, y: 0 };
+  const startPixel = invMat.value.point(lt).x;
+  const endPixel = invMat.value.point(rt).x;
+  const startTime = Math.floor(startPixel / misPixel);
+  const endTime = Math.ceil(endPixel / misPixel);
+
+  return { startTime, endTime };
+});
+
+const strokeWidth = 2;
+const color = "#999";
+
+const shapeConfig = computed(() => {
+  if (!timeRange.value) return;
+
+  const texts: TextConfig[] = [];
+  const lines: LineConfig[] = [];
+
+  for (let i = timeRange.value.startTime; i < timeRange.value.endTime; i++) {
+    const x = i * misPixel;
+    const line: LineConfig = {
+      ...new Transform()
+        .translate(x, 0)
+        .scale(invConfig.value.scaleX, 1)
+        .translate(-x, 0)
+        .decompose(),
+      stroke: color,
+      hitStrokeWidth: 5,
+      strokeWidth,
+    };
+
+    if (i % 10) {
+      line.points = [x, 0, x, 4];
+    } else {
+      line.points = [x, 0, x, 12];
+      texts.push({
+        ...new Transform()
+          .translate(x + 5 * invConfig.value.scaleX, 5)
+          .scale(invConfig.value.scaleX, 1)
+          .decompose(),
+        text: getText(i),
+        fontSize: strokeWidth * 6,
+        fill: color,
+        align: "left",
+        verticalAlign: "top",
+      });
+    }
+    lines.push(line);
+  }
+
+  return { texts, lines };
+});
+
+const line = ref<DC<Line>>();
+useHoverPointer(line);
+
+const stage = useStage();
+const clickHandler = () => {
+  const pos = stage.value!.getNode().pointerPos!;
+  const x = invMat.value.point(pos).x;
+  emit("updateCurrentTime", x / misPixel);
+};
+</script>

+ 12 - 0
src/components/drawing/dec.d.ts

@@ -0,0 +1,12 @@
+import Konva from "konva";
+
+type DC<T extends any> = {
+	getNode: () => T,
+	getStage: () => T
+}
+
+type EntityShape = (Konva.Shape | Konva.Stage | Konva.Layer | Konva.Group) & { repShape?: EntityShape }
+
+
+export type Pos = { x: number; y: number };
+export type Size = { width: number, height: number }

+ 700 - 0
src/components/drawing/hook.ts

@@ -0,0 +1,700 @@
+import {
+  computed,
+  getCurrentInstance,
+  nextTick,
+  reactive,
+  Ref,
+  ref,
+  shallowRef,
+  toRaw,
+  watch,
+  WatchCallback,
+  watchEffect,
+  WatchOptions,
+  WatchSource,
+} from "vue";
+import { v4 as uuid } from "uuid";
+import { DC, EntityShape, Pos, Size } from "./dec";
+import { Stage } from "konva/lib/Stage";
+import { Transform } from "konva/lib/Util";
+import { lineLen } from "./math";
+import { Viewer } from "./viewer";
+import { KonvaEventObject } from "konva/lib/Node";
+
+export const rendererName = "renderer";
+export const rendererMap = new WeakMap<any, { unmounteds: (() => void)[] }>();
+
+export const useRendererInstance = () => {
+  let instance = getCurrentInstance()!;
+  while (instance.type.__name !== rendererName) {
+    if (instance.parent) {
+      instance = instance.parent;
+    } else {
+      throw "未发现渲染实例";
+    }
+  }
+  return instance;
+};
+
+export const installGlobalVar = <T>(
+  create: () => { var: T; onDestroy: () => void } | T,
+  key = Symbol("globalVar")
+) => {
+  const useGlobalVar = (): T => {
+    const instance = useRendererInstance() as any;
+    const { unmounteds } = rendererMap.get(instance)!;
+    if (!(key in instance)) {
+      let val = create() as any;
+      if (typeof val === "object" && "var" in val && "onDestroy" in val) {
+        val.onDestory && unmounteds.push(val.onDestory);
+        if (import.meta.env.DEV) {
+          unmounteds.push(() => {
+            console.log("销毁变量", key);
+          });
+        }
+        val = val.var;
+      }
+      instance[key] = val;
+    }
+    return instance[key];
+  };
+
+  return useGlobalVar;
+};
+
+export const useGlobalVar = installGlobalVar(() => {
+  return {
+    misPixel: 10,
+  };
+});
+
+export const onlyId = () => uuid();
+
+export const stackVar = <T>(init?: T) => {
+  const factory = (init: T) => ({ var: init, id: onlyId() });
+  const stack = reactive([]) as { var: T; id: string }[];
+  if (init) {
+    stack.push(factory(init));
+  }
+  const result = {
+    get value() {
+      return stack[stack.length - 1]?.var;
+    },
+    set value(val) {
+      stack[stack.length - 1].var = val;
+    },
+    push(data: T) {
+      stack.push(factory(data));
+      const item = stack[stack.length - 1];
+      const pop = (() => {
+        const ndx = stack.findIndex(({ id }) => id === item.id);
+        if (~ndx) {
+          stack.splice(ndx, 1);
+        }
+      }) as (() => void) & { set: (data: T) => void };
+      pop.set = (data) => {
+        item.var = data;
+      };
+      return pop;
+    },
+    pop() {
+      if (stack.length - 1 > 0) {
+        stack.pop();
+      } else {
+        console.error("已到达栈顶");
+      }
+    },
+    cycle<R>(data: T, run: () => R): R {
+      result.push(data);
+      const r = run();
+      result.pop();
+      return r;
+    },
+  };
+  return result;
+};
+
+export const useCursor = installGlobalVar(
+  () => stackVar("default"),
+  Symbol("cursor")
+);
+
+/**
+ * 多个函数合并成一个函数
+ * @param fns
+ * @returns
+ */
+export const mergeFuns = (...fns: (() => void)[] | (() => void)[][]) => {
+  return () => {
+    fns.forEach((fn) => {
+      if (Array.isArray(fn)) {
+        fn.forEach((f) => f());
+      } else {
+        fn();
+      }
+    });
+  };
+};
+
+export const useStage = installGlobalVar(
+  () => shallowRef<DC<Stage> | undefined>(),
+  Symbol("stage")
+);
+
+export const listener = <
+  T extends HTMLElement | Window,
+  K extends keyof HTMLElementEventMap
+>(
+  target: T,
+  eventName: K,
+  callback: (this: T, ev: HTMLElementEventMap[K]) => any
+) => {
+  target.addEventListener(eventName, callback as any);
+  return () => {
+    target.removeEventListener(eventName, callback as any);
+  };
+};
+
+export const useGlobalResize = installGlobalVar(() => {
+  const stage = useStage();
+  const size = ref<Size>();
+  const setSize = () => {
+    if (fix.value) return;
+    console.error(stage.value);
+    const container = stage.value?.getStage().container();
+    if (container) {
+      container.style.setProperty("display", "none");
+    }
+
+    const dom = stage.value!.getNode().container().parentElement!;
+    size.value = {
+      width: dom.offsetWidth,
+      height: dom.offsetHeight,
+    };
+    if (container) {
+      container.style.removeProperty("display");
+    }
+  };
+  const stopWatch = watchEffect(() => {
+    if (stage.value) {
+      setSize();
+      nextTick(() => stopWatch());
+    }
+  });
+  let unResize = listener(window, "resize", setSize);
+  const fix = ref(false);
+  let unWatch: (() => void) | null = null;
+
+  const setFixSize = (fixSize: { width: number; height: number } | null) => {
+    if (fixSize) {
+      size.value = { ...fixSize };
+      unWatch && unWatch();
+      unWatch = watchEffect(() => {
+        const $stage = stage.value?.getStage();
+        if ($stage) {
+          $stage.width(fixSize.width);
+          $stage.height(fixSize.height);
+          nextTick(() => unWatch && unWatch());
+        }
+      });
+    }
+    if (fix.value && !fixSize) {
+      unResize = listener(window, "resize", setSize);
+      fix.value = false;
+      nextTick(setSize);
+    } else if (!fix.value && fixSize) {
+      fix.value = true;
+      unResize();
+    }
+  };
+
+  return {
+    var: {
+      setFixSize: setFixSize,
+      updateSize: setSize,
+      size,
+      fix,
+    },
+    onDestroy: () => {
+      fix || unResize();
+      unWatch && unWatch();
+    },
+  };
+}, Symbol("resize"));
+
+export const globalWatch = <T>(
+  source: WatchSource<T>,
+  cb: WatchCallback<T, T>,
+  options?: WatchOptions
+): (() => void) => {
+  let stop: () => void;
+  nextTick(() => {
+    stop = watch(source, cb as any, options as any);
+  });
+  return () => {
+    stop && stop();
+  };
+};
+
+export const getOffset = (
+  ev: MouseEvent | TouchEvent,
+  dom = ev.target! as HTMLElement,
+  ndx = 0
+) => {
+  const event = ev instanceof TouchEvent ? ev.changedTouches[ndx] : ev;
+  const rect = dom.getBoundingClientRect();
+  const offsetX = event.clientX - rect.left;
+  const offsetY = event.clientY - rect.top;
+  return {
+    x: offsetX,
+    y: offsetY,
+  };
+};
+
+type DragProps = {
+  move?: (
+    info: Record<"start" | "prev" | "end", Pos> & { ev: PointerEvent }
+  ) => void;
+  down?: (pos: Pos, ev: PointerEvent) => void;
+  up?: (pos: Pos, ev: PointerEvent) => void;
+  notPrevent?: boolean;
+};
+export const dragListener = (
+  dom: HTMLElement,
+  props: DragProps | DragProps["move"] = {}
+) => {
+  if (typeof props === "function") {
+    props = { move: props };
+  }
+  const { move, up, down } = props;
+  const mount = document.documentElement;
+
+  if (!move && !up && !down) return () => {};
+
+  let moveHandler: any, endHandler: any;
+  const downHandler = (ev: PointerEvent) => {
+    const start = getOffset(ev, dom);
+    let prev = start;
+    down && down(start, ev);
+    props.notPrevent || ev.preventDefault();
+
+    moveHandler = (ev: PointerEvent) => {
+      const end = getOffset(ev, dom);
+      move!({ start, end, prev, ev });
+      prev = end;
+
+      props.notPrevent || ev.preventDefault();
+    };
+    endHandler = (ev: PointerEvent) => {
+      up && up(getOffset(ev, dom), ev);
+      mount.removeEventListener("pointermove", moveHandler);
+      mount.removeEventListener("pointerup", endHandler);
+      props.notPrevent || ev.preventDefault();
+    };
+
+    move &&
+      mount.addEventListener("pointermove", moveHandler, { passive: false });
+    mount.addEventListener("pointerup", endHandler, { passive: false });
+  };
+
+  dom.addEventListener("pointerdown", downHandler, { passive: false });
+  return () => {
+    dom.removeEventListener("pointerdown", downHandler);
+    moveHandler && mount.removeEventListener("pointermove", moveHandler);
+    endHandler && mount.removeEventListener("pointerup", endHandler);
+  };
+};
+
+export const getTouchScaleProps = (
+  ev: TouchEvent,
+  dom = ev.target! as HTMLElement
+) => {
+  const start = getOffset(ev, dom, 0);
+  const end = getOffset(ev, dom, 1);
+  const center = {
+    x: (end.x + start.x) / 2,
+    y: (end.y + start.y) / 2,
+  };
+  const initDist = lineLen(start, end);
+  return {
+    center,
+    dist: initDist,
+  };
+};
+
+export const touchScaleListener = (
+  dom: HTMLElement,
+  cb: (props: { center: Pos; scale: number }) => void
+) => {
+  const mount = document.documentElement;
+  let moveHandler: (ev: TouchEvent) => void;
+  let endHandler: (ev: TouchEvent) => void;
+  const startHandler = (ev: TouchEvent) => {
+    if (ev.changedTouches.length <= 1) return;
+    let prevScale = getTouchScaleProps(ev, dom);
+    ev.preventDefault();
+
+    moveHandler = (ev: TouchEvent) => {
+      if (ev.changedTouches.length <= 1) return;
+      const curScale = getTouchScaleProps(ev, dom);
+      cb({ center: prevScale.center, scale: curScale.dist / prevScale.dist });
+      prevScale = curScale;
+      ev.preventDefault();
+    };
+    endHandler = (ev: TouchEvent) => {
+      mount.removeEventListener("touchmove", moveHandler);
+      mount.removeEventListener("touchend", endHandler);
+      ev.preventDefault();
+    };
+
+    mount.addEventListener("touchmove", moveHandler, {
+      passive: false,
+    });
+    mount.addEventListener("touchend", endHandler, {
+      passive: false,
+    });
+  };
+
+  dom.addEventListener("touchstart", startHandler, { passive: false });
+
+  return () => {
+    dom.removeEventListener("touchstart", startHandler);
+    mount.removeEventListener("touchmove", moveHandler);
+    mount.removeEventListener("touchend", endHandler);
+  };
+};
+
+export const wheelListener = (
+  dom: HTMLElement,
+  cb: (props: { center: Pos; scale: number }) => void
+) => {
+  const wheelHandler = (ev: WheelEvent) => {
+    const scale = 1 - ev.deltaY / 1000;
+    const center = { x: ev.offsetX, y: ev.offsetY };
+    cb({ center, scale });
+    ev.preventDefault();
+  };
+
+  dom.addEventListener("wheel", wheelHandler);
+  return () => {
+    dom.removeEventListener("wheel", wheelHandler);
+  };
+};
+
+export const scaleListener = (
+  dom: HTMLElement,
+  cb: (props: { center: Pos; scale: number }) => void
+) => mergeFuns(touchScaleListener(dom, cb), wheelListener(dom, cb));
+
+export const useViewer = installGlobalVar(() => {
+  const stage = useStage();
+  const viewer = new Viewer();
+  const transform = ref(new Transform());
+  const cursor = useCursor();
+
+  const init = (dom: HTMLDivElement) => {
+    const dragDestroy = dragListener(dom, {
+      move: ({ end, prev }) => {
+        if (cursor.value !== "move") {
+          viewer.movePixel({ x: end.x - prev.x, y: 0 });
+        }
+      },
+      notPrevent: true,
+    });
+    const scaleDestroy = scaleListener(dom, (info) => {
+      const currentScalex = viewer.viewMat.decompose().scaleX;
+      const finalScale = currentScalex * info.scale;
+      const scale = Math.min(Math.max(finalScale, 0.5), 3);
+      if (cursor.value !== "move") {
+        viewer.scalePixel(info.center, { x: scale / currentScalex, y: 1 });
+      }
+    });
+    viewer.bus.on("transformChange", (newTransform) => {
+      // console.log(newTransform.m)
+      transform.value = newTransform;
+    });
+    transform.value = viewer.transform;
+    return mergeFuns(dragDestroy, scaleDestroy);
+  };
+
+  return {
+    var: {
+      transform: transform,
+      viewer,
+    },
+    onDestroy: globalWatch(
+      () => stage.value?.getNode().container(),
+      (dom, _, onCleanup) => {
+        dom && onCleanup(init(dom));
+      },
+      { immediate: true }
+    ),
+  };
+}, Symbol("viewer"));
+
+export const useViewerTransform = installGlobalVar(() => {
+  const viewer = useViewer();
+  return viewer.transform;
+}, Symbol("viewTransform"));
+
+export const useViewerTransformConfig = () => {
+  const transform = useViewerTransform();
+  return computed(() => transform.value.decompose());
+};
+
+export const useViewerInvertTransform = () => {
+  const transform = useViewerTransform();
+  return computed(() => transform.value.copy().invert());
+};
+
+export const useViewerInvertTransformConfig = () => {
+  const transform = useViewerInvertTransform();
+  return computed(() => transform.value.decompose());
+};
+
+export const flatPositions = (positions: Pos[]) =>
+  positions.flatMap((p) => [p.x, p.y]);
+
+export type PausePack<T extends object> = T & {
+  pause: () => void;
+  resume: () => void;
+  isPause: boolean;
+};
+export const usePause = <T extends object>(api?: T): PausePack<T> => {
+  const isPause = ref(false);
+  const result = (api || {}) as PausePack<T>;
+
+  Object.defineProperty(result, "isPause", {
+    get() {
+      return isPause.value;
+    },
+    set(v) {
+      return true;
+    },
+  });
+
+  result.pause = () => (isPause.value = true);
+  result.resume = () => (isPause.value = false);
+
+  return result;
+};
+
+const hoverPointer = (shape: EntityShape, cursor: ReturnType<typeof useCursor>) => {
+  shape.on("pointerenter.hover", () => {
+    const pop = cursor.push("pointer");
+    shape.on("pointerleave.hover", () => {
+      pop();
+      shape.off("pointerleave.hover");
+    });
+  });
+  return () => {
+    shape.off("pointerenter.hover pointerleave.hover");
+  }
+}
+
+export const useHoverPointer = (shape: Ref<DC<EntityShape> |  undefined>) => {
+  const cursor = useCursor()
+  watchEffect((onCleanup) => {
+    if (shape.value) {
+      console.error('shape.value', shape.value)
+      onCleanup(hoverPointer(shape.value.getNode(), cursor))
+    }
+  })
+  return cursor
+}
+
+export const useDrag = (
+  shape: Ref<DC<EntityShape> | DC<EntityShape>[] | undefined>,
+) => {
+  const cursor = useCursor();
+  const stage = useStage();
+  const drag = ref<Pos & { ndx: number }>();
+  const invMat = useViewerInvertTransform();
+
+  const init = (shape: EntityShape, dom: HTMLDivElement, ndx: number) => {
+    shape.on("pointerenter.drag", () => {
+      const pop = cursor.push("pointer");
+      shape.on("pointerleave.drag", () => {
+        pop();
+        shape.off("pointerleave.drag");
+      });
+    });
+
+    let pop: (() => void) | null = null;
+    let start = { x: 0, y: 0 }
+    shape.on("pointerdown.drag", (ev) => {
+      pop = cursor.push("move")
+      start = invMat.value.point(getOffset(ev.evt, stage.value!.getNode().container()));
+    });
+
+    shape.draggable(true);
+    shape.dragBoundFunc(function (this: any, _: any, ev: MouseEvent) {
+      const current = invMat.value.point(getOffset(ev, stage.value!.getNode().container()));
+      drag.value = {
+        x: current.x - start.x,
+        y: current.y - start.y,
+        ndx,
+      };
+      start = current
+      return this.absolutePosition();
+    });
+
+    return mergeFuns(
+      listener(dom, "pointerup", () => {
+        pop && pop();
+        pop = null;
+        drag.value = undefined;
+      }),
+      () => {
+        shape.off("pointerenter.drag pointerleave.drag pointerdown.drag");
+        if (pop) {
+          pop();
+          shape.draggable(false);
+        }
+      }
+    );
+  };
+
+  const result = usePause({
+    drag,
+    stop: () => {
+      stopWatch();
+    },
+  });
+
+  const stopWatch = watch(
+    () => {
+      const shapes = shape.value
+        ? Array.isArray(shape.value)
+          ? [...shape.value]
+          : [shape.value]
+        : [];
+      if (shapes.some((item) => !item)) {
+        return [];
+      }
+      return shapes;
+    },
+    (shapes, _, onCleanup) => {
+      onCleanup(
+        mergeFuns(
+          shapes.map((shape, ndx) =>
+            watchEffect((onCleanup) => {
+              if (!result.isPause && shape?.getNode() && stage.value?.getNode) {
+                onCleanup(
+                  init(
+                    shape?.getNode(),
+                    stage.value?.getNode().container(),
+                    ndx
+                  )
+                );
+              }
+            })
+          )
+        )
+      );
+    },
+    { immediate: true }
+  );
+  return result;
+};
+
+const stageHoverMap = new WeakMap<
+  Stage,
+  { result: Ref<EntityShape | undefined>; count: number; des: () => void }
+>();
+export const getHoverShape = (stage: Stage) => {
+  let isStop = false;
+  const stop = () => {
+    if (isStop || !stageHoverMap.has(stage)) return;
+    isStop = true;
+    const data = stageHoverMap.get(stage)!;
+    if (--data.count <= 0) {
+      data.des();
+    }
+  };
+
+  if (stageHoverMap.has(stage)) {
+    const data = stageHoverMap.get(stage)!;
+    ++data.count;
+    return [data.result, stop] as const;
+  }
+
+  const hover = ref<EntityShape>();
+  const enterHandler = (ev: KonvaEventObject<any, Stage>) => {
+    const target = ev.target;
+    hover.value = target;
+    target.off(`pointerleave`, leaveHandler);
+    target.on(`pointerleave`, leaveHandler as any);
+  };
+
+  const leaveHandler = () => {
+    if (hover.value) {
+      hover.value.off(`pointerleave`, leaveHandler);
+      hover.value = undefined;
+    }
+  };
+
+  stage.on(`pointerenter`, enterHandler);
+  stageHoverMap.set(stage, {
+    result: hover,
+    count: 1,
+    des: () => {
+      stage.off(`pointerenter`, enterHandler);
+      leaveHandler();
+      stageHoverMap.delete(stage);
+    },
+  });
+  return [hover, stop] as const;
+};
+
+export const useShapeIsHover = (shape: Ref<DC<EntityShape> | undefined>) => {
+  const stage = useStage();
+  const isHover = ref(false);
+  const stop = watch(
+    () => ({ stage: stage.value?.getNode(), shape: shape.value?.getNode() }),
+    ({ stage, shape }, _, onCleanup) => {
+      if (!stage || !shape || result.isPause) {
+        isHover.value = false;
+        return;
+      }
+
+      const [hoverShape, stopHoverListener] = getHoverShape(stage);
+
+      watchEffect(() => {
+        isHover.value = !!(
+          hoverShape.value && shapeTreeContain([shape], toRaw(hoverShape.value))
+        );
+      });
+      onCleanup(stopHoverListener);
+    },
+    { immediate: true }
+  );
+  const result = usePause([isHover, stop] as const);
+  return result;
+};
+
+export const shapeTreeContain = (
+  parent: EntityShape | EntityShape[],
+  target: EntityShape,
+  checked: EntityShape[] = []
+) => {
+  const eq = Array.isArray(parent)
+    ? (shape: EntityShape) => parent.includes(shape)
+    : (shape: EntityShape) => parent === shape;
+  return shapeParentsEq(target, eq, checked);
+};
+
+export const shapeParentsEq = (
+  target: EntityShape,
+  eq: (shape: EntityShape) => boolean,
+  checked: EntityShape[] = []
+) => {
+  while (target) {
+    if (checked.includes(target)) return null;
+    if (eq(target)) {
+      return target;
+    }
+    target = target.parent as any;
+  }
+  return null;
+};

+ 10 - 0
src/components/drawing/install-lib.ts

@@ -0,0 +1,10 @@
+import { App } from "vue";
+import VueKonva from "vue-konva";
+
+const installApps = new WeakSet<App>();
+export const install = (app: App) => {
+  if (installApps.has(app)) return;
+  console.error('use vue-konva', VueKonva)
+  app.use(VueKonva);
+  installApps.add(app);
+};

+ 606 - 0
src/components/drawing/math.ts

@@ -0,0 +1,606 @@
+import { Vector2, ShapeUtils, Box2 } from "three";
+import { Transform } from "konva/lib/Util";
+
+export type Pos = { x: number; y: number };
+export type Size = { width: number, height: number }
+
+/**
+ * 四舍五入
+ * @param num
+ * @param b 保留位数
+ * @returns
+ */
+export const round = (num: number, b: number = 2) => {
+  const scale = Math.pow(10, b);
+  return Math.round(num * scale) / scale;
+};
+
+export const vector = (pos: Pos = { x: 0, y: 0 }): Vector2 => {
+  return new Vector2(pos.x, pos.y);
+  // if (pos instanceof Vector2) {
+  //   return pos;
+  // } else {
+  //   return new Vector2(pos.x, pos.y);
+  // }
+};
+export const lVector = (line: Pos[]) => line.map(vector);
+
+export const zeroEq = (n: number) => Math.abs(n) < 0.0001;
+export const numEq = (p1: number, p2: number) => zeroEq(p1 - p2);
+export const vEq = (v1: Pos, v2: Pos) => numEq(v1.x, v2.x) && numEq(v1.y, v2.y);
+
+export const vsBound = (positions: Pos[]) => {
+  const box = new Box2();
+  box.setFromPoints(positions.map(vector));
+  return box;
+};
+
+/**
+ * 获取线段方向
+ * @param line 线段
+ * @returns 方向
+ */
+export const lineVector = (line: Pos[]) =>
+  vector(line[1]).sub(vector(line[0])).normalize();
+
+/**
+ * 点是否相同
+ * @param p1 点1
+ * @param p2 点2
+ * @returns 是否相等
+ */
+export const eqPoint = vEq;
+
+/**
+ * 方向是否相同
+ * @param p1 点1
+ * @param p2 点2
+ * @returns 是否相等
+ */
+export const eqNGDire = (p1: Pos, p2: Pos) =>
+  eqPoint(p1, p2) || eqPoint(p1, vector(p2).multiplyScalar(-1));
+
+/**
+ * 获取两点距离
+ * @param p1 点1
+ * @param p2 点2
+ * @returns 距离
+ */
+export const lineLen = (p1: Pos, p2: Pos) => vector(p1).distanceTo(p2);
+
+export const vectorLen = (dire: Pos) => vector(dire).length();
+
+/**
+ * 获取向量的垂直向量
+ * @param dire 原方向
+ * @returns 垂直向量
+ */
+export const verticalVector = (dire: Pos) =>
+  vector({ x: -dire.y, y: dire.x }).normalize();
+
+/**
+ * 获取旋转指定度数后的向量
+ * @param pos 远向量
+ * @param angleRad 旋转角度
+ * @returns 旋转后向量
+ */
+export const rotateVector = (pos: Pos, angleRad: number) =>
+  new Transform().rotate(angleRad).point(pos);
+
+/**
+ * 创建线段
+ * @param dire 向量
+ * @param start 起始点
+ * @param dis 长度
+ * @returns 线段
+ */
+
+export const getVectorLine = (
+  dire: Pos,
+  start: Pos = { x: 0, y: 0 },
+  dis: number = 1
+) => [start, vector(dire).multiplyScalar(dis).add(start)];
+
+/**
+ * 获取线段的垂直方向向量
+ * @param line 原线段
+ * @returns 垂直向量
+ */
+export const lineVerticalVector = (line: Pos[]) =>
+  verticalVector(lineVector(line));
+
+/**
+ * 获取向量的垂直线段
+ * @param dire 向量
+ * @param start 线段原点
+ * @param len 线段长度
+ */
+export const verticalVectorLine = (
+  dire: Pos,
+  start: Pos = { x: 0, y: 0 },
+  len: number = 1
+) => getVectorLine(verticalVector(dire), start, len);
+
+/**
+ * 获取两向量角度(从向量a出发)
+ * @param v1 向量a
+ * @param v2 向量b
+ * @returns 两向量夹角弧度, 逆时针为正,顺时针为负
+ */
+export const vector2IncludedAngle = (v1: Pos, v2: Pos) => {
+  const start = vector(v1);
+  const end = vector(v2);
+  const angle = start.angleTo(end);
+  return start.cross(end) > 0 ? angle : -angle;
+};
+
+// 判断多边形方向(Shoelace Formula)
+export function getPolygonDirection(points: Pos[]) {
+  let area = 0;
+  const numPoints = points.length;
+  for (let i = 0; i < numPoints; i++) {
+      const p1 = points[i];
+      const p2 = points[(i + 1) % numPoints];
+      area += (p2.x - p1.x) * (p2.y + p1.y);
+  }
+
+  // 如果面积为正,是逆时针;否则是顺时针
+  return area;
+}
+
+/**
+ * 获取两线段角度(从线段a出发)
+ * @param line1 线段a
+ * @param line2 线段b
+ * @returns 两线段夹角弧度
+ */
+export const line2IncludedAngle = (line1: Pos[], line2: Pos[]) =>
+  vector2IncludedAngle(lineVector(line1), lineVector(line2));
+
+/**
+ * 获取向量与X正轴角度
+ * @param v 向量
+ * @returns 夹角弧度
+ */
+const nXAxis = vector({ x: 1, y: 0 });
+export const vectorAngle = (v: Pos) => {
+  const start = vector(v);
+  return start.angleTo(nXAxis);
+};
+
+/**
+ * 获取线段与方向的夹角弧度
+ * @param line 线段
+ * @param dire 方向
+ * @returns 线段与方向夹角弧度
+ */
+export const lineAndVectorIncludedAngle = (line: Pos[], v: Pos) =>
+  vector2IncludedAngle(lineVector(line), v);
+
+/**
+ * 获取线段中心点
+ * @param line
+ * @returns
+ */
+export const lineCenter = (line: Pos[]) =>
+  vector(line[0]).add(line[1]).multiplyScalar(0.5);
+
+
+export const lineSpeed = (line: Pos[], step: number) => {
+  const p = vector(line[0])
+  const v = vector(line[1]).sub(line[0])
+  return p.add(v.multiplyScalar(step))
+}
+
+export const pointsCenter = (points: Pos[]) => {
+  if (points.length === 0) return { x: 0, y: 0 };
+  const v = vector(points[0]);
+  for (let i = 1; i < points.length; i++) {
+    v.add(points[i]);
+  }
+  return v.multiplyScalar(1 / points.length);
+};
+
+export const lineJoin = (l1: Pos[], l2: Pos[]) => {
+  const checks = [
+    [l1[0], l2[0]],
+    [l1[0], l2[1]],
+    [l1[1], l2[0]],
+    [l1[1], l2[1]],
+  ];
+  const ndx = checks.findIndex((line) => eqPoint(line[0], line[1]));
+  if (~ndx) {
+    return checks[ndx];
+  } else {
+    return false;
+  }
+};
+
+export const isLineEqual = (l1: Pos[], l2: Pos[]) =>
+  eqPoint(l1[0], l2[0]) && eqPoint(l1[1], l2[1]);
+
+export const isLineReverseEqual = (l1: Pos[], l2: Pos[]) =>
+  eqPoint(l1[0], l2[1]) && eqPoint(l1[1], l2[0]);
+
+export const isLineIntersect = (l1: Pos[], l2: Pos[]) => {
+  const s1 = l2[1].y - l2[0].y;
+  const s2 = l2[1].x - l2[0].x;
+  const s3 = l1[1].x - l1[0].x;
+  const s4 = l1[1].y - l1[0].y;
+  const s5 = l1[0].y - l2[0].y;
+  const s6 = l1[0].x - l2[0].x;
+
+  const denominator = s1 * s3 - s2 * s4;
+  const ua = round((s2 * s5 - s1 * s6) / denominator, 6);
+  const ub = round((s3 * s5 - s4 * s6) / denominator, 6);
+
+  if (ua >= 0 && ua <= 1 && ub >= 0 && ub <= 1) {
+    return true;
+  } else {
+    return false;
+  }
+};
+
+export const vectorParallel = (dire1: Pos, dire2: Pos) =>
+  zeroEq(vector(dire1).cross(dire2));
+
+export const lineParallelRelationship = (l1: Pos[], l2: Pos[]) => {
+  const dire1 = lineVector(l1);
+  const dire2 = lineVector(l2);
+
+  // 计算线段的法向量
+  const normal1 = verticalVector(dire1);
+  const normal2 = verticalVector(dire2);
+  const startDire = lineVector([l1[0], l2[0]]);
+
+  // 计算线段的参数方程
+  const t1 = round(normal2.dot(startDire) / normal2.dot(dire1), 6);
+  const t2 = round(normal1.dot(startDire) / normal1.dot(dire2), 6);
+
+  if (t1 === 0 && t2 === 0) {
+    return RelationshipEnum.Overlap;
+  }
+
+  if (eqPoint(normal1, normal2) || eqPoint(normal1, normal2.clone().negate())) {
+    return lineJoin(l1, l2)
+      ? RelationshipEnum.Overlap
+      : RelationshipEnum.Parallel;
+  }
+};
+
+export enum RelationshipEnum {
+  // 重叠
+  Overlap = "Overlap",
+  // 相交
+  Intersect = "Intersect",
+  // 延长相交
+  ExtendIntersect = "ExtendIntersect",
+  // 平行
+  Parallel = "Parallel",
+  // 首尾连接
+  Join = "Join",
+  // 一样
+  Equal = "Equal",
+  // 反向
+  ReverseEqual = "ReverseEqual",
+}
+/**
+ * 获取两线段是什么关系,(重叠、相交、平行、首尾相接等)
+ * @param l1
+ * @param l2
+ * @returns RelationshipEnum
+ */
+export const lineRelationship = (l1: Pos[], l2: Pos[]) => {
+  if (isLineEqual(l1, l2)) {
+    return RelationshipEnum.Equal;
+  } else if (isLineReverseEqual(l1, l2)) {
+    return RelationshipEnum.ReverseEqual;
+  }
+
+  const parallelRelationship = lineParallelRelationship(l1, l2);
+  if (parallelRelationship) {
+    return parallelRelationship;
+  } else if (lineJoin(l1, l2)) {
+    return RelationshipEnum.Join;
+  } else if (isLineIntersect(l1, l2)) {
+    return RelationshipEnum.Intersect; // 两线段相交
+  } else {
+    return RelationshipEnum.ExtendIntersect; // 延长可相交
+  }
+};
+
+export const createLine = (p: Pos, v: Pos, l?: number) => {
+  const line = [p];
+  if (l) {
+    v = vector(v).multiplyScalar(l);
+  }
+  line[1] = vector(line[0]).add(v);
+  return line;
+};
+
+/**
+ * 获取两线段交点,可延长相交
+ * @param l1 线段1
+ * @param l2 线段2
+ * @returns 交点坐标
+ */
+export const lineIntersection = (l1: Pos[], l2: Pos[]) => {
+  // 定义两条线段的起点和终点坐标
+  const [line1Start, line1End] = lVector(l1);
+  const [line2Start, line2End] = lVector(l2);
+
+  // 计算线段的方向向量
+  const dir1 = line1End.clone().sub(line1Start);
+  const dir2 = line2End.clone().sub(line2Start);
+
+  // 计算参数方程中的系数
+  const a = dir1.x;
+  const b = -dir2.x;
+  const c = dir1.y;
+  const d = -dir2.y;
+
+  const e = line2Start.x - line1Start.x;
+  const f = line2Start.y - line1Start.y;
+
+  // 求解参数t和s
+  const t = (d * e - b * f) / (a * d - b * c);
+  // 计算交点坐标
+  const p = line1Start.clone().add(dir1.clone().multiplyScalar(t));
+
+  if (isNaN(p.x) || !isFinite(p.x) || isNaN(p.y) || !isFinite(p.y)) return null;
+
+  return p;
+};
+
+/**
+ * 获取点是否在线上
+ * @param line 线段
+ * @param position 点
+ */
+export const lineInner = (line: Pos[], position: Pos) => {
+  // 定义线段的起点和终点坐标
+  const [A, B] = lVector(line);
+  // 定义一个点的坐标
+  const P = vector(position);
+
+  // 计算向量 AP 和 AB
+  const AP = P.clone().sub(A);
+  const AB = B.clone().sub(A);
+
+  // 计算叉积
+  const crossProduct = AP.x * AB.y - AP.y * AB.x;
+
+  // 如果叉积不为 0,说明点 P 不在直线 AB 上
+  if (!zeroEq(crossProduct)) {
+    return false;
+  }
+  // 检查点 P 的坐标是否在 A 和 B 的坐标范围内
+  return (
+    Math.min(A.x, B.x) <= P.x &&
+    P.x <= Math.max(A.x, B.x) &&
+    Math.min(A.y, B.y) <= P.y &&
+    P.y <= Math.max(A.y, B.y)
+  );
+};
+
+/**
+ * 获取点在线段上的投影
+ * @param line 线段
+ * @param position 点
+ * @returns 投影信息
+ */
+export const linePointProjection = (line: Pos[], position: Pos) => {
+  // 定义线段的起点和终点坐标
+  const [lineStart, lineEnd] = lVector(line);
+  // 定义一个点的坐标
+  const point = vector(position);
+
+  // 计算线段的方向向量
+  const lineDir = lineEnd.clone().sub(lineStart);
+  // 计算点到线段起点的向量
+  const pointToLineStart = point.clone().sub(lineStart);
+  // 计算点在线段方向上的投影长度
+  const t = pointToLineStart.dot(lineDir.normalize());
+  // 计算投影点的坐标
+  return lineStart.add(lineDir.multiplyScalar(t));
+};
+
+/**
+ * 获取点距离线段最近距离
+ * @param line 直线
+ * @param position 参考点
+ * @returns 距离
+ */
+export const linePointLen = (line: Pos[], position: Pos) =>
+  lineLen(position, linePointProjection(line, position));
+
+/**
+ * 计算多边形是否为逆时针
+ * @param points 多边形顶点
+ * @returns true | false
+ */
+export const isPolygonCounterclockwise = (points: Pos[]) =>
+  ShapeUtils.isClockWise(points.map(vector));
+
+/**
+ * 切割线段,返回连段切割点
+ * @param line 线段
+ * @param amount 切割份量
+ * @param unit 一份单位大小
+ * @returns 点数组
+ */
+export const lineSlice = (
+  line: Pos[],
+  amount: number,
+  unit = lineLen(line[0], line[1]) / amount
+) =>
+  new Array(unit)
+    .fill(0)
+    .map((_, i) => linePointProjection(line, { x: i * unit, y: i * unit }));
+
+/**
+ * 线段是否相交多边形
+ * @param polygon 多边形
+ * @param line 检测线段
+ * @returns
+ */
+export const isPolygonLineIntersect = (polygon: Pos[], line: Pos[]) => {
+  for (let i = 0; i < polygon.length; i++) {
+    if (isLineIntersect([polygon[i], polygon[i + 1]], line)) {
+      return true;
+    }
+  }
+  return false;
+};
+
+/**
+ * 通过角度和两个点获取两者的连接点,
+ * @param p1
+ * @param p2
+ * @param rad
+ */
+export const joinPoint = (p1: Pos, p2: Pos, rad: number) => {
+  const lvector = new Vector2()
+    .subVectors(p1, p2)
+    .rotateAround({ x: 0, y: 0 }, rad);
+
+  return vector(p2).add(lvector);
+};
+
+/**
+ * 要缩放多少才能到达目标
+ * @param origin 缩放原点
+ * @param scaleDirection 缩放方向
+ * @param p1 当前点位
+ * @param p2 目标点位
+ * @returns
+ */
+export function calculateScaleFactor(
+  origin: Pos,
+  scaleDirection: Pos,
+  p1: Pos,
+  p2: Pos
+) {
+  const op1 = vector(p1).sub(origin);
+  const op2 = vector(p2).sub(origin);
+  const xZero = zeroEq(op1.x);
+  const yZero = zeroEq(op1.y);
+
+  if (zeroEq(op1.x) || zeroEq(op2.y)) return;
+  if (zeroEq(scaleDirection.x)) {
+    if (zeroEq(p2.x - p1.x)) {
+      return zeroEq(op1.y - op2.y) ? 1 : yZero ? null : op2.y / op1.y;
+    } else {
+      return;
+    }
+  }
+  if (zeroEq(scaleDirection.y)) {
+    if (zeroEq(p2.y - p1.y)) {
+      return zeroEq(op1.x - op2.x) ? 1 : xZero ? null : op2.x / op1.x;
+    } else {
+      return;
+    }
+  }
+  if (xZero && yZero) {
+    return null;
+  }
+  const xScaleFactor = op2.x / (op1.x * scaleDirection.x);
+  const yScaleFactor = op2.y / (op1.y * scaleDirection.y);
+
+  if (xZero) {
+    return yScaleFactor;
+  } else if (yZero) {
+    return xScaleFactor;
+  }
+  if (zeroEq(xScaleFactor - yScaleFactor)) {
+    return xScaleFactor;
+  }
+}
+
+// 获取两线段的矩阵关系
+export const getLineRelationMat = (l1: [Pos, Pos], l2: [Pos, Pos]) => {
+  // 提取点
+  const P1 = l1[0]; // l1 的起点
+  const P1End = l1[1]; // l1 的终点
+  const P2 = l2[0]; // l2 的起点
+  const P2End = l2[1]; // l2 的终点
+
+  // 计算方向向量
+  const d1 = { x: P1End.x - P1.x, y: P1End.y - P1.y };
+  const d2 = { x: P2End.x - P2.x, y: P2End.y - P2.y };
+
+  // 计算方向向量的长度
+  const lengthD1 = Math.sqrt(d1.x ** 2 + d1.y ** 2);
+  const lengthD2 = Math.sqrt(d2.x ** 2 + d2.y ** 2);
+
+  if (lengthD1 === 0 || lengthD2 === 0) return new Transform();
+
+  // 归一化方向向量
+  const unitD1 = { x: d1.x / lengthD1, y: d1.y / lengthD1 };
+  const unitD2 = { x: d2.x / lengthD2, y: d2.y / lengthD2 };
+
+  // 计算旋转角度
+  const angle = Math.atan2(unitD2.y, unitD2.x) - Math.atan2(unitD1.y, unitD1.x);
+  // 计算旋转矩阵
+  // 计算缩放因子
+  const scale = lengthD2 / lengthD1;
+  // 计算平移向量
+  const translation = [P2.x - P1.x, P2.y - P1.y];
+
+  const mat = new Transform()
+    .translate(translation[0], translation[1])
+    .translate(P1.x, P1.y)
+    .scale(scale, scale)
+    .rotate(angle)
+    .translate(-P1.x, -P1.y);
+
+  
+  if (!eqPoint(mat.point(P1), P2)) {
+    console.error('对准不正确 旋转后P1', mat.point(P1), P2)
+  }
+  if (!eqPoint(mat.point(P1End), P2End)) {
+    console.error('对准不正确 旋转后P2', mat.point(P1End), P1End)
+  }
+  return mat
+};
+
+// 判断两向量是否垂直
+export const isVertical = (v1: Pos, v2: Pos) => {
+  console.log(vector(v1).dot(v2))
+  return zeroEq(vector(v1).dot(v2))
+}
+
+
+/**
+ * 创建对齐端口矩阵
+ * @param targetView 目标窗口左上右下坐标
+ * @param originView 源窗口左上右下坐标
+ * @param retainScale 是否固定xy的缩放系数
+ * @param padding 留白区域
+ * @returns
+ */
+export const alignPortMat = (
+	targetView: Pos[],
+	originView: Pos[],
+	retainScale = false,
+	padding: Pos | number = 0
+) => {
+	padding = typeof padding === "number" ? { x: padding, y: padding } : padding;
+
+	const pad = vector(padding);
+	const real = vector(originView[1]).sub(originView[0]);
+	const screen = vector(targetView[1]).sub(targetView[0]);
+	const scale = screen.clone().sub(pad.multiplyScalar(2)).divide(real);
+
+	if (retainScale) {
+		scale.x = scale.y = Math.min(scale.x, scale.y); // 选择较小的比例以保持内容比例
+	}
+
+	const offset = screen
+		.clone()
+		.sub(real.clone().multiply(scale))
+		.divideScalar(2)
+		.sub(real.clone().multiply(scale));
+
+	return new Transform().translate(offset.x, offset.y).scale(scale.x, scale.y);
+};

+ 218 - 0
src/components/drawing/operate.vue

@@ -0,0 +1,218 @@
+<template>
+  <Teleport :to="`#draw-renderer`" v-if="stage">
+    <transition name="pointer-fade">
+      <div
+        v-if="pointer"
+        :style="{ transform: pointer }"
+        :size="8"
+        class="propertys-controller"
+        ref="layout"
+      >
+        <Menu>
+          <MenuItem v-for="menu in menus" @click="clickHandler(menu.handler)">
+            <ui-icon :type="menu.icon" />
+            <span>{{ menu.label }}</span>
+          </MenuItem>
+        </Menu>
+      </div>
+    </transition>
+  </Teleport>
+</template>
+
+<script lang="ts" setup>
+import { computed, nextTick, ref, watch, watchEffect } from "vue";
+import { Transform } from "konva/lib/Util";
+import { Menu, MenuItem } from "ant-design-vue";
+import { shapeTreeContain, useStage, useViewerTransformConfig } from "./hook.js";
+import { DC, EntityShape } from "./dec.js";
+
+const props = defineProps<{
+  target: DC<EntityShape> | undefined;
+  data?: Record<string, any>;
+  menus: Array<{ icon?: any; label?: string; handler: () => void }>;
+}>();
+const emit = defineEmits<{
+  (e: "show" | "hide"): void;
+}>();
+
+const layout = ref<HTMLDivElement>();
+const stage = useStage();
+// const status = useMouseShapeStatus(computed(() => props.target));
+const rightClick = ref(false);
+
+const hasRClick = (ev: MouseEvent) => {
+  if (ev.button !== 2) return false;
+  const shape = props.target?.getNode();
+  const pos = stage.value?.getNode().pointerPos;
+  const layer = stage.value?.getNode().children[0];
+  if (!shape || !pos || !layer) return false;
+  let clickShape = layer.getIntersection(pos);
+  ev.preventDefault();
+  return !!clickShape && shapeTreeContain(shape, clickShape) === shape;
+};
+
+watchEffect((onCleanup) => {
+  const dom = stage.value?.getStage().container();
+  if (!dom) return;
+
+  const clickHandler = async (ev: MouseEvent) => {
+    const show = hasRClick(ev);
+    if (show && rightClick.value) {
+      rightClick.value = false;
+      await nextTick();
+    }
+    rightClick.value = show;
+  };
+
+  dom.addEventListener("contextmenu", clickHandler);
+  dom.addEventListener("pointerdown", clickHandler);
+  onCleanup(() => {
+    dom.removeEventListener("contextmenu", clickHandler);
+    dom.removeEventListener("pointerdown", clickHandler);
+  });
+});
+
+const clickHandler = (handler: () => void) => {
+  handler();
+  rightClick.value = false;
+};
+
+const hidden = computed(
+  () =>
+    !stage.value?.getStage() ||
+    !props.target?.getNode() ||
+    // !status.value.hover ||
+    // status.value.active ||
+    // transformIngShapes.value.length !== 0
+    !rightClick.value
+);
+
+const move = new Transform();
+const pointer = ref<string | null>(null);
+const calcPointer = async () => {
+  if (hidden.value) {
+    pointer.value = null;
+    return;
+  } else if (pointer.value) {
+    return;
+  }
+  const $stage = stage.value!.getStage();
+  const mousePosition = $stage.pointerPos;
+  if (!mousePosition) {
+    pointer.value = null;
+    return;
+  }
+  const $shape = props.target!.getNode();
+  const shapeRect = $shape.getClientRect();
+
+  const shapeR = shapeRect.x + shapeRect.width;
+  const shapeB = shapeRect.y + shapeRect.height;
+  let x = Math.min(Math.max(mousePosition.x, shapeRect.x), shapeR);
+  let y = Math.min(Math.max(mousePosition.y, shapeRect.y), shapeB);
+
+  move.reset();
+  move.translate(x, y);
+  pointer.value = `matrix(${move.m.join(",")})`;
+  await nextTick();
+
+  const domRect = layout.value!.getBoundingClientRect();
+  x = x - domRect.width / 2;
+  x = Math.max(x, shapeRect.x);
+
+  if (x + domRect.width > shapeR) {
+    x = shapeR - domRect.width;
+  }
+  if (y + domRect.height > shapeB) {
+    y = y - domRect.height;
+  }
+
+  x = Math.max(x, 10);
+  y = Math.max(y, 10);
+  if (x + domRect.width > $stage.width() - 10) {
+    x = $stage.width() - domRect.width - 10;
+  }
+  if (y + domRect.height > $stage.height() - 10) {
+    y = $stage.height() - domRect.height - 10;
+  }
+
+  move.reset();
+  move.translate(x, y);
+  pointer.value = `matrix(${move.m.join(",")})`;
+};
+watch(
+  () => !!pointer.value,
+  (show) => {
+    emit(show ? "show" : "hide");
+  }
+);
+
+let timeout: any;
+const resetPointer = () => {
+  if (hidden.value) {
+    pointer.value = null;
+    return;
+  } else if (pointer.value) {
+    return;
+  }
+  clearTimeout(timeout);
+  timeout = setTimeout(calcPointer, 16);
+};
+
+watch(hidden, resetPointer);
+watch(useViewerTransformConfig(), () => {
+  pointer.value = null;
+  resetPointer();
+});
+// watch(() => props.data, resetPointer);
+</script>
+
+<style lang="scss">
+.propertys-controller {
+  pointer-events: none;
+  position: absolute;
+  border-radius: 4px;
+  border: 1px solid #e4e7ed;
+  background-color: #ffffff;
+  left: 0;
+  top: 0;
+  overflow: hidden;
+  color: #303133;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.12);
+  min-width: 10px;
+  padding: 5px 0;
+  pointer-events: all;
+  font-size: 14px;
+  border: 1px solid var(--el-border-color-light);
+  box-shadow: var(--el-dropdown-menu-box-shadow);
+  background-color: var(--el-bg-color-overlay);
+  border-radius: var(--el-border-radius-base);
+  --el-menu-base-level-padding: 16px;
+  --el-menu-item-height: 32px;
+  --el-menu-item-font-size: 14px;
+
+  .el-menu-item {
+    align-items: center;
+    padding: 5px 16px 5px 6px !important;
+    color: var(--el-text-color-regular);
+  }
+  .el-menu-item [class^="el-icon"] {
+    margin: 0;
+    font-size: 1em;
+  }
+}
+
+.pointer-fade-enter-active,
+.pointer-fade-leave-active {
+  transition: opacity 0.3s ease;
+  .item {
+    pointer-events: none;
+  }
+}
+.pointer-fade-enter-from,
+.pointer-fade-leave-to {
+  opacity: 0;
+}
+</style>

+ 108 - 0
src/components/drawing/renderer.vue

@@ -0,0 +1,108 @@
+<template>
+  <div class="draw-layout" id="draw-renderer" ref="layout" :style="{ cursor: cursorStyle }">
+    <template v-if="layout">
+      <v-stage ref="stage" :config="size">
+        <v-layer :config="viewerConfig" id="formal">
+          <v-rect :config="config" ref="shape" />
+          <slot />
+        </v-layer>
+      </v-stage>
+    </template>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed, getCurrentInstance, onUnmounted, ref, watch, watchEffect } from "vue";
+import {
+  mergeFuns,
+  rendererMap,
+  useCursor,
+  useGlobalResize,
+  useStage,
+  useViewer,
+  useViewerInvertTransformConfig,
+  useViewerTransformConfig,
+} from "./hook";
+import { install } from "./install-lib";
+
+const props = defineProps<{ scale: number }>();
+const emit = defineEmits<{ (e: "update:scale", v: number): void }>();
+
+const instance = getCurrentInstance();
+install(instance!.appContext.app);
+
+rendererMap.set(instance, { unmounteds: [] });
+onUnmounted(() => {
+  mergeFuns(rendererMap.get(instance)!.unmounteds)();
+});
+
+const stage = useStage();
+const { size } = useGlobalResize();
+const layout = ref();
+const viewerConfig = useViewerTransformConfig();
+
+const cursor = useCursor();
+const cursorStyle = computed(() => {
+  if (cursor.value.includes(".")) {
+    return `url(${cursor.value}), auto`;
+  } else {
+    return cursor.value;
+  }
+});
+
+const viewer = useViewer();
+watch(
+  () => props.scale,
+  (scale) => {
+    if (size.value) {
+      viewer.viewer.scalePixel(
+        { x: size.value?.width / 2, y: size.value?.height / 2 },
+        { x: scale / viewerConfig.value.scaleX, y: 1 }
+      );
+    }
+  }
+);
+watchEffect(
+  () => {
+    if (Math.abs(props.scale - viewerConfig.value.scaleX) > 0.001) {
+      emit("update:scale", viewerConfig.value.scaleX);
+    }
+  },
+  { flush: "post" }
+);
+
+const invConfig = useViewerInvertTransformConfig();
+const config = computed(
+  () =>
+    size.value && {
+      ...invConfig.value,
+      ...size.value,
+    }
+);
+</script>
+
+<style scoped lang="scss">
+.draw-layout {
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  .draw-content {
+    position: relative;
+    width: 100%;
+    height: 100%;
+  }
+}
+
+.mount-mask {
+  position: absolute;
+  inset: 0;
+  overflow: hidden;
+  pointer-events: none;
+  z-index: 999;
+}
+</style>

+ 164 - 0
src/components/drawing/viewer.ts

@@ -0,0 +1,164 @@
+import { Transform } from "konva/lib/Util";
+import mitt from 'mitt'
+import { alignPortMat, Pos } from "./math";
+
+export type ViewerProps = {
+	size?: number[];
+	bound?: number[];
+	padding: number | number[];
+	retain: boolean;
+};
+
+export class Viewer {
+	props: ViewerProps;
+	viewMat: Transform;
+	partMat: Transform = new Transform();
+	bus = mitt<{ transformChange: Transform }>()
+
+	constructor(props: Partial<ViewerProps> = {}) {
+		this.props = {
+			padding: 0,
+			retain: true,
+			...props,
+		}
+		this.viewMat = new Transform();
+	}
+
+	get bound() {
+		return this.props.bound
+	}
+
+	setBound(bound: number[], size?: number[], padding?: number | number[], retain?: boolean) {
+		this.props.bound = bound
+		if (padding) {
+			this.props.padding = padding
+		}
+		if (retain) {
+			this.props.retain = retain
+		}
+		if (size) {
+			this.props.size = size
+		}
+		padding = this.props.padding
+		retain = this.props.retain
+		size = this.props.size
+
+		if (!size) {
+			throw '缺少视窗size'
+		}
+
+		this.partMat = alignPortMat(
+			[
+				{x: bound[0], y: bound[1]},
+				{x: bound[2], y: bound[3]},
+			],
+			[
+				{x: 0, y: 0},
+				{x: size[0], y: size[1]},
+			],
+			retain,
+			typeof padding === "number"
+				? padding
+				: {x: padding[0], y: padding[1]}
+		);
+	}
+
+	move(position: Pos, initMat = this.viewMat) {
+		this.mutMat(new Transform().translate(position.x, position.y), initMat);
+	}
+
+	movePixel(position: Pos, initMat = this.viewMat) {
+		if (isNaN(position.x) || isNaN(position.y)) {
+			console.error(`无效移动位置${position.x} ${position.y}`)
+			return;
+		}
+		const offsetX = position.x
+		const currentX = this.viewMat.decompose().x
+		const finX = Math.min(0, currentX + offsetX)
+		const x = finX - currentX
+
+		const mat = initMat.copy().invert()
+		const p1 = mat.point({x: 0, y: 0})
+		const p2 = mat.point({x, y: position.y})
+		this.move({x: p2.x - p1.x, y: p2.y - p1.y})
+
+		// const info = initMat.decompose()
+		// const tf = new Transform()
+		// tf.rotate(info.rotation)
+		// tf.scale(info.scaleX, info.scaleY)
+		// this.move(tf.invert().point(position), this.viewMat)
+	}
+
+
+	scale(center: Pos, scale: Pos, initMat = this.viewMat) {
+		const base = initMat.decompose().scaleX
+		if (base * scale.x < 0.001 || base * scale.x > 1000) {
+			console.error('缩放范围0.001~1000 已超过范围无法缩放')
+			return;
+		}
+		if (isNaN(center.x) || isNaN(center.y)) {
+			console.error(`无效中心点${center.x} ${center.y}`)
+			return;
+		}
+
+		this.mutMat(
+			new Transform()
+				.translate(center.x, center.y)
+				.multiply(
+					new Transform()
+						.scale(scale.x, scale.y)
+						.multiply(new Transform().translate(-center.x, -center.y))
+				),
+			initMat
+		);
+	}
+
+	scalePixel(center: Pos, scale: Pos, initMat = this.viewMat) {
+		const pos = initMat.copy().invert().point(center)
+		this.scale(pos, scale, initMat)
+	}
+
+	rotate(center: Pos, angleRad: number, initMat = this.viewMat) {
+		this.mutMat(
+			new Transform()
+				.translate(center.x, center.y)
+				.multiply(
+					new Transform()
+						.rotate(angleRad)
+						.multiply(new Transform().translate(-center.x, -center.y))
+				),
+			initMat
+		);
+	}
+
+	rotatePixel(center: Pos, angleRad: number, initMat = this.viewMat) {
+		const pos = initMat.copy().invert().point(center)
+		this.rotate(pos, angleRad, initMat)
+	}
+
+	mutMat(mat: Transform, initMat = this.viewMat) {
+		// this.setViewMat(mat.copy().multiply(initMat))
+		this.setViewMat(initMat.copy().multiply(mat))
+	}
+
+	setViewMat(mat: number[] | Transform) {
+		if (mat instanceof Transform) {
+			this.viewMat = mat.copy();
+		} else {
+			this.viewMat = new Transform(mat)
+		}
+		let dec = this.viewMat.decompose()
+		while(dec.x > 0.001) {
+			this.viewMat = this.viewMat.copy().translate(-dec.x, 0)
+			dec = this.viewMat.decompose()
+		}
+		this.bus.emit('transformChange', this.transform)
+	}
+
+	get transform() {
+		return this.partMat.copy().multiply(this.viewMat);
+	}
+	get current() {
+		return this.viewMat.decompose()
+	}
+}

+ 23 - 20
src/components/list/index.vue

@@ -1,16 +1,19 @@
 <template>
   <ul class="list">
     <li class="header" v-if="title">
-      <h3>{{ title }}</h3>
-      <div class="action" v-if="$slots.action">
-        <slot name="action"></slot>
-      </div>
+      <template v-if="!$slots.header">
+        <h3>{{ title }}</h3>
+        <div class="action" v-if="$slots.action">
+          <slot name="action"></slot>
+        </div>
+      </template>
+      <slot name="header" v-else />
     </li>
     <ul class="content" v-if="showContent">
-      <li 
-        v-for="(item, i) in data" 
-        :key="rawKey ? item.raw[rawKey] : i" 
-        :class="{select: item.select}"
+      <li
+        v-for="(item, i) in data"
+        :key="rawKey ? item.raw[rawKey] : i"
+        :class="{ select: item.select }"
         @click="$emit('changeSelect', item)"
       >
         <div class="atom-content">
@@ -22,20 +25,22 @@
 </template>
 
 <script lang="ts" setup>
-type Item = Record<string, any> & {select?: boolean}
-type ListProps = { title?: string, rawKey?: string, data: Array<Item>, showContent?: boolean}
+type Item = Record<string, any> & { select?: boolean };
+type ListProps = {
+  title?: string;
+  rawKey?: string;
+  data: Array<Item>;
+  showContent?: boolean;
+};
 
-withDefaults(
-  defineProps<ListProps>(),
-  { showContent: true }
-)
+withDefaults(defineProps<ListProps>(), { showContent: true });
 
-defineEmits<{ (e: 'changeSelect', item: Item): void }>()
+defineEmits<{ (e: "changeSelect", item: Item): void }>();
 </script>
 
 <style lang="scss" scoped>
 .header {
-  border-bottom: 1px solid rgba(255,255,255,0.16);
+  border-bottom: 1px solid rgba(255, 255, 255, 0.16);
   display: flex;
   justify-content: space-between;
   padding: 20px;
@@ -53,13 +58,12 @@ defineEmits<{ (e: 'changeSelect', item: Item): void }>()
     cursor: pointer;
 
     &.select {
-      background: rgba(0,200,175,0.1600);
+      background: rgba(0, 200, 175, 0.16);
     }
 
     .atom-content {
       padding: 20px 0;
-      border-bottom: 1px solid rgba(255,255,255,0.16);
-      
+      border-bottom: 1px solid rgba(255, 255, 255, 0.16);
     }
   }
 }
@@ -68,4 +72,3 @@ defineEmits<{ (e: 'changeSelect', item: Item): void }>()
   list-style: none;
 }
 </style>
-

+ 3 - 0
src/env/index.ts

@@ -8,6 +8,7 @@ export const showToolbarStack = stackFactory(ref<boolean>(false))
 export const showHeadBarStack = stackFactory(ref<boolean>(true))
 export const showRightPanoStack = stackFactory(ref<boolean>(true))
 export const showLeftPanoStack = stackFactory(ref<boolean>(false))
+export const moundLeftPanoStack = stackFactory(ref<boolean>(true))
 export const showLeftCtrlPanoStack = stackFactory(ref<boolean>(true))
 export const showModeStack = stackFactory(ref<"pano" | "fuse">("fuse"));
 export const showRightCtrlPanoStack = stackFactory(ref<boolean>(true))
@@ -24,10 +25,12 @@ export const showTaggingPositionsStack = stackFactory(ref<WeakSet<TaggingPositio
 export const currentViewStack = stackFactory(ref<View>())
 
 export const custom = flatStacksValue({
+  
   viewMode: viewModeStack,
   showToolbar: showToolbarStack,
   showRightPano: showRightPanoStack,
   showLeftPano: showLeftPanoStack,
+  moundLeftPano: moundLeftPanoStack,
   showLeftCtrlPano: showLeftCtrlPanoStack,
   shwoRightCtrlPano: showRightCtrlPanoStack,
   showTaggings: showTaggingsStack,

+ 41 - 0
src/hook/ids.ts

@@ -0,0 +1,41 @@
+import { diffArrayChange } from "@/utils";
+import { ref, Ref, watch } from "vue";
+
+export const useSelects = <T extends { id: any }>(items: Ref<T[]>) => {
+  const selects = ref<T[]>([]);
+
+  const updateSelect = (item: T, select: boolean) => {
+    const ndx = selects.value.findIndex((s) => s.id === item.id);
+    if (select) {
+      ~ndx || selects.value.push(item as any);
+    } else {
+      ~ndx && selects.value.splice(ndx, 1);
+    }
+  };
+  const updateSelectId = (id: any, select: boolean) => {
+    const item = items.value.find((s) => s.id === id);
+    item && updateSelect(item, select);
+  };
+
+  const oldItems: T[] = [];
+  watch(
+    items,
+    (items) => {
+      const { added, deleted } = diffArrayChange(
+        items.map((item) => item.id),
+        oldItems.map((item) => item.id)
+      );
+      added.forEach((id) => updateSelectId(id, true));
+      deleted.forEach((id) => updateSelectId(id, false));
+      oldItems.length = 0;
+      oldItems.push(...items);
+    },
+    { deep: true }
+  );
+
+  return {
+    selects,
+    updateSelect,
+    updateSelectId,
+  };
+};

+ 3 - 1
src/layout/left-pano.vue

@@ -1,5 +1,5 @@
 <template>
-  <div id="left-pano">
+  <div id="left-pano" v-if="custom.moundLeftPano || show">
     <span
       class="ctrl-pano-c fun-ctrl strengthen-right strengthen-top strengthen-bottom"
       v-if="custom.viewMode !== 'full' && custom.showLeftCtrlPano"
@@ -16,6 +16,8 @@
 
 <script lang="ts" setup>
 import { custom } from "@/env";
+
+withDefaults(defineProps<{ show?: boolean }>(), { show: false });
 </script>
 
 <style lang="scss" scoped>

+ 3 - 1
src/layout/model-list/index.vue

@@ -2,10 +2,11 @@
   <List
     :title="title"
     rawKey="id"
-    class="scene-model-list"
+    :class="['scene-model-list', listClass]"
     :data="modelList"
     :showContent="showContent"
   >
+    <template #header v-if="$slots.header"><slot name="header" /></template>
     <template #action>
       <slot name="action" />
     </template>
@@ -54,6 +55,7 @@ export type ModelListProps = {
   title?: string;
   canChange?: boolean;
   showContent?: boolean;
+  listClass?: string;
 };
 withDefaults(defineProps<ModelListProps>(), {
   title: "数据列表",

+ 1 - 0
src/main.ts

@@ -7,6 +7,7 @@ import { params } from "@/env";
 import { addHook, addUnsetTokenURLS, delHook, delUnsetTokenURLS } from "@/api";
 import { currentLayout, RoutesName } from "./router";
 import * as URL from "@/api/constant";
+import VueKonva from "vue-konva";
 // import 'ant-design-vue/dist/reset.css';
 import "@/assets/style/global.less";
 

+ 6 - 0
src/router/config.ts

@@ -47,6 +47,12 @@ export const routes = [
             component: () => import('@/views/guide/index.vue')
           },
           {
+            path: paths[RoutesName.animation],
+            name: RoutesName.animation,
+            meta: metas.animation,
+            component: () => import('@/views/animation/index.vue')
+          },
+          {
             path: paths[RoutesName.setting],
             name: RoutesName.setting,
             meta: metas.setting,

+ 9 - 0
src/router/constant.ts

@@ -16,6 +16,9 @@ export enum RoutesName {
   measure = "measure",
   setting = "setting",
 
+  // 动画
+  animation = 'animation',
+
   // 编辑场景,提取视图,录制视频
   sceneEdit = "sceneEdit",
   record = "record",
@@ -52,6 +55,7 @@ export const paths = {
   [RoutesName.tagging]: "tagging",
   [RoutesName.taggingPosition]: "taggingPosition/:id",
   [RoutesName.guide]: "path",
+  [RoutesName.animation]: 'animation',
   [RoutesName.measure]: "measure",
   [RoutesName.setting]: "setting",
 
@@ -88,6 +92,11 @@ export const metas = {
     title: "路径",
     sysTitle: "多元融合",
   },
+  [RoutesName.animation]: {
+    icon: "path",
+    title: "动画",
+    sysTitle: "多元融合",
+  },
   [RoutesName.measure]: {
     icon: "nav-measure",
     title: "测量",

+ 0 - 1
src/sdk/association/fuseMode.ts

@@ -48,7 +48,6 @@ const setModels = (sdk: SDK, models: FuseModels, oldModels: FuseModels) => {
     if (getSceneModel(item)) {
       continue;
     }
-    console.error('addMode', item.status)
     if (item.status !== SceneStatus.SUCCESS) {
       item.error = true;
       item.loaded = true;

+ 70 - 0
src/store/animation.ts

@@ -0,0 +1,70 @@
+
+import { ref } from 'vue'
+import { autoSetModeCallback, createTemploraryID } from './sys'
+import { 
+  fetchAnimationActions,
+  fetchAnimationModels,
+  postDeleteAnimationModel, 
+  postInsertAnimationModel,
+  postUpdateAnimationModel,
+} from '@/api'
+import { 
+  togetherCallback,
+  deleteStoreItem, 
+  addStoreItem, 
+  updateStoreItem, 
+  saveStoreItems,
+  recoverStoreItems
+} from '@/utils'
+
+import type { AnimationAction, AnimationModel, AnimationModels } from '@/api'
+export type { AnimationModel, AnimationModels }
+
+export const ams = ref<AnimationModels>([])
+export const amActions = ref<AnimationAction[]>([])
+
+export const initAnimationActions = async () => {
+  amActions.value = await fetchAnimationActions()
+}
+
+export const createAnimationModel = (am: Partial<AnimationModel> = {}): AnimationModel => ({
+  id: createTemploraryID(),
+  title: `模型`,
+  url: '',
+  showTitle: true,
+  fontSize: 12,
+  globalVisibility: true,
+  visibilityRange: 12,
+  ...am
+})
+
+let bcAms: AnimationModels = []
+export const getBackupAnimationModels = () => bcAms
+export const backupAnimationModels = () => {
+  bcAms = ams.value.map(am => ({...am }))
+}
+export const addAnimationModel = addStoreItem(ams, postInsertAnimationModel)
+export const updateAnimationModels = updateStoreItem(ams, postUpdateAnimationModel)
+export const deleteAnimationModel = deleteStoreItem(ams, ({id}) => postDeleteAnimationModel(id))
+export const initialAnimationModels = async () => {
+  ams.value = await fetchAnimationModels()
+  backupAnimationModels()
+}
+
+export const recoverAnimationModels = recoverStoreItems(ams, getBackupAnimationModels)
+export const saveAnimationModels = saveStoreItems(
+  ams,
+  getBackupAnimationModels,
+  {
+    add: addAnimationModel,
+    update: updateAnimationModels,
+    delete: deleteAnimationModel,
+  }
+)
+export const autoSaveAnimationModel = autoSetModeCallback([ams], {
+  backup: togetherCallback([backupAnimationModels]),
+  recovery: togetherCallback([recoverAnimationModels]),
+  save: async () => {
+    await saveAnimationModels()
+  },
+})

+ 1 - 0
src/store/fuse-model.ts

@@ -101,6 +101,7 @@ watchPostEffect(() => {
   const loaded = fuseModels.value
     .filter((model) => getFuseModelShowVariable(model).value)
     .every((model) => model.loaded || model.error);
+    console.log('a?')
   fuseModelsLoaded.value = loaded;
 });
 

+ 35 - 0
src/utils/index.ts

@@ -55,6 +55,41 @@ export const getSizeStr = (size: number) => {
   return `${mb.toFixed(2)}TB`;
 };
 
+// 日期格式化
+export const formatDate = (date: Date, fmt: string = 'yyyy-MM-dd hh:mm') => {
+  const map = {
+    'M+': date.getMonth() + 1, //月份
+    'd+': date.getDate(), //日
+    'h+': date.getHours(), //小时
+    'm+': date.getMinutes(), //分
+    's+': date.getSeconds(), //秒
+    'q+': Math.floor((date.getMonth() + 3) / 3), //季度
+    S: date.getMilliseconds() //毫秒
+  }
+
+  if (/(y+)/.test(fmt)) {
+    fmt = fmt.replace(
+      RegExp.$1,
+      date
+        .getFullYear()
+        .toString()
+        .substr(4 - RegExp.$1.length)
+    )
+  }
+
+  ;(Object.keys(map) as Array<keyof typeof map>).forEach(k => {
+    if (new RegExp('(' + k + ')').test(fmt)) {
+      const val = map[k].toString()
+      fmt = fmt.replace(
+        RegExp.$1,
+        RegExp.$1.length === 1 ? val : ('00' + val).substr(val.length)
+      )
+    }
+  })
+
+  return fmt
+}
+
 export const togetherCallback = (cbs: (() => void)[]) => () => together(cbs)
 
 export const together = (cbs: (() => void)[]) => {

+ 154 - 0
src/views/animation/bottom/bottom.vue

@@ -0,0 +1,154 @@
+<template>
+  <ui-editor-toolbar toolbar class="animation-toolbar">
+    <div class="top-bar">
+      <div class="play-bar">
+        <ui-icon type="close" ctrl @click="play = !play" />
+      </div>
+      <div class="scale-bar">
+        <ui-icon type="close" class="icon" ctrl />
+        <Slider
+          class="slider"
+          v-model:value="scale"
+          :min="0.5"
+          :max="3"
+          :step="0.01"
+          :tooltipOpen="false"
+        />
+        <ui-icon type="close" class="icon" ctrl />
+      </div>
+    </div>
+    <div class="oper-bar" :class="{ disabled: play }">
+      <Renderer v-model:scale="scale">
+        <TimeLine
+          background="#000"
+          :items="frames"
+          :height="30"
+          :top="24"
+          :itemsRenderer="TimeLineFrame"
+          @update="({ ndx, time }) => (frames[ndx].time = time)"
+          @add="(item) => frames.push(item)"
+          @del="(ndx) => frames.splice(ndx, 1)"
+        />
+
+        <TimeLine
+          :items="actions"
+          :height="30"
+          :top="65"
+          :itemsRenderer="TimeLineAction"
+          @update="({ ndx, time }) => (actions[ndx].time = time)"
+          @add="(item) => actions.push(item)"
+          @del="(ndx) => actions.splice(ndx, 1)"
+        />
+
+        <TimeLine
+          :items="subtitles"
+          :height="30"
+          :top="105"
+          :itemsRenderer="TimeLineAction"
+          @update="({ ndx, time }) => (subtitles[ndx].time = time)"
+          @add="(item) => subtitles.push(item)"
+          @del="(ndx) => subtitles.splice(ndx, 1)"
+        />
+        <TimeLine
+          :items="paths"
+          :height="30"
+          :top="140"
+          :itemsRenderer="TimeLineAction"
+          @update="({ ndx, time }) => (paths[ndx].time = time)"
+          @add="(item) => paths.push(item)"
+          @del="(ndx) => paths.splice(ndx, 1)"
+        />
+
+        <Time @update-current-time="(time) => (currentTime = time)">
+          <TimeCurrent v-model:currentTime="currentTime" :follow="play" />
+        </Time>
+      </Renderer>
+    </div>
+  </ui-editor-toolbar>
+</template>
+
+<script lang="ts" setup>
+import { Slider } from "ant-design-vue";
+import { ref, watch } from "vue";
+import Renderer from "@/components/drawing/renderer.vue";
+import Time from "@/components/drawing-time/time.vue";
+import TimeCurrent from "@/components/drawing-time/current.vue";
+import TimeLine from "@/components/drawing-time-line/index.vue";
+import TimeLineFrame from "@/components/drawing-time-line/frame.vue";
+import TimeLineAction from "@/components/drawing-time-line/action.vue";
+
+const scale = ref(1);
+const currentTime = ref(30);
+const play = ref(false);
+const frames = ref([{ time: 10 }, { time: 20 }, { time: 30 }]);
+const actions = ref([
+  { time: 10, duration: 3, name: "动作1" },
+  { time: 20, duration: 5, name: "动作2" },
+  { time: 30, duration: 3, name: "动作3" },
+]);
+const subtitles = ref([
+  { time: 0, duration: 15, name: "字幕1" },
+  { time: 20, duration: 15, name: "字幕2" },
+  { time: 40, duration: 30, name: "字幕3" },
+]);
+const paths = ref([
+  { time: 0, duration: 15, name: "路径1" },
+  { time: 20, duration: 15, name: "路径2" },
+  { time: 40, duration: 30, name: "路径3" },
+]);
+
+watch(play, (_a, _b, onCleanup) => {
+  let isDes = false;
+  let prevNow = Date.now();
+  const animation = () => {
+    if (play.value && !isDes) {
+      const curNow = Date.now();
+      currentTime.value += (curNow - prevNow) * 0.001;
+      prevNow = curNow;
+      requestAnimationFrame(animation);
+    }
+  };
+  animation();
+  onCleanup(() => (isDes = true));
+});
+</script>
+
+<style scoped lang="scss">
+.top-bar {
+  display: flex;
+  height: 40px;
+  align-items: center;
+  border-bottom: 1px solid rgba(255, 255, 255, 0.16);
+  .play-bar {
+    flex: 1;
+    display: flex;
+    justify-content: center;
+  }
+  .scale-bar {
+    width: 180px;
+    flex: 0 0 auto;
+    display: flex;
+    align-items: center;
+    padding: 0 20px;
+    position: relative;
+    &::before {
+      content: "";
+      position: absolute;
+      left: 0;
+      width: 1px;
+      height: 16px;
+      background: rgba(255, 255, 255, 0.16);
+    }
+
+    .slider {
+      flex: 1;
+    }
+    .icon {
+      margin: 0 5px;
+    }
+  }
+}
+.oper-bar {
+  height: calc(100% - 40px);
+}
+</style>

+ 0 - 0
src/views/animation/bottom/pano.vue


+ 59 - 0
src/views/animation/index.vue

@@ -0,0 +1,59 @@
+<template>
+  <div :class="{ focusAM: focusAM }" class="animation-layout">
+    <Left v-model:focus="focusAM" class="animation-left" />
+    <Right v-if="focusAM" :am="focusAM" class="animation-right" />
+    <Bottom v-if="focusAM" :am="focusAM" class="animation-toolbar" />
+  </div>
+</template>
+
+<script lang="ts" setup>
+import Left from "./left.vue";
+import Right from "./right.vue";
+import Bottom from "./bottom/bottom.vue";
+import router from "@/router";
+import { enterEdit } from "@/store";
+import { useViewStack } from "@/hook";
+import {
+  AnimationModel,
+  autoSaveAnimationModel,
+  initAnimationActions,
+  initialAnimationModels,
+} from "@/store/animation";
+import { ref } from "vue";
+
+enterEdit(() => router.back());
+initialAnimationModels();
+initAnimationActions();
+useViewStack(autoSaveAnimationModel);
+
+const focusAM = ref<AnimationModel>();
+</script>
+
+<style lang="scss" scoped>
+.animation-layout {
+  --bottom-height: 0px;
+  &.focusAM {
+    --bottom-height: 225px;
+  }
+}
+
+.animation-left {
+  height: calc(100vh - var(--bottom-height));
+  position: absolute;
+  width: var(--left-pano-width);
+}
+
+.animation-right {
+  height: calc(100vh - var(--bottom-height));
+  padding-top: 0;
+  position: absolute;
+  right: 0;
+  top: 0;
+}
+
+.animation-toolbar {
+  height: var(--bottom-height);
+  width: 100vw;
+  display: block;
+}
+</style>

+ 166 - 0
src/views/animation/left.vue

@@ -0,0 +1,166 @@
+<template>
+  <LeftPano show class="animation-left">
+    <LeftContent :show-content="activeKey === 'model'" list-class="animation-left-list">
+      <template v-slot:header>
+        <div class="animation-left-header">
+          <Tabs v-model:activeKey="activeKey" width="100%">
+            <TabPane key="model" tab="数据列表" />
+            <TabPane key="animation" tab="动画模型" />
+            <template #rightExtra v-if="activeKey === 'animation'">
+              <ui-icon ctrl type="add" @click="selectModel" />
+            </template>
+          </Tabs>
+        </div>
+      </template>
+    </LeftContent>
+    <List
+      :data="ams.map((am) => ({ raw: am, select: am === focus }))"
+      v-if="activeKey === 'animation'"
+    >
+      <template #atom="{ item: { raw: item } }: any">
+        <div class="sign-layout" @click="clickHandler(item)">
+          <div class="model-header">
+            <p>{{ item.title }}</p>
+            <div class="model-action">
+              <ui-input
+                type="checkbox"
+                @click.stop
+                :modalValue="selectAMs.includes(item)"
+                @update:modalValue="(select: any) => updateSelect(item, select)"
+              />
+              <ui-icon type="del" ctrl @click="delHandler(item)" />
+            </div>
+          </div>
+        </div>
+      </template>
+    </List>
+  </LeftPano>
+</template>
+
+<script lang="ts" setup>
+import { useViewStack } from "@/hook";
+import LeftContent from "@/layout/model-list/index.vue";
+import List from "@/components/list/index.vue";
+import { LeftPano } from "@/layout";
+import { selectMaterials } from "@/components/materials/quisk";
+import { moundLeftPanoStack, showLeftPanoStack } from "@/env";
+import { togetherCallback } from "@/utils";
+import { ref } from "vue";
+import { TabPane, Tabs } from "ant-design-vue";
+import { ams, AnimationModel, createAnimationModel } from "@/store/animation";
+import { useSelects } from "@/hook/ids";
+
+useViewStack(() =>
+  togetherCallback([
+    showLeftPanoStack.push(ref(true)),
+    moundLeftPanoStack.push(ref(false)),
+  ])
+);
+
+const props = defineProps<{ focus?: AnimationModel }>();
+const emit = defineEmits<{ (e: "update:focus", item?: AnimationModel): void }>();
+
+const activeKey = ref("model");
+const { updateSelect, selects: selectAMs } = useSelects(ams);
+
+if (import.meta.env.DEV) {
+  activeKey.value = "animation";
+  setTimeout(() => {
+    emit("update:focus", ams.value[0]);
+  });
+}
+
+const delHandler = (item: AnimationModel) => {
+  const ndx = ams.value.indexOf(item);
+  if (~ndx) {
+    ams.value.splice(ndx, 1);
+  }
+};
+
+const clickHandler = (item: AnimationModel) => {
+  if (props.focus === item) {
+    emit("update:focus");
+  } else {
+    emit("update:focus", item);
+  }
+};
+
+const selectModel = async () => {
+  const list = await selectMaterials({
+    uploadFormat: ["zip"],
+    format: ["obj", "ply", "las", "laz", "b3dm", "shp", "osgb"],
+    maxSize: 2 * 1024 * 1024 * 1024,
+  });
+  if (!list?.length) return;
+  list.forEach((item) => {
+    ams.value.push(createAnimationModel({ title: item.name, url: item.url }));
+  });
+};
+</script>
+
+<style scoped lang="scss">
+
+.animation-left-header {
+  width: 100%;
+}
+
+.sign-layout {
+  margin: -20px 0;
+  padding: 20px 0;
+}
+
+.model-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 10px;
+
+  p {
+    font-size: 14px;
+    color: #fff;
+    height: 1.5em;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+}
+
+.model-desc {
+  color: rgba(255, 255, 255, 0.6);
+  line-height: 18px;
+  font-size: 12px;
+}
+
+.model-action {
+  display: flex;
+  align-items: center;
+  flex: none;
+
+  .active {
+    color: var(--colors-primary-base) !important;
+  }
+
+  > * {
+    margin-left: 20px;
+  }
+}
+</style>
+
+<style lang="scss">
+.animation-left-header .ant-tabs-tab {
+  padding-bottom: 18px;
+  font-size: 16px;
+}
+
+.animation-left-header .ant-tabs-nav {
+  &::before {
+    display: none;
+  }
+
+  margin: 0;
+}
+
+.animation-left-list .header {
+  padding-bottom: 0;
+}
+</style>

+ 168 - 0
src/views/animation/right.vue

@@ -0,0 +1,168 @@
+<template>
+  <RightFillPano class="animation-right">
+    <Tabs v-model:activeKey="activeKey" width="100%">
+      <TabPane key="setting" tab="设置">
+        <ui-group borderBottom>
+          <ui-group-option class="item">
+            <span class="label">名称</span>
+            <span class="oper">
+              <ui-input
+                width="100%"
+                type="text"
+                ref="nameInput"
+                class="nameInput"
+                placeholder="请输入名称"
+                v-model="am.title"
+                :maxlength="100"
+              />
+            </span>
+          </ui-group-option>
+          <ui-group-option class="item">
+            <span class="label">显示名称</span>
+            <span class="oper"> <Switch v-model:checked="am.showTitle" /> </span>
+          </ui-group-option>
+        </ui-group>
+
+        <ui-group borderBottom>
+          <ui-group-option>
+            <SignItem
+              label="文字大小"
+              @apply-global="$emit('applyGlobal', 'visibilityRange')"
+            >
+              <Slider v-model:value="am.fontSize" :min="12" :max="60" :step="0.1" />
+            </SignItem>
+          </ui-group-option>
+        </ui-group>
+        <ui-group borderBottom>
+          <ui-group-option>
+            <SignItem
+              label="可见范围"
+              v-if="!am.globalVisibility"
+              @apply-global="$emit('applyGlobal', 'visibilityRange')"
+            >
+              <Slider
+                v-model:value="am.visibilityRange"
+                :min="1"
+                :max="1000"
+                :step="0.1"
+              />
+            </SignItem>
+          </ui-group-option>
+          <ui-group-option>
+            <ui-input
+              type="checkbox"
+              label="全部范围可视"
+              :modelValue="!!am.globalVisibility"
+              @update:modelValue="(v: boolean) => am.globalVisibility = v"
+            />
+          </ui-group-option>
+        </ui-group>
+      </TabPane>
+      <TabPane key="animation" tab="动画">
+        <ui-group borderBottom>
+          <ui-group-option class="item">
+            <span class="label">加帧</span>
+            <span class="oper"> <ui-icon type="add" ctrl /> </span>
+          </ui-group-option>
+          <ui-group-option class="item">
+            <span class="label">路径</span>
+            <span class="oper"> <ui-icon type="add" ctrl /> </span>
+          </ui-group-option>
+          <ui-group-option class="item">
+            <span class="label">字幕</span>
+            <span class="oper"> <ui-icon type="add" ctrl /> </span>
+          </ui-group-option>
+        </ui-group>
+        <ui-group borderBottom>
+          <ui-group-option class="item">
+            <span class="label">动作库</span>
+          </ui-group-option>
+          <ui-group-option class="actions">
+            <span v-for="action in amActions">
+              <img :src="action.url" />
+            </span>
+          </ui-group-option>
+        </ui-group>
+      </TabPane>
+    </Tabs>
+    <div></div>
+  </RightFillPano>
+</template>
+
+<script lang="ts" setup>
+import { Switch, Slider, TabPane, Tabs } from "ant-design-vue";
+import { AnimationModel } from "@/api";
+import { RightFillPano } from "@/layout";
+import SignItem from "@/views/tagging-position/sign-item.vue";
+import { ref } from "vue";
+import { amActions } from "@/store/animation";
+
+defineProps<{ am: AnimationModel }>();
+const activeKey = ref("setting");
+</script>
+
+<style scoped lang="scss">
+
+.edit-path-point {
+  position: absolute;
+  right: 0;
+  height: 100%;
+  top: 0;
+  --editor-menu-right: 0px;
+}
+
+.item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.pin-position {
+  position: absolute;
+  left: 50%;
+  transform: translate(-50%);
+  width: 64px;
+  height: 64px;
+  background: rgba(27, 27, 28, 0.8);
+  border-radius: 50%;
+  bottom: 20px;
+  z-index: 9;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.actions {
+  display: flex;
+  flex-wrap: wrap;
+
+  span {
+    width: calc((100% - 28px) / 3);
+    background: #fff;
+    margin-top: 7px;
+    margin-bottom: 7px;
+    height: 80px;
+    cursor: pointer;
+
+    &:nth-child(3n - 1) {
+      margin-left: 14px;
+      margin-right: 14px;
+    }
+
+    img {
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+    }
+  }
+}
+</style>
+
+<style lang="scss">
+.animation-right .ui-editor-toolbox {
+  padding-top: 0;
+  .ant-tabs-tab {
+    font-size: 16px;
+    padding: 16px 10px;
+  }
+}
+</style>

+ 7 - 0
src/views/guide/guide/edit.vue

@@ -54,6 +54,13 @@ const deleteGuide = (guide: Guide) => {
 };
 
 useViewStack(autoSaveGuides);
+
+defineExpose({
+  add: () => {
+    edit(createGuide())
+  }
+})
+
 </script>
 
 <style lang="scss" scoped>

+ 60 - 3
src/views/guide/index.vue

@@ -12,17 +12,34 @@
         </span>
       </div>
     </template>
-    <GuideEdit v-if="current === 'guide'" />
-    <PathEdit v-if="current === 'path'" />
+    <GuideEdit v-if="current === 'guide'" ref="quiskObj" />
+    <PathEdit v-if="current === 'path'" ref="quiskObj" />
   </RightFillPano>
+  <Teleport to=".laser-layer">
+    <div class="quisks">
+      <div class="quisk-item fun-ctrl" @click="quiskAdd('guide')">
+        <ui-icon type="close" />
+        <span>导览</span>
+      </div>
+      <div class="quisk-item fun-ctrl" @click="quiskAdd('animation')">
+        <ui-icon type="close" />
+        <span>动画</span>
+      </div>
+      <div class="quisk-item fun-ctrl" @click="quiskAdd('path')">
+        <ui-icon type="close" />
+        <span>路线</span>
+      </div>
+    </div>
+  </Teleport>
 </template>
 
 <script lang="ts" setup>
 import { RightFillPano } from "@/layout";
 import GuideEdit from "./guide/edit.vue";
 import PathEdit from "./path/edit.vue";
-import { reactive, ref, watchEffect } from "vue";
+import { nextTick, reactive, ref, watchEffect } from "vue";
 import { guides, isEdit, paths } from "@/store";
+import router from "@/router";
 
 const current = ref("path");
 const tabs = reactive([
@@ -33,6 +50,16 @@ watchEffect(() => {
   tabs[1].text = `导览(${guides.value.length})`;
   tabs[0].text = `路线(${paths.value.length})`;
 });
+const quiskObj = ref<any>();
+const quiskAdd = async (key: string) => {
+  if (key === "animation") {
+    router.push({ name: key });
+  } else {
+    current.value = key;
+    await nextTick();
+    quiskObj.value.add();
+  }
+};
 </script>
 
 <style lang="scss" scoped>
@@ -74,4 +101,34 @@ watchEffect(() => {
     }
   }
 }
+
+.quisks {
+  position: absolute;
+  bottom: 20px;
+  left: 50%;
+  transform: translateX(-50%);
+  display: flex;
+  align-items: center;
+
+  .quisk-item {
+    width: 80px;
+    height: 80px;
+    border-radius: 10px;
+    background: rgba(27, 27, 28, 0.8);
+    color: #ffffff;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+
+    &:not(:last-child) {
+      margin-right: 20px;
+    }
+
+    span {
+      margin-top: 6px;
+      font-size: 14px;
+    }
+  }
+}
 </style>

+ 6 - 0
src/views/guide/path/edit.vue

@@ -83,6 +83,12 @@ const applyGlobal = async (keys: string | string[]) => {
 };
 
 useViewStack(autoSavePaths);
+
+defineExpose({
+  add: () => {
+    edit(createPath());
+  },
+});
 </script>
 
 <style lang="scss" scoped>