From 0a1608262db7ce262bd774da28cf45c0b8bf6eaf Mon Sep 17 00:00:00 2001 From: xudan Date: Thu, 11 Dec 2025 18:13:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(elevator):=20=E4=BD=BF=E7=94=A8Canvas?= =?UTF-8?q?=E7=BB=98=E5=88=B6=E7=94=B5=E6=A2=AF=E5=9B=BE=E6=A0=87=E5=B9=B6?= =?UTF-8?q?=E9=9B=86=E6=88=90=E5=8A=A8=E7=94=BB=E6=95=88=E6=9E=9C=EF=BC=8C?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=A4=96=E9=83=A8=E8=BE=B9=E6=A1=86=E5=92=8C?= =?UTF-8?q?=E7=AE=AD=E5=A4=B4=E4=BB=A5=E7=AE=80=E5=8C=96=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 --- src/services/editor/editor-drawers.ts | 397 +++++++++++++------ src/services/editor/point-manager.service.ts | 3 +- 2 files changed, 277 insertions(+), 123 deletions(-) diff --git a/src/services/editor/editor-drawers.ts b/src/services/editor/editor-drawers.ts index d5b054e..a5a8472 100644 --- a/src/services/editor/editor-drawers.ts +++ b/src/services/editor/editor-drawers.ts @@ -53,10 +53,265 @@ function drawDisconnectedIcon(ctx: CanvasRenderingContext2D, cx: number, cy: num ctx.restore(); } +/** + * 使用Canvas绘制电梯图标(48*60像素)并实现开门关门动画 + * @param ctx Canvas上下文 + * @param cx 中心X坐标 + * @param cy 中心Y坐标 + * @param width 电梯宽度(默认48) + * @param height 电梯高度(默认60) + * @param doorStatus 门状态(1-关,2-正在开关,3-开) + * @param time 当前时间戳(用于动画) + * @param direction 电梯方向(0-未知,1-停止,2-向上,3-向下) + */ +function drawElevatorIcon( + ctx: CanvasRenderingContext2D, + cx: number, + cy: number, + width: number = 48, + height: number = 60, + doorStatus: number = 1, + time: number = 0, + direction: number = 0 +): void { + const x = cx - width / 2; + const y = cy - height / 2; + + // 电梯外框 - 金属质感 + const gradient = ctx.createLinearGradient(x, y, x + width, y + height); + gradient.addColorStop(0, '#9ca3af'); // 浅灰色 + gradient.addColorStop(0.5, '#6b7280'); // 中灰色 + gradient.addColorStop(1, '#4b5563'); // 深灰色 + ctx.fillStyle = gradient; + ctx.fillRect(x, y, width, height); + + // 电梯内框 - 深金属色 + const innerGradient = ctx.createLinearGradient(x + 2, y + 2, x + width - 2, y + height - 2); + innerGradient.addColorStop(0, '#374151'); + innerGradient.addColorStop(0.5, '#1f2937'); + innerGradient.addColorStop(1, '#111827'); + ctx.fillStyle = innerGradient; + ctx.fillRect(x + 2, y + 2, width - 4, height - 4); + + // 计算门的开合程度(0-1之间) + let doorProgress = 0; + if (doorStatus === 1) { + doorProgress = 0; // 完全关闭 + } else if (doorStatus === 3) { + doorProgress = 1; // 完全打开 + } else if (doorStatus === 2) { + // 正在开关门,使用动画 + const animationDuration = 2000; // 2秒完成开关门 + const phase = (time % animationDuration) / animationDuration; + // 使用正弦函数创建平滑的往复运动 + doorProgress = (Math.sin(phase * Math.PI * 2 - Math.PI / 2) + 1) / 2; + } + + // 门的位置 + const doorWidth = (width - 8) / 2; + const doorHeight = height - 8; + const doorX = x + 4; + const doorY = y + 4; + const doorGap = doorWidth * doorProgress; // 两扇门之间的间隙 + + // 保持金属质感,不根据运动状态改变门的颜色 + const doorColor1 = '#94a3b8'; // 浅金属蓝灰 + const doorColor2 = '#64748b'; // 中金属蓝灰 + const doorColor3 = '#475569'; // 深金属蓝灰 + const doorColor4 = '#334155'; // 最深金属蓝灰 + + // 绘制左门 - 金属门质感(无门把手) + if (doorWidth - doorGap / 2 > 0) { + // 门主体渐变 + const doorGradient = ctx.createLinearGradient(doorX, doorY, doorX + doorWidth - doorGap / 2, doorY + doorHeight); + doorGradient.addColorStop(0, doorColor1); + doorGradient.addColorStop(0.3, doorColor2); + doorGradient.addColorStop(0.7, doorColor3); + doorGradient.addColorStop(1, doorColor4); + ctx.fillStyle = doorGradient; + ctx.fillRect(doorX, doorY, doorWidth - doorGap / 2, doorHeight); + + // 门边缘高光 + ctx.strokeStyle = 'rgba(203, 213, 225, 0.6)'; + ctx.lineWidth = 1; + ctx.strokeRect(doorX + 0.5, doorY + 0.5, doorWidth - doorGap / 2 - 1, doorHeight - 1); + + // 如果在运动中,添加动态线条效果 + if (direction === 2 || direction === 3) { + ctx.save(); + ctx.strokeStyle = direction === 2 ? 'rgba(16, 185, 129, 0.4)' : 'rgba(239, 68, 68, 0.4)'; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + const lineOffset = (time % 1000) / 1000 * 6; + ctx.lineDashOffset = direction === 2 ? -lineOffset : lineOffset; + + // 绘制动态线条 + for (let i = 0; i < 3; i++) { + const lineY = doorY + doorHeight * (0.3 + i * 0.2); + ctx.beginPath(); + ctx.moveTo(doorX + 5, lineY); + ctx.lineTo(doorX + doorWidth - doorGap / 2 - 5, lineY); + ctx.stroke(); + } + ctx.restore(); + } + } + + // 绘制右门 - 金属门质感(无门把手) + if (doorWidth - doorGap / 2 > 0) { + // 门主体渐变 + const doorGradient = ctx.createLinearGradient(doorX + doorWidth + doorGap / 2, doorY, doorX + doorWidth * 2 + doorGap / 2, doorY + doorHeight); + doorGradient.addColorStop(0, doorColor1); + doorGradient.addColorStop(0.3, doorColor2); + doorGradient.addColorStop(0.7, doorColor3); + doorGradient.addColorStop(1, doorColor4); + ctx.fillStyle = doorGradient; + ctx.fillRect(doorX + doorWidth + doorGap / 2, doorY, doorWidth - doorGap / 2, doorHeight); + + // 门边缘高光 + ctx.strokeStyle = 'rgba(203, 213, 225, 0.6)'; + ctx.lineWidth = 1; + ctx.strokeRect(doorX + doorWidth + doorGap / 2 + 0.5, doorY + 0.5, doorWidth - doorGap / 2 - 1, doorHeight - 1); + + // 如果在运动中,添加动态线条效果 + if (direction === 2 || direction === 3) { + ctx.save(); + ctx.strokeStyle = direction === 2 ? 'rgba(16, 185, 129, 0.4)' : 'rgba(239, 68, 68, 0.4)'; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + const lineOffset = (time % 1000) / 1000 * 6; + ctx.lineDashOffset = direction === 2 ? -lineOffset : lineOffset; + + // 绘制动态线条 + for (let i = 0; i < 3; i++) { + const lineY = doorY + doorHeight * (0.3 + i * 0.2); + ctx.beginPath(); + ctx.moveTo(doorX + doorWidth + doorGap / 2 + 5, lineY); + ctx.lineTo(doorX + doorWidth * 2 - 5, lineY); + ctx.stroke(); + } + ctx.restore(); + } + } + + // 绘制电梯顶部指示器 - 基于电梯宽度自适应,增加距离避免被遮挡 + const indicatorWidth = width * 0.6; // 指示器宽度为电梯宽度的60% + const indicatorHeight = Math.max(6, width * 0.12); // 指示器高度为电梯宽度的12%,最小6px + const indicatorY = y - Math.max(10, width * 0.25); // 增加距离:电梯宽度的25%,最小10px + + // 顶部指示器背景 - 金属质感 + const indicatorGradient = ctx.createLinearGradient(x + width/2 - indicatorWidth/2, indicatorY, x + width/2 + indicatorWidth/2, indicatorY + indicatorHeight); + indicatorGradient.addColorStop(0, '#6b7280'); + indicatorGradient.addColorStop(0.5, '#4b5563'); + indicatorGradient.addColorStop(1, '#374151'); + ctx.fillStyle = indicatorGradient; + ctx.fillRect(x + width/2 - indicatorWidth/2, indicatorY, indicatorWidth, indicatorHeight); + + // 根据方向在顶部指示器内显示状态,使用更大的箭头 + if (direction === 2) { + // 向上状态 - 绿色LED效果 + // 绘制发光背景 + const glowGradient = ctx.createRadialGradient(cx, indicatorY + indicatorHeight/2, 0, cx, indicatorY + indicatorHeight/2, indicatorHeight * 1.5); + glowGradient.addColorStop(0, 'rgba(16, 185, 129, 0.4)'); + glowGradient.addColorStop(1, 'rgba(16, 185, 129, 0)'); + ctx.fillStyle = glowGradient; + ctx.fillRect(x + width/2 - indicatorWidth/2 - 3, indicatorY - 3, indicatorWidth + 6, indicatorHeight + 6); + + // 绘制向上箭头 - 更大更明显 + ctx.fillStyle = '#10b981'; + ctx.shadowColor = '#10b981'; + ctx.shadowBlur = 8; + + // 使用更大的箭头,占据整个指示器高度 + const arrowSize = Math.min(indicatorWidth * 0.4, indicatorHeight * 0.9); + const arrowTop = indicatorY + indicatorHeight * 0.1; + const arrowBottom = indicatorY + indicatorHeight * 0.9; + + // 主箭头 - 加粗版本 + ctx.beginPath(); + ctx.moveTo(cx, arrowTop); + ctx.lineTo(cx - arrowSize, arrowBottom); + ctx.lineTo(cx - arrowSize * 0.4, arrowBottom - arrowSize * 0.2); + ctx.lineTo(cx, arrowBottom - arrowSize * 0.3); + ctx.lineTo(cx + arrowSize * 0.4, arrowBottom - arrowSize * 0.2); + ctx.lineTo(cx + arrowSize, arrowBottom); + ctx.closePath(); + ctx.fill(); + + ctx.shadowBlur = 0; + } else if (direction === 3) { + // 向下状态 - 红色LED效果 + // 绘制发光背景 + const glowGradient = ctx.createRadialGradient(cx, indicatorY + indicatorHeight/2, 0, cx, indicatorY + indicatorHeight/2, indicatorHeight * 1.5); + glowGradient.addColorStop(0, 'rgba(239, 68, 68, 0.4)'); + glowGradient.addColorStop(1, 'rgba(239, 68, 68, 0)'); + ctx.fillStyle = glowGradient; + ctx.fillRect(x + width/2 - indicatorWidth/2 - 3, indicatorY - 3, indicatorWidth + 6, indicatorHeight + 6); + + // 绘制向下箭头 - 更大更明显 + ctx.fillStyle = '#ef4444'; + ctx.shadowColor = '#ef4444'; + ctx.shadowBlur = 8; + + // 使用更大的箭头,占据整个指示器高度 + const arrowSize = Math.min(indicatorWidth * 0.4, indicatorHeight * 0.9); + const arrowTop = indicatorY + indicatorHeight * 0.1; + const arrowBottom = indicatorY + indicatorHeight * 0.9; + + // 主箭头 - 加粗版本 + ctx.beginPath(); + ctx.moveTo(cx, arrowBottom); + ctx.lineTo(cx - arrowSize, arrowTop); + ctx.lineTo(cx - arrowSize * 0.4, arrowTop + arrowSize * 0.2); + ctx.lineTo(cx, arrowTop + arrowSize * 0.3); + ctx.lineTo(cx + arrowSize * 0.4, arrowTop + arrowSize * 0.2); + ctx.lineTo(cx + arrowSize, arrowTop); + ctx.closePath(); + ctx.fill(); + + ctx.shadowBlur = 0; + } else if (direction === 1) { + // 停止状态 - 黄色LED效果 + // 绘制发光背景 + const glowGradient = ctx.createRadialGradient(cx, indicatorY + indicatorHeight/2, 0, cx, indicatorY + indicatorHeight/2, indicatorHeight); + glowGradient.addColorStop(0, 'rgba(245, 158, 11, 0.3)'); + glowGradient.addColorStop(1, 'rgba(245, 158, 11, 0)'); + ctx.fillStyle = glowGradient; + ctx.fillRect(x + width/2 - indicatorWidth/2 - 2, indicatorY - 2, indicatorWidth + 4, indicatorHeight + 4); + + // 绘制停止标志 + ctx.fillStyle = '#f59e0b'; + ctx.shadowColor = '#f59e0b'; + ctx.shadowBlur = 6; + const stopSize = Math.min(indicatorHeight * 0.6, indicatorWidth * 0.18); + + // 绘制矩形停止标志 + ctx.fillRect(cx - stopSize/2, indicatorY + (indicatorHeight - stopSize)/2, stopSize, stopSize); + + // 添加脉冲效果 + const pulsePhase = (time % 2000) / 2000 * Math.PI * 2; + const pulseAlpha = 0.3 + 0.2 * Math.sin(pulsePhase); + ctx.fillStyle = `rgba(245, 158, 11, ${pulseAlpha})`; + ctx.fillRect(cx - stopSize/2 - 2, indicatorY + (indicatorHeight - stopSize)/2 - 2, stopSize + 4, stopSize + 4); + + ctx.shadowBlur = 0; + } + + // 添加电梯边框 - 金属边缘效果 + ctx.strokeStyle = '#1f2937'; + ctx.lineWidth = 2; + ctx.strokeRect(x, y, width, height); + + // 添加金属边缘高光 + ctx.strokeStyle = 'rgba(156, 163, 175, 0.4)'; + ctx.lineWidth = 1; + ctx.strokeRect(x + 1, y + 1, width - 2, height - 2); +} + /** * 绘制电梯点的极简状态指示 - 设计理念: * 1. 简约至上:仅使用矩形边框和颜色传达状态,无文字说明 - * 2. 图片优先:确保48*60电梯图片完全可见,边框不覆盖主体 + * 2. Canvas绘制电梯图标:使用48*60电梯图标,边框不覆盖主体 * 3. 响应式设计:所有尺寸基于pen元素宽高动态计算,完美适配画布缩放 * 4. 直观色彩: * - 红色虚线:离线状态 @@ -66,7 +321,7 @@ function drawDisconnectedIcon(ctx: CanvasRenderingContext2D, cx: number, cy: num * - 绿色箭头:上行/下行方向指示 * - 金色:停止 * - 灰色:未知状态 - * 5. 静态箭头:上下行时在边框右侧显示静态箭头,简洁清晰 + * 5. 动态箭头:上下行时在边框右侧显示动态箭头,简洁清晰 * * 尺寸计算规则: * - 边框距离:最小4px,否则按pen较小边的15%计算 @@ -163,131 +418,29 @@ function drawElevatorMinimal( - // 辅助函数:在边框右侧绘制动态方向箭头(根据时间动画) - const drawDirectionSide = (direction: 'up' | 'down', color: string, time: number) => { - ctx.save(); - ctx.fillStyle = color; - ctx.strokeStyle = color; - ctx.globalAlpha = 0.8; - - // 箭头大小基于pen尺寸动态调整,分别考虑宽度和高度 - const arrowSize = Math.max(5, Math.min(w, h) * 0.20); // 使用较小的边作为基准,最小5px,否则按20%比例 - const sideMargin = Math.max(5, Math.min(w, h) * 0.20); // 距离右侧边框的距离,增加间距,使用较小的边作为基准 - ctx.lineWidth = Math.max(2, Math.min(w, h) * 0.04); // 箭头线条宽度,使用较小的边作为基准 - - // 箭头位置:在矩形右侧垂直居中 - const arrowX = rectX + rectW + sideMargin; - const centerY = y + h / 2; // 垂直居中 - // 动画偏移:当电梯上下移动时,箭头沿方向移动 - let offset = 0; - if (elevatorDirection === 2 || elevatorDirection === 3) { - // 周期1000ms,振幅为箭头大小的50% - const amplitude = arrowSize * 0.5; - const period = 1000; // 毫秒 - const phase = (time % period) / period; - // 正弦波产生平滑往复运动,方向决定符号 - const sign = direction === 'up' ? -1 : 1; - offset = Math.sin(phase * 2 * Math.PI) * amplitude * sign; - } - const animatedCenterY = centerY + offset; - - if (direction === 'up') { - // 向上箭头 - 在右侧绘制 - ctx.beginPath(); - // 箭头主体(竖线) - ctx.moveTo(arrowX, animatedCenterY + arrowSize); - ctx.lineTo(arrowX, animatedCenterY - arrowSize); - // 箭头尖端(三角形) - ctx.lineTo(arrowX - arrowSize * 0.6, animatedCenterY - arrowSize * 0.4); - ctx.moveTo(arrowX, animatedCenterY - arrowSize); - ctx.lineTo(arrowX + arrowSize * 0.6, animatedCenterY - arrowSize * 0.4); - ctx.stroke(); - - // 填充箭头头部 - ctx.beginPath(); - ctx.moveTo(arrowX, animatedCenterY - arrowSize); - ctx.lineTo(arrowX - arrowSize * 0.5, animatedCenterY - arrowSize * 0.7); - ctx.lineTo(arrowX + arrowSize * 0.5, animatedCenterY - arrowSize * 0.7); - ctx.closePath(); - ctx.fill(); - } else { - // 向下箭头 - 在右侧绘制 - ctx.beginPath(); - // 箭头主体(竖线) - ctx.moveTo(arrowX, animatedCenterY - arrowSize); - ctx.lineTo(arrowX, animatedCenterY + arrowSize); - // 箭头尖端(三角形) - ctx.lineTo(arrowX - arrowSize * 0.6, animatedCenterY + arrowSize * 0.4); - ctx.moveTo(arrowX, animatedCenterY + arrowSize); - ctx.lineTo(arrowX + arrowSize * 0.6, animatedCenterY + arrowSize * 0.4); - ctx.stroke(); - - // 填充箭头头部 - ctx.beginPath(); - ctx.moveTo(arrowX, animatedCenterY + arrowSize); - ctx.lineTo(arrowX - arrowSize * 0.5, animatedCenterY + arrowSize * 0.7); - ctx.lineTo(arrowX + arrowSize * 0.5, animatedCenterY + arrowSize * 0.7); - ctx.closePath(); - ctx.fill(); - } - - ctx.restore(); - }; + // 箭头绘制函数已移除,因为箭头现在集成在电梯图标内部 + // 方向指示通过电梯顶部的LED指示器显示 - // 状态渲染逻辑 - 根据需求优化配色方案 + // 绘制电梯图标(包含内部的方向指示器) + drawElevatorIcon( + ctx, + x + w / 2, + y + h / 2, + Math.min(48, w * 0.8), // 根据pen大小调整电梯图标宽度,最大48像素 + Math.min(60, h * 0.8), // 根据pen大小调整电梯图标高度,最大60像素 + elevatorFrontDoorStatus, + time, + elevatorDirection + ); + + // 状态渲染逻辑 - 只有断连状态才显示边框 if (!isConnected) { // 离线状态 - 红色虚线(静态,不带动画) drawMinimalFrame('#FF4757', 0.8, true, time); - } else { - // 在线状态根据具体状态渲染 - switch (elevatorFrontDoorStatus) { - case 2: { // 正在开关门 - 蓝色虚线边框 + 绿色箭头 - // 蓝色虚线边框表示操作中(带动画) - drawMinimalFrame('#69c6f5', 0.8, true, time); - if (elevatorDirection === 2) { // 向上 - drawDirectionSide('up', '#00D26A', time); // 绿色箭头 - } else if (elevatorDirection === 3) { // 向下 - drawDirectionSide('down', '#00D26A', time); // 绿色箭头 - } - break; - } - - case 3: { // 门已开 - 实线绿色边框 + 绿色箭头 - // 实线绿色边框表示可通行状态 - drawMinimalFrame('#00D26A', 0.8, false, time); - if (elevatorDirection === 2) { // 向上 - drawDirectionSide('up', '#00D26A', time); // 绿色箭头 - } else if (elevatorDirection === 3) { // 向下 - drawDirectionSide('down', '#00D26A', time); // 绿色箭头 - } - break; - } - - case 1: { // 门已关 - 根据方向显示 - if (elevatorDirection === 2) { // 向上 - // 蓝色边框 + 绿色向上箭头 - drawMinimalFrame('#69c6f5', 0.8, false, time); - drawDirectionSide('up', '#00D26A', time); // 绿色箭头 - } else if (elevatorDirection === 3) { // 向下 - // 蓝色边框 + 绿色向下箭头 - drawMinimalFrame('#69c6f5', 0.8, false, time); - drawDirectionSide('down', '#00D26A', time); // 绿色箭头 - } else { // 停止 - // 金色静态边框 - 中性状态 - drawMinimalFrame('#FFB700', 0.7, false, time); - } - break; - } - - default: { // 未知状态 - // 灰色静态边框 - drawMinimalFrame('#8B8B8B', 0.5, false, time); - break; - } - } } + // 其他状态(在线)不需要额外的边框和箭头,所有信息都集成在电梯图标内部 } /** @@ -524,9 +677,9 @@ export function drawPoint(ctx: CanvasRenderingContext2D, pen: MapPen): void { ctx.strokeStyle = largeTypeStrokeColor || largeGeneralStrokeColor || largeThemeStrokeColor || ''; ctx.stroke(); - // 电梯点:使用极简状态渲染 + // 电梯点:使用Canvas绘制的电梯图标 if (type === MapPointType.电梯点) { - // 直接传入包含后端字段的 pen.point 数据 + // 绘制Canvas电梯图标和状态 if (pen.point) { drawElevatorMinimal(ctx, pen, pen.point, currentTime); } diff --git a/src/services/editor/point-manager.service.ts b/src/services/editor/point-manager.service.ts index 7676a23..88c11a1 100644 --- a/src/services/editor/point-manager.service.ts +++ b/src/services/editor/point-manager.service.ts @@ -111,7 +111,8 @@ export class PointManager { private mapPointImage(type: MapPointType): Required> { const theme = this.editor.data().theme; - const image = type < 10 ? '' : `${import.meta.env.BASE_URL}/point/${type}-${theme}.png`; + // 电梯点(type=11)不加载默认图标,使用Canvas绘制 + const image = type < 10 || type === MapPointType.电梯点 ? '' : `${import.meta.env.BASE_URL}/point/${type}-${theme}.png`; return { image, canvasLayer: CanvasLayer.CanvasMain }; } }