1506 lines
47 KiB
TypeScript
1506 lines
47 KiB
TypeScript
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<GroupSceneDetail>,
|
||
isImport = false,
|
||
): Promise<void> {
|
||
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<string, unknown>)?.binLocationsList;
|
||
}
|
||
|
||
/**
|
||
* 加载机器人数据到编辑器
|
||
* @param groups 机器人组列表
|
||
* @param robots 机器人信息列表
|
||
*/
|
||
|
||
/**
|
||
* 从场景数据加载点位到画布
|
||
* @param points 标准场景点位数据数组
|
||
* @param isImport 是否为导入场景文件,true时进行反向坐标转换
|
||
*/
|
||
async #loadScenePoints(points?: StandardScenePoint[], isImport = false): Promise<void> {
|
||
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(<any>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], <MapRouteType>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<void> {
|
||
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<string, string[]> = {};
|
||
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<string, string[]> => 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<Point>(
|
||
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<RobotGroup[] | undefined> {
|
||
return this.robotService.robotGroups;
|
||
}
|
||
|
||
public get robotLabels(): Ref<RobotLabel[] | undefined> {
|
||
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<RobotInfo>): 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<RobotInfo>): 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<void> {
|
||
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<StandardScene>;
|
||
|
||
#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<boolean>();
|
||
|
||
/** 区域大小变化防抖处理的事件流 */
|
||
readonly #areaSizeChange$$ = new Subject<MapPen>();
|
||
|
||
/** 防抖处理后的区域大小变化事件,延迟500ms执行 */
|
||
readonly #debouncedAreaSizeChange = this.#areaSizeChange$$.pipe(
|
||
debounceTime(500), // 500ms防抖延迟
|
||
map((pen) => pen),
|
||
);
|
||
|
||
/** 当前选中的图形对象,响应式更新 */
|
||
public readonly current = useObservable<MapPen>(
|
||
this.#change$$.pipe(
|
||
debounceTime(100),
|
||
map(() => <MapPen>clone(this.store.active?.[0])),
|
||
),
|
||
);
|
||
|
||
/** 当前选中的图形ID列表,响应式更新 */
|
||
public readonly selected = useObservable<string[], string[]>(
|
||
this.#change$$.pipe(
|
||
filter((v) => !v),
|
||
debounceTime(100),
|
||
map(() => this.store.active?.map(({ id }) => id).filter((v) => !isNil(v)) ?? []),
|
||
),
|
||
{ initialValue: new Array<string>() },
|
||
);
|
||
|
||
/** 画布上所有图形对象列表,响应式更新 */
|
||
public readonly pens = useObservable<MapPen[]>(
|
||
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<MapPen>, 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<string, { occupied?: boolean; locked?: boolean }>,
|
||
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<MapPen[], MapPen[]>(
|
||
this.#change$$.pipe(
|
||
filter((v) => v),
|
||
debounceTime(100),
|
||
map(() => this.find('point')),
|
||
),
|
||
{ initialValue: new Array<MapPen>() },
|
||
);
|
||
|
||
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<void> {
|
||
await this.pointManager.addPoint(p, type, id);
|
||
}
|
||
|
||
public updatePoint(id: string, info: Partial<MapPointInfo>, 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<MapPen[], MapPen[]>(
|
||
this.#change$$.pipe(
|
||
filter((v) => v),
|
||
debounceTime(100),
|
||
map(() => this.find('route').map((v) => ({ ...v, label: this.getRouteLabel(v.id) }))),
|
||
),
|
||
{ initialValue: new Array<MapPen>() },
|
||
);
|
||
|
||
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<MapRouteInfo>): 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<MapPen[], MapPen[]>(
|
||
this.#change$$.pipe(
|
||
filter((v) => v),
|
||
debounceTime(100),
|
||
map(() => this.find('area')),
|
||
),
|
||
{ initialValue: new Array<MapPen>() },
|
||
);
|
||
|
||
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<void> {
|
||
await this.areaManager.addArea(p1, p2, type, id);
|
||
}
|
||
|
||
public updateArea(id: string, info: Partial<MapAreaInfo>): 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个子元素的拖放功能
|
||
(<HTMLDivElement>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<MapPen> }>): 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
|