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

2448 lines
83 KiB
TypeScript
Raw Normal View History

2025-05-09 00:17:02 +08:00
import {
EDITOR_CONFIG,
type MapAreaInfo,
MapAreaType,
type MapPen,
type MapPointInfo,
MapPointType,
2025-05-09 20:15:04 +08:00
type MapRouteInfo,
2025-05-09 00:17:02 +08:00
MapRoutePassType,
MapRouteType,
type Point,
type Rect,
2025-05-09 00:17:02 +08:00
} from '@api/map';
import type { RobotGroup, RobotInfo, RobotType } from '@api/robot';
2025-05-17 13:08:29 +08:00
import type {
GroupSceneDetail,
SceneData,
StandardScene,
StandardSceneArea,
StandardScenePoint,
StandardSceneRoute,
} from '@api/scene';
2025-04-20 00:49:14 +08:00
import sTheme from '@core/theme.service';
2025-05-11 18:46:59 +08:00
import { CanvasLayer, LockState, Meta2d, type Meta2dStore, type Pen, s8 } from '@meta2d/core';
2025-04-27 00:05:18 +08:00
import { useObservable } from '@vueuse/rxjs';
import { clone, get, isEmpty, isNil, isString, nth, pick, remove, some } from 'lodash-es';
2025-05-05 01:06:09 +08:00
import { BehaviorSubject, debounceTime, filter, map, Subject, switchMap } from 'rxjs';
2025-05-05 23:21:31 +08:00
import { reactive, watch } from 'vue';
2025-04-20 00:49:14 +08:00
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 { LayerManagerService } from './layer-manager.service';
import { createStorageLocationUpdater,StorageLocationService } from './storage-location.service';
import { AutoStorageGenerator } from './utils/auto-storage-generator';
/**
*
* Meta2D
*
*
* -
* - 线
* -
* -
* -
*/
2025-04-27 00:05:18 +08:00
export class EditorService extends Meta2d {
/** 图层管理服务实例 */
private readonly layerManager: LayerManagerService;
/** 库位服务实例 */
private readonly storageLocationService: StorageLocationService;
/** 区域操作服务实例 */
private readonly areaOperationService!: AreaOperationService;
/** 自动生成库位工具实例 */
private readonly autoStorageGenerator!: AutoStorageGenerator;
//#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> {
2025-05-17 16:56:01 +08:00
const scene: StandardScene = map ? JSON.parse(map) : {};
if (!isEmpty(detail?.group)) {
scene.robotGroups = [detail.group];
scene.robots = detail.robots;
2025-05-11 18:46:59 +08:00
}
const { robotGroups, robots, points, routes, areas, ...extraFields } = scene;
// 保存所有额外字段包括width、height等
this.#originalSceneData = extraFields;
// 颜色配置现在使用本地存储,不再从场景数据加载
2025-05-17 22:53:30 +08:00
this.open();
2025-05-05 23:21:31 +08:00
this.setState(editable);
2025-05-17 22:53:30 +08:00
this.#loadRobots(robotGroups, robots);
await this.#loadScenePoints(points, isImport);
this.#loadSceneRoutes(routes, isImport);
await this.#loadSceneAreas(areas, isImport);
// 确保正确的层级顺序:路线 < 点位 < 机器人
this.#ensureCorrectLayerOrder();
// 为所有动作点创建库位pen对象
this.createAllStorageLocationPens();
2025-05-17 22:53:30 +08:00
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);
// }
2025-04-20 00:49:14 +08:00
}
/**
* JSON字符串
* @returns JSON字符串
*/
2025-04-20 00:49:14 +08:00
public save(): string {
2025-07-01 21:32:27 +08:00
const { scale, x, y, origin } = this.data();
2025-05-17 13:08:29 +08:00
const scene: StandardScene = {
2025-07-01 21:32:27 +08:00
scale,
origin: { x: x + origin.x, y: y + origin.y },
2025-05-17 13:08:29 +08:00
robotGroups: this.robotGroups.value,
robots: this.robots,
points: this.points.value.map((v) => this.#mapScenePoint(v)).filter((v) => !isNil(v)),
routes: this.routes.value.map((v) => this.#mapSceneRoute(v)).filter((v) => !isNil(v)),
areas: this.areas.value.map((v) => this.#mapSceneArea(v)).filter((v) => !isNil(v)),
blocks: [],
colorConfig: colorConfig.getConfigForSave(), // 添加颜色配置到场景数据
...this.#originalSceneData, // 统一保留所有额外字段包括width、height等
2025-05-17 13:08:29 +08:00
};
2025-05-17 13:08:29 +08:00
return JSON.stringify(scene);
}
/** BinTask管理服务实例 */
private readonly binTaskManager: BinTaskManagerService;
/**
* BinTask管理服务实例
* @returns BinTask管理服务实例
*/
public getBinTaskManager(): BinTaskManagerService {
return this.binTaskManager;
}
/**
*
* @param pointName
* @param locationName
* @returns truefalse
*/
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
*/
2025-05-17 22:53:30 +08:00
#loadRobots(groups?: RobotGroup[], robots?: RobotInfo[]): void {
this.#robotMap.clear();
robots?.forEach((v) => this.#robotMap.set(v.id, v));
this.#robotGroups$$.next(groups ?? []);
}
/**
*
* @param points
* @param isImport true时进行反向坐标转换
*/
async #loadScenePoints(points?: StandardScenePoint[], isImport = false): Promise<void> {
2025-05-17 16:56:01 +08:00
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);
2025-05-17 16:56:01 +08:00
this.setValue(
{
id,
label: name,
desc,
properties,
point: { type, extensionType, robots, actions, associatedStorageLocations, deviceId, enabled },
},
2025-05-17 16:56:01 +08:00
{ render: false, history: false, doEvent: false },
);
}),
);
}
/**
* 线
* @param routes 线
* @param isImport true时进行反向坐标转换
*/
#loadSceneRoutes(routes?: StandardSceneRoute[], isImport = false): void {
2025-05-17 22:53:30 +08:00
if (!routes?.length) return;
routes.map((v) => {
const { id, desc, from, to, type, pass, c1, c2, properties } = v;
2025-06-11 00:22:16 +08:00
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 };
}
}
2025-05-17 22:53:30 +08:00
this.setValue(
2025-06-11 00:22:16 +08:00
{
id,
desc,
properties,
route: {
type,
pass,
c1: transformedC1,
c2: transformedC2,
2025-06-11 00:22:16 +08:00
},
},
2025-05-17 22:53:30 +08:00
{ render: false, history: false, doEvent: false },
);
});
}
/**
*
* @param areas
* @param isImport true时进行反向坐标转换
*/
async #loadSceneAreas(areas?: StandardSceneArea[], isImport = false): Promise<void> {
2025-05-17 22:53:30 +08:00
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;
// 只有在导入场景文件时才进行反向坐标转换
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 = points;
if (type === MapAreaType. && points?.length) {
// 将点位名称数组转换为点位ID数组
const actionPoints = this.find('point').filter(
(pen: MapPen) => pen.point?.type === MapPointType. && points.includes(pen.label || pen.id!),
);
processedPoints = actionPoints.map((pen) => pen.id!);
// 如果有storageLocations数据更新对应动作点的库位信息
if (storageLocations && Array.isArray(storageLocations)) {
// 将数组格式转换为对象格式以便查找
const storageLocationsMap: Record<string, string[]> = {};
storageLocations.forEach((item) => {
Object.entries(item).forEach(([pointName, locations]) => {
storageLocationsMap[pointName] = locations;
});
});
actionPoints.forEach((pen) => {
const pointName = pen.label || pen.id!;
if (storageLocationsMap[pointName]) {
this.setValue(
{ id: pen.id, point: { ...pen.point, associatedStorageLocations: storageLocationsMap[pointName] } },
{ render: false, history: false, doEvent: false },
);
}
});
}
}
2025-05-17 22:53:30 +08:00
this.setValue(
{ id, label: name, desc, properties, area: { type, points: processedPoints, routes, maxAmr, inoutflag } },
2025-05-17 22:53:30 +08:00
{ render: false, history: false, doEvent: false },
);
}),
);
}
2025-05-17 13:08:29 +08:00
#mapScenePoint(pen?: MapPen): StandardScenePoint | null {
if (!pen?.id || isEmpty(pen?.point)) return null;
// 过滤掉临时视图中心点
if (pen.id.includes('view-center-point')) {
return null;
}
2025-05-17 16:56:01 +08:00
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);
2025-05-17 13:08:29 +08:00
const point: StandardScenePoint = {
id: id,
name: label || id,
desc,
x: transformedCoords.x,
y: transformedCoords.y,
2025-05-17 13:08:29 +08:00
type,
extensionType,
2025-05-17 13:08:29 +08:00
config: {},
properties,
};
if ([MapPointType., MapPointType.].includes(type)) {
point.robots = robots?.filter((v) => this.#robotMap.has(v));
}
if (MapPointType. === type) {
point.actions = actions?.filter((v) => this.getPenById(v)?.point?.type === MapPointType.);
}
if (MapPointType. === type) {
point.associatedStorageLocations = associatedStorageLocations;
}
if (MapPointType. === type) {
point.deviceId = deviceId;
}
if (MapPointType. === type) {
point.enabled = enabled === 0 ? 0 : 1; //默认启用
}
2025-05-17 13:08:29 +08:00
return point;
}
#mapSceneRoute(pen?: MapPen): StandardSceneRoute | null {
if (!pen?.id || pen.anchors?.length !== 2 || isEmpty(pen?.route)) return null;
const { id, anchors, desc, properties } = pen;
const { type, direction = 1, pass, c1, c2 } = pen.route;
2025-06-11 00:22:16 +08:00
const [p1, p2] = anchors.map((v) => this.getPenById(v.connectTo!));
if (isNil(p1) || isNil(p2)) return null;
2025-05-17 13:08:29 +08:00
const route: StandardSceneRoute = {
id: id,
desc,
2025-06-11 00:22:16 +08:00
from: direction < 0 ? p2.id! : p1.id!,
to: direction < 0 ? p1.id! : p2.id!,
2025-05-17 13:08:29 +08:00
type,
pass,
config: {},
properties,
};
2025-06-11 00:22:16 +08:00
const { x: x1, y: y1 } = this.getPointRect(p1)!;
const { x: x2, y: y2 } = this.getPointRect(p2)!;
2025-06-15 16:20:58 +08:00
const cp1 = { x: x1 + (c1?.x ?? 0), y: y1 + (c1?.y ?? 0) };
const cp2 = { x: x2 + (c2?.x ?? 0), y: y2 + (c2?.y ?? 0) };
2025-05-17 13:08:29 +08:00
switch (type) {
case MapRouteType.线:
// 对控制点进行坐标转换
route.c1 = this.#transformCoordinate(cp1.x, cp1.y);
2025-05-17 13:08:29 +08:00
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;
2025-05-17 13:08:29 +08:00
break;
}
2025-05-17 13:08:29 +08:00
default:
break;
}
return route;
}
#mapSceneArea(pen: MapPen): StandardSceneArea | null {
if (!pen.id || isEmpty(pen.area)) return null;
2025-05-17 16:56:01 +08:00
const { id, label, desc, properties } = pen;
const { type, points, maxAmr, inoutflag } = pen.area;
2025-05-17 16:56:01 +08:00
const { x, y, width, height } = this.getPenRect(pen);
// 进行坐标转换:左上角原点 -> 中心点原点同时应用ratio缩放
const transformedCoords = this.#transformCoordinate(x, y);
2025-05-17 13:08:29 +08:00
const area: StandardSceneArea = {
id,
name: label || id,
desc,
x: transformedCoords.x,
y: transformedCoords.y,
w: this.#transformSize(width),
h: this.#transformSize(height),
2025-05-17 13:08:29 +08:00
type,
config: {},
properties,
};
if (type === MapAreaType.) {
area.maxAmr = maxAmr;
}
2025-05-17 13:08:29 +08:00
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;
2025-05-17 13:08:29 +08:00
}
if ([MapAreaType., MapAreaType., MapAreaType.].includes(type)) {
2025-05-17 13:08:29 +08:00
area.points = points?.filter((v) => {
2025-05-10 00:49:45 +08:00
const { point } = this.getPenById(v) ?? {};
2025-05-17 13:08:29 +08:00
if (isNil(point)) return false;
if (point.type === MapPointType.) return false;
return true;
2025-05-10 00:49:45 +08:00
});
2025-05-17 13:08:29 +08:00
}
// 互斥区不再保存路段信息
2025-05-17 13:08:29 +08:00
return area;
2025-04-20 00:49:14 +08:00
}
/**
*
*
*/
#ensureCorrectLayerOrder(): void {
this.layerManager.ensureCorrectLayerOrder();
}
2025-05-17 13:08:29 +08:00
//#endregion
2025-05-05 23:21:31 +08:00
/**
*
* @param editable true为可编辑状态false为只读状态
*/
2025-05-05 23:21:31 +08:00
public setState(editable?: boolean): void {
2025-05-09 20:15:04 +08:00
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();
2025-05-05 23:21:31 +08:00
}
public override data(): SceneData {
return super.data();
2025-04-20 00:49:14 +08:00
}
/** 鼠标事件流主体,用于内部事件分发 */
2025-04-27 00:05:18 +08:00
readonly #mouse$$ = new Subject<{ type: 'click' | 'mousedown' | 'mouseup'; value: Point }>();
/** 鼠标点击事件的响应式流,防抖处理后的点击坐标 */
2025-04-27 00:05:18 +08:00
public readonly mouseClick = useObservable<Point>(
this.#mouse$$.pipe(
filter(({ type }) => type === 'click'),
debounceTime(100),
map(({ value }) => value),
),
);
/** 鼠标拖拽事件的响应式流,返回起始点和结束点坐标,用于创建区域 */
2025-04-27 00:05:18 +08:00
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 机器人组管理
/** 机器人信息映射表,响应式存储所有机器人数据 */
2025-05-05 23:21:31 +08:00
readonly #robotMap = reactive<Map<RobotInfo['id'], RobotInfo>>(new Map());
/** 获取所有机器人信息数组 */
2025-05-05 01:06:09 +08:00
public get robots(): RobotInfo[] {
return Array.from(this.#robotMap.values());
}
2025-05-25 00:07:22 +08:00
public checkRobotById(id: RobotInfo['id']): boolean {
return this.#robotMap.has(id);
}
2025-05-05 01:06:09 +08:00
public getRobotById(id: RobotInfo['id']): RobotInfo | undefined {
return this.#robotMap.get(id);
}
2025-05-25 00:07:22 +08:00
public updateRobot(id: RobotInfo['id'], value: Partial<RobotInfo>): void {
const robot = this.getRobotById(id);
if (isNil(robot)) return;
this.#robotMap.set(id, { ...robot, ...value });
2025-06-08 16:36:57 +08:00
if (value.label) {
this.setValue({ id, text: value.label }, { render: false, history: false, doEvent: false });
2025-06-08 16:36:57 +08:00
}
2025-05-25 00:07:22 +08:00
(<SceneData>this.store.data).robots = [...this.#robotMap.values()];
}
2025-05-05 01:06:09 +08:00
public addRobots(gid: RobotInfo['gid'], robots: RobotInfo[]): void {
const groups = clone(this.#robotGroups$$.value);
const group = groups.find((v) => v.id === gid);
if (isNil(group)) throw Error('未找到目标机器人组');
group.robots ??= [];
robots.forEach((v) => {
if (this.#robotMap.has(v.id)) return;
this.#robotMap.set(v.id, { ...v, gid });
group.robots?.push(v.id);
});
this.#robotGroups$$.next(groups);
(<SceneData>this.store.data).robots = [...this.#robotMap.values()];
(<SceneData>this.store.data).robotGroups = this.#robotGroups$$.value;
}
2025-05-05 23:21:31 +08:00
public removeRobots(ids: RobotInfo['id'][]): void {
ids?.forEach((v) => this.#robotMap.delete(v));
const groups = clone(this.#robotGroups$$.value);
groups.forEach(({ robots }) => remove(robots ?? [], (v) => !this.#robotMap.has(v)));
this.#robotGroups$$.next(groups);
(<SceneData>this.store.data).robots = [...this.#robotMap.values()];
(<SceneData>this.store.data).robotGroups = this.#robotGroups$$.value;
}
2025-05-25 00:07:22 +08:00
public updateRobots(ids: RobotInfo['id'][], value: Partial<RobotInfo>): void {
2025-05-05 23:21:31 +08:00
ids?.forEach((v) => {
const robot = this.#robotMap.get(v);
if (isNil(robot)) return;
2025-05-25 00:07:22 +08:00
this.#robotMap.set(v, { ...robot, ...value });
2025-05-05 23:21:31 +08:00
});
(<SceneData>this.store.data).robots = [...this.#robotMap.values()];
}
2025-05-05 01:06:09 +08:00
readonly #robotGroups$$ = new BehaviorSubject<RobotGroup[]>([]);
public readonly robotGroups = useObservable<RobotGroup[]>(this.#robotGroups$$.pipe(debounceTime(300)));
public createRobotGroup(): void {
const id = s8();
2025-05-06 23:48:21 +08:00
const label = `RG${id}`;
2025-05-05 01:06:09 +08:00
const groups = clone(this.#robotGroups$$.value);
groups.push({ id, label });
this.#robotGroups$$.next(groups);
(<SceneData>this.store.data).robotGroups = this.#robotGroups$$.value;
}
public deleteRobotGroup(id: RobotGroup['id']): void {
const groups = clone(this.#robotGroups$$.value);
const group = groups.find((v) => v.id === id);
group?.robots?.forEach((v) => this.#robotMap.delete(v));
remove(groups, group);
this.#robotGroups$$.next(groups);
(<SceneData>this.store.data).robots = [...this.#robotMap.values()];
(<SceneData>this.store.data).robotGroups = this.#robotGroups$$.value;
}
public updateRobotGroupLabel(id: RobotGroup['id'], label: RobotGroup['label']): void {
const groups = this.#robotGroups$$.value;
const group = groups.find((v) => v.id === id);
if (isNil(group)) throw Error('未找到目标机器人组');
if (some(groups, ['label', label])) throw Error('机器人组名称已经存在');
group.label = label;
this.#robotGroups$$.next([...groups]);
(<SceneData>this.store.data).robotGroups = this.#robotGroups$$.value;
}
//#endregion
/** 保存从后台传来的所有额外字段除了已处理的robotGroups、robots、points、routes、areas之外的字段 */
#originalSceneData?: Partial<StandardScene>;
/** 坐标转换方法 - 将左上角原点的坐标转换为中心点原点的坐标 */
#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));
}
/**
* -
*
*/
#calculatePixelAlignedOffset(baseOffset: number): number {
const scale = this.store.data.scale || 1;
const devicePixelRatio = window.devicePixelRatio || 1;
// 计算实际像素偏移量
const scaledOffset = baseOffset * scale;
// 多重对齐策略:
// 1. 设备像素对齐 - 确保在高DPI屏幕上也能对齐
const deviceAlignedOffset = Math.round(scaledOffset * devicePixelRatio) / devicePixelRatio;
// 2. 子像素对齐 - 对于常见的缩放比例使用特殊处理
let finalOffset = deviceAlignedOffset;
// 针对常见问题缩放比例的特殊处理
const roundedScale = Math.round(scale * 100) / 100; // 避免浮点数精度问题
if (roundedScale <= 0.2) {
// 极小缩放使用更粗粒度的对齐0.5像素边界)
finalOffset = Math.round(scaledOffset * 2) / 2;
} else if (roundedScale <= 0.5) {
// 小缩放使用0.25像素边界对齐
finalOffset = Math.round(scaledOffset * 4) / 4;
} else if (roundedScale >= 2) {
// 大缩放:使用精确的像素边界对齐
finalOffset = Math.round(scaledOffset);
} else {
// 标准缩放:使用设备像素对齐结果,但增加额外的精度控制
const precisionFactor = 8; // 1/8像素精度
finalOffset = Math.round(scaledOffset * precisionFactor) / precisionFactor;
}
// 3. 转换回逻辑坐标系并应用精度控制
const logicalOffset = finalOffset / scale;
// 4. 使用现有的精度控制方法确保数值稳定性
return this.#fixPrecision(logicalOffset);
}
/** 画布变化事件流,用于触发响应式数据更新 */
2025-05-05 23:21:31 +08:00
readonly #change$$ = new Subject<boolean>();
/** 区域大小变化防抖处理的事件流 */
readonly #areaSizeChange$$ = new Subject<MapPen>();
/** 防抖处理后的区域大小变化事件延迟500ms执行 */
readonly #debouncedAreaSizeChange = this.#areaSizeChange$$.pipe(
debounceTime(500), // 500ms防抖延迟
map((pen) => pen),
);
/** 当前选中的图形对象,响应式更新 */
2025-05-07 20:12:15 +08:00
public readonly current = useObservable<MapPen>(
this.#change$$.pipe(
debounceTime(100),
map(() => <MapPen>clone(this.store.active?.[0])),
),
);
/** 当前选中的图形ID列表响应式更新 */
2025-05-05 23:21:31 +08:00
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>() },
);
/** 画布上所有图形对象列表,响应式更新 */
2025-05-05 23:21:31 +08:00
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);
}
2025-05-06 23:48:21 +08:00
public getPenById(id?: string): MapPen | undefined {
if (!id) return;
return this.find(id)[0];
}
2025-05-05 23:21:31 +08:00
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();
}
2025-06-29 14:15:55 +08:00
public gotoById(id: string): void {
const pen = this.getPenById(id);
if (isNil(pen)) return;
// 判断机器人是否可见,如果不可见直接返回
if (pen.visible === false && pen.tags?.includes('robot')) return;
2025-06-29 14:15:55 +08:00
this.gotoView(pen);
}
2025-05-13 22:37:34 +08:00
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 });
2025-05-07 20:12:15 +08:00
}
/**
*
* @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 deviceStatus 0=1=
* @param isConnected true=false=
* @param active
* @param pointId ID使
*/
public updateAutoDoorByDeviceId(
deviceId: string,
deviceStatus: 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,
deviceStatus,
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 });
}
}
}
2025-05-25 00:07:22 +08:00
//#region 实时机器人
public async initRobots(): Promise<void> {
await Promise.all(
this.robots.map(async ({ id, label, type }) => {
const pen: MapPen = {
...this.#mapRobotImage(type, true, label), // 传递机器人名称(label)
2025-05-25 00:07:22 +08:00
id,
name: 'robot',
tags: ['robot'],
x: 0,
y: 0,
width: 120,
height: 120,
2025-05-25 00:07:22 +08:00
lineWidth: 1,
robot: { type },
visible: false,
text: label,
textTop: -18, // 调整标签位置,更靠近机器人本体,减少被覆盖的可能性
2025-05-25 00:07:22 +08:00
whiteSpace: 'nowrap',
ellipsis: false,
locked: LockState.Disable,
};
await this.addPen(pen, false, true, true);
}),
);
// 机器人初始化完成后,确保层级正确(机器人应在最顶层)
this.#ensureCorrectLayerOrder();
2025-05-25 00:07:22 +08:00
}
// public refreshRobot(id: RobotInfo['id'], info: Partial<RobotRealtimeInfo>): void {
// const pen = this.getPenById(id);
// const { rotate: or, robot } = pen ?? {};
// if (!robot?.type) return;
// const { x: ox, y: oy } = this.getPenRect(pen!);
// const { x: cx = 37, y: cy = 37, active, angle, path: points, isWaring, isFault } = info;
// const x = cx - 60;
// const y = cy - 60;
// const rotate = angle ?? or;
// const path =
// points?.map((p) => ({ x: p.x - cx, y: p.y - cy })) ??
// robot.path?.map((p) => ({ x: p.x + ox! - x, y: p.y + oy! - y }));
// const o = { ...robot, ...omitBy({ active, path, isWaring, isFault }, isNil) };
// if (isNil(active)) {
// this.setValue(
// { id, x, y, rotate, robot: o, visible: true, locked: LockState.None },
// { render: true, history: false, doEvent: false }
// );
// } else {
// this.setValue(
// {
// id,
// ...this.#mapRobotImage(robot.type, active),
// x, y, rotate, robot: o,
// visible: true,
// locked: LockState.None
// },
// { render: true, history: false, doEvent: false },
// );
// }
// }
2025-05-25 00:07:22 +08:00
/**
*
*
*/
public updateAllRobotImageSizes(): void {
this.robots.forEach(({ id, type, label }) => {
const pen = this.getPenById(id);
if (pen && pen.robot) {
const imageConfig = this.#mapRobotImage(type, pen.robot.active, label); // 传递机器人名称
this.setValue(
{
id,
iconWidth: imageConfig.iconWidth,
iconHeight: imageConfig.iconHeight,
iconTop: imageConfig.iconTop,
},
{ render: true, history: false, doEvent: false },
);
}
});
}
/**
*
*
* @param robotName
*/
public updateRobotImage(robotName: string): void {
const robot = this.robots.find(r => r.label === robotName);
if (!robot) return;
const pen = this.getPenById(robot.id);
if (pen && pen.robot) {
const imageConfig = this.#mapRobotImage(robot.type, pen.robot.active, robotName);
this.setValue(
{
id: robot.id,
image: imageConfig.image,
iconWidth: imageConfig.iconWidth,
iconHeight: imageConfig.iconHeight,
iconTop: imageConfig.iconTop,
},
{ render: true, history: false, doEvent: false },
);
}
}
2025-05-25 00:07:22 +08:00
#mapRobotImage(
type: RobotType,
active?: boolean,
robotName?: string,
): Required<Pick<MapPen, 'image' | 'iconWidth' | 'iconHeight' | 'iconTop' | 'canvasLayer'>> {
2025-05-25 00:07:22 +08:00
const theme = this.data().theme;
// 检查是否启用自定义图片且有机器人名称
const useCustomImages = colorConfig.getColor('robot.useCustomImages') === 'true';
let image: string;
if (useCustomImages && robotName) {
// 尝试获取自定义图片
const customImage = colorConfig.getRobotCustomImage(robotName, active ? 'active' : 'normal');
if (customImage) {
image = customImage;
} else {
// 如果没有自定义图片,回退到默认图片
image = import.meta.env.BASE_URL + (active ? `/robot/${type}-active-${theme}.png` : `/robot/${type}-${theme}.png`);
}
} else {
// 使用默认图片
image = import.meta.env.BASE_URL + (active ? `/robot/${type}-active-${theme}.png` : `/robot/${type}-${theme}.png`);
}
// 使用配置的机器人图片尺寸,如果没有配置则使用默认值
const iconWidth = colorConfig.getColor('robot.imageWidth') ? Number(colorConfig.getColor('robot.imageWidth')) : 42;
const iconHeight = colorConfig.getColor('robot.imageHeight')
? Number(colorConfig.getColor('robot.imageHeight'))
: 76;
// 使用优化的像素对齐算法,确保小车和光圈精确重合
const iconTop = this.#calculatePixelAlignedOffset(-16);
return { image, iconWidth, iconHeight, iconTop, canvasLayer: CanvasLayer.CanvasImage };
2025-05-25 00:07:22 +08:00
}
//#endregion
2025-04-20 00:49:14 +08:00
//#region 点位
/** 画布上所有点位对象列表,响应式更新 */
2025-05-05 23:21:31 +08:00
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 {
if (isNil(pen)) return null;
const { x, y, width, height } = this.getPenRect(pen);
return { x: x + width / 2, y: y + height / 2, width, height };
}
/**
*
* @param p
* @param type
* @param id ID
*/
2025-05-25 00:07:22 +08:00
public async addPoint(p: Point, type = MapPointType., id?: string): Promise<void> {
2025-05-17 16:56:01 +08:00
id ||= s8();
const pointInfo: MapPointInfo = { type };
// 为停靠点设置默认启用状态
if (type === MapPointType.) {
pointInfo.enabled = 1;
}
2025-04-20 00:49:14 +08:00
const pen: MapPen = {
2025-04-28 00:43:33 +08:00
...p,
...this.#mapPoint(type),
2025-04-28 20:04:46 +08:00
...this.#mapPointImage(type),
2025-05-05 01:06:09 +08:00
id,
2025-04-20 00:49:14 +08:00
name: 'point',
2025-05-07 20:12:15 +08:00
tags: ['point'],
2025-05-06 23:48:21 +08:00
label: `P${id}`,
point: pointInfo,
locked: LockState.DisableEdit,
2025-04-20 00:49:14 +08:00
};
pen.x! -= pen.width! / 2;
pen.y! -= pen.height! / 2;
const addedPen = await this.addPen(pen, false, true, true);
// 将新创建的点位移到最上层
this.layerManager.adjustElementLayer(addedPen);
// 如果是动作点且有库位信息创建库位pen对象
if (type === MapPointType. && pointInfo.associatedStorageLocations?.length) {
this.createStorageLocationPens(addedPen.id!, pointInfo.associatedStorageLocations);
}
2025-04-20 00:49:14 +08:00
}
2025-04-28 00:43:33 +08:00
public updatePoint(id: string, info: Partial<MapPointInfo>, autoCreateStorage = true): void {
2025-05-08 00:42:08 +08:00
const { point } = this.getPenById(id) ?? {};
if (!point?.type) return;
2025-05-08 19:42:45 +08:00
const o = { ...point, ...info };
this.setValue({ id, point: o }, { render: true, history: true, doEvent: true });
// 如果是动作点且库位信息发生变化重新创建库位pen对象
if (point.type === MapPointType. && info.associatedStorageLocations && autoCreateStorage) {
this.createStorageLocationPens(id, info.associatedStorageLocations);
}
2025-05-08 00:42:08 +08:00
}
2025-05-07 20:12:15 +08:00
public changePointType(id: string, type: MapPointType): void {
const pen = this.getPenById(id);
const rect = this.getPointRect(pen);
if (isNil(rect)) return;
// 如果原点位是动作点,需要先删除相关的库位元素
if (pen?.point?.type === MapPointType.) {
this.removeStorageLocationPens(id);
}
const point = this.#mapPoint(type);
const pointInfo: MapPointInfo = { type };
// 为停靠点设置默认启用状态
if (type === MapPointType.) {
pointInfo.enabled = 1;
}
2025-05-07 20:12:15 +08:00
this.setValue(
{
id,
x: rect.x - point.width / 2,
y: rect.y - point.height / 2,
...point,
...this.#mapPointImage(type),
point: pointInfo,
},
2025-05-07 20:12:15 +08:00
{ render: true, history: true, doEvent: true },
);
// 如果是大点位类型(需要图片),异步重新加载主题确保图片正确显示
if (type >= 10) {
requestAnimationFrame(() => {
this.reloadTheme();
});
}
2025-05-07 20:12:15 +08:00
}
2025-04-28 20:04:46 +08:00
#mapPoint(type: MapPointType): Required<Pick<MapPen, 'width' | 'height' | 'lineWidth' | 'iconSize'>> {
2025-04-28 00:43:33 +08:00
const width = type < 10 ? 24 : 48;
const height = type < 10 ? 24 : 60;
const lineWidth = type < 10 ? 2 : 3;
2025-04-28 20:04:46 +08:00
const iconSize = type < 10 ? 4 : 10;
return { width, height, lineWidth, iconSize };
}
#mapPointImage(type: MapPointType): Required<Pick<MapPen, 'image' | 'canvasLayer'>> {
const theme = this.data().theme;
2025-06-06 22:36:16 +08:00
const image = type < 10 ? '' : `${import.meta.env.BASE_URL}/point/${type}-${theme}.png`;
2025-04-28 20:04:46 +08:00
return { image, canvasLayer: CanvasLayer.CanvasMain };
2025-04-28 00:43:33 +08:00
}
2025-04-20 00:49:14 +08:00
//#endregion
//#region 线路
/** 画布上所有路线对象列表,响应式更新,包含动态生成的标签 */
2025-05-08 19:42:45 +08:00
public readonly routes = useObservable<MapPen[], MapPen[]>(
this.#change$$.pipe(
filter((v) => v),
debounceTime(100),
2025-05-10 00:49:45 +08:00
map(() => this.find('route').map((v) => ({ ...v, label: this.getRouteLabel(v.id) }))),
2025-05-08 19:42:45 +08:00
),
{ initialValue: new Array<MapPen>() },
);
2025-05-09 20:15:04 +08:00
public getRouteLabel(id?: string, d?: number): string {
2025-05-06 23:48:21 +08:00
if (!id) return '';
const pen = this.getPenById(id);
if (isNil(pen)) return '';
const [a1, a2] = pen.anchors ?? [];
if (!a1?.connectTo || !a2?.connectTo) return '';
const p1 = this.getPenById(a1.connectTo);
const p2 = this.getPenById(a2.connectTo);
if (isNil(p1) || isNil(p2)) return '';
const { direction = 1 } = pen.route ?? {};
2025-05-09 20:15:04 +08:00
return `${p1.label}${(d ?? direction) > 0 ? '→' : '←'}${p2.label}`;
2025-05-06 23:48:21 +08:00
}
2025-05-09 00:17:02 +08:00
/**
* 线
* @param p
* @param type 线线
* @param id 线ID
*/
2025-06-11 00:22:16 +08:00
public addRoute(p: [MapPen, MapPen], type = MapRouteType.线, id?: string): void {
const [p1, p2] = p;
2025-05-09 00:17:02 +08:00
if (!p1?.anchors?.length || !p2?.anchors?.length) return;
2025-05-17 16:56:01 +08:00
const line = this.connectLine(p1, p2, undefined, undefined, false);
id ||= line.id!;
this.changePenId(line.id!, id);
const pen: MapPen = {
tags: ['route'],
route: { type },
lineWidth: 1,
locked: LockState.DisableEdit,
canvasLayer: CanvasLayer.CanvasMain,
};
2025-05-17 16:56:01 +08:00
this.setValue({ id, ...pen }, { render: false, history: false, doEvent: false });
2025-05-09 00:17:02 +08:00
this.updateLineType(line, type);
// 将路线移到底层,确保点位能覆盖在路线之上
this.bottom([line]);
2025-05-17 16:56:01 +08:00
this.active(id);
2025-05-09 00:17:02 +08:00
this.render();
}
2025-05-09 20:15:04 +08:00
public updateRoute(id: string, info: Partial<MapRouteInfo>): void {
const { route } = this.getPenById(id) ?? {};
if (!route?.type) return;
const o = { ...route, ...info };
this.setValue({ id, route: o }, { render: true, history: true, doEvent: true });
}
2025-05-09 00:17:02 +08:00
public changeRouteType(id: string, type: MapRouteType): void {
const pen = this.getPenById(id);
if (isNil(pen)) return;
this.updateLineType(pen, type);
this.setValue({ id, route: { type } }, { render: true, history: true, doEvent: true });
}
/**
* 线
* @param point1Id ID
* @param point2Id ID
* @returns 线
*/
public getRoutesBetweenPoints(point1Id: string, point2Id: string): MapPen[] {
return this.find('route').filter(route => {
const [a1, a2] = route.anchors ?? [];
if (!a1?.connectTo || !a2?.connectTo) return false;
return (a1.connectTo === point1Id && a2.connectTo === point2Id) ||
(a1.connectTo === point2Id && a2.connectTo === point1Id);
});
}
/**
* 线线
* @param routeId 线ID
* @returns 线null
*/
public getReverseRoute(routeId: string): MapPen | null {
const route = this.getPenById(routeId);
if (!route) return null;
const [a1, a2] = route.anchors ?? [];
if (!a1?.connectTo || !a2?.connectTo) return null;
const reverseRoutes = this.getRoutesBetweenPoints(a1.connectTo, a2.connectTo);
return reverseRoutes.find(r => r.id !== routeId) || null;
}
/**
* 线
* @param p
* @param type 线线
* @param forwardId 线ID
* @param reverseId 线ID
*/
public addBidirectionalRoute(
p: [MapPen, MapPen],
type = MapRouteType.线,
forwardId?: string,
reverseId?: string
): void {
const [p1, p2] = p;
if (!p1?.anchors?.length || !p2?.anchors?.length) return;
// 创建正向路线
const forwardLine = this.connectLine(p1, p2, undefined, undefined, false);
forwardId ||= forwardLine.id!;
this.changePenId(forwardLine.id!, forwardId);
const forwardPen: MapPen = {
tags: ['route'],
route: { type, direction: 1 },
lineWidth: 1,
locked: LockState.DisableEdit,
canvasLayer: CanvasLayer.CanvasMain,
};
this.setValue({ id: forwardId, ...forwardPen }, { render: false, history: false, doEvent: false });
this.updateLineType(forwardLine, type);
// 创建反向路线
const reverseLine = this.connectLine(p2, p1, undefined, undefined, false);
reverseId ||= reverseLine.id!;
this.changePenId(reverseLine.id!, reverseId);
const reversePen: MapPen = {
tags: ['route'],
route: { type, direction: -1 },
lineWidth: 1,
locked: LockState.DisableEdit,
canvasLayer: CanvasLayer.CanvasMain,
};
this.setValue({ id: reverseId, ...reversePen }, { render: false, history: false, doEvent: false });
this.updateLineType(reverseLine, type);
// 将路线移到底层,确保点位能覆盖在路线之上
this.bottom([forwardLine, reverseLine]);
// 将反向路线移到正向路线之上,解决重叠选择问题
this.top([reverseLine]);
this.active(forwardId);
this.render();
}
/**
* 线
* @param routeId 线ID
*/
public removeBidirectionalRoute(routeId: string): void {
const route = this.getPenById(routeId);
if (!route) return;
const [a1, a2] = route.anchors ?? [];
if (!a1?.connectTo || !a2?.connectTo) return;
// 找到所有连接这两个点位的路线
const routesBetweenPoints = this.getRoutesBetweenPoints(a1.connectTo, a2.connectTo);
// 删除所有相关路线
routesBetweenPoints.forEach(r => {
this.delete([r]);
});
this.render();
}
/**
* 线
* @param point1Id ID
* @param point2Id ID
* @returns 线
*/
public hasBidirectionalRoute(point1Id: string, point2Id: string): boolean {
const routes = this.getRoutesBetweenPoints(point1Id, point2Id);
return routes.length >= 2;
}
2025-04-20 00:49:14 +08:00
//#endregion
//#region 区域
/** 画布上所有区域对象列表,响应式更新 */
2025-05-06 23:48:21 +08:00
public readonly areas = useObservable<MapPen[], MapPen[]>(
this.#change$$.pipe(
filter((v) => v),
debounceTime(100),
map(() => this.find('area')),
),
{ initialValue: new Array<MapPen>() },
);
2025-05-08 00:42:08 +08:00
public getBoundAreas(id: string = '', name: 'point' | 'line', type: MapAreaType): MapPen[] {
if (!id) return [];
return this.find(`area-${type}`).filter(({ area }) => {
if (name === 'point') return area?.points?.includes(id);
if (name === 'line') return area?.routes?.includes(id);
return false;
});
}
/**
*
* @param p1
* @param p2
* @param type
* @param id ID
*/
2025-05-17 16:56:01 +08:00
public async addArea(p1: Point, p2: Point, type = MapAreaType., id?: string) {
2025-04-27 00:05:18 +08:00
const w = Math.abs(p1.x - p2.x);
const h = Math.abs(p1.y - p2.y);
2025-06-29 14:15:55 +08:00
if (w < 50 || h < 60) return;
2025-05-06 23:48:21 +08:00
const points = new Array<string>();
const routes = new Array<string>();
if (!id) {
id = s8();
2025-05-17 22:53:30 +08:00
const selected = <MapPen[]>this.store.active;
switch (type) {
case MapAreaType.:
selected?.filter(({ point }) => point?.type === MapPointType.).forEach(({ id }) => points.push(id!));
break;
case MapAreaType.:
selected?.filter(({ point }) => point?.type).forEach(({ id }) => points.push(id!));
// 互斥区不再绑定路段
2025-05-17 22:53:30 +08:00
break;
case MapAreaType.:
selected?.filter(({ point }) => point?.type).forEach(({ id }) => points.push(id!));
break;
case MapAreaType.:
selected?.filter(({ point }) => point?.type).forEach(({ id }) => points.push(id!));
break;
case MapAreaType.:
// 描述区不需要绑定点位或路线
break;
2025-05-17 22:53:30 +08:00
default:
break;
}
2025-05-06 23:48:21 +08:00
}
const areaInfo: MapAreaInfo = { type, points, routes };
if (type === MapAreaType.) {
areaInfo.inoutflag = 2;
}
2025-04-27 00:05:18 +08:00
const pen: MapPen = {
2025-05-06 23:48:21 +08:00
id,
2025-04-27 00:05:18 +08:00
name: 'area',
2025-04-28 20:04:46 +08:00
tags: ['area', `area-${type}`],
2025-05-06 23:48:21 +08:00
label: `A${id}`,
2025-04-27 00:05:18 +08:00
x: Math.min(p1.x, p2.x),
y: Math.min(p1.y, p2.y),
width: w,
height: h,
2025-05-06 23:48:21 +08:00
lineWidth: 1,
area: areaInfo,
locked: LockState.None,
2025-04-27 00:05:18 +08:00
};
2025-05-17 22:53:30 +08:00
const area = await this.addPen(pen, true, true, true);
2025-04-27 00:05:18 +08:00
this.bottom(area);
// 如果是库区且包含动作点,触发自动生成库位的确认对话框
if (type === MapAreaType. && points.length > 0) {
this.autoStorageGenerator.triggerAutoCreateStorageDialog(pen, points);
}
2025-04-27 00:05:18 +08:00
}
2025-05-08 19:42:45 +08:00
public updateArea(id: string, info: Partial<MapAreaInfo>): void {
const { area } = this.getPenById(id) ?? {};
if (!area?.type) return;
const o = { ...area, ...info };
this.setValue({ id, area: o }, { render: true, history: true, doEvent: true });
}
2025-04-20 00:49:14 +08:00
//#endregion
/**
* -
* @param container DOM元素
*/
2025-04-20 00:49:14 +08:00
constructor(container: HTMLDivElement) {
2025-04-27 00:05:18 +08:00
super(container, EDITOR_CONFIG);
2025-04-20 00:49:14 +08:00
// 初始化图层管理服务
this.layerManager = new LayerManagerService(this);
// 初始化区域操作服务
this.areaOperationService = new AreaOperationService();
// 初始化库位服务
this.storageLocationService = new StorageLocationService(this, '');
// 初始化自动生成库位工具
this.autoStorageGenerator = new AutoStorageGenerator(this);
// 初始化BinTask管理服务
this.binTaskManager = new BinTaskManagerService(this);
// 设置颜色配置服务的编辑器实例
colorConfig.setEditorService(this);
// 禁用第6个子元素的拖放功能
2025-04-20 00:49:14 +08:00
(<HTMLDivElement>container.children.item(5)).ondrop = null;
// 监听所有画布事件
2025-04-27 00:05:18 +08:00
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();
event.stopPropagation();
}, true);
}
// 注册自定义绘制函数和锚点
2025-04-20 00:49:14 +08:00
this.#register();
// 监听主题变化并重新加载样式
2025-04-20 00:49:14 +08:00
watch(
() => sTheme.theme,
2025-04-28 20:04:46 +08:00
(v) => this.#load(v),
2025-04-20 00:49:14 +08:00
{ immediate: true },
);
// 订阅防抖处理后的区域大小变化事件
this.#debouncedAreaSizeChange.subscribe((pen: MapPen) => {
this.areaOperationService.handleAreaSizeChange(
pen,
(type) => this.find(type),
(id, info) => this.updateArea(id, info),
);
});
2025-04-20 00:49:14 +08:00
}
2025-05-05 23:21:31 +08:00
#load(theme: string): void {
this.setTheme(theme);
2025-05-25 00:07:22 +08:00
this.setOptions({ color: get(sTheme.editor, 'color') });
2025-04-28 20:04:46 +08:00
this.find('point').forEach((pen) => {
if (!pen.point?.type) return;
if (pen.point.type < 10) return;
this.canvas.updateValue(pen, this.#mapPointImage(pen.point.type));
});
2025-05-25 00:07:22 +08:00
this.find('robot').forEach((pen) => {
if (!pen.robot?.type) return;
// 从pen的text属性获取机器人名称
const robotName = pen.text || '';
this.canvas.updateValue(pen, this.#mapRobotImage(pen.robot.type, pen.robot.active, robotName));
2025-05-25 00:07:22 +08:00
});
2025-04-28 20:04:46 +08:00
this.render();
}
/**
*
*
* UI
*/
public reloadTheme(): void {
const currentTheme = this.data().theme || 'light';
// 使用 requestAnimationFrame 确保在下一个渲染帧执行避免阻塞UI
requestAnimationFrame(() => {
this.#load(currentTheme);
});
}
2025-05-10 00:49:45 +08:00
#onDelete(pens?: MapPen[]): void {
pens?.forEach((pen) => {
switch (pen.name) {
case 'point':
2025-05-11 18:46:59 +08:00
this.delete(this.getLines(pen), true, false);
2025-05-10 00:49:45 +08:00
break;
default:
break;
}
});
}
2025-05-09 20:15:04 +08:00
2025-04-20 00:49:14 +08:00
#listen(e: unknown, v: any) {
switch (e) {
2025-04-28 20:04:46 +08:00
case 'opened':
2025-05-05 23:21:31 +08:00
this.#load(sTheme.theme);
this.#change$$.next(true);
break;
case 'add':
2025-05-09 20:15:04 +08:00
this.#change$$.next(true);
break;
2025-05-05 23:21:31 +08:00
case 'delete':
2025-05-10 00:49:45 +08:00
this.#onDelete(v);
2025-05-09 20:15:04 +08:00
this.#change$$.next(true);
break;
2025-05-05 23:21:31 +08:00
case 'update':
2025-05-09 20:15:04 +08:00
this.#change$$.next(true);
break;
2025-05-05 23:21:31 +08:00
case 'valueUpdate':
this.#change$$.next(true);
// 检查是否是区域属性更新
if (v && v.area?.type && ['area'].includes(v.tags?.[0] || '')) {
// 发送到防抖流,避免频繁触发
this.#areaSizeChange$$.next(v);
}
2025-04-28 20:04:46 +08:00
break;
2025-05-05 23:21:31 +08:00
case 'active':
case 'inactive':
this.#change$$.next(false);
break;
2025-04-27 00:05:18 +08:00
case 'click':
case 'mousedown':
case 'mouseup':
2025-06-29 14:15:55 +08:00
this.#mouse$$.next({ type: e, value: pick(this.getPenRect(v), 'x', 'y') });
2025-04-27 00:05:18 +08:00
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;
2025-04-27 00:05:18 +08:00
2025-04-20 00:49:14 +08:00
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();
}
2025-04-20 00:49:14 +08:00
#register() {
2025-05-09 20:15:04 +08:00
this.register({ line: () => new Path2D() });
this.registerCanvasDraw({
point: drawPoint,
line: drawLine,
area: drawArea,
robot: drawRobot,
'storage-location': drawStorageLocation,
'storage-more': drawStorageMore,
'storage-background': drawStorageBackground
});
2025-04-27 00:05:18 +08:00
this.registerAnchors({ point: anchorPoint });
2025-05-17 13:08:29 +08:00
this.addDrawLineFn('bezier2', lineBezier2);
2025-05-09 00:17:02 +08:00
this.addDrawLineFn('bezier3', lineBezier3);
2025-04-20 00:49:14 +08:00
}
}
//#region 自定义绘制函数
/**
*
* @param ctx Canvas 2D绘制上下文
* @param pen
*/
2025-04-20 00:49:14 +08:00
function drawPoint(ctx: CanvasRenderingContext2D, pen: MapPen): void {
2025-04-28 00:43:33 +08:00
const theme = sTheme.editor;
2025-04-28 20:04:46 +08:00
const { active, iconSize: r = 0, fontSize = 14, lineHeight = 1.5, fontFamily } = pen.calculative ?? {};
const { x = 0, y = 0, width: w = 0, height: h = 0 } = pen.calculative?.worldRect ?? {};
const { type, isConnected, deviceStatus, active: pointActive } = pen.point ?? {};
const { label = '', statusStyle } = pen ?? {};
2025-04-28 00:43:33 +08:00
2025-04-27 00:05:18 +08:00
ctx.save();
// 自动门点:根据连接与开关状态绘制矩形光圈(无边框)
if (type === MapPointType. && pointActive) {
// 让光圈随点位尺寸等比缩放,避免缩放画布时视觉上变大
const base = Math.min(w, h);
const padding = Math.max(2, Math.min(10, base * 0.2));
const rx = x - padding;
const ry = y - padding;
const rw = w + padding * 2;
const rh = h + padding * 2;
// 使用与点位相同的圆角半径,使观感统一
ctx.beginPath();
ctx.roundRect(rx, ry, rw, rh, r);
if (isConnected === false) {
// 未连接:深红色实心,不描边
ctx.fillStyle = colorConfig.getColor('autoDoor.strokeClosed') || '#fe5a5ae0';
} else {
// 已连接根据门状态显示颜色0=关门-浅红1=开门-蓝色)
if (deviceStatus === 0) {
ctx.fillStyle = colorConfig.getColor('autoDoor.fillClosed') || '#cddc39';
} else {
ctx.fillStyle = colorConfig.getColor('autoDoor.fillOpen') || '#1890FF';
}
}
ctx.fill();
// 重置路径,避免后续对点位边框的 stroke 影响到光圈路径
ctx.beginPath();
}
2025-04-28 00:43:33 +08:00
switch (type) {
case MapPointType.:
case MapPointType.:
case MapPointType.:
case MapPointType.:
case MapPointType.: {
2025-04-28 20:04:46 +08:00
ctx.beginPath();
ctx.moveTo(x + w / 2 - r, y + r);
ctx.arcTo(x + w / 2, y, x + w - r, y + h / 2 - r, r);
ctx.arcTo(x + w, y + h / 2, x + w / 2 + r, y + h - r, r);
ctx.arcTo(x + w / 2, y + h, x + r, y + h / 2 + r, r);
ctx.arcTo(x, y + h / 2, x + r, y + h / 2 - r, r);
ctx.closePath();
// 优先使用小点位专用颜色,如果没有则使用类型专用颜色
const smallGeneralColor = colorConfig.getColor(`point.small.fill.${type}`);
const smallTypeColor = colorConfig.getColor(`point.types.${type}.fill`);
const smallThemeColor = get(theme, `point-s.fill-${type}`);
const finalColor = smallGeneralColor || smallTypeColor || smallThemeColor || '';
ctx.fillStyle = finalColor;
2025-04-28 20:04:46 +08:00
ctx.fill();
const smallTypeStrokeColor = colorConfig.getColor(`point.types.${type}.stroke`);
const smallGeneralStrokeColor = colorConfig.getColor(active ? 'point.small.strokeActive' : 'point.small.stroke');
const smallThemeStrokeColor = get(theme, active ? 'point-s.strokeActive' : 'point-s.stroke');
ctx.strokeStyle = smallTypeStrokeColor || smallGeneralStrokeColor || smallThemeStrokeColor || '';
2025-04-28 20:04:46 +08:00
if (type === MapPointType.) {
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(x + 0.66 * r, y + h / 2 - 0.66 * r);
ctx.lineTo(x + r, y + h / 2 - r);
ctx.moveTo(x + w / 2 - 0.66 * r, y + 0.66 * r);
ctx.lineTo(x + w / 2 - r, y + r);
ctx.moveTo(x + w / 2 + 0.66 * r, y + 0.66 * r);
ctx.lineTo(x + w / 2 + r, y + r);
ctx.moveTo(x + w - 0.66 * r, y + h / 2 - 0.66 * r);
ctx.lineTo(x + w - r, y + h / 2 - r);
ctx.moveTo(x + w - 0.66 * r, y + h / 2 + 0.66 * r);
ctx.lineTo(x + w - r, y + h / 2 + r);
ctx.moveTo(x + w / 2 + 0.66 * r, y + h - 0.66 * r);
ctx.lineTo(x + w / 2 + r, y + h - r);
ctx.moveTo(x + w / 2 - 0.66 * r, y + h - 0.66 * r);
ctx.lineTo(x + w / 2 - r, y + h - r);
ctx.moveTo(x + 0.66 * r, y + h / 2 + 0.66 * r);
ctx.lineTo(x + r, y + h / 2 + r);
}
ctx.stroke();
2025-04-28 00:43:33 +08:00
break;
}
2025-04-28 00:43:33 +08:00
case MapPointType.:
case MapPointType.:
case MapPointType.:
case MapPointType.:
case MapPointType.:
case MapPointType.: {
2025-04-28 20:04:46 +08:00
ctx.roundRect(x, y, w, h, r);
// 优先使用类型专用颜色,如果没有则使用通用颜色
const largeTypeStrokeColor = colorConfig.getColor(`point.types.${type}.stroke`);
const largeGeneralStrokeColor = colorConfig.getColor(active ? 'point.large.strokeActive' : 'point.large.stroke');
const largeThemeStrokeColor = get(theme, active ? 'point-l.strokeActive' : 'point-l.stroke');
ctx.strokeStyle = statusStyle ?? (largeTypeStrokeColor || largeGeneralStrokeColor || largeThemeStrokeColor || '');
2025-04-28 00:43:33 +08:00
ctx.stroke();
break;
}
2025-04-28 00:43:33 +08:00
default:
break;
}
ctx.fillStyle = colorConfig.getColor('common.color') || (get(theme, 'color') ?? '');
2025-04-28 20:04:46 +08:00
ctx.font = `${fontSize}px/${lineHeight} ${fontFamily}`;
2025-04-28 00:43:33 +08:00
ctx.textAlign = 'center';
2025-04-28 20:04:46 +08:00
ctx.textBaseline = 'top';
ctx.fillText(label, x + w / 2, y - fontSize * lineHeight);
// 库位2x3栅格动作点在画布上直接绘制静态数据
// 注意库位现在通过动态pen对象渲染不再使用静态绘制
// if (type === MapPointType.动作点) {
// drawStorageGrid(ctx, pen, {
// fontFamily,
// stateResolver: (penId: string, layerName: string) => {
// const inner = storageStateMap.get(penId);
// if (!inner) {
// return {};
// }
// const state = inner.get(layerName);
// return state ?? {};
// },
// });
// }
2025-04-27 00:05:18 +08:00
ctx.restore();
}
/**
*
* @param pen
*/
2025-04-27 00:05:18 +08:00
function anchorPoint(pen: MapPen): void {
2025-05-09 20:15:04 +08:00
pen.anchors = [
2025-05-17 16:56:01 +08:00
{ penId: pen.id, id: '0', x: 0.5, y: 0.5 },
// { penId: pen.id, id: 't', x: 0.5, y: 0 },
// { penId: pen.id, id: 'b', x: 0.5, y: 1 },
// { penId: pen.id, id: 'l', x: 0, y: 0.5 },
// { penId: pen.id, id: 'r', x: 1, y: 0.5 },
2025-05-09 20:15:04 +08:00
];
2025-04-27 00:05:18 +08:00
}
/**
* 线
* @param ctx Canvas 2D绘制上下文
* @param pen 线
*/
2025-04-27 00:05:18 +08:00
function drawLine(ctx: CanvasRenderingContext2D, pen: MapPen): void {
2025-05-09 00:17:02 +08:00
const theme = sTheme.editor;
2025-06-15 16:20:58 +08:00
const { active, lineWidth: s = 1 } = pen.calculative ?? {};
2025-04-27 00:05:18 +08:00
const [p1, p2] = pen.calculative?.worldAnchors ?? [];
2025-05-09 00:17:02 +08:00
const { x: x1 = 0, y: y1 = 0 } = p1 ?? {};
const { x: x2 = 0, y: y2 = 0 } = p2 ?? {};
const { type, direction = 1, pass = 0, c1, c2 } = pen.route ?? {};
2025-06-15 16:20:58 +08:00
const { x: dx1 = 0, y: dy1 = 0 } = c1 ?? {};
const { x: dx2 = 0, y: dy2 = 0 } = c2 ?? {};
2025-06-21 01:13:26 +08:00
const [c1x, c1y] = [x1 + dx1 * s, y1 + dy1 * s];
const [c2x, c2y] = [x2 + dx2 * s, y2 + dy2 * s];
2025-05-09 00:17:02 +08:00
2025-04-27 00:05:18 +08:00
ctx.save();
2025-05-09 20:15:04 +08:00
ctx.beginPath();
// 根据路线通行类型获取颜色
let routeColor = '';
if (active) {
routeColor = colorConfig.getColor('route.strokeActive') || get(theme, 'route.strokeActive') || '';
} else {
// 根据通行类型选择颜色
switch (pass) {
case MapRoutePassType.:
routeColor = colorConfig.getColor('route.strokeNone') || get(theme, 'route.stroke-0') || '';
break;
case MapRoutePassType.:
routeColor = colorConfig.getColor('route.strokeEmpty') || get(theme, 'route.stroke-empty') || '';
break;
case MapRoutePassType.:
routeColor = colorConfig.getColor('route.strokeLoaded') || get(theme, 'route.stroke-loaded') || '';
break;
case MapRoutePassType.:
routeColor = colorConfig.getColor('route.strokeForbidden') || get(theme, 'route.stroke-forbidden') || '';
break;
default:
// 无限制路线使用无路线颜色作为默认
routeColor = colorConfig.getColor('route.strokeNone') || get(theme, 'route.stroke-0') || '';
break;
}
}
ctx.strokeStyle = routeColor;
// 使用配置的路线宽度
const routeWidth = colorConfig.getRouteWidth(active);
ctx.lineWidth = routeWidth * s;
2025-05-09 20:15:04 +08:00
ctx.moveTo(x1, y1);
2025-05-09 00:17:02 +08:00
switch (type) {
case MapRouteType.线:
ctx.lineTo(x2, y2);
break;
2025-05-17 13:08:29 +08:00
case MapRouteType.线:
2025-06-21 01:13:26 +08:00
ctx.quadraticCurveTo(c1x, c1y, x2, y2);
2025-06-15 16:20:58 +08:00
p1.next = { x: x1 + (2 / 3) * dx1 * s, y: y1 + (2 / 3) * dy1 * s };
2025-06-21 01:13:26 +08:00
p2.prev = { x: x2 / 3 + (2 / 3) * c1x, y: y2 / 3 + (2 / 3) * c1y };
2025-05-17 13:08:29 +08:00
break;
2025-05-09 00:17:02 +08:00
case MapRouteType.线:
2025-06-21 01:13:26 +08:00
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, x2, y2);
p1.next = { x: c1x, y: c1y };
p2.prev = { x: c2x, y: c2y };
2025-05-09 00:17:02 +08:00
break;
default:
break;
}
if (pass === MapRoutePassType.) {
2025-06-15 16:20:58 +08:00
ctx.setLineDash([s * 5]);
2025-05-09 00:17:02 +08:00
}
2025-05-09 20:15:04 +08:00
ctx.stroke();
ctx.beginPath();
ctx.setLineDash([0]);
2025-06-21 01:13:26 +08:00
const { dx, dy, r } = (() => {
switch (type) {
case MapRouteType.线: {
const t = direction < 0 ? 0.55 : 0.45;
const dx = x1 + (x2 - x1) * t;
const dy = y1 + (y2 - y1) * t;
const r = Math.atan2(y2 - y1, x2 - x1) + (direction > 0 ? Math.PI : 0);
return { dx, dy, r };
2025-06-21 01:13:26 +08:00
}
case MapRouteType.线: {
const { x: dx, y: dy, t } = getBezier2Center(p1, { x: c1x, y: c1y }, p2);
const r = getBezier2Tange(p1, { x: c1x, y: c1y }, p2, t) + (direction > 0 ? Math.PI : 0);
return { dx, dy, r };
2025-06-21 01:13:26 +08:00
}
case MapRouteType.线: {
const { x: dx, y: dy, t } = getBezier3Center(p1, { x: c1x, y: c1y }, { x: c2x, y: c2y }, p2);
const r = getBezier3Tange(p1, { x: c1x, y: c1y }, { x: c2x, y: c2y }, p2, t) + (direction > 0 ? Math.PI : 0);
return { dx, dy, r };
2025-06-21 01:13:26 +08:00
}
default:
return { dx: 0, dy: 0, r: 0 };
}
})();
ctx.translate(dx, dy);
2025-06-15 16:20:58 +08:00
ctx.moveTo(Math.cos(r + Math.PI / 5) * s * 10, Math.sin(r + Math.PI / 5) * s * 10);
2025-05-09 20:15:04 +08:00
ctx.lineTo(0, 0);
2025-06-15 16:20:58 +08:00
ctx.lineTo(Math.cos(r - Math.PI / 5) * s * 10, Math.sin(r - Math.PI / 5) * s * 10);
2025-05-09 20:15:04 +08:00
ctx.stroke();
ctx.setTransform(1, 0, 0, 1, 0, 0);
2025-04-27 00:05:18 +08:00
ctx.restore();
}
2025-05-17 13:08:29 +08:00
function lineBezier2(_: Meta2dStore, pen: MapPen): void {
if (pen.calculative?.worldAnchors?.length !== 2) return;
const { c1 } = pen.route ?? {};
2025-06-15 16:20:58 +08:00
const { lineWidth: s = 1 } = pen.calculative ?? {};
2025-05-17 13:08:29 +08:00
const [p1, p2] = pen.calculative?.worldAnchors ?? [];
const { x: x1 = 0, y: y1 = 0 } = p1 ?? {};
const { x: x2 = 0, y: y2 = 0 } = p2 ?? {};
2025-06-15 16:20:58 +08:00
const { x: dx = 0, y: dy = 0 } = c1 ?? {};
pen.calculative.worldAnchors[0].next = { x: x1 + (2 / 3) * dx * s, y: y1 + (2 / 3) * dy * s };
pen.calculative.worldAnchors[1].prev = { x: x2 / 3 + (2 / 3) * (x1 + dx * s), y: y2 / 3 + (2 / 3) * (y1 + dy * s) };
2025-05-17 13:08:29 +08:00
}
2025-05-09 00:17:02 +08:00
function lineBezier3(_: Meta2dStore, pen: MapPen): void {
if (pen.calculative?.worldAnchors?.length !== 2) return;
const { c1, c2 } = pen.route ?? {};
2025-06-15 16:20:58 +08:00
const { lineWidth: s = 1 } = pen.calculative ?? {};
2025-05-09 00:17:02 +08:00
const [p1, p2] = pen.calculative?.worldAnchors ?? [];
const { x: x1 = 0, y: y1 = 0 } = p1 ?? {};
const { x: x2 = 0, y: y2 = 0 } = p2 ?? {};
2025-06-15 16:20:58 +08:00
const { x: dx1 = 0, y: dy1 = 0 } = c1 ?? {};
const { x: dx2 = 0, y: dy2 = 0 } = c2 ?? {};
pen.calculative.worldAnchors[0].next = { x: x1 + dx1 * s, y: y1 + dy1 * s };
pen.calculative.worldAnchors[1].prev = { x: x2 + dx2 * s, y: y2 + dy2 * s };
2025-05-09 00:17:02 +08:00
}
2025-04-27 00:05:18 +08:00
/**
*
* @param ctx Canvas 2D绘制上下文
* @param pen
*/
2025-04-27 00:05:18 +08:00
function drawArea(ctx: CanvasRenderingContext2D, pen: MapPen): void {
2025-05-06 23:48:21 +08:00
const theme = sTheme.editor;
const { active, fontSize = 14, lineHeight = 1.5, fontFamily } = pen.calculative ?? {};
const { x = 0, y = 0, width: w = 0, height: h = 0 } = pen.calculative?.worldRect ?? {};
2025-04-27 00:05:18 +08:00
const { type } = pen.area ?? {};
const { label = '', desc = '' } = pen ?? {};
2025-05-06 23:48:21 +08:00
2025-04-27 00:05:18 +08:00
ctx.save();
2025-05-06 23:48:21 +08:00
ctx.rect(x, y, w, h);
// 优先使用通用颜色,如果没有则使用类型专用颜色
const generalFillColor = colorConfig.getColor(`area.fill.${type}`);
const typeFillColor = colorConfig.getColor(`area.types.${type}.fill`);
const themeFillColor = get(theme, `area.fill-${type}`);
const finalFillColor = generalFillColor || typeFillColor || themeFillColor || '';
ctx.fillStyle = finalFillColor;
2025-05-06 23:48:21 +08:00
ctx.fill();
// 获取边框颜色 - 优先使用新的边框颜色配置
const borderColor = type ? colorConfig.getAreaBorderColor(type) : '';
const generalStrokeColor = colorConfig.getColor(active ? 'area.strokeActive' : `area.stroke.${type}`);
const typeStrokeColor = colorConfig.getColor(`area.types.${type}.stroke`);
const themeStrokeColor = get(theme, active ? 'area.strokeActive' : `area.stroke-${type}`);
// 获取边框宽度和透明度
const borderWidth = type ? colorConfig.getAreaBorderWidth(type) : 1;
const borderOpacity = type ? colorConfig.getAreaBorderOpacity(type) : 0.15;
// 设置边框宽度和样式
ctx.lineWidth = borderWidth;
ctx.setLineDash([]); // 固定为实线
// 优先使用边框颜色,然后是通用颜色,最后是主题颜色
let finalStrokeColor = borderColor || generalStrokeColor || typeStrokeColor || themeStrokeColor || '';
// 应用透明度
if (borderOpacity < 1 && finalStrokeColor.startsWith('#')) {
const alpha = Math.round(borderOpacity * 255).toString(16).padStart(2, '0');
finalStrokeColor = finalStrokeColor + alpha;
}
ctx.strokeStyle = finalStrokeColor;
2025-05-06 23:48:21 +08:00
ctx.stroke();
// 如果是描述区且有描述内容,渲染描述文字
if (type === MapAreaType. && desc) {
ctx.fillStyle = colorConfig.getColor('common.color') || (get(theme, 'color') ?? '');
// 动态计算字体大小,让文字填充区域
let descFontSize = Math.min(w / 6, h / 4, 200);
let lines: string[] = [];
while (descFontSize > 1) {
ctx.font = `${descFontSize}px ${fontFamily}`;
const maxCharsPerLine = Math.floor(w / (descFontSize * 0.8));
if (maxCharsPerLine < 1) {
descFontSize = Math.floor(descFontSize * 0.9);
continue;
}
// 文字换行
lines = [];
for (let i = 0; i < desc.length; i += maxCharsPerLine) {
lines.push(desc.slice(i, i + maxCharsPerLine));
}
// 计算文本高度
const textMetrics = ctx.measureText('测试文字');
const lineHeight = textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent;
const totalHeight = lines.length * lineHeight * 1.1;
if (totalHeight <= h * 0.9) break;
descFontSize = Math.floor(descFontSize * 0.9);
}
// 渲染文字
ctx.font = `${descFontSize}px ${fontFamily}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
const textMetrics = ctx.measureText('测试文字');
const lineHeight = textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent;
const totalHeight = lines.length * lineHeight * 1.1;
const startY = y + h / 2 - totalHeight / 2;
lines.forEach((line, index) => {
ctx.fillText(line, x + w / 2, startY + index * lineHeight * 1.1);
});
} else if (type !== MapAreaType. && label) {
// 非描述区才显示标签
ctx.fillStyle = colorConfig.getColor('common.color') || (get(theme, 'color') ?? '');
ctx.font = `${fontSize}px/${lineHeight} ${fontFamily}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(label, x + w / 2, y - fontSize * lineHeight);
}
2025-04-27 00:05:18 +08:00
ctx.restore();
2025-04-20 00:49:14 +08:00
}
2025-05-25 00:07:22 +08:00
/**
*
* @param isWaring
* @param isFault
* @returns : 'fault' | 'warning' | 'normal'
*
*
* - isWaring=true, isFault=true
* - isWaring=false, isFault=true
* - isWaring=true, isFault=false
* - isWaring=false, isFault=false
*/
function getRobotStatus(isWaring?: boolean, isFault?: boolean): 'fault' | 'warning' | 'normal' {
// 只要 isFault 为 true无论 isWaring 是什么,都是故障状态
if (isFault) return 'fault';
// 如果 isFault 为 false 但 isWaring 为 true则是告警状态
if (isWaring) return 'warning';
// 两者都为 false 时,为正常状态
return 'normal';
}
/**
*
* @param ctx Canvas 2D绘制上下文
* @param pen
*/
2025-05-25 00:07:22 +08:00
function drawRobot(ctx: CanvasRenderingContext2D, pen: MapPen): void {
const theme = sTheme.editor;
const { lineWidth: s = 1 } = pen.calculative ?? {};
const { x = 0, y = 0, width: w = 0, height: h = 0, rotate: deg = 0 } = pen.calculative?.worldRect ?? {};
const { active, path, isWaring, isFault } = pen.robot ?? {};
2025-05-25 00:07:22 +08:00
2025-05-25 16:45:45 +08:00
if (!active) return;
// 根据机器人状态获取颜色
const status = getRobotStatus(isWaring, isFault);
// 基于机器人图片大小计算光圈半径光圈比图片大20%,并随画布缩放
const imageWidth = colorConfig.getColor('robot.imageWidth')
? Number(colorConfig.getColor('robot.imageWidth')) : 60;
const imageHeight = colorConfig.getColor('robot.imageHeight')
? Number(colorConfig.getColor('robot.imageHeight')) : 80;
// 取图片的较大边作为基准,计算圆形光圈的半径
const imageSize = Math.max(imageWidth, imageHeight);
const robotRadius = (imageSize * 1.2 * s) / 2; // 光圈半径比图片大20%,并随画布缩放
2025-05-25 16:45:45 +08:00
const ox = x + w / 2;
const oy = y + h / 2;
2025-05-25 00:07:22 +08:00
ctx.save();
ctx.beginPath();
ctx.arc(ox, oy, robotRadius, 0, Math.PI * 2);
ctx.fillStyle = colorConfig.getColor(`robot.fill${status === 'normal' ? 'Normal' : status === 'warning' ? 'Warning' : 'Fault'}`) || (get(theme, `robot.fill-${status}`) ?? (get(theme, 'robot.fill') ?? ''));
2025-05-25 16:45:45 +08:00
ctx.fill();
ctx.strokeStyle = colorConfig.getColor(`robot.stroke${status === 'normal' ? 'Normal' : status === 'warning' ? 'Warning' : 'Fault'}`) || (get(theme, `robot.stroke-${status}`) ?? (get(theme, 'robot.stroke') ?? ''));
2025-05-25 16:45:45 +08:00
ctx.stroke();
if (path?.length) {
ctx.strokeStyle = colorConfig.getColor('robot.line') || (get(theme, 'robot.line') ?? '');
2025-05-25 16:45:45 +08:00
ctx.lineCap = 'round';
ctx.lineWidth = s * 4;
ctx.setLineDash([s * 5, s * 10]);
ctx.translate(ox, oy);
ctx.rotate((-deg * Math.PI) / 180);
ctx.beginPath();
ctx.moveTo(0, 0);
path.forEach((d) => ctx.lineTo(d.x * s, d.y * s));
2025-05-25 00:07:22 +08:00
ctx.stroke();
const { x: ex1 = 0, y: ey1 = 0 } = nth(path, -1) ?? {};
const { x: ex2 = 0, y: ey2 = 0 } = nth(path, -2) ?? {};
2025-05-25 16:45:45 +08:00
const r = Math.atan2(ey1 - ey2, ex1 - ex2) + Math.PI;
ctx.setLineDash([0]);
ctx.translate(ex1 * s, ey1 * s);
ctx.beginPath();
ctx.moveTo(Math.cos(r + Math.PI / 4) * s * 10, Math.sin(r + Math.PI / 4) * s * 10);
ctx.lineTo(0, 0);
ctx.lineTo(Math.cos(r - Math.PI / 4) * s * 10, Math.sin(r - Math.PI / 4) * s * 10);
ctx.stroke();
ctx.setTransform(1, 0, 0, 1, 0, 0);
2025-05-25 00:07:22 +08:00
}
ctx.restore();
}
2025-04-20 00:49:14 +08:00
//#endregion
//#region 辅助函数
function getBezier2Center(p1: Point, c1: Point, p2: Point): Point & { t: number } {
const fn = (t: number) => {
const x = (1 - t) ** 2 * p1.x + 2 * (1 - t) * t * c1.x + t ** 2 * p2.x;
const y = (1 - t) ** 2 * p1.y + 2 * (1 - t) * t * c1.y + t ** 2 * p2.y;
return { x, y };
};
return calcBezierCenter(fn);
}
function getBezier2Tange(p1: Point, c1: Point, p2: Point, t: number): number {
const dx = 2 * (1 - t) * (c1.x - p1.x) + 2 * t * (p2.x - c1.x);
const dy = 2 * (1 - t) * (c1.y - p1.y) + 2 * t * (p2.y - c1.y);
return Math.atan2(dy, dx);
}
function getBezier3Center(p1: Point, c1: Point, c2: Point, p2: Point): Point & { t: number } {
const fn = (t: number) => {
const x = (1 - t) ** 3 * p1.x + 3 * (1 - t) ** 2 * t * c1.x + 3 * (1 - t) * t ** 2 * c2.x + t ** 3 * p2.x;
const y = (1 - t) ** 3 * p1.y + 3 * (1 - t) ** 2 * t * c1.y + 3 * (1 - t) * t ** 2 * c2.y + t ** 3 * p2.y;
return { x, y };
};
return calcBezierCenter(fn);
}
function getBezier3Tange(p1: Point, c1: Point, c2: Point, p2: Point, t: number): number {
const t1 = 3 * Math.pow(1 - t, 2);
const t2 = 6 * (1 - t) * t;
const t3 = 3 * Math.pow(t, 2);
const dx = t1 * (c1.x - p1.x) + t2 * (c2.x - c1.x) + t3 * (p2.x - c2.x);
const dy = t1 * (c1.y - p1.y) + t2 * (c2.y - c1.y) + t3 * (p2.y - c2.y);
return Math.atan2(dy, dx);
}
function calcBezierCenter(bezierFn: (t: number) => Point): Point & { t: number } {
const count = 23;
let length = 0;
let temp = bezierFn(0);
const samples = Array.from({ length: count }, (_, i) => {
const t = (i + 1) / count;
const point = bezierFn(t);
const dx = point.x - temp.x;
const dy = point.y - temp.y;
length += Math.sqrt(dx * dx + dy * dy);
temp = point;
return { ...point, t };
});
2025-06-29 14:15:55 +08:00
const target = length * 0.45;
let accumulated = 0;
for (let i = 0; i < samples.length - 1; i++) {
const p1 = samples[i];
const p2 = samples[i + 1];
const segment = Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2);
if (accumulated + segment >= target) {
const ratio = (target - accumulated) / segment;
return {
x: p1.x + (p2.x - p1.x) * ratio,
y: p1.y + (p2.y - p1.y) * ratio,
t: p1.t + ratio * (p2.t - p1.t),
};
}
accumulated += segment;
}
return samples[samples.length - 1];
}
//#endregion