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

1348 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import {
EDITOR_CONFIG,
type MapAreaInfo,
MapAreaType,
type MapPen,
type MapPointInfo,
MapPointType,
type MapRouteInfo,
MapRoutePassType,
MapRouteType,
type Point,
type Rect,
} from '@api/map';
import 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