refactor: 重构 EditorService,整合机器人相关逻辑至 EditorRobotService,简化代码结构并提升可维护性

This commit is contained in:
xudan 2025-10-14 15:13:43 +08:00
parent de5c0e6bac
commit ca69dbccf8
2 changed files with 594 additions and 569 deletions

View File

@ -0,0 +1,524 @@
import type { MapPen, Rect } from '@api/map';
import type { RobotGroup, RobotInfo, RobotLabel, RobotType } from '@api/robot';
import type { SceneData } from '@api/scene';
import sTheme from '@core/theme.service';
import { CanvasLayer, LockState, type Meta2dStore, s8 } from '@meta2d/core';
import { useObservable } from '@vueuse/rxjs';
import { clone, get, isNil, nth, remove, some } from 'lodash-es';
import { BehaviorSubject, debounceTime } from 'rxjs';
import { reactive } from 'vue';
import cargoIcon from '../assets/icons/png/cargo.png';
import chargingIcon from '../assets/icons/png/charging.png';
import notAcceptingOrdersIcon from '../assets/icons/png/notAcceptingOrders.png';
import colorConfig from './color/color-config.service';
interface RobotStatusPosition {
x: number;
y: number;
rotate: number;
}
export interface EditorRobotContext {
store: Meta2dStore;
data(): any;
setValue(pen: Partial<MapPen> & { id: string }, options: { render: boolean; history: boolean; doEvent: boolean }): void;
updatePen(id: string, pen: Partial<MapPen>, record?: boolean, render?: boolean): void;
addPen(pen: MapPen, history?: boolean, storeData?: boolean, render?: boolean): Promise<MapPen>;
top(pens: MapPen[]): void;
render(): void;
getPenById(id?: string): MapPen | undefined;
getPenRect(pen: MapPen): Rect;
find(target: string): MapPen[];
inactive(): void;
ensureCorrectLayerOrder(): void;
}
export class EditorRobotService {
private readonly robotMap = reactive<Map<RobotInfo['id'], RobotInfo>>(new Map());
private readonly robotGroups$$ = new BehaviorSubject<RobotGroup[]>([]);
private readonly robotLabels$$ = new BehaviorSubject<RobotLabel[]>([]);
public readonly robotGroups = useObservable<RobotGroup[]>(this.robotGroups$$.pipe(debounceTime(300)));
public readonly robotLabels = useObservable<RobotLabel[]>(this.robotLabels$$.pipe(debounceTime(300)));
constructor(private readonly ctx: EditorRobotContext) {}
//#region 初始化与基础数据
public loadInitialData(groups?: RobotGroup[], robots?: RobotInfo[], labels?: RobotLabel[]): void {
this.robotMap.clear();
robots?.forEach((v) => this.robotMap.set(v.id, v));
this.robotGroups$$.next(groups ?? []);
this.robotLabels$$.next(labels ?? []);
this.syncRobots();
this.syncRobotGroups();
this.syncRobotLabels();
}
public get robots(): RobotInfo[] {
return Array.from(this.robotMap.values());
}
public hasRobot(id: RobotInfo['id']): boolean {
return this.robotMap.has(id);
}
public getRobotById(id: RobotInfo['id']): RobotInfo | undefined {
return this.robotMap.get(id);
}
public updateRobot(id: RobotInfo['id'], value: Partial<RobotInfo>): void {
const robot = this.getRobotById(id);
if (isNil(robot)) return;
this.robotMap.set(id, { ...robot, ...value });
if (value.label) {
this.ctx.setValue({ id, text: value.label }, { render: false, history: false, doEvent: false });
}
this.syncRobots();
}
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((robot) => {
if (this.robotMap.has(robot.id)) return;
this.robotMap.set(robot.id, { ...robot, gid });
group.robots?.push(robot.id);
});
this.robotGroups$$.next(groups);
this.syncRobots();
this.syncRobotGroups();
}
public removeRobots(ids: RobotInfo['id'][]): void {
ids?.forEach((id) => this.robotMap.delete(id));
const groups = clone(this.robotGroups$$.value);
groups.forEach(({ robots }) => remove(robots ?? [], (id) => !this.robotMap.has(id)));
this.robotGroups$$.next(groups);
const labels = clone(this.robotLabels$$.value);
labels.forEach(({ robots }) => remove(robots ?? [], (id) => !this.robotMap.has(id)));
this.robotLabels$$.next(labels);
this.syncRobots();
this.syncRobotGroups();
this.syncRobotLabels();
}
public updateRobots(ids: RobotInfo['id'][], value: Partial<RobotInfo>): void {
ids?.forEach((id) => {
const robot = this.robotMap.get(id);
if (isNil(robot)) return;
this.robotMap.set(id, { ...robot, ...value });
});
this.syncRobots();
}
public createRobotGroup(): void {
const id = s8();
const label = `RG${id}`;
const groups = clone(this.robotGroups$$.value);
groups.push({ id, label });
this.robotGroups$$.next(groups);
this.syncRobotGroups();
}
public deleteRobotGroup(id: RobotGroup['id']): void {
const groups = clone(this.robotGroups$$.value);
const group = groups.find((v) => v.id === id);
group?.robots?.forEach((rid) => this.robotMap.delete(rid));
remove(groups, group);
this.robotGroups$$.next(groups);
this.syncRobots();
this.syncRobotGroups();
}
public updateRobotGroupLabel(id: RobotGroup['id'], label: RobotGroup['label']): void {
const groups = this.robotGroups$$.value;
const group = groups.find((v) => v.id === id);
if (isNil(group)) throw Error('未找到目标机器人组');
if (some(groups, ['label', label])) throw Error('机器人组名称已存在');
group.label = label;
this.robotGroups$$.next([...groups]);
this.syncRobotGroups();
}
public createRobotLabel(): void {
const id = s8();
const label = `RL${id}`;
const labels = clone(this.robotLabels$$.value);
labels.push({ id, label });
this.robotLabels$$.next(labels);
this.syncRobotLabels();
}
public deleteRobotLabel(id: RobotLabel['id']): void {
const labels = clone(this.robotLabels$$.value);
const labelToDelete = labels.find((v) => v.id === id);
if (!labelToDelete) return;
const robotsInLabel = labelToDelete.robots;
if (robotsInLabel) {
robotsInLabel.forEach((robotId) => {
const robot = this.getRobotById(robotId);
if (robot?.lid) {
const newLid = robot.lid.filter((lid) => lid !== id);
this.updateRobot(robot.id, { lid: newLid });
}
});
}
remove(labels, (l) => l.id === id);
this.robotLabels$$.next(labels);
this.syncRobotLabels();
}
public updateRobotLabel(id: RobotLabel['id'], labelName: RobotLabel['label']): void {
const labels = this.robotLabels$$.value;
const label = labels.find((v) => v.id === id);
if (isNil(label)) throw Error('未找到目标标签');
if (some(labels, ['label', labelName])) throw Error('机器人标签名称已存在');
label.label = labelName;
this.robotLabels$$.next([...labels]);
this.syncRobotLabels();
}
public addRobotsToLabel(lid: RobotLabel['id'], robots: RobotInfo[]): void {
const labels = clone(this.robotLabels$$.value);
const label = labels.find((v) => v.id === lid);
if (isNil(label)) throw Error('未找到目标标签');
label.robots ??= [];
robots.forEach((robot) => {
if (this.robotMap.has(robot.id)) {
const existingRobot = this.robotMap.get(robot.id)!;
existingRobot.lid ??= [];
if (!existingRobot.lid.includes(lid)) {
existingRobot.lid.push(lid);
}
if (!label.robots!.includes(robot.id)) {
label.robots!.push(robot.id);
}
} else {
const newRobot = { ...robot, lid: [lid] };
this.robotMap.set(robot.id, newRobot);
label.robots?.push(robot.id);
}
});
this.robotLabels$$.next(labels);
this.syncRobots();
this.syncRobotLabels();
}
public removeRobotFromLabel(labelId: string, robotId: string): void {
const labels = clone(this.robotLabels$$.value);
const label = labels.find((v) => v.id === labelId);
if (label?.robots) {
remove(label.robots, (id) => id === robotId);
}
this.robotLabels$$.next(labels);
const robot = this.getRobotById(robotId);
if (robot?.lid) {
const newLid = robot.lid.filter((id) => id !== labelId);
this.updateRobot(robotId, { lid: newLid });
}
this.syncRobotLabels();
this.syncRobots();
}
public removeRobotsFromAllLabels(robotIds: string[]): void {
robotIds.forEach((robotId) => {
const robot = this.getRobotById(robotId);
if (robot) {
this.updateRobot(robotId, { lid: [] });
}
});
const labels = clone(this.robotLabels$$.value);
labels.forEach((label) => {
if (label.robots) {
remove(label.robots, (id) => robotIds.includes(id));
}
});
this.robotLabels$$.next(labels);
this.syncRobots();
this.syncRobotLabels();
}
//#endregion
//#region 画布绘制
public async initRobots(): Promise<void> {
await Promise.all(
this.robots.map(async ({ id, label, type }) => {
const pen: MapPen = {
...this.mapRobotImage(type, true, label),
id,
name: 'robot',
tags: ['robot'],
x: 0,
y: 0,
width: 120,
height: 120,
lineWidth: 1,
robot: { type },
visible: false,
text: label,
textTop: -18,
whiteSpace: 'nowrap',
ellipsis: false,
locked: LockState.Disable,
};
await this.ctx.addPen(pen, false, true, true);
this.updateRobotStatusOverlay(id, false);
}),
);
this.ctx.ensureCorrectLayerOrder();
}
public updateAllRobotImageSizes(): void {
this.robots.forEach(({ id, type, label }) => {
const pen = this.ctx.getPenById(id);
if (pen && pen.robot) {
const imageConfig = this.mapRobotImage(type, pen.robot.active, label);
this.ctx.setValue(
{
id,
iconWidth: imageConfig.iconWidth,
iconHeight: imageConfig.iconHeight,
iconTop: imageConfig.iconTop,
},
{ render: true, history: false, doEvent: false },
);
this.updateRobotStatusOverlay(id, true);
}
});
}
public updateRobotImage(robotName: string): void {
const robot = this.robots.find((r) => r.label === robotName);
if (!robot) return;
const pen = this.ctx.getPenById(robot.id);
if (pen && pen.robot) {
const imageConfig = this.mapRobotImage(robot.type, pen.robot.active, robotName);
this.ctx.setValue(
{
id: robot.id,
image: imageConfig.image,
iconWidth: imageConfig.iconWidth,
iconHeight: imageConfig.iconHeight,
iconTop: imageConfig.iconTop,
},
{ render: true, history: false, doEvent: false },
);
}
}
public updateRobotStatusOverlay(id: string, render = false, newPosition?: RobotStatusPosition): void {
const pen = this.ctx.getPenById(id);
if (!pen) return;
const icon = this.getRobotStatusIcon(pen);
const robotVisible = pen.visible !== false;
const baseW = (pen as any).iconWidth ?? 42;
const baseH = (pen as any).iconHeight ?? 76;
const oW = Math.max(8, Math.floor(baseW * 0.5));
const oH = Math.max(8, Math.floor(baseH * 0.5));
const rect = this.ctx.getPenRect(pen);
const deg: number = newPosition?.rotate ?? pen.rotate ?? 0;
const theta = (deg * Math.PI) / 180;
const cx = (newPosition?.x ?? rect.x) + rect.width / 2;
const cy = (newPosition?.y ?? rect.y) + rect.height / 2;
const iconTop = (pen as any).iconTop ?? 0;
const localDx = 0;
const localDy = iconTop + 16;
const rotDx = localDx * Math.cos(theta) - localDy * Math.sin(theta);
const rotDy = localDx * Math.sin(theta) + localDy * Math.cos(theta);
const icx = cx + rotDx;
const icy = cy + rotDy;
const ox = icx - oW / 2;
const oy = icy - oH / 2;
const overlayId = `robot-status-${id}`;
const exist = this.ctx.getPenById(overlayId);
if (!robotVisible) {
if (exist) {
this.ctx.setValue({ id: overlayId, visible: false }, { render, history: false, doEvent: false });
}
return;
}
if (!icon) {
return;
}
if (exist) {
this.ctx.setValue(
{
id: overlayId,
image: icon,
x: ox,
y: oy,
width: oW,
height: oH,
rotate: deg,
visible: true,
locked: LockState.Disable,
},
{ render: false, history: false, doEvent: false },
);
this.ctx.top([exist]);
if (render) this.ctx.render();
return;
}
const overlayPen: MapPen = {
id: overlayId,
name: 'image',
tags: ['robot-status'],
x: ox,
y: oy,
width: oW,
height: oH,
image: icon,
rotate: deg,
canvasLayer: CanvasLayer.CanvasImage,
locked: LockState.Disable,
visible: true,
} as any;
this.ctx.addPen(overlayPen, false, false, true);
this.ctx.top([overlayPen]);
if (render) this.ctx.render();
}
public mapRobotImage(type: RobotType, active?: boolean, robotName?: string): Required<Pick<MapPen, 'image' | 'iconWidth' | 'iconHeight' | 'iconTop' | 'canvasLayer'>> {
const theme = this.ctx.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 };
}
//#endregion
private getRobotStatusIcon(pen?: MapPen): string | null {
if (!pen) return null;
const r1: any = pen.robot ?? {};
const r2 = this.getRobotById(pen.id || '');
const toBool = (v: any) => v === true || v === 1 || v === '1';
const isCarrying = toBool(r1?.isCarrying ?? r2?.isCarrying);
const canOrder = toBool(r1?.canOrder ?? r2?.canOrder);
const isCharging = toBool(r1?.isCharging ?? r2?.isCharging);
if (isCarrying) return cargoIcon;
if (!canOrder) return notAcceptingOrdersIcon;
if (isCharging) return chargingIcon;
return null;
}
private calculatePixelAlignedOffset(baseOffset: number): number {
const ratio = window.devicePixelRatio || 1;
const scaled = baseOffset * ratio;
const snapped = Math.round(scaled);
const logicalOffset = snapped / ratio;
return this.fixPrecision(logicalOffset);
}
private fixPrecision(value: number): number {
const truncated = Math.floor(value * 1000) / 1000;
return parseFloat(truncated.toFixed(3));
}
private syncRobots(): void {
(this.ctx.store.data as SceneData).robots = [...this.robotMap.values()];
}
private syncRobotGroups(): void {
(this.ctx.store.data as SceneData).robotGroups = this.robotGroups$$.value;
}
private syncRobotLabels(): void {
(this.ctx.store.data as SceneData).robotLabels = this.robotLabels$$.value;
}
}
function getRobotStatus(isWaring?: boolean, isFault?: boolean): 'fault' | 'warning' | 'normal' {
if (isFault) return 'fault';
if (isWaring) return 'warning';
return 'normal';
}
export function drawRobot(ctx: CanvasRenderingContext2D, pen: MapPen): void {
const theme = sTheme.editor;
const { lineWidth: s = 1 } = pen.calculative ?? {};
const { x = 0, y = 0, width: w = 0, height: h = 0, rotate: deg = 0 } = pen.calculative?.worldRect ?? {};
const { active, path, isWaring, isFault } = pen.robot ?? {};
if (!active) return;
const status = getRobotStatus(isWaring, isFault);
const 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;
const ox = x + w / 2;
const oy = y + h / 2;
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') ?? '');
ctx.fill();
ctx.strokeStyle =
colorConfig.getColor(`robot.stroke${status === 'normal' ? 'Normal' : status === 'warning' ? 'Warning' : 'Fault'}`) ||
(get(theme, `robot.stroke-${status}`) ?? get(theme, 'robot.stroke') ?? '');
ctx.stroke();
if (path?.length) {
ctx.strokeStyle = colorConfig.getColor('robot.line') || (get(theme, 'robot.line') ?? '');
ctx.lineCap = 'round';
ctx.lineWidth = s * 4;
ctx.setLineDash([s * 5, s * 10]);
ctx.translate(ox, oy);
ctx.rotate((-deg * Math.PI) / 180);
ctx.beginPath();
ctx.moveTo(0, 0);
path.forEach((d) => ctx.lineTo(d.x * s, d.y * s));
ctx.stroke();
const { x: ex1 = 0, y: ey1 = 0 } = nth(path, -1) ?? {};
const { x: ex2 = 0, y: ey2 = 0 } = nth(path, -2) ?? {};
const r = Math.atan2(ey1 - ey2, ex1 - ex2) + Math.PI;
ctx.setLineDash([0]);
ctx.translate(ex1 * s, ey1 * s);
ctx.beginPath();
ctx.moveTo(Math.cos(r + Math.PI / 4) * s * 10, Math.sin(r + Math.PI / 4) * s * 10);
ctx.lineTo(0, 0);
ctx.lineTo(Math.cos(r - Math.PI / 4) * s * 10, Math.sin(r - Math.PI / 4) * s * 10);
ctx.stroke();
ctx.setTransform(1, 0, 0, 1, 0, 0);
}
ctx.restore();
}

