import { type AnchorPosition, EDITOR_CONFIG, type MapAreaInfo, MapAreaType, type MapPen, type MapPointInfo, MapPointType, type MapRouteInfo, MapRoutePassType, MapRouteType, type Point, } from '@api/map'; import type { RobotGroup, RobotInfo } from '@api/robot'; import type { SceneData } from '@api/scene'; import sTheme from '@core/theme.service'; import { CanvasLayer, EditType, LockState, Meta2d, type Meta2dStore, type Pen, s8 } from '@meta2d/core'; import { useObservable } from '@vueuse/rxjs'; import { clone, cloneDeep, get, isNil, isString, mapKeys, pick, remove, some } from 'lodash-es'; import { BehaviorSubject, debounceTime, filter, map, Subject, switchMap } from 'rxjs'; import { reactive, watch } from 'vue'; export class EditorService extends Meta2d { public async load(map?: string, editable = false): Promise { const data = map ? JSON.parse(map) : undefined; this.open(data); this.setState(editable); setTimeout(() => { const pens = this.data().pens; this.addRoute([pens[4].id, pens[9].id]); // this.addRoute(['21b74c90', '7f201a25'], 'bezier3'); }, 1000); } public save(): string { const data = this.data(); data.pens.forEach((pen: MapPen) => { remove(pen.point?.robots ?? [], (v) => !this.#robotMap.has(v)); remove(pen.point?.actions ?? [], (v) => this.getPenById(v)?.point?.type !== MapPointType.动作点); remove(pen.area?.points ?? [], (v) => { const { point } = this.getPenById(v) ?? {}; if (isNil(point)) return true; if (point.type === MapPointType.禁行点) return true; if (pen.area?.type === MapAreaType.库区 && point.type !== MapPointType.动作点) return true; return false; }); remove(pen.area?.routes ?? [], (v) => isNil(this.getPenById(v))); }); return JSON.stringify(data); } public setState(editable?: boolean): void { this.lock(editable ? LockState.None : LockState.DisableEdit); } public override data(): SceneData { return super.data(); } readonly #mouse$$ = new Subject<{ type: 'click' | 'mousedown' | 'mouseup'; value: Point }>(); public readonly mouseClick = useObservable( 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>(new Map()); public get robots(): RobotInfo[] { return Array.from(this.#robotMap.values()); } public getRobotById(id: RobotInfo['id']): RobotInfo | undefined { return this.#robotMap.get(id); } 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); (this.store.data).robots = [...this.#robotMap.values()]; (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); (this.store.data).robots = [...this.#robotMap.values()]; (this.store.data).robotGroups = this.#robotGroups$$.value; } public updateRobots(ids: RobotInfo['id'][], values: Partial): void { ids?.forEach((v) => { const robot = this.#robotMap.get(v); if (isNil(robot)) return; this.#robotMap.set(v, { ...robot, ...values }); }); (this.store.data).robots = [...this.#robotMap.values()]; } readonly #robotGroups$$ = new BehaviorSubject([]); public readonly robotGroups = useObservable(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); (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); (this.store.data).robots = [...this.#robotMap.values()]; (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]); (this.store.data).robotGroups = this.#robotGroups$$.value; } //#endregion readonly #change$$ = new Subject(); public readonly current = useObservable( this.#change$$.pipe( debounceTime(100), map(() => clone(this.store.active?.[0])), ), ); public readonly selected = useObservable( this.#change$$.pipe( filter((v) => !v), debounceTime(100), map(() => this.store.active?.map(({ id }) => id).filter((v) => !isNil(v)) ?? []), ), { initialValue: new Array() }, ); public readonly pens = useObservable( 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 updatePen(id: string, pen: Partial, record = true): void { this.setValue({ ...pen, id }, { render: true, history: record, doEvent: true }); } //#region 点位 public readonly points = useObservable( this.#change$$.pipe( filter((v) => v), debounceTime(100), map(() => this.find('point')), ), { initialValue: new Array() }, ); public async addPoint(p: Point, type = MapPointType.普通点): Promise { const id = s8(); const pen: MapPen = { ...p, ...this.#mapPoint(type), ...this.#mapPointImage(type), id, name: 'point', tags: ['point'], label: `P${id}`, point: { type }, }; await this.addPen(pen, false, true, true); // this.pushHistory({ type: EditType.Add, pens: [cloneDeep(pen)] }); } public updatePoint(id: string, info: Partial): 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 { this.setValue( { id, ...this.#mapPoint(type), ...this.#mapPointImage(type), point: { type } }, { render: true, history: true, doEvent: true }, ); } #mapPoint(type: MapPointType): Required> { 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> { const theme = this.data().theme; const image = type < 10 ? '' : `/point/${type}-${theme}.png`; return { image, canvasLayer: CanvasLayer.CanvasMain }; } //#endregion //#region 线路 public readonly routes = useObservable( this.#change$$.pipe( filter((v) => v), debounceTime(100), map(() => this.find('route').map((v) => ({ ...v, label: this.getRouteLabel(v.id) }))), ), { initialValue: new Array() }, ); 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}`; } public addRoute(p: [string, string], type = MapRouteType.直线, from?: AnchorPosition, to?: AnchorPosition): void { const [p1, p2] = p.map((v) => this.getPenById(v)); if (!p1?.anchors?.length || !p2?.anchors?.length) return; const a1 = p1.anchors.find(({ id }) => id === from); const a2 = p2.anchors.find(({ id }) => id === to); const line = this.connectLine(p1, p2, a1, a2, false); const pen: MapPen = { tags: ['route'], route: { type }, lineWidth: 2, iconSize: 10 }; this.setValue({ id: line.id, ...pen }, { render: false, history: false, doEvent: false }); this.updateLineType(line, type); // this.pushHistory({ type: EditType.Add, pens: [cloneDeep(line)] }); this.active([line]); this.render(); } public updateRoute(id: string, info: Partial): 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( this.#change$$.pipe( filter((v) => v), debounceTime(100), map(() => this.find('area')), ), { initialValue: new Array() }, ); 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; }); } public async addArea(p1: Point, p2: Point, type = MapAreaType.库区) { const scale = this.data().scale ?? 1; const w = Math.abs(p1.x - p2.x); const h = Math.abs(p1.y - p2.y); if (w * scale < 50 || h * scale < 60) return; const selected = this.store.active; const id = s8(); const points = new Array(); const routes = new Array(); 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; default: break; } 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: { type, points, routes }, locked: LockState.DisableMoveScale, }; const area = await this.addPen(pen, false, true, true); this.bottom(area); // this.pushHistory({ type: EditType.Add, pens: [cloneDeep(pen)] }); } public updateArea(id: string, info: Partial): 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 constructor(container: HTMLDivElement) { super(container, EDITOR_CONFIG); (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); const { robots, robotGroups } = this.data(); this.#robotMap.clear(); robots?.forEach((r) => this.#robotMap.set(r.id, r)); this.#robotGroups$$.next(robotGroups ?? []); 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.render(); } #onDelete(pens?: MapPen[]): void { pens?.forEach((pen) => { switch (pen.name) { case 'point': { const lines = this.getLines(pen); this.delete(lines, 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(v, 'x', 'y') }); break; default: // console.log(e, v); break; } } #register() { this.register({ line: () => new Path2D() }); this.registerCanvasDraw({ point: drawPoint, line: drawLine, area: drawArea }); this.registerAnchors({ point: anchorPoint }); this.addDrawLineFn('bezier3', lineBezier3); } } //#region 绘制函数 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 = '' } = pen ?? {}; ctx.save(); switch (type) { 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 = 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(); } function anchorPoint(pen: MapPen): void { pen.anchors = [ { 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 }, ]; } function drawLine(ctx: CanvasRenderingContext2D, pen: MapPen): void { const theme = sTheme.editor; const { iconSize: s = 10 } = 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 = x2 - x1, y: dy1 = 0 } = c1 ?? {}; const { x: dx2 = 0, y: dy2 = y2 - y1 } = c2 ?? {}; ctx.save(); ctx.beginPath(); ctx.strokeStyle = get(theme, `route.stroke-${pass}`) ?? ''; ctx.moveTo(x1, y1); switch (type) { case MapRouteType.直线: ctx.lineTo(x2, y2); break; case MapRouteType.三阶贝塞尔曲线: ctx.bezierCurveTo(x1 + dx1, y1 + dy1, x2 - dx2, y2 - dy2, x2, y2); p1.next = { x: x1 + dx1, y: y1 + dy1 }; p2.prev = { x: x2 - dx2, y: y2 - dy2 }; break; default: break; } if (pass === MapRoutePassType.禁行) { ctx.setLineDash([s / 2]); } ctx.stroke(); ctx.beginPath(); ctx.setLineDash([0]); let r = (() => { switch (type) { case MapRouteType.直线: return Math.atan2(y2 - y1, x2 - x1); case MapRouteType.三阶贝塞尔曲线: return direction < 0 ? Math.atan2(dy1, dx1) : Math.atan2(dy2, dx2); default: return 0; } })(); if (direction < 0) { ctx.translate(x1, y1); } else { ctx.translate(x2, y2); r += Math.PI; } ctx.moveTo(Math.cos(r + Math.PI / 5) * s, Math.sin(r + Math.PI / 5) * s); ctx.lineTo(0, 0); ctx.lineTo(Math.cos(r - Math.PI / 5) * s, Math.sin(r - Math.PI / 5) * s); ctx.stroke(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.restore(); } function lineBezier3(_: Meta2dStore, pen: MapPen): void { if (pen.calculative?.worldAnchors?.length !== 2) return; const { c1, c2 } = pen.route ?? {}; 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 = x2 - x1, y: dy1 = 0 } = c1 ?? {}; const { x: dx2 = 0, y: dy2 = y2 - y1 } = c2 ?? {}; pen.calculative.worldAnchors[0].next = { x: x1 + dx1, y: y1 + dy1 }; pen.calculative.worldAnchors[1].prev = { x: x2 - dx2, y: y2 - dy2 }; } 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(); } //#endregion