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, RobotLabel } from '@api/robot'; import type { GroupSceneDetail, SceneData, StandardScene, StandardSceneArea, StandardScenePoint, StandardSceneRoute, } from '@api/scene'; import sTheme from '@core/theme.service'; import { LockState, Meta2d, type Pen } from '@meta2d/core'; import { useObservable } from '@vueuse/rxjs'; import { clone, get, isEmpty, isNil, isString, pick } from 'lodash-es'; import { debounceTime, filter, map, Subject, switchMap } from 'rxjs'; import type { Ref } from 'vue'; import { watch } from 'vue'; import { AreaOperationService } from './area-operation.service'; import { BinTaskManagerService } from './bintask-manager.service'; import colorConfig from './color/color-config.service'; import { drawStorageBackground, drawStorageLocation, drawStorageMore } from './draw/storage-location-drawer'; import { AreaManager } from './editor/area-manager.service'; import { anchorPoint, drawArea, drawLine, drawPoint, lineBezier2, lineBezier3 } from './editor/editor-drawers'; import { drawRobot, EditorRobotService } from './editor/editor-robot.service'; import { PointManager } from './editor/point-manager.service'; import { RouteManager } from './editor/route-manager.service'; import { LayerManagerService } from './layer-manager.service'; import { createStorageLocationUpdater, StorageLocationService } from './storage-location.service'; import { AutoStorageGenerator } from './utils/auto-storage-generator'; /** * 场景编辑器服务类 * 继承自Meta2D,提供完整的场景编辑功能 * * 主要功能: * - 场景文件的加载、保存和管理 * - 点位、路线、区域的创建和编辑 * - 机器人组的管理和实时状态更新 * - 鼠标事件的处理和响应式数据流 * - 自定义绘制和渲染逻辑 */ export class EditorService extends Meta2d { /** 图层管理服务实例 */ private readonly layerManager: LayerManagerService; /** 库位服务实例 */ private readonly storageLocationService: StorageLocationService; private readonly robotService: EditorRobotService; /** 区域操作服务实例 */ private readonly areaOperationService!: AreaOperationService; /** 自动生成库位工具实例 */ private readonly autoStorageGenerator!: AutoStorageGenerator; /** 点位管理模块 */ private readonly pointManager: PointManager; /** 路线管理模块 */ private readonly routeManager: RouteManager; /** 区域管理模块 */ private readonly areaManager: AreaManager; //#region 场景文件管理 /** * 加载场景文件到编辑器 * @param map 场景文件的JSON字符串,为空则创建新场景 * @param editable 是否可编辑状态,控制编辑器锁定状态 * @param detail 群组场景详情,包含机器人组和机器人信息 * @param isImport 是否为导入场景文件,true时进行反向坐标转换 */ public async load( map?: string, editable = false, detail?: Partial, isImport = false, ): Promise { const sceneData = isString(map) ? (map ? JSON.parse(map) : {}) : map; const scene: StandardScene = sceneData || {}; if (!isEmpty(detail?.group)) { scene.robotGroups = [detail.group]; scene.robots = detail.robots; } const { robotGroups, robots, points, routes, areas, robotLabels, ...extraFields } = scene; // 保存所有额外字段(包括width、height等) this.#originalSceneData = extraFields; // 颜色配置现在使用本地存储,不再从场景数据加载 this.open(); this.setState(editable); this.robotService.loadInitialData(robotGroups, robots, robotLabels); await this.#loadScenePoints(points, isImport); this.#loadSceneRoutes(routes, isImport); await this.#loadSceneAreas(areas, isImport); // 确保正确的层级顺序:路线 < 点位 < 机器人 this.ensureCorrectLayerOrder(); // 为所有动作点创建库位pen对象 this.createAllStorageLocationPens(); 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, robotLabels: this.robotLabels.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: [], colorConfig: colorConfig.getConfigForSave(), // 添加颜色配置到场景数据 ...this.#originalSceneData, // 统一保留所有额外字段(包括width、height等) }; return JSON.stringify(scene); } /** BinTask管理服务实例 */ private readonly binTaskManager: BinTaskManagerService; /** * 获取BinTask管理服务实例 * @returns BinTask管理服务实例 */ public getBinTaskManager(): BinTaskManagerService { return this.binTaskManager; } /** * 检查库位项是否存在 * @param pointName 动作点名称 * @param locationName 库位名称 * @returns 如果库位项存在则返回true,否则返回false */ public checkBinLocationExists(pointName: string, locationName: string): boolean { return this.binTaskManager.hasBinTaskConfig(pointName, locationName); } /** * 更新库位任务配置中的库位名称 * @param pointName 动作点名称 * @param oldLocationName 旧的库位名称 * @param newLocationName 新的库位名称 */ public updateBinLocationName(pointName: string, oldLocationName: string, newLocationName: string): void { this.binTaskManager.updateBinLocationName(pointName, oldLocationName, newLocationName); } /** * 删除库位任务配置中的库位 * @param pointName 动作点名称 * @param locationName 要删除的库位名称 */ public removeBinLocation(pointName: string, locationName: string): void { this.binTaskManager.removeBinLocation(pointName, locationName); } /** * 更新库位的BinTask配置 * @param pointName 动作点名称 * @param locationName 库位名称 * @param binTasks BinTask配置数据 */ public updateBinTask(pointName: string, locationName: string, binTasks: any[]): void { this.binTaskManager.updateBinTask(pointName, locationName, binTasks); } /** * 获取库位任务配置数据 * @returns 库位任务配置列表 */ public getBinLocationsList(): unknown { return (this.#originalSceneData as Record)?.binLocationsList; } /** * 加载机器人数据到编辑器 * @param groups 机器人组列表 * @param robots 机器人信息列表 */ /** * 从场景数据加载点位到画布 * @param points 标准场景点位数据数组 * @param isImport 是否为导入场景文件,true时进行反向坐标转换 */ async #loadScenePoints(points?: StandardScenePoint[], isImport = false): Promise { if (!points?.length) return; await Promise.all( points.map(async (v) => { const { id, name, desc, x, y, type, extensionType, robots, actions, associatedStorageLocations, properties, deviceId, enabled, } = v; // 只有在导入场景文件时才进行反向坐标转换 let finalX = x; let finalY = y; if (isImport) { const transformedCoords = this.#reverseTransformCoordinate(x, y); finalX = transformedCoords.x; finalY = transformedCoords.y; } await this.addPoint({ x: finalX, y: finalY }, type, id); // 若为充电点/停靠点,且未提供 enabled,则默认启用为 1 const pointPayload: any = { type, extensionType, robots, actions, associatedStorageLocations, deviceId, }; if ([MapPointType.充电点, MapPointType.停靠点].includes(type)) { pointPayload.enabled = (enabled ?? 1) as 0 | 1; } else if (enabled !== undefined) { pointPayload.enabled = enabled; } this.setValue( { id, label: name, desc, properties, point: pointPayload, }, { render: false, history: false, doEvent: false }, ); }), ); } /** * 从场景数据加载路线到画布 * @param routes 标准场景路线数据数组 * @param isImport 是否为导入场景文件,true时进行反向坐标转换 */ #loadSceneRoutes(routes?: StandardSceneRoute[], isImport = false): void { if (!routes?.length) return; routes.map((v) => { const { id, desc, from, to, type, pass, c1, c2, properties, maxSpeed } = v as any; 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)!; // 只有在导入场景文件时才对控制点坐标进行反向转换 let transformedC1 = { x: (c1?.x ?? 0) - x1, y: (c1?.y ?? 0) - y1 }; let transformedC2 = { x: (c2?.x ?? 0) - x2, y: (c2?.y ?? 0) - y2 }; if (isImport) { if (c1 && c1.x !== undefined && c1.y !== undefined) { const reversedC1 = this.#reverseTransformCoordinate(c1.x, c1.y); transformedC1 = { x: reversedC1.x - x1, y: reversedC1.y - y1 }; } if (c2 && c2.x !== undefined && c2.y !== undefined) { const reversedC2 = this.#reverseTransformCoordinate(c2.x, c2.y); transformedC2 = { x: reversedC2.x - x2, y: reversedC2.y - y2 }; } } this.setValue( { id, desc, properties, route: { type, pass, c1: transformedC1, c2: transformedC2, maxSpeed, // 门区域扩展字段 deviceId: (v as any).deviceId, doorStatus: (v as any).doorStatus, isConnected: (v as any).isConnected, }, }, { render: false, history: false, doEvent: false }, ); }); } /** * 从场景数据加载区域到画布 * @param areas 标准场景区域数据数组 * @param isImport 是否为导入场景文件,true时进行反向坐标转换 */ async #loadSceneAreas(areas?: StandardSceneArea[], isImport = false): 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 as any; // 只有在导入场景文件时才进行反向坐标转换 let finalX = x; let finalY = y; let finalW = w; let finalH = h; if (isImport) { const transformedCoords = this.#reverseTransformCoordinate(x, y); finalX = transformedCoords.x; finalY = transformedCoords.y; finalW = this.#reverseTransformSize(w); finalH = this.#reverseTransformSize(h); } await this.addArea({ x: finalX, y: finalY }, { x: finalX + finalW, y: finalY + finalH }, type, id); // 对于库区类型,需要将点位名称数组转换为点位ID数组,并更新动作点的库位信息 let processedPoints: string[] | undefined = points as any; 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 as unknown as string[]; }); }); 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, // 门区域扩展字段 doorDeviceId: (v as any).doorDeviceId, doorStatus: (v as any).doorStatus, isConnected: (v as any).isConnected, }, }, { render: false, history: false, doEvent: false }, ); }), ); } #mapScenePoint(pen?: MapPen): StandardScenePoint | null { if (!pen?.id || isEmpty(pen?.point)) return null; // 过滤掉临时视图中心点 if (pen.id.includes('view-center-point')) { return null; } const { id, label, desc, properties } = pen; const { type, extensionType, robots, actions, associatedStorageLocations, deviceId, enabled } = 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.robotService.hasRobot(v)); // 若未提供 enabled,则默认启用 point.enabled = (enabled ?? 1) as 0 | 1; } if (MapPointType.等待点 === type) { point.actions = actions?.filter((v) => this.getPenById(v)?.point?.type === MapPointType.动作点); } if (MapPointType.动作点 === type) { point.associatedStorageLocations = associatedStorageLocations; } if (MapPointType.自动门点 === type) { point.deviceId = deviceId; } 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, maxSpeed, deviceId, doorStatus, isConnected } = pen.route as any; 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, maxSpeed, config: {}, properties, }; // 门区域扩展字段:保持到顶层,便于后端识别 (route as any).deviceId = deviceId; (route as any).doorStatus = doorStatus; (route as any).isConnected = isConnected; 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, doorDeviceId, doorStatus, isConnected } = pen.area as any; 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, }; // 门区域扩展字段 (area as any).routes = routes; (area as any).doorDeviceId = doorDeviceId; (area as any).doorStatus = doorStatus; (area as any).isConnected = isConnected; 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; }); } // 互斥区不再保存路段信息 return area; } /** * 确保正确的层级顺序 * 委托给图层管理服务处理 */ public ensureCorrectLayerOrder(): void { this.layerManager.ensureCorrectLayerOrder(); } //#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 机器人服务 public get robots(): RobotInfo[] { return this.robotService.robots; } public get robotGroups(): Ref { return this.robotService.robotGroups; } public get robotLabels(): Ref { return this.robotService.robotLabels; } public checkRobotById(id: RobotInfo['id']): boolean { return this.robotService.hasRobot(id); } public getRobotById(id: RobotInfo['id']): RobotInfo | undefined { return this.robotService.getRobotById(id); } public updateRobot(id: RobotInfo['id'], value: Partial): void { this.robotService.updateRobot(id, value); } public addRobots(gid: RobotInfo['gid'], robots: RobotInfo[]): void { this.robotService.addRobots(gid, robots); } public removeRobots(ids: RobotInfo['id'][]): void { this.robotService.removeRobots(ids); } public updateRobots(ids: RobotInfo['id'][], value: Partial): void { this.robotService.updateRobots(ids, value); } public createRobotGroup(): void { this.robotService.createRobotGroup(); } public deleteRobotGroup(id: RobotGroup['id']): void { this.robotService.deleteRobotGroup(id); } public updateRobotGroupLabel(id: RobotGroup['id'], label: RobotGroup['label']): void { this.robotService.updateRobotGroupLabel(id, label); } public createRobotLabel(): void { this.robotService.createRobotLabel(); } public deleteRobotLabel(id: RobotLabel['id']): void { this.robotService.deleteRobotLabel(id); } public updateRobotLabel(id: RobotLabel['id'], labelName: RobotLabel['label']): void { this.robotService.updateRobotLabel(id, labelName); } public addRobotsToLabel(lid: RobotLabel['id'], robots: RobotInfo[]): void { this.robotService.addRobotsToLabel(lid, robots); } public removeRobotFromLabel(labelId: string, robotId: string): void { this.robotService.removeRobotFromLabel(labelId, robotId); } public removeRobotsFromAllLabels(robotIds: string[]): void { this.robotService.removeRobotsFromAllLabels(robotIds); } public async initRobots(): Promise { await this.robotService.initRobots(); } public updateAllRobotImageSizes(): void { this.robotService.updateAllRobotImageSizes(); } public updateRobotImage(robotName: string): void { this.robotService.updateRobotImage(robotName); } public updateRobotStatusOverlay( id: string, render = false, newPosition?: { x: number; y: number; rotate: number }, ): void { this.robotService.updateRobotStatusOverlay(id, render, newPosition); } //#endregion /** 保存从后台传来的所有额外字段(除了已处理的robotGroups、robots、points、routes、areas之外的字段) */ #originalSceneData?: Partial; #sceneId: string; public getSceneId(): string | undefined { return this.#sceneId; } /** 坐标转换方法 - 将左上角原点的坐标转换为中心点原点的坐标 */ #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); } /** 反向坐标转换方法 - 将中心点原点的坐标转换为左上角原点的坐标 */ #reverseTransformCoordinate(x: number, y: number): { x: number; y: number } { const { ratio = 1, width = 0, height = 0 } = this.#originalSceneData ?? {}; // 先进行坐标系转换:中心点原点 -> 左上角原点 const topLeftX = x + width / 2; const topLeftY = height / 2 - y; // 再根据ratio进行缩放 const scaledX = topLeftX * ratio; const scaledY = topLeftY * ratio; // 应用精度控制:保留3位小数,之后直接舍去 return { x: this.#fixPrecision(scaledX), y: this.#fixPrecision(scaledY), }; } /** 反向尺寸转换方法 - 根据ratio还原尺寸 */ #reverseTransformSize(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)); } /** * 优化的像素对齐算法 - 确保在所有缩放比例下都能精确对齐像素边界 * 解决小车和光圈在特定缩放比例下不重合的问题 */ /** 画布变化事件流,用于触发响应式数据更新 */ readonly #change$$ = new Subject(); /** 区域大小变化防抖处理的事件流 */ readonly #areaSizeChange$$ = new Subject(); /** 防抖处理后的区域大小变化事件,延迟500ms执行 */ readonly #debouncedAreaSizeChange = this.#areaSizeChange$$.pipe( debounceTime(500), // 500ms防抖延迟 map((pen) => pen), ); /** 当前选中的图形对象,响应式更新 */ 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, render = true): void { this.setValue({ ...pen, id }, { render, history: record, doEvent: true }); } /** * 更新动作点的边框颜色 * @param pointId 动作点ID * @param color 边框颜色(如 '#ff4d4f' 红色或 '#52c41a' 绿色) */ public updatePointBorderColor(pointId: string, color: string): void { const pen = this.getPenById(pointId); if (!pen || pen.name !== 'point') return; this.updatePen(pointId, { statusStyle: color }, false); } /** * 为动作点创建库位pen对象 * @param pointId 动作点ID * @param storageLocations 库位名称列表 * @param render 是否立即渲染,默认为true */ public createStorageLocationPens(pointId: string, storageLocations: string[], render = true): void { this.storageLocationService?.create(pointId, storageLocations, render); } /** * 删除动作点的所有库位pen对象 * @param pointId 动作点ID */ public removeStorageLocationPens(pointId: string): void { this.storageLocationService?.delete(pointId); } /** * 创建库位 * @param pointId 动作点ID * @param storageLocations 库位名称列表 */ public createStorageLocation(pointId: string, storageLocations: string[]): void { this.storageLocationService?.create(pointId, storageLocations); } /** * 更新库位状态 * @param pointId 动作点ID * @param updates 更新操作,可以是单个库位或批量更新 * @param state 单个库位状态(当updates为字符串时使用) */ public updateStorageLocation( pointId: string, updates: string | Record, state?: { occupied?: boolean; locked?: boolean }, ): void { this.storageLocationService?.update(pointId, updates, state); } /** * 删除库位 * @param pointId 动作点ID */ public deleteStorageLocation(pointId: string): void { this.storageLocationService?.delete(pointId); } /** * 获取库位更新器实例 * @returns 库位更新器实例 */ public getStorageLocationUpdater() { if (!this.storageLocationService) { throw new Error('StorageLocationService 未初始化'); } return createStorageLocationUpdater(this.storageLocationService); } /** * 为所有动作点创建库位pen对象 * 用于初始化或刷新所有动作点的库位显示 * 优化性能:使用批量操作减少DOM操作次数 */ public createAllStorageLocationPens(): void { if (!this.storageLocationService) return; // 使用 requestIdleCallback 在浏览器空闲时执行,避免阻塞UI if (typeof requestIdleCallback !== 'undefined') { requestIdleCallback( () => { this.storageLocationService?.createAll(); }, { timeout: 200 }, ); } else { // 降级到 requestAnimationFrame requestAnimationFrame(() => { this.storageLocationService?.createAll(); }); } } /** * 自动为动作点生成库位 * @param areaName 库区名称 * @param actionPoints 动作点列表 */ public autoCreateStorageLocations(areaName: string, actionPoints: MapPen[]): void { this.autoStorageGenerator.autoCreateStorageLocations(areaName, actionPoints); } /** * 为指定动作点生成库位 * @param pointId 动作点ID * @param areaName 库区名称 * @returns 生成的库位名称,如果失败返回null */ public generateStorageForPoint(pointId: string, areaName: string): string | null { return this.autoStorageGenerator.generateStorageForPoint(pointId, areaName); } /** * 批量生成库位 * @param pointIds 动作点ID列表 * @param areaName 库区名称 * @returns 生成结果统计 */ public batchGenerateStorageForPoints( pointIds: string[], areaName: string, ): { success: number; skipped: number; failed: number; generatedNames: string[]; } { return this.autoStorageGenerator.batchGenerateStorageForPoints(pointIds, areaName); } /** * 根据设备ID更新自动门点状态 * @param deviceId 设备ID * @param doorStatus 设备状态(0=关门,1=开门) * @param isConnected 连接状态(true=已连接,false=未连接) * @param active 是否显示光圈 * @param pointId 可选的点位ID,如果提供则直接使用,避免查找 */ public updateAutoDoorByDeviceId( deviceId: string, doorStatus: number, isConnected: boolean, active = true, pointId?: string, ): void { let autoDoorPointId = pointId; // 如果没有提供pointId,则通过deviceId查找 if (!autoDoorPointId) { const autoDoorPoint = this.data().pens.find( (pen) => pen.name === 'point' && (pen as MapPen).point?.type === MapPointType.自动门点 && (pen as MapPen).point?.deviceId === deviceId, ) as MapPen | undefined; if (!autoDoorPoint?.id) { console.warn(`未找到设备ID为 ${deviceId} 的自动门点`); return; } autoDoorPointId = autoDoorPoint.id; } // 通过pointId获取点位对象 const autoDoorPoint = this.getPenById(autoDoorPointId) as MapPen | undefined; if (!autoDoorPoint?.point) { console.warn(`自动门点 ${autoDoorPointId} 不存在或数据异常`); return; } // 更新自动门点状态 this.updatePen( autoDoorPointId, { point: { ...autoDoorPoint.point, doorStatus, isConnected, active, }, }, false, ); } public updatePointLockIcon(pointId: string, show: boolean): void { const pointPen = this.getPenById(pointId); if (!pointPen || pointPen.name !== 'point') return; const lockIconId = `lock-icon-${pointId}`; const existingIcon = this.getPenById(lockIconId); if (show) { if (existingIcon) { this.setValue({ id: lockIconId, visible: true }, { render: true, history: false, doEvent: false }); } else { const { x = 0, y = 0 } = this.getPenRect(pointPen); const iconPen: MapPen = { id: lockIconId, name: 'circle', tags: ['lock-icon'], x: x + 48, // 在点右侧显示 y: y + 60, width: 8, height: 8, background: '#ff4d4f', // 红色背景 color: '#ff4d4f', locked: LockState.Disable, visible: true, }; this.addPen(iconPen, false, false, true); this.inactive(); } } else { if (existingIcon) { this.setValue({ id: lockIconId, visible: false }, { render: true, history: false, doEvent: false }); } } } //#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 { return this.pointManager.getPointRect(pen); } /** * 在指定位置添加点位 * @param p 点位坐标 * @param type 点位类型,默认为普通点 * @param id 点位ID,未指定则自动生成 */ public async addPoint(p: Point, type = MapPointType.普通点, id?: string): Promise { await this.pointManager.addPoint(p, type, id); } public updatePoint(id: string, info: Partial, autoCreateStorage = true): void { this.pointManager.updatePoint(id, info, autoCreateStorage); } public changePointType(id: string, type: MapPointType): void { this.pointManager.changePointType(id, type); } //#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, directionOverride?: number): string { return this.routeManager.getRouteLabel(id, directionOverride); } public addRoute(p: [MapPen, MapPen], type = MapRouteType.直线, id?: string): void { this.routeManager.addRoute(p, type, id); } public updateRoute(id: string, info: Partial): void { this.routeManager.updateRoute(id, info); } public changeRouteType(id: string, type: MapRouteType): void { this.routeManager.changeRouteType(id, type); } public getRoutesBetweenPoints(point1Id: string, point2Id: string): MapPen[] { return this.routeManager.getRoutesBetweenPoints(point1Id, point2Id); } public getReverseRoute(routeId: string): MapPen | null { return this.routeManager.getReverseRoute(routeId); } public addBidirectionalRoute( p: [MapPen, MapPen], type = MapRouteType.直线, forwardId?: string, reverseId?: string, ): void { this.routeManager.addBidirectionalRoute(p, type, forwardId, reverseId); } public removeBidirectionalRoute(routeId: string): void { this.routeManager.removeBidirectionalRoute(routeId); } public hasBidirectionalRoute(point1Id: string, point2Id: string): boolean { return this.routeManager.hasBidirectionalRoute(point1Id, point2Id); } //#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[] { return this.areaManager.getBoundAreas(id, name, type); } public async addArea(p1: Point, p2: Point, type = MapAreaType.库区, id?: string): Promise { await this.areaManager.addArea(p1, p2, type, id); } public updateArea(id: string, info: Partial): void { this.areaManager.updateArea(id, info); } //#endregion /** * 构造函数 - 初始化场景编辑器 * @param container 编辑器容器DOM元素 */ constructor(container: HTMLDivElement, sceneId: string) { super(container, EDITOR_CONFIG); this.#sceneId = sceneId; // 初始化图层管理服务 this.layerManager = new LayerManagerService(this); // 初始化区域操作服务 this.areaOperationService = new AreaOperationService(); // 初始化库位服务 this.storageLocationService = new StorageLocationService(this, ''); this.robotService = new EditorRobotService(this); // 初始化自动生成库位工具 this.autoStorageGenerator = new AutoStorageGenerator(this); // 初始化BinTask管理服务 this.binTaskManager = new BinTaskManagerService(this); this.pointManager = new PointManager(this, this.layerManager); this.routeManager = new RouteManager(this); this.areaManager = new AreaManager(this, this.autoStorageGenerator); // 设置颜色配置服务的编辑器实例 colorConfig.setEditorService(this); // 禁用第6个子元素的拖放功能 (container.children.item(5)).ondrop = null; // 监听所有画布事件 this.on('*', (e, v) => this.#listen(e, v)); // 添加额外的右键事件监听器,确保阻止默认行为 const canvasElement = this.canvas as unknown as HTMLCanvasElement; if (canvasElement && canvasElement.addEventListener) { canvasElement.addEventListener( 'contextmenu', (event) => { event.preventDefault(); // 仅屏蔽浏览器默认菜单,保留 Meta2D 的事件派发 }, true, ); } // 注册自定义绘制函数和锚点 this.#register(); // 监听主题变化并重新加载样式 watch( () => sTheme.theme, (v) => this.#load(v), { immediate: true }, ); // 订阅防抖处理后的区域大小变化事件 this.#debouncedAreaSizeChange.subscribe((pen: MapPen) => { this.areaOperationService.handleAreaSizeChange( pen, (type) => this.find(type), (id, info) => this.updateArea(id, info), ); }); } #load(theme: string): void { this.setTheme(theme); this.setOptions({ color: get(sTheme.editor, 'color') }); this.pointManager.refreshPointImages(); this.find('robot').forEach((pen) => { if (!pen.robot?.type) return; // 从pen的text属性获取机器人名称 const robotName = pen.text || ''; this.canvas.updateValue(pen, this.robotService.mapRobotImage(pen.robot.type, pen.robot.active, robotName)); }); this.render(); } /** * 重新加载主题和图片配置 * 用于批量编辑后确保大点位图片正确显示 * 异步执行,避免阻塞UI */ public reloadTheme(): void { const currentTheme = this.data().theme || 'light'; // 使用 requestAnimationFrame 确保在下一个渲染帧执行,避免阻塞UI requestAnimationFrame(() => { this.#load(currentTheme); }); } #onDelete(pens?: MapPen[]): void { pens?.forEach((pen) => { switch (pen.name) { case 'point': this.delete(this.getLines(pen), true, false); break; default: break; } }); } #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); // 检查是否是区域属性更新 if (v && v.area?.type && ['area'].includes(v.tags?.[0] || '')) { // 发送到防抖流,避免频繁触发 this.#areaSizeChange$$.next(v); } 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; case 'contextmenu': // 右键菜单事件由 Meta2D 自动处理,不需要额外处理 // 事件会直接传递给外部监听器 console.log('EditorService 捕获到右键菜单事件:', v); // 触发自定义的右键菜单事件,传递画布数据 this.emit('customContextMenu', v); break; // 监听区域调整大小事件 case 'resizePens': { // resizePens 事件的目标可能是 undefined,需要从 store.active 获取 const activePens = this.store.active; if (activePens && activePens.length > 0) { activePens.forEach((pen: MapPen) => { if (pen.area?.type && ['area'].includes(pen.tags?.[0] || '')) { // 发送到防抖流,避免频繁触发 this.#areaSizeChange$$.next(pen); } }); } break; } // 监听区域线条更新事件,这通常是区域位置变化时触发的 case 'updateLines': if (v && v.area?.type && ['area'].includes(v.tags?.[0] || '')) { // 发送到防抖流,避免频繁触发 this.#areaSizeChange$$.next(v); } break; default: break; } } /** * 手动检查并更新指定区域的点位绑定 * 当用户调整区域大小后,可以调用此方法手动触发检查 * @param areaId 区域ID */ public checkAndUpdateAreaPoints(areaId: string): void { this.areaOperationService.checkAndUpdateAreaPoints( areaId, (id) => this.getPenById(id), (type) => this.find(type), (id, info) => this.updateArea(id, info), ); } /** * 检查并更新所有互斥区和非互斥区的点位绑定 * 用于批量检查和更新 */ public checkAndUpdateAllAreas(): void { this.areaOperationService.checkAndUpdateAllAreas( (type) => this.find(type), (type) => this.find(type), (id, info) => this.updateArea(id, info), ); } /** * 批量更新点位类型 * @param pointIds 点位ID数组 * @param pointType 新的点位类型 */ public batchUpdatePointType(pointIds: string[], pointType: MapPointType): void { pointIds.forEach((id) => { const pen = this.getPenById(id); if (pen?.name === 'point') { this.updatePen(id, { point: { ...pen.point, type: pointType, }, }); } }); } /** * 批量更新路线类型 * @param routeIds 路线ID数组 * @param routeType 新的路线类型 */ public batchUpdateRouteType(routeIds: string[], routeType: MapRouteType): void { routeIds.forEach((id) => { const pen = this.getPenById(id); if (pen?.name === 'line' && pen.route) { this.updatePen(id, { route: { ...pen.route, type: routeType, }, }); } }); } /** * 批量更新路线通行类型 * @param routeIds 路线ID数组 * @param passType 新的通行类型 */ public batchUpdateRoutePassType(routeIds: string[], passType: MapRoutePassType): void { routeIds.forEach((id) => { const pen = this.getPenById(id); if (pen?.name === 'line' && pen.route) { this.updatePen(id, { route: { ...pen.route, pass: passType, }, }); } }); } /** * 批量更新路线方向 * @param routeIds 路线ID数组 * @param direction 新的方向 */ public batchUpdateRouteDirection(routeIds: string[], direction: 1 | -1): void { routeIds.forEach((id) => { const pen = this.getPenById(id); if (pen?.name === 'line' && pen.route) { this.updatePen(id, { route: { ...pen.route, direction, }, }); } }); } /** * 批量更新多个属性 * @param updates 更新配置数组 */ public batchUpdate(updates: Array<{ id: string; updates: Partial }>): void { // 批量更新所有点位,避免多次渲染 updates.forEach(({ id, updates }) => { this.updatePen(id, updates, true, false); // 记录历史但不立即渲染 }); // 统一渲染一次 this.render(); } #register() { this.register({ line: () => new Path2D() }); this.registerCanvasDraw({ point: drawPoint, line: drawLine, area: drawArea, robot: drawRobot, 'storage-location': drawStorageLocation, 'storage-more': drawStorageMore, 'storage-background': drawStorageBackground, }); this.registerAnchors({ point: anchorPoint }); this.addDrawLineFn('bezier2', lineBezier2); this.addDrawLineFn('bezier3', lineBezier3); } } //#region 自定义绘制函数 // 已拆分至 ./editor/editor-drawers.ts //#endregion