1348 lines
47 KiB
TypeScript
1348 lines
47 KiB
TypeScript
import {
|
||
EDITOR_CONFIG,
|
||
type MapAreaInfo,
|
||
MapAreaType,
|
||
type MapPen,
|
||
type MapPointInfo,
|
||
MapPointType,
|
||
type MapRouteInfo,
|
||
MapRoutePassType,
|
||
MapRouteType,
|
||
type Point,
|
||
type Rect,
|
||
} from '@api/map';
|
||
import type { RobotGroup, RobotInfo, RobotRealtimeInfo, RobotType } from '@api/robot';
|
||
import type {
|
||
GroupSceneDetail,
|
||
SceneData,
|
||
StandardScene,
|
||
StandardSceneArea,
|
||
StandardScenePoint,
|
||
StandardSceneRoute,
|
||
} from '@api/scene';
|
||
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, omitBy, pick, remove, some } from 'lodash-es';
|
||
import { BehaviorSubject, debounceTime, filter, map, Subject, switchMap } from 'rxjs';
|
||
import { reactive, watch } from 'vue';
|
||
|
||
/**
|
||
* 场景编辑器服务类
|
||
* 继承自Meta2D,提供完整的场景编辑功能
|
||
*
|
||
* 主要功能:
|
||
* - 场景文件的加载、保存和管理
|
||
* - 点位、路线、区域的创建和编辑
|
||
* - 机器人组的管理和实时状态更新
|
||
* - 鼠标事件的处理和响应式数据流
|
||
* - 自定义绘制和渲染逻辑
|
||
*/
|
||
export class EditorService extends Meta2d {
|
||
//#region 场景文件管理
|
||
/**
|
||
* 加载场景文件到编辑器
|
||
* @param map 场景文件的JSON字符串,为空则创建新场景
|
||
* @param editable 是否可编辑状态,控制编辑器锁定状态
|
||
* @param detail 群组场景详情,包含机器人组和机器人信息
|
||
*/
|
||
public async load(map?: string, editable = false, detail?: Partial<GroupSceneDetail>): Promise<void> {
|
||
const scene: StandardScene = map ? JSON.parse(map) : {};
|
||
if (!isEmpty(detail?.group)) {
|
||
scene.robotGroups = [detail.group];
|
||
scene.robots = detail.robots;
|
||
}
|
||
const { robotGroups, robots, points, routes, areas, ...extraFields } = scene;
|
||
// 保存所有额外字段(包括width、height等)
|
||
this.#originalSceneData = extraFields;
|
||
|
||
this.open();
|
||
this.setState(editable);
|
||
this.#loadRobots(robotGroups, robots);
|
||
await this.#loadScenePoints(points);
|
||
this.#loadSceneRoutes(routes);
|
||
await this.#loadSceneAreas(areas);
|
||
this.store.historyIndex = undefined;
|
||
this.store.histories = [];
|
||
// this.scale(scale);与xd 自定义缩放冲突,暂时去掉
|
||
// if (isEmpty(origin)) {
|
||
// this.centerView();
|
||
// } else {
|
||
// this.translate(origin.x / scale, origin.y / scale);
|
||
// }
|
||
}
|
||
/**
|
||
* 保存当前场景为JSON字符串
|
||
* @returns 包含完整场景数据的JSON字符串
|
||
*/
|
||
public save(): string {
|
||
const { scale, x, y, origin } = this.data();
|
||
const scene: StandardScene = {
|
||
scale,
|
||
origin: { x: x + origin.x, y: y + origin.y },
|
||
robotGroups: this.robotGroups.value,
|
||
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: [],
|
||
...this.#originalSceneData, // 统一保留所有额外字段(包括width、height等)
|
||
};
|
||
|
||
return JSON.stringify(scene);
|
||
}
|
||
|
||
/**
|
||
* 加载机器人数据到编辑器
|
||
* @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 ?? []);
|
||
}
|
||
/**
|
||
* 从场景数据加载点位到画布
|
||
* @param points 标准场景点位数据数组
|
||
*/
|
||
async #loadScenePoints(points?: StandardScenePoint[]): Promise<void> {
|
||
if (!points?.length) return;
|
||
await Promise.all(
|
||
points.map(async (v) => {
|
||
const { id, name, desc, x, y, type, extensionType, robots, actions, properties } = v;
|
||
await this.addPoint({ x, y }, type, id);
|
||
this.setValue(
|
||
{ id, label: name, desc, properties, point: { type, extensionType, robots, actions } },
|
||
{ render: false, history: false, doEvent: false },
|
||
);
|
||
}),
|
||
);
|
||
}
|
||
/**
|
||
* 从场景数据加载路线到画布
|
||
* @param routes 标准场景路线数据数组
|
||
*/
|
||
#loadSceneRoutes(routes?: StandardSceneRoute[]): void {
|
||
if (!routes?.length) return;
|
||
routes.map((v) => {
|
||
const { id, desc, from, to, type, pass, c1, c2, properties } = v;
|
||
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)!;
|
||
this.setValue(
|
||
{
|
||
id,
|
||
desc,
|
||
properties,
|
||
route: {
|
||
type,
|
||
pass,
|
||
c1: { x: (c1?.x ?? 0) - x1, y: (c1?.y ?? 0) - y1 },
|
||
c2: { x: (c2?.x ?? 0) - x2, y: (c2?.y ?? 0) - y2 },
|
||
},
|
||
},
|
||
{ render: false, history: false, doEvent: false },
|
||
);
|
||
});
|
||
}
|
||
async #loadSceneAreas(areas?: StandardSceneArea[]): Promise<void> {
|
||
if (!areas?.length) return;
|
||
await Promise.all(
|
||
areas.map(async (v) => {
|
||
const { id, name, desc, x, y, w, h, type, points, routes, maxAmr, inoutflag, storageLocations, properties } = v;
|
||
await this.addArea({ x, y }, { x: x + w, y: y + h }, 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 },
|
||
);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
this.setValue(
|
||
{ id, label: name, desc, properties, area: { type, points: processedPoints, routes, maxAmr, inoutflag } },
|
||
{ render: false, history: false, doEvent: false },
|
||
);
|
||
}),
|
||
);
|
||
}
|
||
|
||
#mapScenePoint(pen?: MapPen): StandardScenePoint | null {
|
||
if (!pen?.id || isEmpty(pen?.point)) return null;
|
||
const { id, label, desc, properties } = pen;
|
||
const { type, extensionType, robots, actions, associatedStorageLocations } = pen.point;
|
||
const { x = 0, y = 0 } = this.getPointRect(pen) ?? {};
|
||
|
||
// 进行坐标转换:左上角原点 -> 中心点原点,同时应用ratio缩放
|
||
const transformedCoords = this.#transformCoordinate(x, y);
|
||
|
||
const point: StandardScenePoint = {
|
||
id: id,
|
||
name: label || id,
|
||
desc,
|
||
x: transformedCoords.x,
|
||
y: transformedCoords.y,
|
||
type,
|
||
extensionType,
|
||
config: {},
|
||
properties,
|
||
};
|
||
if ([MapPointType.充电点, MapPointType.停靠点].includes(type)) {
|
||
point.robots = robots?.filter((v) => this.#robotMap.has(v));
|
||
}
|
||
if (MapPointType.等待点 === type) {
|
||
point.actions = actions?.filter((v) => this.getPenById(v)?.point?.type === MapPointType.动作点);
|
||
}
|
||
if (MapPointType.动作点 === type) {
|
||
point.associatedStorageLocations = associatedStorageLocations;
|
||
}
|
||
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;
|
||
const [p1, p2] = anchors.map((v) => this.getPenById(v.connectTo!));
|
||
if (isNil(p1) || isNil(p2)) return null;
|
||
const route: StandardSceneRoute = {
|
||
id: id,
|
||
desc,
|
||
from: direction < 0 ? p2.id! : p1.id!,
|
||
to: direction < 0 ? p1.id! : p2.id!,
|
||
type,
|
||
pass,
|
||
config: {},
|
||
properties,
|
||
};
|
||
const { x: x1, y: y1 } = this.getPointRect(p1)!;
|
||
const { x: x2, y: y2 } = this.getPointRect(p2)!;
|
||
const cp1 = { x: x1 + (c1?.x ?? 0), y: y1 + (c1?.y ?? 0) };
|
||
const cp2 = { x: x2 + (c2?.x ?? 0), y: y2 + (c2?.y ?? 0) };
|
||
|
||
switch (type) {
|
||
case MapRouteType.二阶贝塞尔曲线:
|
||
// 对控制点进行坐标转换
|
||
route.c1 = this.#transformCoordinate(cp1.x, cp1.y);
|
||
break;
|
||
case MapRouteType.三阶贝塞尔曲线: {
|
||
// 对两个控制点进行坐标转换
|
||
const transformedCp1 = this.#transformCoordinate(cp1.x, cp1.y);
|
||
const transformedCp2 = this.#transformCoordinate(cp2.x, cp2.y);
|
||
route.c1 = direction < 0 ? transformedCp2 : transformedCp1;
|
||
route.c2 = direction < 0 ? transformedCp1 : transformedCp2;
|
||
break;
|
||
}
|
||
default:
|
||
break;
|
||
}
|
||
return route;
|
||
}
|
||
#mapSceneArea(pen: MapPen): StandardSceneArea | null {
|
||
if (!pen.id || isEmpty(pen.area)) return null;
|
||
const { id, label, desc, properties } = pen;
|
||
const { type, points, routes, maxAmr, inoutflag } = pen.area;
|
||
const { x, y, width, height } = this.getPenRect(pen);
|
||
// 进行坐标转换:左上角原点 -> 中心点原点,同时应用ratio缩放
|
||
const transformedCoords = this.#transformCoordinate(x, y);
|
||
const area: StandardSceneArea = {
|
||
id,
|
||
name: label || id,
|
||
desc,
|
||
x: transformedCoords.x,
|
||
y: transformedCoords.y,
|
||
w: this.#transformSize(width),
|
||
h: this.#transformSize(height),
|
||
type,
|
||
config: {},
|
||
properties,
|
||
};
|
||
if (type === MapAreaType.约束区) {
|
||
area.maxAmr = maxAmr;
|
||
}
|
||
if (MapAreaType.库区 === type) {
|
||
// 获取库区内的动作点
|
||
const actionPoints =
|
||
points
|
||
?.map((id) => this.getPenById(id))
|
||
.filter((pen): pen is MapPen => !!pen && pen.point?.type === MapPointType.动作点) ?? [];
|
||
|
||
// 保存动作点名称
|
||
area.points = actionPoints.map((pen) => pen.label || pen.id!);
|
||
|
||
// 构建storageLocations数组:[{动作点名称: [库位列表]}]
|
||
area.storageLocations = actionPoints
|
||
.map((pen) => {
|
||
const pointName = pen.label || pen.id!;
|
||
const storageLocations = pen.point?.associatedStorageLocations ?? [];
|
||
|
||
return { [pointName]: storageLocations };
|
||
})
|
||
.filter((item): item is Record<string, string[]> => item !== null);
|
||
|
||
area.inoutflag = inoutflag;
|
||
}
|
||
if ([MapAreaType.互斥区, MapAreaType.非互斥区, MapAreaType.约束区].includes(type)) {
|
||
area.points = points?.filter((v) => {
|
||
const { point } = this.getPenById(v) ?? {};
|
||
if (isNil(point)) return false;
|
||
if (point.type === MapPointType.禁行点) return false;
|
||
return true;
|
||
});
|
||
}
|
||
if (MapAreaType.互斥区 === type) {
|
||
area.routes = routes?.filter((v) => !isEmpty(this.getPenById(v)?.area));
|
||
}
|
||
return area;
|
||
}
|
||
//#endregion
|
||
|
||
/**
|
||
* 设置编辑器状态
|
||
* @param editable 是否可编辑,true为可编辑状态,false为只读状态
|
||
*/
|
||
public setState(editable?: boolean): void {
|
||
this.lock(editable ? LockState.None : LockState.DisableEdit);
|
||
this.data().pens.forEach((pen: MapPen) => {
|
||
if (pen.name !== 'area') {
|
||
if (pen.locked !== LockState.DisableEdit) {
|
||
this.setValue(
|
||
{ id: pen.id, locked: LockState.DisableEdit },
|
||
{ render: false, history: false, doEvent: false },
|
||
);
|
||
}
|
||
}
|
||
});
|
||
this.render();
|
||
}
|
||
|
||
public override data(): SceneData {
|
||
return super.data();
|
||
}
|
||
|
||
/** 鼠标事件流主体,用于内部事件分发 */
|
||
readonly #mouse$$ = new Subject<{ type: 'click' | 'mousedown' | 'mouseup'; value: Point }>();
|
||
|
||
/** 鼠标点击事件的响应式流,防抖处理后的点击坐标 */
|
||
public readonly mouseClick = useObservable<Point>(
|
||
this.#mouse$$.pipe(
|
||
filter(({ type }) => type === 'click'),
|
||
debounceTime(100),
|
||
map(({ value }) => value),
|
||
),
|
||
);
|
||
|
||
/** 鼠标拖拽事件的响应式流,返回起始点和结束点坐标,用于创建区域 */
|
||
public readonly mouseBrush = useObservable<[Point, Point]>(
|
||
this.#mouse$$.pipe(
|
||
filter(({ type }) => type === 'mousedown'),
|
||
switchMap(({ value: s }) =>
|
||
this.#mouse$$.pipe(
|
||
filter(({ type }) => type === 'mouseup'),
|
||
map(({ value: e }) => <[Point, Point]>[s, e]),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
|
||
//#region 机器人组管理
|
||
/** 机器人信息映射表,响应式存储所有机器人数据 */
|
||
readonly #robotMap = reactive<Map<RobotInfo['id'], RobotInfo>>(new Map());
|
||
|
||
/** 获取所有机器人信息数组 */
|
||
public get robots(): RobotInfo[] {
|
||
return Array.from(this.#robotMap.values());
|
||
}
|
||
|
||
public checkRobotById(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.setValue({ id, text: value.label }, { render: true, history: false, doEvent: false });
|
||
}
|
||
(<SceneData>this.store.data).robots = [...this.#robotMap.values()];
|
||
}
|
||
|
||
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;
|
||
}
|
||
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;
|
||
}
|
||
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()];
|
||
}
|
||
|
||
readonly #robotGroups$$ = new BehaviorSubject<RobotGroup[]>([]);
|
||
public readonly robotGroups = useObservable<RobotGroup[]>(this.#robotGroups$$.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;
|
||
}
|
||
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);
|
||
}
|
||
|
||
/** 精度控制方法 - 固定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);
|
||
}
|
||
|
||
/** 画布变化事件流,用于触发响应式数据更新 */
|
||
readonly #change$$ = new Subject<boolean>();
|
||
|
||
/** 当前选中的图形对象,响应式更新 */
|
||
public readonly current = useObservable<MapPen>(
|
||
this.#change$$.pipe(
|
||
debounceTime(100),
|
||
map(() => <MapPen>clone(this.store.active?.[0])),
|
||
),
|
||
);
|
||
|
||
/** 当前选中的图形ID列表,响应式更新 */
|
||
public readonly selected = useObservable<string[], string[]>(
|
||
this.#change$$.pipe(
|
||
filter((v) => !v),
|
||
debounceTime(100),
|
||
map(() => this.store.active?.map(({ id }) => id).filter((v) => !isNil(v)) ?? []),
|
||
),
|
||
{ initialValue: new Array<string>() },
|
||
);
|
||
|
||
/** 画布上所有图形对象列表,响应式更新 */
|
||
public readonly pens = useObservable<MapPen[]>(
|
||
this.#change$$.pipe(
|
||
filter((v) => v),
|
||
debounceTime(100),
|
||
map(() => this.data().pens),
|
||
),
|
||
);
|
||
|
||
public override find(target: string): MapPen[] {
|
||
return super.find(target);
|
||
}
|
||
public getPenById(id?: string): MapPen | undefined {
|
||
if (!id) return;
|
||
return this.find(id)[0];
|
||
}
|
||
|
||
public override active(target: string | Pen[], emit?: boolean): void {
|
||
const pens = isString(target) ? this.find(target) : target;
|
||
super.active(pens, emit);
|
||
this.render();
|
||
}
|
||
public override inactive(): void {
|
||
super.inactive();
|
||
this.render();
|
||
}
|
||
|
||
public gotoById(id: string): void {
|
||
const pen = this.getPenById(id);
|
||
if (isNil(pen)) return;
|
||
// 判断机器人是否可见,如果不可见直接返回
|
||
if (pen.visible === false && pen.tags?.includes('robot')) return;
|
||
this.gotoView(pen);
|
||
}
|
||
|
||
public deleteById(id?: string): void {
|
||
const pen = this.getPenById(id);
|
||
if (pen?.name !== 'area') return;
|
||
this.delete([pen], true, true);
|
||
}
|
||
|
||
public updatePen(id: string, pen: Partial<MapPen>, record = true): void {
|
||
this.setValue({ ...pen, id }, { render: true, history: record, doEvent: true });
|
||
}
|
||
|
||
//#region 实时机器人
|
||
public async initRobots(): Promise<void> {
|
||
await Promise.all(
|
||
this.robots.map(async ({ id, label, type }) => {
|
||
const pen: MapPen = {
|
||
...this.#mapRobotImage(type, true),
|
||
id,
|
||
name: 'robot',
|
||
tags: ['robot'],
|
||
x: 0,
|
||
y: 0,
|
||
width: 74,
|
||
height: 74,
|
||
lineWidth: 1,
|
||
robot: { type },
|
||
visible: false,
|
||
text: label,
|
||
textTop: -24,
|
||
whiteSpace: 'nowrap',
|
||
ellipsis: false,
|
||
locked: LockState.Disable,
|
||
};
|
||
await this.addPen(pen, false, true, true);
|
||
}),
|
||
);
|
||
}
|
||
|
||
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 - 37;
|
||
const y = cy - 37;
|
||
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 }, { render: true, history: false, doEvent: false });
|
||
} else {
|
||
this.setValue(
|
||
{ id, ...this.#mapRobotImage(robot.type, active), x, y, rotate, robot: o, visible: true },
|
||
{ render: true, history: false, doEvent: false },
|
||
);
|
||
}
|
||
}
|
||
|
||
#mapRobotImage(
|
||
type: RobotType,
|
||
active?: boolean,
|
||
): Required<Pick<MapPen, 'image' | 'iconWidth' | 'iconHeight' | 'iconTop'>> {
|
||
const theme = this.data().theme;
|
||
const image =
|
||
import.meta.env.BASE_URL + (active ? `/robot/${type}-active-${theme}.png` : `/robot/${type}-${theme}.png`);
|
||
|
||
// 使用优化的像素对齐算法,确保小车和光圈精确重合
|
||
const iconTop = this.#calculatePixelAlignedOffset(-10);
|
||
|
||
return { image, iconWidth: 34, iconHeight: 54, iconTop };
|
||
}
|
||
//#endregion
|
||
|
||
//#region 点位
|
||
/** 画布上所有点位对象列表,响应式更新 */
|
||
public readonly points = useObservable<MapPen[], MapPen[]>(
|
||
this.#change$$.pipe(
|
||
filter((v) => v),
|
||
debounceTime(100),
|
||
map(() => this.find('point')),
|
||
),
|
||
{ initialValue: new Array<MapPen>() },
|
||
);
|
||
|
||
public getPointRect(pen?: MapPen): Rect | null {
|
||
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,未指定则自动生成
|
||
*/
|
||
public async addPoint(p: Point, type = MapPointType.普通点, id?: string): Promise<void> {
|
||
id ||= s8();
|
||
const pen: MapPen = {
|
||
...p,
|
||
...this.#mapPoint(type),
|
||
...this.#mapPointImage(type),
|
||
id,
|
||
name: 'point',
|
||
tags: ['point'],
|
||
label: `P${id}`,
|
||
point: { type },
|
||
locked: LockState.DisableEdit,
|
||
};
|
||
pen.x! -= pen.width! / 2;
|
||
pen.y! -= pen.height! / 2;
|
||
await this.addPen(pen, false, true, true);
|
||
}
|
||
|
||
public updatePoint(id: string, info: Partial<MapPointInfo>): void {
|
||
const { point } = this.getPenById(id) ?? {};
|
||
if (!point?.type) return;
|
||
const o = { ...point, ...info };
|
||
this.setValue({ id, point: o }, { render: true, history: true, doEvent: true });
|
||
}
|
||
public changePointType(id: string, type: MapPointType): void {
|
||
const pen = this.getPenById(id);
|
||
const rect = this.getPointRect(pen);
|
||
if (isNil(rect)) return;
|
||
const point = this.#mapPoint(type);
|
||
this.setValue(
|
||
{
|
||
id,
|
||
x: rect.x - point.width / 2,
|
||
y: rect.y - point.height / 2,
|
||
...point,
|
||
...this.#mapPointImage(type),
|
||
point: { type },
|
||
},
|
||
{ render: true, history: true, doEvent: true },
|
||
);
|
||
}
|
||
|
||
#mapPoint(type: MapPointType): Required<Pick<MapPen, 'width' | 'height' | 'lineWidth' | 'iconSize'>> {
|
||
const width = type < 10 ? 24 : 48;
|
||
const height = type < 10 ? 24 : 60;
|
||
const lineWidth = type < 10 ? 2 : 3;
|
||
const iconSize = type < 10 ? 4 : 10;
|
||
return { width, height, lineWidth, iconSize };
|
||
}
|
||
#mapPointImage(type: MapPointType): Required<Pick<MapPen, 'image' | 'canvasLayer'>> {
|
||
const theme = this.data().theme;
|
||
const image = type < 10 ? '' : `${import.meta.env.BASE_URL}/point/${type}-${theme}.png`;
|
||
return { image, canvasLayer: CanvasLayer.CanvasMain };
|
||
}
|
||
|
||
//#endregion
|
||
|
||
//#region 线路
|
||
/** 画布上所有路线对象列表,响应式更新,包含动态生成的标签 */
|
||
public readonly routes = useObservable<MapPen[], MapPen[]>(
|
||
this.#change$$.pipe(
|
||
filter((v) => v),
|
||
debounceTime(100),
|
||
map(() => this.find('route').map((v) => ({ ...v, label: this.getRouteLabel(v.id) }))),
|
||
),
|
||
{ initialValue: new Array<MapPen>() },
|
||
);
|
||
|
||
public getRouteLabel(id?: string, d?: number): string {
|
||
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 ?? {};
|
||
return `${p1.label}${(d ?? direction) > 0 ? '→' : '←'}${p2.label}`;
|
||
}
|
||
|
||
/**
|
||
* 在两个点位之间添加路线
|
||
* @param p 两个点位的数组
|
||
* @param type 路线类型,默认为直线
|
||
* @param id 路线ID,未指定则自动生成
|
||
*/
|
||
public addRoute(p: [MapPen, MapPen], type = MapRouteType.直线, id?: string): void {
|
||
const [p1, p2] = p;
|
||
if (!p1?.anchors?.length || !p2?.anchors?.length) return;
|
||
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 };
|
||
this.setValue({ id, ...pen }, { render: false, history: false, doEvent: false });
|
||
this.updateLineType(line, type);
|
||
this.active(id);
|
||
this.render();
|
||
}
|
||
|
||
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 });
|
||
}
|
||
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 });
|
||
}
|
||
//#endregion
|
||
|
||
//#region 区域
|
||
/** 画布上所有区域对象列表,响应式更新 */
|
||
public readonly areas = useObservable<MapPen[], MapPen[]>(
|
||
this.#change$$.pipe(
|
||
filter((v) => v),
|
||
debounceTime(100),
|
||
map(() => this.find('area')),
|
||
),
|
||
{ initialValue: new Array<MapPen>() },
|
||
);
|
||
|
||
public getBoundAreas(id: string = '', name: 'point' | 'line', type: MapAreaType): MapPen[] {
|
||
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,未指定则自动生成
|
||
*/
|
||
public async addArea(p1: Point, p2: Point, type = MapAreaType.库区, id?: string) {
|
||
const w = Math.abs(p1.x - p2.x);
|
||
const h = Math.abs(p1.y - p2.y);
|
||
if (w < 50 || h < 60) return;
|
||
const points = new Array<string>();
|
||
const routes = new Array<string>();
|
||
if (!id) {
|
||
id = s8();
|
||
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!));
|
||
selected?.filter(({ route }) => route?.type).forEach(({ id }) => routes.push(id!));
|
||
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;
|
||
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
const areaInfo: MapAreaInfo = { type, points, routes };
|
||
if (type === MapAreaType.库区) {
|
||
areaInfo.inoutflag = 1;
|
||
}
|
||
const pen: MapPen = {
|
||
id,
|
||
name: 'area',
|
||
tags: ['area', `area-${type}`],
|
||
label: `A${id}`,
|
||
x: Math.min(p1.x, p2.x),
|
||
y: Math.min(p1.y, p2.y),
|
||
width: w,
|
||
height: h,
|
||
lineWidth: 1,
|
||
area: areaInfo,
|
||
locked: LockState.None,
|
||
};
|
||
const area = await this.addPen(pen, true, true, true);
|
||
this.bottom(area);
|
||
}
|
||
|
||
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 });
|
||
}
|
||
//#endregion
|
||
|
||
/**
|
||
* 构造函数 - 初始化场景编辑器
|
||
* @param container 编辑器容器DOM元素
|
||
*/
|
||
constructor(container: HTMLDivElement) {
|
||
super(container, EDITOR_CONFIG);
|
||
|
||
// 禁用第6个子元素的拖放功能
|
||
(<HTMLDivElement>container.children.item(5)).ondrop = null;
|
||
// 监听所有画布事件
|
||
this.on('*', (e, v) => this.#listen(e, v));
|
||
// 注册自定义绘制函数和锚点
|
||
this.#register();
|
||
|
||
// 监听主题变化并重新加载样式
|
||
watch(
|
||
() => sTheme.theme,
|
||
(v) => this.#load(v),
|
||
{ immediate: true },
|
||
);
|
||
}
|
||
|
||
#load(theme: string): void {
|
||
this.setTheme(theme);
|
||
|
||
this.setOptions({ color: get(sTheme.editor, 'color') });
|
||
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));
|
||
});
|
||
this.find('robot').forEach((pen) => {
|
||
if (!pen.robot?.type) return;
|
||
this.canvas.updateValue(pen, this.#mapRobotImage(pen.robot.type, pen.robot.active));
|
||
});
|
||
this.render();
|
||
}
|
||
|
||
#onDelete(pens?: MapPen[]): void {
|
||
pens?.forEach((pen) => {
|
||
switch (pen.name) {
|
||
case 'point':
|
||
this.delete(this.getLines(pen), true, false);
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
});
|
||
}
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
#listen(e: unknown, v: any) {
|
||
switch (e) {
|
||
case 'opened':
|
||
this.#load(sTheme.theme);
|
||
this.#change$$.next(true);
|
||
break;
|
||
|
||
case 'add':
|
||
this.#change$$.next(true);
|
||
break;
|
||
case 'delete':
|
||
this.#onDelete(v);
|
||
this.#change$$.next(true);
|
||
break;
|
||
case 'update':
|
||
this.#change$$.next(true);
|
||
break;
|
||
case 'valueUpdate':
|
||
this.#change$$.next(true);
|
||
break;
|
||
|
||
case 'active':
|
||
case 'inactive':
|
||
this.#change$$.next(false);
|
||
break;
|
||
|
||
case 'click':
|
||
case 'mousedown':
|
||
case 'mouseup':
|
||
this.#mouse$$.next({ type: e, value: pick(this.getPenRect(v), 'x', 'y') });
|
||
break;
|
||
|
||
default:
|
||
// console.log(e, v);
|
||
break;
|
||
}
|
||
}
|
||
|
||
#register() {
|
||
this.register({ line: () => new Path2D() });
|
||
this.registerCanvasDraw({ point: drawPoint, line: drawLine, area: drawArea, robot: drawRobot });
|
||
this.registerAnchors({ point: anchorPoint });
|
||
this.addDrawLineFn('bezier2', lineBezier2);
|
||
this.addDrawLineFn('bezier3', lineBezier3);
|
||
}
|
||
}
|
||
|
||
//#region 自定义绘制函数
|
||
/**
|
||
* 绘制点位的自定义函数
|
||
* @param ctx Canvas 2D绘制上下文
|
||
* @param pen 点位图形对象
|
||
*/
|
||
function drawPoint(ctx: CanvasRenderingContext2D, pen: MapPen): void {
|
||
const theme = sTheme.editor;
|
||
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 } = pen.point ?? {};
|
||
const { label = '', statusStyle } = pen ?? {};
|
||
|
||
ctx.save();
|
||
switch (type) {
|
||
case MapPointType.普通点:
|
||
case MapPointType.等待点:
|
||
case MapPointType.避让点:
|
||
case MapPointType.临时避让点:
|
||
case MapPointType.库区点:
|
||
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();
|
||
ctx.fillStyle = get(theme, `point-s.fill-${type}`) ?? '';
|
||
ctx.fill();
|
||
ctx.strokeStyle = get(theme, active ? 'point-s.strokeActive' : 'point-s.stroke') ?? '';
|
||
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();
|
||
break;
|
||
case MapPointType.电梯点:
|
||
case MapPointType.自动门点:
|
||
case MapPointType.充电点:
|
||
case MapPointType.停靠点:
|
||
case MapPointType.动作点:
|
||
case MapPointType.禁行点:
|
||
ctx.roundRect(x, y, w, h, r);
|
||
ctx.strokeStyle = statusStyle ?? get(theme, active ? 'point-l.strokeActive' : 'point-l.stroke') ?? '';
|
||
ctx.stroke();
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
ctx.fillStyle = get(theme, 'color') ?? '';
|
||
ctx.font = `${fontSize}px/${lineHeight} ${fontFamily}`;
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'top';
|
||
ctx.fillText(label, x + w / 2, y - fontSize * lineHeight);
|
||
ctx.restore();
|
||
}
|
||
/**
|
||
* 设置点位的连接锚点
|
||
* @param pen 点位图形对象
|
||
*/
|
||
function anchorPoint(pen: MapPen): void {
|
||
pen.anchors = [
|
||
{ 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 },
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 绘制路线的自定义函数
|
||
* @param ctx Canvas 2D绘制上下文
|
||
* @param pen 路线图形对象
|
||
*/
|
||
function drawLine(ctx: CanvasRenderingContext2D, pen: MapPen): void {
|
||
const theme = sTheme.editor;
|
||
const { active, lineWidth: s = 1 } = pen.calculative ?? {};
|
||
const [p1, p2] = pen.calculative?.worldAnchors ?? [];
|
||
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 ?? {};
|
||
const { x: dx1 = 0, y: dy1 = 0 } = c1 ?? {};
|
||
const { x: dx2 = 0, y: dy2 = 0 } = c2 ?? {};
|
||
const [c1x, c1y] = [x1 + dx1 * s, y1 + dy1 * s];
|
||
const [c2x, c2y] = [x2 + dx2 * s, y2 + dy2 * s];
|
||
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.strokeStyle = get(theme, active ? 'route.strokeActive' : `route.stroke-${pass}`) ?? '';
|
||
ctx.lineWidth = active ? 3 * s : 2 * s;
|
||
ctx.moveTo(x1, y1);
|
||
switch (type) {
|
||
case MapRouteType.直线:
|
||
ctx.lineTo(x2, y2);
|
||
break;
|
||
case MapRouteType.二阶贝塞尔曲线:
|
||
ctx.quadraticCurveTo(c1x, c1y, x2, y2);
|
||
p1.next = { x: x1 + (2 / 3) * dx1 * s, y: y1 + (2 / 3) * dy1 * s };
|
||
p2.prev = { x: x2 / 3 + (2 / 3) * c1x, y: y2 / 3 + (2 / 3) * c1y };
|
||
break;
|
||
case MapRouteType.三阶贝塞尔曲线:
|
||
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, x2, y2);
|
||
p1.next = { x: c1x, y: c1y };
|
||
p2.prev = { x: c2x, y: c2y };
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
if (pass === MapRoutePassType.禁行) {
|
||
ctx.setLineDash([s * 5]);
|
||
}
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.setLineDash([0]);
|
||
|
||
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 };
|
||
}
|
||
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 };
|
||
}
|
||
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 };
|
||
}
|
||
default:
|
||
return { dx: 0, dy: 0, r: 0 };
|
||
}
|
||
})();
|
||
ctx.translate(dx, dy);
|
||
ctx.moveTo(Math.cos(r + Math.PI / 5) * s * 10, Math.sin(r + Math.PI / 5) * s * 10);
|
||
ctx.lineTo(0, 0);
|
||
ctx.lineTo(Math.cos(r - Math.PI / 5) * s * 10, Math.sin(r - Math.PI / 5) * s * 10);
|
||
ctx.stroke();
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
ctx.restore();
|
||
}
|
||
function lineBezier2(_: Meta2dStore, pen: MapPen): void {
|
||
if (pen.calculative?.worldAnchors?.length !== 2) return;
|
||
const { c1 } = pen.route ?? {};
|
||
const { lineWidth: s = 1 } = pen.calculative ?? {};
|
||
const [p1, p2] = pen.calculative?.worldAnchors ?? [];
|
||
const { x: x1 = 0, y: y1 = 0 } = p1 ?? {};
|
||
const { x: x2 = 0, y: y2 = 0 } = p2 ?? {};
|
||
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) };
|
||
}
|
||
function lineBezier3(_: Meta2dStore, pen: MapPen): void {
|
||
if (pen.calculative?.worldAnchors?.length !== 2) return;
|
||
const { c1, c2 } = pen.route ?? {};
|
||
const { lineWidth: s = 1 } = pen.calculative ?? {};
|
||
const [p1, p2] = pen.calculative?.worldAnchors ?? [];
|
||
const { x: x1 = 0, y: y1 = 0 } = p1 ?? {};
|
||
const { x: x2 = 0, y: y2 = 0 } = p2 ?? {};
|
||
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 };
|
||
}
|
||
|
||
/**
|
||
* 绘制区域的自定义函数
|
||
* @param ctx Canvas 2D绘制上下文
|
||
* @param pen 区域图形对象
|
||
*/
|
||
function drawArea(ctx: CanvasRenderingContext2D, pen: MapPen): void {
|
||
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 ?? {};
|
||
const { type } = pen.area ?? {};
|
||
const { label = '' } = pen ?? {};
|
||
|
||
ctx.save();
|
||
ctx.rect(x, y, w, h);
|
||
ctx.fillStyle = get(theme, `area.fill-${type}`) ?? '';
|
||
ctx.fill();
|
||
ctx.strokeStyle = get(theme, active ? 'area.strokeActive' : `area.stroke-${type}`) ?? '';
|
||
ctx.stroke();
|
||
ctx.fillStyle = get(theme, 'color') ?? '';
|
||
ctx.font = `${fontSize}px/${lineHeight} ${fontFamily}`;
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'top';
|
||
ctx.fillText(label, x + w / 2, y - fontSize * lineHeight);
|
||
ctx.restore();
|
||
}
|
||
|
||
/**
|
||
* 获取机器人状态
|
||
* @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 机器人图形对象
|
||
*/
|
||
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 ox = x + w / 2;
|
||
const oy = y + h / 2;
|
||
ctx.save();
|
||
ctx.ellipse(ox, oy, w / 2, h / 2, 0, 0, Math.PI * 2);
|
||
ctx.fillStyle = get(theme, `robot.fill-${status}`) ?? get(theme, 'robot.fill') ?? '';
|
||
ctx.fill();
|
||
ctx.strokeStyle = get(theme, `robot.stroke-${status}`) ?? get(theme, 'robot.stroke') ?? '';
|
||
ctx.stroke();
|
||
if (path?.length) {
|
||
ctx.strokeStyle = 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 辅助函数
|
||
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 };
|
||
});
|
||
|
||
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
|