From 7d0abea45ea974f30136de35ce045119974ca1cf Mon Sep 17 00:00:00 2001 From: xudan Date: Tue, 21 Oct 2025 17:02:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(auto-door):=20=E6=9B=B4=E6=96=B0=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E9=97=A8=E8=AE=BE=E5=A4=87=E7=8A=B6=E6=80=81=E6=A8=A1?= =?UTF-8?q?=E6=8B=9F=E9=80=BB=E8=BE=91=EF=BC=8C=E4=BC=98=E5=8C=96=E9=97=A8?= =?UTF-8?q?=E5=8C=BA=E5=9F=9F=E9=85=8D=E8=89=B2=E4=B8=8E=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.development | 2 +- src/assets/icons/png/guanmen.png | Bin 0 -> 574 bytes src/assets/icons/png/weixinkaimen.png | Bin 0 -> 418 bytes src/pages/movement-supervision.vue | 15 +- src/services/color/color-config.service.ts | 29 +- src/services/editor/editor-drawers.ts | 897 +++++++++++---------- 6 files changed, 486 insertions(+), 457 deletions(-) create mode 100644 src/assets/icons/png/guanmen.png create mode 100644 src/assets/icons/png/weixinkaimen.png diff --git a/.env.development b/.env.development index 8157535..0cafeed 100644 --- a/.env.development +++ b/.env.development @@ -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 diff --git a/src/assets/icons/png/guanmen.png b/src/assets/icons/png/guanmen.png new file mode 100644 index 0000000000000000000000000000000000000000..556700a0e0faabd9000b3229f8e5b7c783123385 GIT binary patch literal 574 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezr3(FtK{NIEGZrc{}T2uCSwk+y2!D z+}W2gIkCoQ9gr0$-w<-Zk97z00;b7a9##cf9V;7NCsaP}W);d-ZZX-Z@q5YgxffsN zJ-5CqpMLpeXR(=sSElAm*|>!Mz&E?s-JTk>FUM^5#D6O4%m#lHGP`Z<#Hu}~x7>Km z@|@|;W*yVQZu73>318UeW;3mcSY7giooD})lKyj2mwZp74^p)P!XETn_V{hgpaD%jVUq)>%Gc>X)$E%=@7!y?+h+4dsnBN0i&n=`i2< zbjNZ3LAmB-wjgO9gGb);-o9r1w&T*_cttsbk~GN!KfAK$*)Pl7uludy$c34?4f_*c zY+;zWFTg#SXxipI!}U9Ryfk~SaQ8g1<$F{zMRHZK)m3huJy+HzU-N!rm?%>1Ip1{e zk$U^IQVzpSGWV;Vz4_+mSm0*UA-LiA;qU`dPh%MNXLk3W&1QNNv3d^A1A!!g{P@fE o)j9@U?{^%o$*Wj?SpBTOS@<;v-ZgvY0@DS9r>mdKI;Vst0Dc_&oB#j- literal 0 HcmV?d00001 diff --git a/src/assets/icons/png/weixinkaimen.png b/src/assets/icons/png/weixinkaimen.png new file mode 100644 index 0000000000000000000000000000000000000000..92b98b64cf83ffeaa6ff19a4d43d04335e865264 GIT binary patch literal 418 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezr3(FlKtXIEGZrd3*D%FO#D{`^TGH zJA~G?ymna5w2?`dQR~Bt2Ud*Q64uf4KObT`=n~nsJ*DpCWYfAEXXod)sPOBt-HE!| za5U-S(kW?wEMFC!<9)tj>dHWE6CUSBduA*?J^l34=yN-#db;!WT4lXZ370*fzWdpX z$+NThPsrSx(qpscheOrP$tyY-_Xit&5@62KkgV)rGzCf=X^`@AlS^{oRaNFUT+lXY zV!K3u^T`v3kFwW%I&l6w=et`4dnN!yyZJTv3&Qo4v8S$Jpv)D;gdo+<46#8CcK zh`Ddd@9jdOHnS|x10z_4fVboFyt=akR{0N%j1NB{r; literal 0 HcmV?d00001 diff --git a/src/pages/movement-supervision.vue b/src/pages/movement-supervision.vue index 9f54018..6f9d6f6 100644 --- a/src/pages/movement-supervision.vue +++ b/src/pages/movement-supervision.vue @@ -80,6 +80,8 @@ const client = shallowRef(); // 模拟门设备WS推送(仅开发调试使用) let doorMockTimer: number | undefined; let doorMockStatus: 0 | 1 = 0; +// 模拟门设备断连/连通的节奏控制 +let doorMockTick = 0; // 左侧边栏元素引用(用于 Ctrl/Cmd+F 聚焦搜索框) const leftSiderEl = shallowRef(); const isPlaybackControllerVisible = ref(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, diff --git a/src/services/color/color-config.service.ts b/src/services/color/color-config.service.ts index e0d4734..d14ad84 100644 --- a/src/services/color/color-config.service.ts +++ b/src/services/color/color-config.service.ts @@ -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: { diff --git a/src/services/editor/editor-drawers.ts b/src/services/editor/editor-drawers.ts index af5623a..8bb00d2 100644 --- a/src/services/editor/editor-drawers.ts +++ b/src/services/editor/editor-drawers.ts @@ -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]; +}