web-map/src/services/editor.service.ts

1527 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
EDITOR_CONFIG,
type MapAreaInfo,
MapAreaType,
type MapPen,
type MapPointInfo,
MapPointType,
type MapRouteInfo,
MapRoutePassType,
MapRouteType,
type Point,
type Rect,
} from '@api/map';
import { DOOR_AREA_TYPE } from '@api/map/door-area';
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;
case 'area':
// 删除门区域时清空绑定路段的设备ID
if (pen.area?.type === DOOR_AREA_TYPE && pen.area?.routes) {
pen.area.routes.forEach((routeId: string) => {
const routePen = this.getPenById(routeId) as MapPen | undefined;
if (routePen?.route?.deviceId) {
// 清空路段的设备ID
const updatedRoute = { ...routePen.route };
delete updatedRoute.deviceId;
this.setValue(
{
id: routeId,
route: updatedRoute
},
{ render: true, history: true, doEvent: 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