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

574 lines
19 KiB
TypeScript

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 mergeRobotsData(groups?: RobotGroup[], robots?: RobotInfo[], labels?: RobotLabel[]): void {
const existingGroups = clone(this.robotGroups$$.value);
const existingLabels = clone(this.robotLabels$$.value);
// Merge robot groups
groups?.forEach(newGroup => {
const existingGroup = existingGroups.find(g => g.id === newGroup.id || g.label === newGroup.label);
if (existingGroup) {
// Merge robots within the group
newGroup.robots?.forEach(robotId => {
if (!existingGroup.robots?.includes(robotId)) {
existingGroup.robots?.push(robotId);
}
});
} else {
existingGroups.push(newGroup);
}
});
// Merge robot labels
labels?.forEach(newLabel => {
const existingLabel = existingLabels.find(l => l.id === newLabel.id || l.label === newLabel.label);
if (existingLabel) {
// Merge robots within the label
newLabel.robots?.forEach(robotId => {
if (!existingLabel.robots?.includes(robotId)) {
existingLabel.robots?.push(robotId);
}
});
} else {
existingLabels.push(newLabel);
}
});
// Merge robots
robots?.forEach(robot => {
if (!this.robotMap.has(robot.id)) {
this.robotMap.set(robot.id, robot);
}
});
this.robotGroups$$.next(existingGroups);
this.robotLabels$$.next(existingLabels);
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();
}