feat(auto-door): 更新自动门设备状态模拟逻辑,优化门区域配色与状态显示

This commit is contained in:
xudan 2025-10-21 17:02:41 +08:00
parent 151534ae38
commit 7d0abea45e
6 changed files with 486 additions and 457 deletions

View File

@ -4,5 +4,5 @@ ENV_WEBSOCKET_BASE=/ws
ENV_STORAGE_WEBSOCKET_BASE=/vwedWs
# 开发环境token配置 - 可以手动设置或从另一个项目获取后填入
ENV_DEV_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3NjAxMDE2MTgsInVzZXJuYW1lIjoiYWRtaW4ifQ.e9kehve_MAqVPDRHRpMsJp2rEgyPW5pz_s0XDYUoxyk
ENV_DEV_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3NjExNjM1NjQsInVzZXJuYW1lIjoiYWRtaW4ifQ.zJ0CJJzwxX2ZptxZjLfOL6upjrqYzdn3yQlOtQzO85A
ENV_DEV_TENANT_ID=1000

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 B

View File

@ -80,6 +80,8 @@ const client = shallowRef<WebSocket>();
// WS使
let doorMockTimer: number | undefined;
let doorMockStatus: 0 | 1 = 0;
// /
let doorMockTick = 0;
// Ctrl/Cmd+F
const leftSiderEl = shallowRef<HTMLElement>();
const isPlaybackControllerVisible = ref<boolean>(true);
@ -406,7 +408,16 @@ const monitorScene = async () => {
//
}
doorMockTimer = window.setInterval(() => {
doorMockStatus = doorMockStatus === 0 ? 1 : 0;
doorMockTick++;
// 7 14 2 4
const phase = doorMockTick % 7;
const mockIsConnected = !(phase === 0 || phase === 1);
// /0
if (mockIsConnected) {
doorMockStatus = doorMockStatus === 0 ? 1 : 0;
} else {
doorMockStatus = 0;
}
const mock: AutoDoorWebSocketData = {
gid: 'mock-gid',
id: mockDeviceId,
@ -415,7 +426,7 @@ const monitorScene = async () => {
type: 99,
ip: null,
battery: 100,
isConnected: true,
isConnected: mockIsConnected,
state: 0,
canOrder: true,
canStop: null,

View File

@ -313,23 +313,25 @@ const DEFAULT_COLORS: EditorColorConfig = {
},
area: {
strokeActive: '#EBB214',
stroke: generateAreaStrokeColors(),
fill: generateAreaFillColors(),
// 为门区域(15)定制更友好的配色:蓝色系,提升可读性
stroke: { ...generateAreaStrokeColors(), 15: '#1890FF99' },
fill: { ...generateAreaFillColors(), 15: '#1890FF33' },
// 边框配置
border: {
width: 1, // 默认边框宽度
opacity: 0.15, // 默认边框透明度 15%
colors: generateAreaBorderColors(),
colors: { ...generateAreaBorderColors(), 15: '#1890FF' },
},
types: {
...generateAreaTypeColors(),
// 门区域(15)专属:统一蓝色系,边框由上方 colors[15] 提供
15: {
stroke: '#52C41A',
strokeActive: '#52C41A',
fill: '#52C41A33',
borderColor: '#52C41A',
stroke: '#1890FF99',
strokeActive: '#1890FF99',
fill: '#1890FF33',
borderColor: '#1890FF',
borderWidth: 1,
borderOpacity: 0.3,
borderOpacity: 0.22,
},
},
},
@ -420,6 +422,7 @@ const DARK_THEME_COLORS: EditorColorConfig = {
12: '#0DBB8A99',
13: '#e61e4aad',
14: '#FFD70099',
15: '#1890FF99',
},
fill: {
1: '#9ACDFF33',
@ -427,6 +430,7 @@ const DARK_THEME_COLORS: EditorColorConfig = {
12: '#0DBB8A33',
13: '#e61e4a33',
14: '#FFD70033',
15: '#1890FF26',
},
// 边框配置
border: {
@ -438,6 +442,7 @@ const DARK_THEME_COLORS: EditorColorConfig = {
12: '#52C41A',
13: '#FA8C16',
14: '#722ED1',
15: '#1890FF',
},
},
types: {
@ -481,6 +486,14 @@ const DARK_THEME_COLORS: EditorColorConfig = {
borderWidth: 1,
borderOpacity: 0.15,
},
15: {
stroke: '#1890FF99',
strokeActive: '#FCC947',
fill: '#1890FF26',
borderColor: '#1890FF',
borderWidth: 1,
borderOpacity: 0.2,
},
},
},
robot: {

View File

@ -1,446 +1,451 @@
import {
MapAreaType,
type MapPen,
MapPointType,
MapRoutePassType,
MapRouteType,
type Point,
} from '@api/map';
import { DOOR_AREA_TYPE } from '@api/map/door-area';
import sTheme from '@core/theme.service';
import { type Meta2dStore } from '@meta2d/core';
import { get } from 'lodash-es';
import colorConfig from '../color/color-config.service';
export 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, isConnected, deviceStatus, active: pointActive } = pen.point ?? {};
const { label = '', statusStyle } = pen ?? {};
ctx.save();
// 自动门点:根据连接与开关状态绘制矩形光圈(无边框)
if (type === MapPointType. && pointActive) {
// 让光圈随点位尺寸等比缩放,避免缩放画布时视觉上变大
const base = Math.min(w, h);
const padding = Math.max(2, Math.min(10, base * 0.2));
const rx = x - padding;
const ry = y - padding;
const rw = w + padding * 2;
const rh = h + padding * 2;
// 使用与点位相同的圆角半径,使观感统一
ctx.beginPath();
ctx.roundRect(rx, ry, rw, rh, r);
if (isConnected === false) {
// 未连接:深红色实心,不描边
ctx.fillStyle = colorConfig.getColor('autoDoor.strokeClosed') || '#fe5a5ae0';
} else {
// 已连接根据门状态显示颜色0=关门-浅红1=开门-蓝色)
if (deviceStatus === 0) {
ctx.fillStyle = colorConfig.getColor('autoDoor.fillClosed') || '#cddc39';
} else {
ctx.fillStyle = colorConfig.getColor('autoDoor.fillOpen') || '#1890FF';
}
}
ctx.fill();
// 重置路径,避免后续对点位边框的 stroke 影响到光圈路径
ctx.beginPath();
}
switch (type) {
case MapPointType.:
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();
// 优先使用小点位专用颜色,如果没有则使用类型专用颜色
const smallGeneralColor = colorConfig.getColor(`point.small.fill.${type}`);
const smallTypeColor = colorConfig.getColor(`point.types.${type}.fill`);
const smallThemeColor = get(theme, `point-s.fill-${type}`);
const finalColor = smallGeneralColor || smallTypeColor || smallThemeColor || '';
ctx.fillStyle = finalColor;
ctx.fill();
const smallTypeStrokeColor = colorConfig.getColor(`point.types.${type}.stroke`);
const smallGeneralStrokeColor = colorConfig.getColor(active ? 'point.small.strokeActive' : 'point.small.stroke');
const smallThemeStrokeColor = get(theme, active ? 'point-s.strokeActive' : 'point-s.stroke');
ctx.strokeStyle = smallTypeStrokeColor || smallGeneralStrokeColor || smallThemeStrokeColor || '';
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);
// 优先使用类型专用颜色,如果没有则使用通用颜色
const largeTypeStrokeColor = colorConfig.getColor(`point.types.${type}.stroke`);
const largeGeneralStrokeColor = colorConfig.getColor(active ? 'point.large.strokeActive' : 'point.large.stroke');
const largeThemeStrokeColor = get(theme, active ? 'point-l.strokeActive' : 'point-l.stroke');
ctx.strokeStyle = statusStyle ?? (largeTypeStrokeColor || largeGeneralStrokeColor || largeThemeStrokeColor || '');
ctx.stroke();
break;
}
default:
break;
}
ctx.fillStyle = colorConfig.getColor('common.color') || (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();
}
export function anchorPoint(pen: MapPen): void {
pen.anchors = [
{ penId: pen.id, id: '0', x: 0.5, y: 0.5 },
];
}
export 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();
// 根据路线通行类型获取颜色
let routeColor = '';
if (active) {
routeColor = colorConfig.getColor('route.strokeActive') || get(theme, 'route.strokeActive') || '';
} else {
// 根据通行类型选择颜色
switch (pass) {
case MapRoutePassType.:
routeColor = colorConfig.getColor('route.strokeNone') || get(theme, 'route.stroke-0') || '';
break;
case MapRoutePassType.:
routeColor = colorConfig.getColor('route.strokeEmpty') || get(theme, 'route.stroke-empty') || '';
break;
case MapRoutePassType.:
routeColor = colorConfig.getColor('route.strokeLoaded') || get(theme, 'route.stroke-loaded') || '';
break;
case MapRoutePassType.:
routeColor = colorConfig.getColor('route.strokeForbidden') || get(theme, 'route.stroke-forbidden') || '';
break;
default:
// 无限制路线使用无路线颜色作为默认
routeColor = colorConfig.getColor('route.strokeNone') || get(theme, 'route.stroke-0') || '';
break;
}
}
ctx.strokeStyle = routeColor;
// 使用配置的路线宽度
const routeWidth = colorConfig.getRouteWidth(active);
ctx.lineWidth = routeWidth * 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();
}
export 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) };
}
export 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 };
}
export 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 = '', desc = '' } = pen ?? {};
ctx.save();
ctx.rect(x, y, w, h);
// 填充颜色:优先通用,再类型,再主题
const generalFillColor = colorConfig.getColor(`area.fill.${type}`);
const typeFillColor = colorConfig.getColor(`area.types.${type}.fill`);
const themeFillColor = get(theme, `area.fill-${type}`);
const finalFillColor =
generalFillColor || typeFillColor || themeFillColor || colorConfig.getColor('area.fill.1') || '#e6f4ff33';
ctx.fillStyle = finalFillColor;
ctx.fill();
// 边框颜色与样式
const borderColor = type ? colorConfig.getAreaBorderColor(type) : '';
const generalStrokeColor = colorConfig.getColor(active ? 'area.strokeActive' : `area.stroke.${type}`);
const typeStrokeColor = colorConfig.getColor(`area.types.${type}.stroke`);
const themeStrokeColor = get(theme, active ? 'area.strokeActive' : `area.stroke-${type}`);
const borderWidth = type ? colorConfig.getAreaBorderWidth(type) : 1;
const borderOpacity = type ? colorConfig.getAreaBorderOpacity(type) : 0.15;
ctx.lineWidth = borderWidth;
ctx.setLineDash([]);
let finalStrokeColor = borderColor || generalStrokeColor || typeStrokeColor || themeStrokeColor || '';
if (borderOpacity < 1 && finalStrokeColor.startsWith('#')) {
const alpha = Math.round(borderOpacity * 255).toString(16).padStart(2, '0');
finalStrokeColor = finalStrokeColor + alpha;
}
if (!finalStrokeColor) {
finalStrokeColor = colorConfig.getColor('area.strokeActive') || '#8C8C8C';
}
ctx.strokeStyle = finalStrokeColor;
ctx.stroke();
// 描述区渲染文字
if (type === MapAreaType. && desc) {
ctx.fillStyle = colorConfig.getColor('common.color') || (get(theme, 'color') ?? '');
let descFontSize = Math.min(w / 6, h / 4, 200);
let lines: string[] = [];
while (descFontSize > 1) {
ctx.font = `${descFontSize}px ${fontFamily}`;
const maxCharsPerLine = Math.floor(w / (descFontSize * 0.8));
if (maxCharsPerLine < 1) {
descFontSize = Math.floor(descFontSize * 0.9);
continue;
}
lines = [];
for (let i = 0; i < desc.length; i += maxCharsPerLine) {
lines.push(desc.slice(i, i + maxCharsPerLine));
}
const textMetrics = ctx.measureText('测试文字');
const lh = (textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent) || descFontSize;
const totalHeight = lines.length * lh * 1.1;
if (totalHeight <= h * 0.9) break;
descFontSize = Math.floor(descFontSize * 0.9);
}
ctx.font = `${descFontSize}px ${fontFamily}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
const textMetrics = ctx.measureText('测试文字');
const lh = (textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent) || descFontSize;
const totalHeight = lines.length * lh * 1.1;
const startY = y + h / 2 - totalHeight / 2;
lines.forEach((line, index) => {
ctx.fillText(line, x + w / 2, startY + index * lh * 1.1);
});
} else if (type !== MapAreaType. && label) {
// 非描述区显示标签
ctx.fillStyle = colorConfig.getColor('common.color') || (get(theme, 'color') ?? '');
ctx.font = `${fontSize}px/${lineHeight} ${fontFamily}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(label, x + w / 2, y - fontSize * lineHeight);
}
// 门区域:右上角角标显示门状态
if ((type as any) === DOOR_AREA_TYPE) {
const isConnected = (pen.area as any)?.isConnected;
const deviceStatus = (pen.area as any)?.deviceStatus;
const r2 = Math.max(6, Math.min(12, Math.min(w, h) * 0.08));
const cx2 = x + w - r2 - 6;
const cy2 = y + r2 + 6;
ctx.beginPath();
ctx.arc(cx2, cy2, r2, 0, Math.PI * 2);
if (isConnected === false) {
ctx.fillStyle = colorConfig.getColor('autoDoor.strokeClosed') || '#fe5a5ae0';
} else if (deviceStatus === 1) {
ctx.fillStyle = colorConfig.getColor('autoDoor.fillOpen') || '#1890FF';
} else {
ctx.fillStyle = colorConfig.getColor('autoDoor.fillClosed') || '#cddc39';
}
ctx.fill();
}
ctx.restore();
}
// 辅助函数仅在本模块内部使用
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];
}
import { MapAreaType, type MapPen, MapPointType, MapRoutePassType, MapRouteType, type Point } from '@api/map';
import { DOOR_AREA_TYPE } from '@api/map/door-area';
import sTheme from '@core/theme.service';
import { type Meta2dStore } from '@meta2d/core';
import { get } from 'lodash-es';
import doorClosedUrl from '../../assets/icons/png/guanmen.png';
import doorOpenUrl from '../../assets/icons/png/weixinkaimen.png';
import colorConfig from '../color/color-config.service';
const __doorImgOpen = new Image();
__doorImgOpen.src = (doorOpenUrl as unknown as string) || '';
const __doorImgClosed = new Image();
__doorImgClosed.src = (doorClosedUrl as unknown as string) || '';
export 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, isConnected, deviceStatus, active: pointActive } = pen.point ?? {};
const { label = '', statusStyle } = pen ?? {};
ctx.save();
// 自动门点:根据连接与开关状态绘制矩形光圈(无边框)
if (type === MapPointType. && pointActive) {
// 让光圈随点位尺寸等比缩放,避免缩放画布时视觉上变大
const base = Math.min(w, h);
const padding = Math.max(2, Math.min(10, base * 0.2));
const rx = x - padding;
const ry = y - padding;
const rw = w + padding * 2;
const rh = h + padding * 2;
// 使用与点位相同的圆角半径,使观感统一
ctx.beginPath();
ctx.roundRect(rx, ry, rw, rh, r);
if (isConnected === false) {
// 未连接:深红色实心,不描边
ctx.fillStyle = colorConfig.getColor('autoDoor.strokeClosed') || '#fe5a5ae0';
} else {
// 已连接根据门状态显示颜色0=关门-浅红1=开门-蓝色)
if (deviceStatus === 0) {
ctx.fillStyle = colorConfig.getColor('autoDoor.fillClosed') || '#cddc39';
} else {
ctx.fillStyle = colorConfig.getColor('autoDoor.fillOpen') || '#1890FF';
}
}
ctx.fill();
// 重置路径,避免后续对点位边框的 stroke 影响到光圈路径
ctx.beginPath();
}
switch (type) {
case MapPointType.:
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();
// 优先使用小点位专用颜色,如果没有则使用类型专用颜色
const smallGeneralColor = colorConfig.getColor(`point.small.fill.${type}`);
const smallTypeColor = colorConfig.getColor(`point.types.${type}.fill`);
const smallThemeColor = get(theme, `point-s.fill-${type}`);
const finalColor = smallGeneralColor || smallTypeColor || smallThemeColor || '';
ctx.fillStyle = finalColor;
ctx.fill();
const smallTypeStrokeColor = colorConfig.getColor(`point.types.${type}.stroke`);
const smallGeneralStrokeColor = colorConfig.getColor(active ? 'point.small.strokeActive' : 'point.small.stroke');
const smallThemeStrokeColor = get(theme, active ? 'point-s.strokeActive' : 'point-s.stroke');
ctx.strokeStyle = smallTypeStrokeColor || smallGeneralStrokeColor || smallThemeStrokeColor || '';
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);
// 优先使用类型专用颜色,如果没有则使用通用颜色
const largeTypeStrokeColor = colorConfig.getColor(`point.types.${type}.stroke`);
const largeGeneralStrokeColor = colorConfig.getColor(active ? 'point.large.strokeActive' : 'point.large.stroke');
const largeThemeStrokeColor = get(theme, active ? 'point-l.strokeActive' : 'point-l.stroke');
ctx.strokeStyle = statusStyle ?? (largeTypeStrokeColor || largeGeneralStrokeColor || largeThemeStrokeColor || '');
ctx.stroke();
break;
}
default:
break;
}
ctx.fillStyle = colorConfig.getColor('common.color') || (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();
}
export function anchorPoint(pen: MapPen): void {
pen.anchors = [{ penId: pen.id, id: '0', x: 0.5, y: 0.5 }];
}
export 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();
// 根据路线通行类型获取颜色
let routeColor = '';
if (active) {
routeColor = colorConfig.getColor('route.strokeActive') || get(theme, 'route.strokeActive') || '';
} else {
// 根据通行类型选择颜色
switch (pass) {
case MapRoutePassType.:
routeColor = colorConfig.getColor('route.strokeNone') || get(theme, 'route.stroke-0') || '';
break;
case MapRoutePassType.:
routeColor = colorConfig.getColor('route.strokeEmpty') || get(theme, 'route.stroke-empty') || '';
break;
case MapRoutePassType.:
routeColor = colorConfig.getColor('route.strokeLoaded') || get(theme, 'route.stroke-loaded') || '';
break;
case MapRoutePassType.:
routeColor = colorConfig.getColor('route.strokeForbidden') || get(theme, 'route.stroke-forbidden') || '';
break;
default:
// 无限制路线使用无路线颜色作为默认
routeColor = colorConfig.getColor('route.strokeNone') || get(theme, 'route.stroke-0') || '';
break;
}
}
ctx.strokeStyle = routeColor;
// 使用配置的路线宽度
const routeWidth = colorConfig.getRouteWidth(active);
ctx.lineWidth = routeWidth * 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();
}
export 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) };
}
export 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 };
}
export 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 = '', desc = '' } = pen ?? {};
ctx.save();
ctx.rect(x, y, w, h);
// 填充颜色:优先通用,再类型,再主题
const generalFillColor = colorConfig.getColor(`area.fill.${type}`);
const typeFillColor = colorConfig.getColor(`area.types.${type}.fill`);
const themeFillColor = get(theme, `area.fill-${type}`);
const finalFillColor =
generalFillColor || typeFillColor || themeFillColor || colorConfig.getColor('area.fill.1') || '#e6f4ff33';
ctx.fillStyle = finalFillColor;
ctx.fill();
// 边框颜色与样式
const borderColor = type ? colorConfig.getAreaBorderColor(type) : '';
const generalStrokeColor = colorConfig.getColor(active ? 'area.strokeActive' : `area.stroke.${type}`);
const typeStrokeColor = colorConfig.getColor(`area.types.${type}.stroke`);
const themeStrokeColor = get(theme, active ? 'area.strokeActive' : `area.stroke-${type}`);
const borderWidth = type ? colorConfig.getAreaBorderWidth(type) : 1;
const borderOpacity = type ? colorConfig.getAreaBorderOpacity(type) : 0.15;
ctx.lineWidth = borderWidth;
ctx.setLineDash([]);
let finalStrokeColor = borderColor || generalStrokeColor || typeStrokeColor || themeStrokeColor || '';
if (borderOpacity < 1 && finalStrokeColor.startsWith('#')) {
const alpha = Math.round(borderOpacity * 255)
.toString(16)
.padStart(2, '0');
finalStrokeColor = finalStrokeColor + alpha;
}
if (!finalStrokeColor) {
finalStrokeColor = colorConfig.getColor('area.strokeActive') || '#8C8C8C';
}
// 门区域断连:加粗、红色(不闪烁)
if ((type as any) === DOOR_AREA_TYPE) {
const isConnected = (pen.area as any)?.isConnected;
if (isConnected === false) {
finalStrokeColor = '#ff4d4f';
ctx.lineWidth = Math.max(borderWidth * 2, 4);
ctx.setLineDash([]);
}
}
ctx.strokeStyle = finalStrokeColor;
ctx.stroke();
// 门区域图标背景:根据设备连接与开关状态绘制淡化图标
if ((type as any) === DOOR_AREA_TYPE) {
const isConnected = (pen.area as any)?.isConnected;
const deviceStatus = (pen.area as any)?.deviceStatus;
const img = isConnected === false ? __doorImgClosed : deviceStatus === 1 ? __doorImgOpen : __doorImgClosed;
if (img && img.complete) {
const padding = Math.max(6, Math.min(20, Math.min(w, h) * 0.08));
const availW = Math.max(0, w - padding * 2);
const availH = Math.max(0, h - padding * 2);
const ratio = img.naturalWidth > 0 && img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 1;
let drawW = availW;
let drawH = drawW / ratio;
if (drawH > availH) {
drawH = availH;
drawW = drawH * ratio;
}
const dx = x + (w - drawW) / 2;
const dy = y + (h - drawH) / 2;
// 按原图不透明绘制
ctx.drawImage(img, dx, dy, drawW, drawH);
}
}
// 描述区渲染文字
if (type === MapAreaType. && desc) {
ctx.fillStyle = colorConfig.getColor('common.color') || (get(theme, 'color') ?? '');
let descFontSize = Math.min(w / 6, h / 4, 200);
let lines: string[] = [];
while (descFontSize > 1) {
ctx.font = `${descFontSize}px ${fontFamily}`;
const maxCharsPerLine = Math.floor(w / (descFontSize * 0.8));
if (maxCharsPerLine < 1) {
descFontSize = Math.floor(descFontSize * 0.9);
continue;
}
lines = [];
for (let i = 0; i < desc.length; i += maxCharsPerLine) {
lines.push(desc.slice(i, i + maxCharsPerLine));
}
const textMetrics = ctx.measureText('测试文字');
const lh = textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent || descFontSize;
const totalHeight = lines.length * lh * 1.1;
if (totalHeight <= h * 0.9) break;
descFontSize = Math.floor(descFontSize * 0.9);
}
ctx.font = `${descFontSize}px ${fontFamily}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
const textMetrics = ctx.measureText('测试文字');
const lh = textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent || descFontSize;
const totalHeight = lines.length * lh * 1.1;
const startY = y + h / 2 - totalHeight / 2;
lines.forEach((line, index) => {
ctx.fillText(line, x + w / 2, startY + index * lh * 1.1);
});
} else if (type !== MapAreaType. && label) {
// 非描述区显示标签
ctx.fillStyle = colorConfig.getColor('common.color') || (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 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];
}