574 lines
19 KiB
TypeScript
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();
|
|
}
|