View File

@ -11,7 +11,7 @@ import {
type Point,
type Rect,
} from '@api/map';
import type { RobotGroup, RobotInfo, RobotLabel,RobotType } from '@api/robot';
import type { RobotGroup, RobotInfo, RobotLabel } from '@api/robot';
import type {
GroupSceneDetail,
SceneData,
@ -23,14 +23,11 @@ import type {
import sTheme from '@core/theme.service';
import { CanvasLayer, LockState, Meta2d, type Meta2dStore, type Pen, s8 } from '@meta2d/core';
import { useObservable } from '@vueuse/rxjs';
import { clone, get, isEmpty, isNil, isString, nth, pick, remove, some } from 'lodash-es';
import { BehaviorSubject, debounceTime, filter, map, Subject, switchMap } from 'rxjs';
import { reactive, watch } from 'vue';
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 cargoIcon from '../assets/icons/png/cargo.png';
import chargingIcon from '../assets/icons/png/charging.png';
import notAcceptingOrdersIcon from '../assets/icons/png/notAcceptingOrders.png';
import { AreaOperationService } from './area-operation.service';
import { BinTaskManagerService } from './bintask-manager.service';
import colorConfig from './color/color-config.service';
@ -39,6 +36,7 @@ import {
drawStorageLocation,
drawStorageMore,
} from './draw/storage-location-drawer';
import { drawRobot,EditorRobotService } from './editor-robot.service';
import { LayerManagerService } from './layer-manager.service';
import { createStorageLocationUpdater,StorageLocationService } from './storage-location.service';
import { AutoStorageGenerator } from './utils/auto-storage-generator';
@ -59,6 +57,7 @@ export class EditorService extends Meta2d {
private readonly layerManager: LayerManagerService;
/** 库位服务实例 */
private readonly storageLocationService: StorageLocationService;
private readonly robotService: EditorRobotService;
/** 区域操作服务实例 */
private readonly areaOperationService!: AreaOperationService;
@ -94,14 +93,13 @@ export class EditorService extends Meta2d {
this.open();
this.setState(editable);
this.#loadRobots(robotGroups, robots);
this.#loadLabels(robotLabels);
this.robotService.loadInitialData(robotGroups, robots, robotLabels);
await this.#loadScenePoints(points, isImport);
this.#loadSceneRoutes(routes, isImport);
await this.#loadSceneAreas(areas, isImport);
// 确保正确的层级顺序:路线 < 点位 < 机器人
this.#ensureCorrectLayerOrder();
this.ensureCorrectLayerOrder();
// 为所有动作点创建库位pen对象
this.createAllStorageLocationPens();
@ -201,14 +199,8 @@ export class EditorService extends Meta2d {
* @param groups
* @param robots
*/
#loadRobots(groups?: RobotGroup[], robots?: RobotInfo[]): void {
this.#robotMap.clear();
robots?.forEach((v) => this.#robotMap.set(v.id, v));
this.#robotGroups$$.next(groups ?? []);
}
#loadLabels(labels?: RobotLabel[]): void {
this.#robotLabels$$.next(labels ?? []);
}
/**
*
* @param points
@ -410,7 +402,7 @@ export class EditorService extends Meta2d {
properties,
};
if ([MapPointType., MapPointType.].includes(type)) {
point.robots = robots?.filter((v) => this.#robotMap.has(v));
point.robots = robots?.filter((v) => this.robotService.hasRobot(v));
// 若未提供 enabled则默认启用
point.enabled = (enabled ?? 1) as 0 | 1;
}
@ -526,7 +518,7 @@ export class EditorService extends Meta2d {
*
*
*/
#ensureCorrectLayerOrder(): void {
public ensureCorrectLayerOrder(): void {
this.layerManager.ensureCorrectLayerOrder();
}
//#endregion
@ -579,184 +571,94 @@ export class EditorService extends Meta2d {
),
);
//#region 机器人组管理
/** 机器人信息映射表,响应式存储所有机器人数据 */
readonly #robotMap = reactive<Map<RobotInfo['id'], RobotInfo>>(new Map());
/** 获取所有机器人信息数组 */
//#region 机器人服务
public get robots(): RobotInfo[] {
return Array.from(this.#robotMap.values());
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.#robotMap.has(id);
return this.robotService.hasRobot(id);
}
public getRobotById(id: RobotInfo['id']): RobotInfo | undefined {
return this.#robotMap.get(id);
return this.robotService.getRobotById(id);
}
public updateRobot(id: RobotInfo['id'], value: Partial<RobotInfo>): void {
const robot = this.getRobotById(id);
if (isNil(robot)) return;
this.#robotMap.set(id, { ...robot, ...value });
if (value.label) {
this.setValue({ id, text: value.label }, { render: false, history: false, doEvent: false });
}
(<SceneData>this.store.data).robots = [...this.#robotMap.values()];
this.robotService.updateRobot(id, value);
}
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;
this.robotService.addRobots(gid, robots);
}
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);
const labels = clone(this.#robotLabels$$.value);
labels.forEach(({ robots }) => remove(robots ?? [], (v) => !this.#robotMap.has(v)));
this.#robotLabels$$.next(labels);
(<SceneData>this.store.data).robots = [...this.#robotMap.values()];
(<SceneData>this.store.data).robotGroups = this.#robotGroups$$.value;
(<SceneData>this.store.data).robotLabels = this.#robotLabels$$.value;
this.robotService.removeRobots(ids);
}
public updateRobots(ids: RobotInfo['id'][], value: Partial<RobotInfo>): void {
ids?.forEach((v) => {
const robot = this.#robotMap.get(v);
if (isNil(robot)) return;
this.#robotMap.set(v, { ...robot, ...value });
});
(<SceneData>this.store.data).robots = [...this.#robotMap.values()];
this.robotService.updateRobots(ids, value);
}
readonly #robotGroups$$ = new BehaviorSubject<RobotGroup[]>([]);
public readonly robotGroups = useObservable<RobotGroup[]>(this.#robotGroups$$.pipe(debounceTime(300)));
readonly #robotLabels$$ = new BehaviorSubject<RobotLabel[]>([]);
public readonly robotLabels = useObservable<RobotLabel[]>(this.#robotLabels$$.pipe(debounceTime(300)));
public createRobotGroup(): void {
const id = s8();
const label = `RG${id}`;
const groups = clone(this.#robotGroups$$.value);
groups.push({ id, label });
this.#robotGroups$$.next(groups);
(<SceneData>this.store.data).robotGroups = this.#robotGroups$$.value;
this.robotService.createRobotGroup();
}
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;
this.robotService.deleteRobotGroup(id);
}
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;
this.robotService.updateRobotGroupLabel(id, label);
}
public createRobotLabel(): void {
const id = s8();
const label = `RL${id}`;
const labels = clone(this.#robotLabels$$.value);
labels.push({ id, label });
this.#robotLabels$$.next(labels);
(<SceneData>this.store.data).robotLabels = this.#robotLabels$$.value;
this.robotService.createRobotLabel();
}
public deleteRobotLabel(id: RobotLabel['id']): void {
const labels = clone(this.#robotLabels$$.value);
const labelToDelete = labels.find((v) => v.id === id);
if (!labelToDelete) return;
// Remove the label id from all robots
const robotsInLabel = labelToDelete.robots;
if (robotsInLabel) {
robotsInLabel.forEach((robotId) => {
const robot = this.getRobotById(robotId);
if (robot?.lid) {
const newLid = robot.lid.filter((lid) => lid !== id);
this.updateRobot(robot.id, { lid: newLid });
}
});
}
remove(labels, (l) => l.id === id);
this.#robotLabels$$.next(labels);
(<SceneData>this.store.data).robotLabels = this.#robotLabels$$.value;
this.robotService.deleteRobotLabel(id);
}
public updateRobotLabel(id: RobotLabel['id'], labelName: RobotLabel['label']): void {
const labels = this.#robotLabels$$.value;
const label = labels.find((v) => v.id === id);
if (isNil(label)) throw Error('未找到目标机器人标签');
if (some(labels, (l) => l.label === labelName && l.id !== id)) throw Error('机器人标签名称已经存在');
label.label = labelName;
this.#robotLabels$$.next([...labels]);
(<SceneData>this.store.data).robotLabels = this.#robotLabels$$.value;
this.robotService.updateRobotLabel(id, labelName);
}
public addRobotsToLabel(lid: RobotLabel['id'], robots: RobotInfo[]): void {
const labels = clone(this.#robotLabels$$.value);
const label = labels.find((v) => v.id === lid);
if (isNil(label)) throw Error('未找到目标机器人标签');
label.robots ??= [];
robots.forEach((v) => {
const existingRobot = this.#robotMap.get(v.id);
if (existingRobot) {
// If robot is already in scene
if (!existingRobot.lid) {
existingRobot.lid = [];
}
if (!existingRobot.lid.includes(lid)) {
existingRobot.lid.push(lid);
}
if (label.robots && !label.robots.includes(v.id)) {
label.robots.push(v.id);
}
} else {
// If robot is new to the scene
const newRobot = { ...v, lid: [lid] };
this.#robotMap.set(v.id, newRobot);
if (label.robots) {
label.robots.push(v.id);
}
}
});
this.#robotLabels$$.next(labels);
(<SceneData>this.store.data).robots = [...this.#robotMap.values()];
(<SceneData>this.store.data).robotLabels = this.#robotLabels$$.value;
this.robotService.addRobotsToLabel(lid, robots);
}
public removeRobotFromLabel(labelId: string, robotId: string): void {
const labels = clone(this.#robotLabels$$.value);
const label = labels.find((v) => v.id === labelId);
if (label?.robots) {
remove(label.robots, (id) => id === robotId);
}
this.#robotLabels$$.next(labels);
this.robotService.removeRobotFromLabel(labelId, robotId);
}
const robot = this.getRobotById(robotId);
if (robot?.lid) {
const newLid = robot.lid.filter((id) => id !== labelId);
this.updateRobot(robotId, { lid: newLid });
}
public removeRobotsFromAllLabels(robotIds: string[]): void {
this.robotService.removeRobotsFromAllLabels(robotIds);
}
(<SceneData>this.store.data).robotLabels = this.#robotLabels$$.value;
(<SceneData>this.store.data).robots = [...this.#robotMap.values()];
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
@ -837,44 +739,7 @@ export class EditorService extends Meta2d {
* -
*
*/
#calculatePixelAlignedOffset(baseOffset: number): number {
const scale = this.store.data.scale || 1;
const devicePixelRatio = window.devicePixelRatio || 1;
// 计算实际像素偏移量
const scaledOffset = baseOffset * scale;
// 多重对齐策略:
// 1. 设备像素对齐 - 确保在高DPI屏幕上也能对齐
const deviceAlignedOffset = Math.round(scaledOffset * devicePixelRatio) / devicePixelRatio;
// 2. 子像素对齐 - 对于常见的缩放比例使用特殊处理
let finalOffset = deviceAlignedOffset;
// 针对常见问题缩放比例的特殊处理
const roundedScale = Math.round(scale * 100) / 100; // 避免浮点数精度问题
if (roundedScale <= 0.2) {
// 极小缩放使用更粗粒度的对齐0.5像素边界)
finalOffset = Math.round(scaledOffset * 2) / 2;
} else if (roundedScale <= 0.5) {
// 小缩放使用0.25像素边界对齐
finalOffset = Math.round(scaledOffset * 4) / 4;
} else if (roundedScale >= 2) {
// 大缩放:使用精确的像素边界对齐
finalOffset = Math.round(scaledOffset);
} else {
// 标准缩放:使用设备像素对齐结果,但增加额外的精度控制
const precisionFactor = 8; // 1/8像素精度
finalOffset = Math.round(scaledOffset * precisionFactor) / precisionFactor;
}
// 3. 转换回逻辑坐标系并应用精度控制
const logicalOffset = finalOffset / scale;
// 4. 使用现有的精度控制方法确保数值稳定性
return this.#fixPrecision(logicalOffset);
}
/** 画布变化事件流,用于触发响应式数据更新 */
readonly #change$$ = new Subject<boolean>();
@ -1022,7 +887,6 @@ export class EditorService extends Meta2d {
return createStorageLocationUpdater(this.storageLocationService);
}
/**
* pen对象
*
@ -1080,8 +944,6 @@ export class EditorService extends Meta2d {
/**
* ID更新自动门点状态
* @param deviceId ID
@ -1174,281 +1036,6 @@ export class EditorService extends Meta2d {
}
}
//#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)
id,
name: 'robot',
tags: ['robot'],
x: 0,
y: 0,
width: 120,
height: 120,
lineWidth: 1,
robot: { type },
visible: false,
text: label,
textTop: -18, // 调整标签位置,更靠近机器人本体,减少被覆盖的可能性
whiteSpace: 'nowrap',
ellipsis: false,
locked: LockState.Disable,
};
await this.addPen(pen, false, true, true);
// 初始化时创建/同步一次状态覆盖图标(默认隐藏或根据当前状态显示)
this.updateRobotStatusOverlay(id, false);
}),
);
// 机器人初始化完成后,确保层级正确(机器人应在最顶层)
this.#ensureCorrectLayerOrder();
}
// 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 },
// );
// }
// }
/**
*
*
*/
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 },
);
// 同步状态图标尺寸/位置
this.updateRobotStatusOverlay(id, true);
}
});
}
/**
*
*
* @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 },
);
}
}
#mapRobotImage(
type: RobotType,
active?: boolean,
robotName?: string,
): Required<Pick<MapPen, 'image' | 'iconWidth' | 'iconHeight' | 'iconTop' | 'canvasLayer'>> {
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 };
}
//#endregion
/**
* > >
*/
#getRobotStatusIcon(pen?: MapPen): string | null {
if (!pen) return null;
// 兼容:状态可能存放在 pen.robot 或 机器人信息表中
const r1: any = pen.robot ?? {};
const r2 = this.getRobotById(pen.id || '');
const toBool = (v: any) => v === true || v === 1 || v === '1';
const isCarrying = toBool(r1?.isCarrying ?? r2?.isCarrying);
const canOrder = toBool(r1?.canOrder ?? r2?.canOrder);
const isCharging = toBool(r1?.isCharging ?? r2?.isCharging);
if (isCarrying) return cargoIcon;
if (!canOrder) return notAcceptingOrdersIcon;
if (isCharging) return chargingIcon;
return null;
}
/**
* //
* @param id ID
* @param render
* @param newPosition
*/
public updateRobotStatusOverlay(
id: string,
render = false,
newPosition?: { x: number; y: number; rotate: number },
): void {
const pen = this.getPenById(id);
if (!pen) return;
const icon = this.#getRobotStatusIcon(pen);
const robotVisible = pen.visible !== false;
// 计算覆盖图标的尺寸(为机器人图片的一半)
const baseW = (pen as any).iconWidth ?? 42;
const baseH = (pen as any).iconHeight ?? 76;
const oW = Math.max(8, Math.floor(baseW * 0.5));
const oH = Math.max(8, Math.floor(baseH * 0.5));
// 以机器人中心为基准,做一个轻微向下的偏移,并随机器人一起旋转
const rect = this.getPenRect(pen);
const deg: number = newPosition?.rotate ?? pen.rotate ?? 0;
const theta = (deg * Math.PI) / 180;
// 如果有新位置,使用新位置的 x,y + 容器宽高的一半;否则用旧方法
const cx = (newPosition?.x ?? rect.x) + rect.width / 2;
const cy = (newPosition?.y ?? rect.y) + rect.height / 2;
const iconTop = (pen as any).iconTop ?? 0;
// 在本地坐标中的偏移以机器人中心为原点y向下为正
const localDx = 0;
const localDy = iconTop + 16;
// 旋转后的偏移
const rotDx = localDx * Math.cos(theta) - localDy * Math.sin(theta);
const rotDy = localDx * Math.sin(theta) + localDy * Math.cos(theta);
// 覆盖图标中心点
const icx = cx + rotDx;
const icy = cy + rotDy;
const ox = icx - oW / 2;
const oy = icy - oH / 2;
const overlayId = `robot-status-${id}`;
const exist = this.getPenById(overlayId);
// 机器人不可见:隐藏覆盖图标
if (!robotVisible) {
if (exist) {
this.setValue({ id: overlayId, visible: false }, { render, history: false, doEvent: false });
}
return;
}
// 没有状态图标时:如果之前有覆盖图标,则保持现状,不主动隐藏,避免无新数据推送时图标消失
if (!icon) {
return;
}
// 如果已存在覆盖图标,更新其属性;否则创建新的 image 图元
if (exist) {
this.setValue(
{
id: overlayId,
image: icon,
x: ox,
y: oy,
width: oW,
height: oH,
rotate: deg,
visible: true,
locked: LockState.Disable,
},
{ render: false, history: false, doEvent: false },
);
// 置顶,保证覆盖在机器人之上
this.top([exist]);
if (render) this.render();
return;
}
const overlayPen: MapPen = {
id: overlayId,
name: 'image',
tags: ['robot-status'],
x: ox,
y: oy,
width: oW,
height: oH,
image: icon,
rotate: deg,
canvasLayer: CanvasLayer.CanvasImage,
locked: LockState.Disable,
visible: true,
} as any;
this.addPen(overlayPen, false, false, true);
this.top([overlayPen]);
if (render) this.render();
}
//#region 点位
/** 画布上所有点位对象列表,响应式更新 */
public readonly points = useObservable<MapPen[], MapPen[]>(
@ -1863,6 +1450,7 @@ export class EditorService extends Meta2d {
// 初始化库位服务
this.storageLocationService = new StorageLocationService(this, '');
this.robotService = new EditorRobotService(this);
// 初始化自动生成库位工具
this.autoStorageGenerator = new AutoStorageGenerator(this);
@ -1920,7 +1508,7 @@ export class EditorService extends Meta2d {
if (!pen.robot?.type) return;
// 从pen的text属性获取机器人名称
const robotName = pen.text || '';
this.canvas.updateValue(pen, this.#mapRobotImage(pen.robot.type, pen.robot.active, robotName));
this.canvas.updateValue(pen, this.robotService.mapRobotImage(pen.robot.type, pen.robot.active, robotName));
});
this.render();
}
@ -2153,25 +1741,7 @@ export class EditorService extends Meta2d {
this.addDrawLineFn('bezier3', lineBezier3);
}
public removeRobotsFromAllLabels(robotIds: string[]): void {
robotIds.forEach((robotId) => {
const robot = this.getRobotById(robotId);
if (robot) {
this.updateRobot(robotId, { lid: [] });
}
});
const labels = clone(this.#robotLabels$$.value);
labels.forEach((label) => {
if (label.robots) {
remove(label.robots, (id) => robotIds.includes(id));
}
});
this.#robotLabels$$.next(labels);
(<SceneData>this.store.data).robots = [...this.#robotMap.values()];
(<SceneData>this.store.data).robotLabels = this.#robotLabels$$.value;
}
}
//#region 自定义绘制函数
@ -2577,75 +2147,6 @@ function drawArea(ctx: CanvasRenderingContext2D, pen: MapPen): void {
* - isWaring=true, isFault=false
* - isWaring=false, isFault=false
*/
function getRobotStatus(isWaring?: boolean, isFault?: boolean): 'fault' | 'warning' | 'normal' {
// 只要 isFault 为 true无论 isWaring 是什么,都是故障状态
if (isFault) return 'fault';
// 如果 isFault 为 false 但 isWaring 为 true则是告警状态
if (isWaring) return 'warning';
// 两者都为 false 时,为正常状态
return 'normal';
}
/**
*
* @param ctx Canvas 2D绘制上下文
* @param pen
*/
function drawRobot(ctx: CanvasRenderingContext2D, pen: MapPen): void {
const theme = sTheme.editor;
const { lineWidth: s = 1 } = pen.calculative ?? {};
const { x = 0, y = 0, width: w = 0, height: h = 0, rotate: deg = 0 } = pen.calculative?.worldRect ?? {};
const { active, path, isWaring, isFault } = pen.robot ?? {};
if (!active) return;
// 根据机器人状态获取颜色
const status = getRobotStatus(isWaring, isFault);
// 基于机器人图片大小计算光圈半径光圈比图片大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%,并随画布缩放
const ox = x + w / 2;
const oy = y + h / 2;
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') ?? ''));
ctx.fill();
ctx.strokeStyle = colorConfig.getColor(`robot.stroke${status === 'normal' ? 'Normal' : status === 'warning' ? 'Warning' : 'Fault'}`) || (get(theme, `robot.stroke-${status}`) ?? (get(theme, 'robot.stroke') ?? ''));
ctx.stroke();
if (path?.length) {
ctx.strokeStyle = colorConfig.getColor('robot.line') || (get(theme, 'robot.line') ?? '');
ctx.lineCap = 'round';
ctx.lineWidth = s * 4;
ctx.setLineDash([s * 5, s * 10]);
ctx.translate(ox, oy);
ctx.rotate((-deg * Math.PI) / 180);
ctx.beginPath();
ctx.moveTo(0, 0);
path.forEach((d) => ctx.lineTo(d.x * s, d.y * s));
ctx.stroke();
const { x: ex1 = 0, y: ey1 = 0 } = nth(path, -1) ?? {};
const { x: ex2 = 0, y: ey2 = 0 } = nth(path, -2) ?? {};
const r = Math.atan2(ey1 - ey2, ex1 - ex2) + Math.PI;
ctx.setLineDash([0]);
ctx.translate(ex1 * s, ey1 * s);
ctx.beginPath();
ctx.moveTo(Math.cos(r + Math.PI / 4) * s * 10, Math.sin(r + Math.PI / 4) * s * 10);
ctx.lineTo(0, 0);
ctx.lineTo(Math.cos(r - Math.PI / 4) * s * 10, Math.sin(r - Math.PI / 4) * s * 10);
ctx.stroke();
ctx.setTransform(1, 0, 0, 1, 0, 0);
}
ctx.restore();
}
//#endregion
//#region 辅助函数