import { EDITOR_CONFIG, type MapAreaInfo, MapAreaType, type MapPen, type MapPointInfo, MapPointType, type MapRouteInfo, MapRoutePassType, MapRouteType, type Point, type Rect, } from '@api/map'; import type { RobotGroup, RobotInfo, RobotRealtimeInfo, RobotType } from '@api/robot'; import type { GroupSceneDetail, SceneData, StandardScene, StandardSceneArea, StandardScenePoint, StandardSceneRoute, } from '@api/scene'; import sTheme from '@core/theme.service'; import { CanvasLayer, LockState, Meta2d, type Meta2dStore, type Pen, s8 } from '@meta2d/core'; import { useObservable } from '@vueuse/rxjs'; import { clone, get, isEmpty, isNil, isString, nth, omitBy, pick, remove, some } from 'lodash-es'; import { BehaviorSubject, debounceTime, filter, map, Subject, switchMap } from 'rxjs'; import { reactive, watch } from 'vue'; /** * 场景编辑器服务类 * 继承自Meta2D,提供完整的场景编辑功能 * * 主要功能: * - 场景文件的加载、保存和管理 * - 点位、路线、区域的创建和编辑 * - 机器人组的管理和实时状态更新 * - 鼠标事件的处理和响应式数据流 * - 自定义绘制和渲染逻辑 */ export class EditorService extends Meta2d { //#region 场景文件管理 /** * 加载场景文件到编辑器 * @param map 场景文件的JSON字符串,为空则创建新场景 * @param editable 是否可编辑状态,控制编辑器锁定状态 * @param detail 群组场景详情,包含机器人组和机器人信息 */ public async load(map?: string, editable = false, detail?: Partial): Promise { const scene: StandardScene = map ? JSON.parse(map) : {}; if (!isEmpty(detail?.group)) { scene.robotGroups = [detail.group]; scene.robots = detail.robots; } const { robotGroups, robots, points, routes, areas, ...extraFields } = scene; // 保存所有额外字段(包括width、height等) this.#originalSceneData = extraFields; this.open(); this.setState(editable); this.#loadRobots(robotGroups, robots); await this.#loadScenePoints(points); this.#loadSceneRoutes(routes); await this.#loadSceneAreas(areas); this.store.historyIndex = undefined; this.store.histories = []; // this.scale(scale);与xd 自定义缩放冲突,暂时去掉 // if (isEmpty(origin)) { // this.centerView(); // } else { // this.translate(origin.x / scale, origin.y / scale); // } } /** * 保存当前场景为JSON字符串 * @returns 包含完整场景数据的JSON字符串 */ public save(): string { const { scale, x, y, origin } = this.data(); const scene: StandardScene = { scale, origin: { x: x + origin.x, y: y + origin.y }, robotGroups: this.robotGroups.value, robots: this.robots, points: this.points.value.map((v) => this.#mapScenePoint(v)).filter((v) => !isNil(v)), routes: this.routes.value.map((v) => this.#mapSceneRoute(v)).filter((v) => !isNil(v)), areas: this.areas.value.map((v) => this.#mapSceneArea(v)).filter((v) => !isNil(v)), blocks: [], ...this.#originalSceneData, // 统一保留所有额外字段(包括width、height等) }; return JSON.stringify(scene); } /** * 加载机器人数据到编辑器 * @param groups 机器人组列表 * @param robots 机器人信息列表 */ #loadRobots(groups?: RobotGroup[], robots?: RobotInfo[]): void { this.#robotMap.clear(); robots?.forEach((v) => this.#robotMap.set(v.id, v)); this.#robotGroups$$.next(groups ?? []); } /** * 从场景数据加载点位到画布 * @param points 标准场景点位数据数组 */ async #loadScenePoints(points?: StandardScenePoint[]): Promise { if (!points?.length) return; await Promise.all( points.map(async (v) => { const { id, name, desc, x, y, type, extensionType, robots, actions, properties } = v; await this.addPoint({ x, y }, type, id); this.setValue( { id, label: name, desc, properties, point: { type, extensionType, robots, actions } }, { render: false, history: false, doEvent: false }, ); }), ); } /** * 从场景数据加载路线到画布 * @param routes 标准场景路线数据数组 */ #loadSceneRoutes(routes?: StandardSceneRoute[]): void { if (!routes?.length) return; routes.map((v) => { const { id, desc, from, to, type, pass, c1, c2, properties } = v; const p1 = this.getPenById(from); const p2 = this.getPenById(to); if (isNil(p1) || isNil(p2)) return; this.addRoute([p1, p2], type, id); const { x: x1, y: y1 } = this.getPointRect(p1)!; const { x: x2, y: y2 } = this.getPointRect(p2)!; this.setValue( { id, desc, properties, route: { type, pass, c1: { x: (c1?.x ?? 0) - x1, y: (c1?.y ?? 0) - y1 }, c2: { x: (c2?.x ?? 0) - x2, y: (c2?.y ?? 0) - y2 }, }, }, { render: false, history: false, doEvent: false }, ); }); } async #loadSceneAreas(areas?: StandardSceneArea[]): Promise { if (!areas?.length) return; await Promise.all( areas.map(async (v) => { const { id, name, desc, x, y, w, h, type, points, routes, maxAmr, inoutflag, storageLocations, properties } = v; await this.addArea({ x, y }, { x: x + w, y: y + h }, type, id); // 对于库区类型,需要将点位名称数组转换为点位ID数组,并更新动作点的库位信息 let processedPoints = points; if (type === MapAreaType.库区 && points?.length) { // 将点位名称数组转换为点位ID数组 const actionPoints = this.find('point').filter( (pen: MapPen) => pen.point?.type === MapPointType.动作点 && points.includes(pen.label || pen.id!), ); processedPoints = actionPoints.map((pen) => pen.id!); // 如果有storageLocations数据,更新对应动作点的库位信息 if (storageLocations && Array.isArray(storageLocations)) { // 将数组格式转换为对象格式以便查找 const storageLocationsMap: Record = {}; storageLocations.forEach((item) => { Object.entries(item).forEach(([pointName, locations]) => { storageLocationsMap[pointName] = locations; }); }); actionPoints.forEach((pen) => { const pointName = pen.label || pen.id!; if (storageLocationsMap[pointName]) { this.setValue( { id: pen.id, point: { ...pen.point, associatedStorageLocations: storageLocationsMap[pointName] } }, { render: false, history: false, doEvent: false }, ); } }); } } this.setValue( { id, label: name, desc, properties, area: { type, points: processedPoints, routes, maxAmr, inoutflag } }, { render: false, history: false, doEvent: false }, ); }), ); } #mapScenePoint(pen?: MapPen): StandardScenePoint | null { if (!pen?.id || isEmpty(pen?.point)) return null; const { id, label, desc, properties } = pen; const { type, extensionType, robots, actions, associatedStorageLocations } = pen.point; const { x = 0, y = 0 } = this.getPointRect(pen) ?? {}; // 进行坐标转换:左上角原点 -> 中心点原点,同时应用ratio缩放 const transformedCoords = this.#transformCoordinate(x, y); const point: StandardScenePoint = { id: id, name: label || id, desc, x: transformedCoords.x, y: transformedCoords.y, type, extensionType, config: {}, properties, }; if ([MapPointType.充电点, MapPointType.停靠点].includes(type)) { point.robots = robots?.filter((v) => this.#robotMap.has(v)); } if (MapPointType.等待点 === type) { point.actions = actions?.filter((v) => this.getPenById(v)?.point?.type === MapPointType.动作点); } if (MapPointType.动作点 === type) { point.associatedStorageLocations = associatedStorageLocations; } return point; } #mapSceneRoute(pen?: MapPen): StandardSceneRoute | null { if (!pen?.id || pen.anchors?.length !== 2 || isEmpty(pen?.route)) return null; const { id, anchors, desc, properties } = pen; const { type, direction = 1, pass, c1, c2 } = pen.route; const [p1, p2] = anchors.map((v) => this.getPenById(v.connectTo!)); if (isNil(p1) || isNil(p2)) return null; const route: StandardSceneRoute = { id: id, desc, from: direction < 0 ? p2.id! : p1.id!, to: direction < 0 ? p1.id! : p2.id!, type, pass, config: {}, properties, }; const { x: x1, y: y1 } = this.getPointRect(p1)!; const { x: x2, y: y2 } = this.getPointRect(p2)!; const cp1 = { x: x1 + (c1?.x ?? 0), y: y1 + (c1?.y ?? 0) }; const cp2 = { x: x2 + (c2?.x ?? 0), y: y2 + (c2?.y ?? 0) }; switch (type) { case MapRouteType.二阶贝塞尔曲线: // 对控制点进行坐标转换 route.c1 = this.#transformCoordinate(cp1.x, cp1.y); break; case MapRouteType.三阶贝塞尔曲线: { // 对两个控制点进行坐标转换 const transformedCp1 = this.#transformCoordinate(cp1.x, cp1.y); const transformedCp2 = this.#transformCoordinate(cp2.x, cp2.y); route.c1 = direction < 0 ? transformedCp2 : transformedCp1; route.c2 = direction < 0 ? transformedCp1 : transformedCp2; break; } default: break; } return route; } #mapSceneArea(pen: MapPen): StandardSceneArea | null { if (!pen.id || isEmpty(pen.area)) return null; const { id, label, desc, properties } = pen; const { type, points, routes, maxAmr, inoutflag } = pen.area; const { x, y, width, height } = this.getPenRect(pen); // 进行坐标转换:左上角原点 -> 中心点原点,同时应用ratio缩放 const transformedCoords = this.#transformCoordinate(x, y); const area: StandardSceneArea = { id, name: label || id, desc, x: transformedCoords.x, y: transformedCoords.y, w: this.#transformSize(width), h: this.#transformSize(height), type, config: {}, properties, }; if (type === MapAreaType.约束区) { area.maxAmr = maxAmr; } if (MapAreaType.库区 === type) { // 获取库区内的动作点 const actionPoints = points ?.map((id) => this.getPenById(id)) .filter((pen): pen is MapPen => !!pen && pen.point?.type === MapPointType.动作点) ?? []; // 保存动作点名称 area.points = actionPoints.map((pen) => pen.label || pen.id!); // 构建storageLocations数组:[{动作点名称: [库位列表]}] area.storageLocations = actionPoints .map((pen) => { const pointName = pen.label || pen.id!; const storageLocations = pen.point?.associatedStorageLocations ?? []; return { [pointName]: storageLocations }; }) .filter((item): item is Record => item !== null); area.inoutflag = inoutflag; } if ([MapAreaType.互斥区, MapAreaType.非互斥区, MapAreaType.约束区].includes(type)) { area.points = points?.filter((v) => { const { point } = this.getPenById(v) ?? {}; if (isNil(point)) return false; if (point.type === MapPointType.禁行点) return false; return true; }); } if (MapAreaType.互斥区 === type) { area.routes = routes?.filter((v) => !isEmpty(this.getPenById(v)?.area)); } return area; } //#endregion /** * 设置编辑器状态 * @param editable 是否可编辑,true为可编辑状态,false为只读状态 */ public setState(editable?: boolean): void { this.lock(editable ? LockState.None : LockState.DisableEdit); this.data().pens.forEach((pen: MapPen) => { if (pen.name !== 'area') { if (pen.locked !== LockState.DisableEdit) { this.setValue( { id: pen.id, locked: LockState.DisableEdit }, { render: false, history: false, doEvent: false }, ); } } }); this.render(); } public override data(): SceneData { return super.data(); } /** 鼠标事件流主体,用于内部事件分发 */ readonly #mouse$$ = new Subject<{ type: 'click' | 'mousedown' | 'mouseup'; value: Point }>(); /** 鼠标点击事件的响应式流,防抖处理后的点击坐标 */ public readonly mouseClick = useObservable( this.#mouse$$.pipe( filter(({ type }) => type === 'click'), debounceTime(100), map(({ value }) => value), ), ); /** 鼠标拖拽事件的响应式流,返回起始点和结束点坐标,用于创建区域 */ public readonly mouseBrush = useObservable<[Point, Point]>( this.#mouse$$.pipe( filter(({ type }) => type === 'mousedown'), switchMap(({ value: s }) => this.#mouse$$.pipe( filter(({ type }) => type === 'mouseup'), map(({ value: e }) => <[Point, Point]>[s, e]), ), ), ), ); //#region 机器人组管理 /** 机器人信息映射表,响应式存储所有机器人数据 */ readonly #robotMap = reactive>(new Map()); /** 获取所有机器人信息数组 */ public get robots(): RobotInfo[] { return Array.from(this.#robotMap.values()); } public checkRobotById(id: RobotInfo['id']): boolean { return this.#robotMap.has(id); } public getRobotById(id: RobotInfo['id']): RobotInfo | undefined { return this.#robotMap.get(id); } public updateRobot(id: RobotInfo['id'], value: Partial): void { const robot = this.getRobotById(id); if (isNil(robot)) return; this.#robotMap.set(id, { ...robot, ...value }); if (value.label) { this.setValue({ id, text: value.label }, { render: true, history: false, doEvent: false }); } (this.store.data).robots = [...this.#robotMap.values()]; } public addRobots(gid: RobotInfo['gid'], robots: RobotInfo[]): void { const groups = clone(this.#robotGroups$$.value); const group = groups.find((v) => v.id === gid); if (isNil(group)) throw Error('未找到目标机器人组'); group.robots ??= []; robots.forEach((v) => { if (this.#robotMap.has(v.id)) return; this.#robotMap.set(v.id, { ...v, gid }); group.robots?.push(v.id); }); this.#robotGroups$$.next(groups); (this.store.data).robots = [...this.#robotMap.values()]; (this.store.data).robotGroups = this.#robotGroups$$.value; } public removeRobots(ids: RobotInfo['id'][]): void { ids?.forEach((v) => this.#robotMap.delete(v)); const groups = clone(this.#robotGroups$$.value); groups.forEach(({ robots }) => remove(robots ?? [], (v) => !this.#robotMap.has(v))); this.#robotGroups$$.next(groups); (this.store.data).robots = [...this.#robotMap.values()]; (this.store.data).robotGroups = this.#robotGroups$$.value; } public updateRobots(ids: RobotInfo['id'][], value: Partial): void { ids?.forEach((v) => { const robot = this.#robotMap.get(v); if (isNil(robot)) return; this.#robotMap.set(v, { ...robot, ...value }); }); (this.store.data).robots = [...this.#robotMap.values()]; } readonly #robotGroups$$ = new BehaviorSubject([]); public readonly robotGroups = useObservable(this.#robotGroups$$.pipe(debounceTime(300))); public createRobotGroup(): void { const id = s8(); const label = `RG${id}`; const groups = clone(this.#robotGroups$$.value); groups.push({ id, label }); this.#robotGroups$$.next(groups); (this.store.data).robotGroups = this.#robotGroups$$.value; } public deleteRobotGroup(id: RobotGroup['id']): void { const groups = clone(this.#robotGroups$$.value); const group = groups.find((v) => v.id === id); group?.robots?.forEach((v) => this.#robotMap.delete(v)); remove(groups, group); this.#robotGroups$$.next(groups); (this.store.data).robots = [...this.#robotMap.values()]; (this.store.data).robotGroups = this.#robotGroups$$.value; } public updateRobotGroupLabel(id: RobotGroup['id'], label: RobotGroup['label']): void { const groups = this.#robotGroups$$.value; const group = groups.find((v) => v.id === id); if (isNil(group)) throw Error('未找到目标机器人组'); if (some(groups, ['label', label])) throw Error('机器人组名称已经存在'); group.label = label; this.#robotGroups$$.next([...groups]); (this.store.data).robotGroups = this.#robotGroups$$.value; } //#endregion /** 保存从后台传来的所有额外字段(除了已处理的robotGroups、robots、points、routes、areas之外的字段) */ #originalSceneData?: Partial; /** 坐标转换方法 - 将左上角原点的坐标转换为中心点原点的坐标 */ #transformCoordinate(x: number, y: number): { x: number; y: number } { const { ratio = 1, width = 0, height = 0 } = this.#originalSceneData ?? {}; // 先根据ratio进行缩放 const scaledX = x / ratio; const scaledY = y / ratio; // 再进行坐标系转换:左上角原点 -> 中心点原点 const centerX = scaledX - width / 2; const centerY = height / 2 - scaledY; // 应用精度控制:保留3位小数,之后直接舍去 return { x: this.#fixPrecision(centerX), y: this.#fixPrecision(centerY), }; } /** 尺寸转换方法 - 根据ratio缩放尺寸 */ #transformSize(size: number): number { const { ratio = 1 } = this.#originalSceneData ?? {}; const scaledSize = size / ratio; // 应用精度控制:保留3位小数,之后直接舍去 return this.#fixPrecision(scaledSize); } /** 精度控制方法 - 固定3位小数,3位之后直接舍去(不四舍五入),不足3位则补齐 */ #fixPrecision(value: number): number { // 先截断到3位小数(不四舍五入) const truncated = Math.floor(value * 1000) / 1000; // 然后格式化为固定3位小数的字符串,再转回数字 return parseFloat(truncated.toFixed(3)); } /** * 优化的像素对齐算法 - 确保在所有缩放比例下都能精确对齐像素边界 * 解决小车和光圈在特定缩放比例下不重合的问题 */ #calculatePixelAlignedOffset(baseOffset: number): number { const scale = this.store.data.scale || 1; const devicePixelRatio = window.devicePixelRatio || 1; // 计算实际像素偏移量 const scaledOffset = baseOffset * scale; // 多重对齐策略: // 1. 设备像素对齐 - 确保在高DPI屏幕上也能对齐 const deviceAlignedOffset = Math.round(scaledOffset * devicePixelRatio) / devicePixelRatio; // 2. 子像素对齐 - 对于常见的缩放比例使用特殊处理 let finalOffset = deviceAlignedOffset; // 针对常见问题缩放比例的特殊处理 const roundedScale = Math.round(scale * 100) / 100; // 避免浮点数精度问题 if (roundedScale <= 0.2) { // 极小缩放:使用更粗粒度的对齐(0.5像素边界) finalOffset = Math.round(scaledOffset * 2) / 2; } else if (roundedScale <= 0.5) { // 小缩放:使用0.25像素边界对齐 finalOffset = Math.round(scaledOffset * 4) / 4; } else if (roundedScale >= 2) { // 大缩放:使用精确的像素边界对齐 finalOffset = Math.round(scaledOffset); } else { // 标准缩放:使用设备像素对齐结果,但增加额外的精度控制 const precisionFactor = 8; // 1/8像素精度 finalOffset = Math.round(scaledOffset * precisionFactor) / precisionFactor; } // 3. 转换回逻辑坐标系并应用精度控制 const logicalOffset = finalOffset / scale; // 4. 使用现有的精度控制方法确保数值稳定性 return this.#fixPrecision(logicalOffset); } /** 画布变化事件流,用于触发响应式数据更新 */ readonly #change$$ = new Subject(); /** 当前选中的图形对象,响应式更新 */ public readonly current = useObservable( this.#change$$.pipe( debounceTime(100), map(() => clone(this.store.active?.[0])), ), ); /** 当前选中的图形ID列表,响应式更新 */ public readonly selected = useObservable( this.#change$$.pipe( filter((v) => !v), debounceTime(100), map(() => this.store.active?.map(({ id }) => id).filter((v) => !isNil(v)) ?? []), ), { initialValue: new Array() }, ); /** 画布上所有图形对象列表,响应式更新 */ public readonly pens = useObservable( this.#change$$.pipe( filter((v) => v), debounceTime(100), map(() => this.data().pens), ), ); public override find(target: string): MapPen[] { return super.find(target); } public getPenById(id?: string): MapPen | undefined { if (!id) return; return this.find(id)[0]; } public override active(target: string | Pen[], emit?: boolean): void { const pens = isString(target) ? this.find(target) : target; super.active(pens, emit); this.render(); } public override inactive(): void { super.inactive(); this.render(); } public gotoById(id: string): void { const pen = this.getPenById(id); if (isNil(pen)) return; // 判断机器人是否可见,如果不可见直接返回 if (pen.visible === false && pen.tags?.includes('robot')) return; this.gotoView(pen); } public deleteById(id?: string): void { const pen = this.getPenById(id); if (pen?.name !== 'area') return; this.delete([pen], true, true); } public updatePen(id: string, pen: Partial, record = true): void { this.setValue({ ...pen, id }, { render: true, history: record, doEvent: true }); } //#region 实时机器人 public async initRobots(): Promise { await Promise.all( this.robots.map(async ({ id, label, type }) => { const pen: MapPen = { ...this.#mapRobotImage(type, true), id, name: 'robot', tags: ['robot'], x: 0, y: 0, width: 74, height: 74, lineWidth: 1, robot: { type }, visible: false, text: label, textTop: -24, whiteSpace: 'nowrap', ellipsis: false, locked: LockState.Disable, }; await this.addPen(pen, false, true, true); }), ); } public refreshRobot(id: RobotInfo['id'], info: Partial): void { const pen = this.getPenById(id); const { rotate: or, robot } = pen ?? {}; if (!robot?.type) return; const { x: ox, y: oy } = this.getPenRect(pen!); const { x: cx = 37, y: cy = 37, active, angle, path: points, isWaring, isFault } = info; const x = cx - 37; const y = cy - 37; const rotate = angle ?? or; const path = points?.map((p) => ({ x: p.x - cx, y: p.y - cy })) ?? robot.path?.map((p) => ({ x: p.x + ox! - x, y: p.y + oy! - y })); const o = { ...robot, ...omitBy({ active, path, isWaring, isFault }, isNil) }; if (isNil(active)) { this.setValue({ id, x, y, rotate, robot: o, visible: true }, { render: true, history: false, doEvent: false }); } else { this.setValue( { id, ...this.#mapRobotImage(robot.type, active), x, y, rotate, robot: o, visible: true }, { render: true, history: false, doEvent: false }, ); } } #mapRobotImage( type: RobotType, active?: boolean, ): Required> { const theme = this.data().theme; const image = import.meta.env.BASE_URL + (active ? `/robot/${type}-active-${theme}.png` : `/robot/${type}-${theme}.png`); // 使用优化的像素对齐算法,确保小车和光圈精确重合 const iconTop = this.#calculatePixelAlignedOffset(-10); return { image, iconWidth: 34, iconHeight: 54, iconTop }; } //#endregion //#region 点位 /** 画布上所有点位对象列表,响应式更新 */ public readonly points = useObservable( this.#change$$.pipe( filter((v) => v), debounceTime(100), map(() => this.find('point')), ), { initialValue: new Array() }, ); public getPointRect(pen?: MapPen): Rect | null { if (isNil(pen)) return null; const { x, y, width, height } = this.getPenRect(pen); return { x: x + width / 2, y: y + height / 2, width, height }; } /** * 在指定位置添加点位 * @param p 点位坐标 * @param type 点位类型,默认为普通点 * @param id 点位ID,未指定则自动生成 */ public async addPoint(p: Point, type = MapPointType.普通点, id?: string): Promise { id ||= s8(); const pen: MapPen = { ...p, ...this.#mapPoint(type), ...this.#mapPointImage(type), id, name: 'point', tags: ['point'], label: `P${id}`, point: { type }, locked: LockState.DisableEdit, }; pen.x! -= pen.width! / 2; pen.y! -= pen.height! / 2; await this.addPen(pen, false, true, true); } public updatePoint(id: string, info: Partial): void { const { point } = this.getPenById(id) ?? {}; if (!point?.type) return; const o = { ...point, ...info }; this.setValue({ id, point: o }, { render: true, history: true, doEvent: true }); } public changePointType(id: string, type: MapPointType): void { const pen = this.getPenById(id); const rect = this.getPointRect(pen); if (isNil(rect)) return; const point = this.#mapPoint(type); this.setValue( { id, x: rect.x - point.width / 2, y: rect.y - point.height / 2, ...point, ...this.#mapPointImage(type), point: { type }, }, { render: true, history: true, doEvent: true }, ); } #mapPoint(type: MapPointType): Required> { const width = type < 10 ? 24 : 48; const height = type < 10 ? 24 : 60; const lineWidth = type < 10 ? 2 : 3; const iconSize = type < 10 ? 4 : 10; return { width, height, lineWidth, iconSize }; } #mapPointImage(type: MapPointType): Required> { const theme = this.data().theme; const image = type < 10 ? '' : `${import.meta.env.BASE_URL}/point/${type}-${theme}.png`; return { image, canvasLayer: CanvasLayer.CanvasMain }; } //#endregion //#region 线路 /** 画布上所有路线对象列表,响应式更新,包含动态生成的标签 */ public readonly routes = useObservable( this.#change$$.pipe( filter((v) => v), debounceTime(100), map(() => this.find('route').map((v) => ({ ...v, label: this.getRouteLabel(v.id) }))), ), { initialValue: new Array() }, ); public getRouteLabel(id?: string, d?: number): string { if (!id) return ''; const pen = this.getPenById(id); if (isNil(pen)) return ''; const [a1, a2] = pen.anchors ?? []; if (!a1?.connectTo || !a2?.connectTo) return ''; const p1 = this.getPenById(a1.connectTo); const p2 = this.getPenById(a2.connectTo); if (isNil(p1) || isNil(p2)) return ''; const { direction = 1 } = pen.route ?? {}; return `${p1.label}${(d ?? direction) > 0 ? '→' : '←'}${p2.label}`; } /** * 在两个点位之间添加路线 * @param p 两个点位的数组 * @param type 路线类型,默认为直线 * @param id 路线ID,未指定则自动生成 */ public addRoute(p: [MapPen, MapPen], type = MapRouteType.直线, id?: string): void { const [p1, p2] = p; if (!p1?.anchors?.length || !p2?.anchors?.length) return; const line = this.connectLine(p1, p2, undefined, undefined, false); id ||= line.id!; this.changePenId(line.id!, id); const pen: MapPen = { tags: ['route'], route: { type }, lineWidth: 1, locked: LockState.DisableEdit }; this.setValue({ id, ...pen }, { render: false, history: false, doEvent: false }); this.updateLineType(line, type); this.active(id); this.render(); } public updateRoute(id: string, info: Partial): void { const { route } = this.getPenById(id) ?? {}; if (!route?.type) return; const o = { ...route, ...info }; this.setValue({ id, route: o }, { render: true, history: true, doEvent: true }); } public changeRouteType(id: string, type: MapRouteType): void { const pen = this.getPenById(id); if (isNil(pen)) return; this.updateLineType(pen, type); this.setValue({ id, route: { type } }, { render: true, history: true, doEvent: true }); } //#endregion //#region 区域 /** 画布上所有区域对象列表,响应式更新 */ public readonly areas = useObservable( this.#change$$.pipe( filter((v) => v), debounceTime(100), map(() => this.find('area')), ), { initialValue: new Array() }, ); public getBoundAreas(id: string = '', name: 'point' | 'line', type: MapAreaType): MapPen[] { if (!id) return []; return this.find(`area-${type}`).filter(({ area }) => { if (name === 'point') return area?.points?.includes(id); if (name === 'line') return area?.routes?.includes(id); return false; }); } /** * 在指定区域添加功能区域 * @param p1 区域起始坐标 * @param p2 区域结束坐标 * @param type 区域类型,默认为库区 * @param id 区域ID,未指定则自动生成 */ public async addArea(p1: Point, p2: Point, type = MapAreaType.库区, id?: string) { const w = Math.abs(p1.x - p2.x); const h = Math.abs(p1.y - p2.y); if (w < 50 || h < 60) return; const points = new Array(); const routes = new Array(); if (!id) { id = s8(); const selected = this.store.active; switch (type) { case MapAreaType.库区: selected?.filter(({ point }) => point?.type === MapPointType.动作点).forEach(({ id }) => points.push(id!)); break; case MapAreaType.互斥区: selected?.filter(({ point }) => point?.type).forEach(({ id }) => points.push(id!)); selected?.filter(({ route }) => route?.type).forEach(({ id }) => routes.push(id!)); break; case MapAreaType.非互斥区: selected?.filter(({ point }) => point?.type).forEach(({ id }) => points.push(id!)); break; case MapAreaType.约束区: selected?.filter(({ point }) => point?.type).forEach(({ id }) => points.push(id!)); break; default: break; } } const areaInfo: MapAreaInfo = { type, points, routes }; if (type === MapAreaType.库区) { areaInfo.inoutflag = 1; } const pen: MapPen = { id, name: 'area', tags: ['area', `area-${type}`], label: `A${id}`, x: Math.min(p1.x, p2.x), y: Math.min(p1.y, p2.y), width: w, height: h, lineWidth: 1, area: areaInfo, locked: LockState.None, }; const area = await this.addPen(pen, true, true, true); this.bottom(area); } public updateArea(id: string, info: Partial): void { const { area } = this.getPenById(id) ?? {}; if (!area?.type) return; const o = { ...area, ...info }; this.setValue({ id, area: o }, { render: true, history: true, doEvent: true }); } //#endregion /** * 构造函数 - 初始化场景编辑器 * @param container 编辑器容器DOM元素 */ constructor(container: HTMLDivElement) { super(container, EDITOR_CONFIG); // 禁用第6个子元素的拖放功能 (container.children.item(5)).ondrop = null; // 监听所有画布事件 this.on('*', (e, v) => this.#listen(e, v)); // 注册自定义绘制函数和锚点 this.#register(); // 监听主题变化并重新加载样式 watch( () => sTheme.theme, (v) => this.#load(v), { immediate: true }, ); } #load(theme: string): void { this.setTheme(theme); this.setOptions({ color: get(sTheme.editor, 'color') }); this.find('point').forEach((pen) => { if (!pen.point?.type) return; if (pen.point.type < 10) return; this.canvas.updateValue(pen, this.#mapPointImage(pen.point.type)); }); this.find('robot').forEach((pen) => { if (!pen.robot?.type) return; this.canvas.updateValue(pen, this.#mapRobotImage(pen.robot.type, pen.robot.active)); }); this.render(); } #onDelete(pens?: MapPen[]): void { pens?.forEach((pen) => { switch (pen.name) { case 'point': this.delete(this.getLines(pen), true, false); break; default: break; } }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any #listen(e: unknown, v: any) { switch (e) { case 'opened': this.#load(sTheme.theme); this.#change$$.next(true); break; case 'add': this.#change$$.next(true); break; case 'delete': this.#onDelete(v); this.#change$$.next(true); break; case 'update': this.#change$$.next(true); break; case 'valueUpdate': this.#change$$.next(true); break; case 'active': case 'inactive': this.#change$$.next(false); break; case 'click': case 'mousedown': case 'mouseup': this.#mouse$$.next({ type: e, value: pick(this.getPenRect(v), 'x', 'y') }); break; default: // console.log(e, v); break; } } #register() { this.register({ line: () => new Path2D() }); this.registerCanvasDraw({ point: drawPoint, line: drawLine, area: drawArea, robot: drawRobot }); this.registerAnchors({ point: anchorPoint }); this.addDrawLineFn('bezier2', lineBezier2); this.addDrawLineFn('bezier3', lineBezier3); } } //#region 自定义绘制函数 /** * 绘制点位的自定义函数 * @param ctx Canvas 2D绘制上下文 * @param pen 点位图形对象 */ function drawPoint(ctx: CanvasRenderingContext2D, pen: MapPen): void { const theme = sTheme.editor; const { active, iconSize: r = 0, fontSize = 14, lineHeight = 1.5, fontFamily } = pen.calculative ?? {}; const { x = 0, y = 0, width: w = 0, height: h = 0 } = pen.calculative?.worldRect ?? {}; const { type } = pen.point ?? {}; const { label = '', statusStyle } = pen ?? {}; ctx.save(); switch (type) { case MapPointType.普通点: case MapPointType.等待点: case MapPointType.避让点: case MapPointType.临时避让点: case MapPointType.库区点: ctx.beginPath(); ctx.moveTo(x + w / 2 - r, y + r); ctx.arcTo(x + w / 2, y, x + w - r, y + h / 2 - r, r); ctx.arcTo(x + w, y + h / 2, x + w / 2 + r, y + h - r, r); ctx.arcTo(x + w / 2, y + h, x + r, y + h / 2 + r, r); ctx.arcTo(x, y + h / 2, x + r, y + h / 2 - r, r); ctx.closePath(); ctx.fillStyle = get(theme, `point-s.fill-${type}`) ?? ''; ctx.fill(); ctx.strokeStyle = get(theme, active ? 'point-s.strokeActive' : 'point-s.stroke') ?? ''; if (type === MapPointType.临时避让点) { ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(x + 0.66 * r, y + h / 2 - 0.66 * r); ctx.lineTo(x + r, y + h / 2 - r); ctx.moveTo(x + w / 2 - 0.66 * r, y + 0.66 * r); ctx.lineTo(x + w / 2 - r, y + r); ctx.moveTo(x + w / 2 + 0.66 * r, y + 0.66 * r); ctx.lineTo(x + w / 2 + r, y + r); ctx.moveTo(x + w - 0.66 * r, y + h / 2 - 0.66 * r); ctx.lineTo(x + w - r, y + h / 2 - r); ctx.moveTo(x + w - 0.66 * r, y + h / 2 + 0.66 * r); ctx.lineTo(x + w - r, y + h / 2 + r); ctx.moveTo(x + w / 2 + 0.66 * r, y + h - 0.66 * r); ctx.lineTo(x + w / 2 + r, y + h - r); ctx.moveTo(x + w / 2 - 0.66 * r, y + h - 0.66 * r); ctx.lineTo(x + w / 2 - r, y + h - r); ctx.moveTo(x + 0.66 * r, y + h / 2 + 0.66 * r); ctx.lineTo(x + r, y + h / 2 + r); } ctx.stroke(); break; case MapPointType.电梯点: case MapPointType.自动门点: case MapPointType.充电点: case MapPointType.停靠点: case MapPointType.动作点: case MapPointType.禁行点: ctx.roundRect(x, y, w, h, r); ctx.strokeStyle = statusStyle ?? get(theme, active ? 'point-l.strokeActive' : 'point-l.stroke') ?? ''; ctx.stroke(); break; default: break; } ctx.fillStyle = get(theme, 'color') ?? ''; ctx.font = `${fontSize}px/${lineHeight} ${fontFamily}`; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText(label, x + w / 2, y - fontSize * lineHeight); ctx.restore(); } /** * 设置点位的连接锚点 * @param pen 点位图形对象 */ function anchorPoint(pen: MapPen): void { pen.anchors = [ { penId: pen.id, id: '0', x: 0.5, y: 0.5 }, // { penId: pen.id, id: 't', x: 0.5, y: 0 }, // { penId: pen.id, id: 'b', x: 0.5, y: 1 }, // { penId: pen.id, id: 'l', x: 0, y: 0.5 }, // { penId: pen.id, id: 'r', x: 1, y: 0.5 }, ]; } /** * 绘制路线的自定义函数 * @param ctx Canvas 2D绘制上下文 * @param pen 路线图形对象 */ function drawLine(ctx: CanvasRenderingContext2D, pen: MapPen): void { const theme = sTheme.editor; const { active, lineWidth: s = 1 } = pen.calculative ?? {}; const [p1, p2] = pen.calculative?.worldAnchors ?? []; const { x: x1 = 0, y: y1 = 0 } = p1 ?? {}; const { x: x2 = 0, y: y2 = 0 } = p2 ?? {}; const { type, direction = 1, pass = 0, c1, c2 } = pen.route ?? {}; const { x: dx1 = 0, y: dy1 = 0 } = c1 ?? {}; const { x: dx2 = 0, y: dy2 = 0 } = c2 ?? {}; const [c1x, c1y] = [x1 + dx1 * s, y1 + dy1 * s]; const [c2x, c2y] = [x2 + dx2 * s, y2 + dy2 * s]; ctx.save(); ctx.beginPath(); ctx.strokeStyle = get(theme, active ? 'route.strokeActive' : `route.stroke-${pass}`) ?? ''; ctx.lineWidth = active ? 3 * s : 2 * s; ctx.moveTo(x1, y1); switch (type) { case MapRouteType.直线: ctx.lineTo(x2, y2); break; case MapRouteType.二阶贝塞尔曲线: ctx.quadraticCurveTo(c1x, c1y, x2, y2); p1.next = { x: x1 + (2 / 3) * dx1 * s, y: y1 + (2 / 3) * dy1 * s }; p2.prev = { x: x2 / 3 + (2 / 3) * c1x, y: y2 / 3 + (2 / 3) * c1y }; break; case MapRouteType.三阶贝塞尔曲线: ctx.bezierCurveTo(c1x, c1y, c2x, c2y, x2, y2); p1.next = { x: c1x, y: c1y }; p2.prev = { x: c2x, y: c2y }; break; default: break; } if (pass === MapRoutePassType.禁行) { ctx.setLineDash([s * 5]); } ctx.stroke(); ctx.beginPath(); ctx.setLineDash([0]); const { dx, dy, r } = (() => { switch (type) { case MapRouteType.直线: { const t = direction < 0 ? 0.55 : 0.45; const dx = x1 + (x2 - x1) * t; const dy = y1 + (y2 - y1) * t; const r = Math.atan2(y2 - y1, x2 - x1) + (direction > 0 ? Math.PI : 0); return { dx, dy, r }; } case MapRouteType.二阶贝塞尔曲线: { const { x: dx, y: dy, t } = getBezier2Center(p1, { x: c1x, y: c1y }, p2); const r = getBezier2Tange(p1, { x: c1x, y: c1y }, p2, t) + (direction > 0 ? Math.PI : 0); return { dx, dy, r }; } case MapRouteType.三阶贝塞尔曲线: { const { x: dx, y: dy, t } = getBezier3Center(p1, { x: c1x, y: c1y }, { x: c2x, y: c2y }, p2); const r = getBezier3Tange(p1, { x: c1x, y: c1y }, { x: c2x, y: c2y }, p2, t) + (direction > 0 ? Math.PI : 0); return { dx, dy, r }; } default: return { dx: 0, dy: 0, r: 0 }; } })(); ctx.translate(dx, dy); ctx.moveTo(Math.cos(r + Math.PI / 5) * s * 10, Math.sin(r + Math.PI / 5) * s * 10); ctx.lineTo(0, 0); ctx.lineTo(Math.cos(r - Math.PI / 5) * s * 10, Math.sin(r - Math.PI / 5) * s * 10); ctx.stroke(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.restore(); } function lineBezier2(_: Meta2dStore, pen: MapPen): void { if (pen.calculative?.worldAnchors?.length !== 2) return; const { c1 } = pen.route ?? {}; const { lineWidth: s = 1 } = pen.calculative ?? {}; const [p1, p2] = pen.calculative?.worldAnchors ?? []; const { x: x1 = 0, y: y1 = 0 } = p1 ?? {}; const { x: x2 = 0, y: y2 = 0 } = p2 ?? {}; const { x: dx = 0, y: dy = 0 } = c1 ?? {}; pen.calculative.worldAnchors[0].next = { x: x1 + (2 / 3) * dx * s, y: y1 + (2 / 3) * dy * s }; pen.calculative.worldAnchors[1].prev = { x: x2 / 3 + (2 / 3) * (x1 + dx * s), y: y2 / 3 + (2 / 3) * (y1 + dy * s) }; } function lineBezier3(_: Meta2dStore, pen: MapPen): void { if (pen.calculative?.worldAnchors?.length !== 2) return; const { c1, c2 } = pen.route ?? {}; const { lineWidth: s = 1 } = pen.calculative ?? {}; const [p1, p2] = pen.calculative?.worldAnchors ?? []; const { x: x1 = 0, y: y1 = 0 } = p1 ?? {}; const { x: x2 = 0, y: y2 = 0 } = p2 ?? {}; const { x: dx1 = 0, y: dy1 = 0 } = c1 ?? {}; const { x: dx2 = 0, y: dy2 = 0 } = c2 ?? {}; pen.calculative.worldAnchors[0].next = { x: x1 + dx1 * s, y: y1 + dy1 * s }; pen.calculative.worldAnchors[1].prev = { x: x2 + dx2 * s, y: y2 + dy2 * s }; } /** * 绘制区域的自定义函数 * @param ctx Canvas 2D绘制上下文 * @param pen 区域图形对象 */ function drawArea(ctx: CanvasRenderingContext2D, pen: MapPen): void { const theme = sTheme.editor; const { active, fontSize = 14, lineHeight = 1.5, fontFamily } = pen.calculative ?? {}; const { x = 0, y = 0, width: w = 0, height: h = 0 } = pen.calculative?.worldRect ?? {}; const { type } = pen.area ?? {}; const { label = '' } = pen ?? {}; ctx.save(); ctx.rect(x, y, w, h); ctx.fillStyle = get(theme, `area.fill-${type}`) ?? ''; ctx.fill(); ctx.strokeStyle = get(theme, active ? 'area.strokeActive' : `area.stroke-${type}`) ?? ''; ctx.stroke(); ctx.fillStyle = get(theme, 'color') ?? ''; ctx.font = `${fontSize}px/${lineHeight} ${fontFamily}`; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText(label, x + w / 2, y - fontSize * lineHeight); ctx.restore(); } /** * 获取机器人状态 * @param isWaring 是否告警 * @param isFault 是否故障 * @returns 机器人状态: 'fault' | 'warning' | 'normal' * * 判断逻辑: * - isWaring=true, isFault=true → 故障 * - isWaring=false, isFault=true → 故障 * - isWaring=true, isFault=false → 告警 * - isWaring=false, isFault=false → 正常 */ function getRobotStatus(isWaring?: boolean, isFault?: boolean): 'fault' | 'warning' | 'normal' { // 只要 isFault 为 true,无论 isWaring 是什么,都是故障状态 if (isFault) return 'fault'; // 如果 isFault 为 false 但 isWaring 为 true,则是告警状态 if (isWaring) return 'warning'; // 两者都为 false 时,为正常状态 return 'normal'; } /** * 绘制机器人的自定义函数 * @param ctx Canvas 2D绘制上下文 * @param pen 机器人图形对象 */ function drawRobot(ctx: CanvasRenderingContext2D, pen: MapPen): void { const theme = sTheme.editor; const { lineWidth: s = 1 } = pen.calculative ?? {}; const { x = 0, y = 0, width: w = 0, height: h = 0, rotate: deg = 0 } = pen.calculative?.worldRect ?? {}; const { active, path, isWaring, isFault } = pen.robot ?? {}; if (!active) return; // 根据机器人状态获取颜色 const status = getRobotStatus(isWaring, isFault); const ox = x + w / 2; const oy = y + h / 2; ctx.save(); ctx.ellipse(ox, oy, w / 2, h / 2, 0, 0, Math.PI * 2); ctx.fillStyle = get(theme, `robot.fill-${status}`) ?? get(theme, 'robot.fill') ?? ''; ctx.fill(); ctx.strokeStyle = get(theme, `robot.stroke-${status}`) ?? get(theme, 'robot.stroke') ?? ''; ctx.stroke(); if (path?.length) { ctx.strokeStyle = get(theme, 'robot.line') ?? ''; ctx.lineCap = 'round'; ctx.lineWidth = s * 4; ctx.setLineDash([s * 5, s * 10]); ctx.translate(ox, oy); ctx.rotate((-deg * Math.PI) / 180); ctx.beginPath(); ctx.moveTo(0, 0); path.forEach((d) => ctx.lineTo(d.x * s, d.y * s)); ctx.stroke(); const { x: ex1 = 0, y: ey1 = 0 } = nth(path, -1) ?? {}; const { x: ex2 = 0, y: ey2 = 0 } = nth(path, -2) ?? {}; const r = Math.atan2(ey1 - ey2, ex1 - ex2) + Math.PI; ctx.setLineDash([0]); ctx.translate(ex1 * s, ey1 * s); ctx.beginPath(); ctx.moveTo(Math.cos(r + Math.PI / 4) * s * 10, Math.sin(r + Math.PI / 4) * s * 10); ctx.lineTo(0, 0); ctx.lineTo(Math.cos(r - Math.PI / 4) * s * 10, Math.sin(r - Math.PI / 4) * s * 10); ctx.stroke(); ctx.setTransform(1, 0, 0, 1, 0, 0); } ctx.restore(); } //#endregion //#region 辅助函数 function getBezier2Center(p1: Point, c1: Point, p2: Point): Point & { t: number } { const fn = (t: number) => { const x = (1 - t) ** 2 * p1.x + 2 * (1 - t) * t * c1.x + t ** 2 * p2.x; const y = (1 - t) ** 2 * p1.y + 2 * (1 - t) * t * c1.y + t ** 2 * p2.y; return { x, y }; }; return calcBezierCenter(fn); } function getBezier2Tange(p1: Point, c1: Point, p2: Point, t: number): number { const dx = 2 * (1 - t) * (c1.x - p1.x) + 2 * t * (p2.x - c1.x); const dy = 2 * (1 - t) * (c1.y - p1.y) + 2 * t * (p2.y - c1.y); return Math.atan2(dy, dx); } function getBezier3Center(p1: Point, c1: Point, c2: Point, p2: Point): Point & { t: number } { const fn = (t: number) => { const x = (1 - t) ** 3 * p1.x + 3 * (1 - t) ** 2 * t * c1.x + 3 * (1 - t) * t ** 2 * c2.x + t ** 3 * p2.x; const y = (1 - t) ** 3 * p1.y + 3 * (1 - t) ** 2 * t * c1.y + 3 * (1 - t) * t ** 2 * c2.y + t ** 3 * p2.y; return { x, y }; }; return calcBezierCenter(fn); } function getBezier3Tange(p1: Point, c1: Point, c2: Point, p2: Point, t: number): number { const t1 = 3 * Math.pow(1 - t, 2); const t2 = 6 * (1 - t) * t; const t3 = 3 * Math.pow(t, 2); const dx = t1 * (c1.x - p1.x) + t2 * (c2.x - c1.x) + t3 * (p2.x - c2.x); const dy = t1 * (c1.y - p1.y) + t2 * (c2.y - c1.y) + t3 * (p2.y - c2.y); return Math.atan2(dy, dx); } function calcBezierCenter(bezierFn: (t: number) => Point): Point & { t: number } { const count = 23; let length = 0; let temp = bezierFn(0); const samples = Array.from({ length: count }, (_, i) => { const t = (i + 1) / count; const point = bezierFn(t); const dx = point.x - temp.x; const dy = point.y - temp.y; length += Math.sqrt(dx * dx + dy * dy); temp = point; return { ...point, t }; }); const target = length * 0.45; let accumulated = 0; for (let i = 0; i < samples.length - 1; i++) { const p1 = samples[i]; const p2 = samples[i + 1]; const segment = Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2); if (accumulated + segment >= target) { const ratio = (target - accumulated) / segment; return { x: p1.x + (p2.x - p1.x) * ratio, y: p1.y + (p2.y - p1.y) * ratio, t: p1.t + ratio * (p2.t - p1.t), }; } accumulated += segment; } return samples[samples.length - 1]; } //#endregion