web-map/src/services/draw/storage-location-drawer.ts

511 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { MapPen } from '@api/map';
import { LockState } from '@meta2d/core';
import colorConfig from '../color-config.service';
// 预加载锁定图标(仅一次)
const lockedIcon = new Image();
lockedIcon.src = new URL('../../assets/icons/png/locked.png', import.meta.url).toString();
function roundedRectPath(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
const rr = Math.max(0, Math.min(r, Math.min(w, h) / 2));
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
}
/**
* 在点位右上角绘制自适应库位栅格(静态,来自 associatedStorageLocations
* 随画布缩放和平移,由 Meta2D 世界坐标系统自然处理。
*/
export function drawStorageGrid(
ctx: CanvasRenderingContext2D,
pen: MapPen,
opts?: {
fontFamily?: string;
stateResolver?: (penId: string, layerName: string) => { occupied?: boolean; locked?: boolean } | undefined;
},
): void {
const { x = 0, y = 0, width: w = 0, height: h = 0 } = pen.calculative?.worldRect ?? {};
const assoc: string[] = pen.point?.associatedStorageLocations || [];
if (!assoc.length) return;
const fontFamily = opts?.fontFamily ?? pen.calculative?.fontFamily;
// 尺寸与间距(世界坐标)- 放大库位方框
const base = Math.min(w, h);
// 单个库位时使用更大的方框,多个库位时使用正常大小
const cell = Math.max(16, Math.min(32, base * 0.70)); // 基础库位方框大小
const gap = Math.max(1, Math.min(2, base * 0.03)); // 进一步减少间距
// 自适应网格布局:根据库位数量动态调整行列数,确保正方形背景
const totalSlots = assoc.length;
let gridCols: number;
let gridRows: number;
let isSingleSlot = false;
if (totalSlots === 1) {
gridCols = 1;
gridRows = 1; // 单个库位使用1x1布局突出显示
isSingleSlot = true;
} else if (totalSlots <= 2) {
gridCols = 2;
gridRows = 2; // 2个库位时使用2x2正方形
} else if (totalSlots <= 4) {
gridCols = 2;
gridRows = 2; // 3-4个库位时使用2x2正方形
} else if (totalSlots <= 9) {
gridCols = 3;
gridRows = 3; // 5-9个库位时使用3x3正方形
} else if (totalSlots <= 16) {
gridCols = 4;
gridRows = 4; // 10-16个库位时使用4x4正方形
} else {
gridCols = 5;
gridRows = 5; // 17+个库位时使用5x5正方形
}
// 根据库位数量调整方框大小和间距
let finalCell = cell;
let finalGap = gap;
if (isSingleSlot) {
// 单个库位时方框更大
finalCell = Math.max(20, Math.min(40, base * 0.85));
} else {
// 多个库位时边距更大,确保库位之间有明显间距
finalGap = Math.max(6, Math.min(12, base * 0.12));
}
const gridW = gridCols * finalCell + (gridCols - 1) * finalGap;
const gridH = gridRows * finalCell + (gridRows - 1) * finalGap;
// 确保背景是正方形,取较大的尺寸
// 单个库位时使用更小的正方形来突出显示
const squareSize = isSingleSlot ? Math.max(gridW, gridH) * 0.8 : Math.max(gridW, gridH);
const gridW_final = squareSize;
const gridH_final = squareSize;
// 右上角位置 - 进一步减少外边距
const outer = Math.max(0.5, gap * 0.3);
const gx = x + w + outer;
const gy = y - gridH_final - outer;
ctx.save();
// 背景底 - 透明背景,使用正方形尺寸
ctx.beginPath();
roundedRectPath(
ctx,
gx - finalGap * 0.5,
gy - finalGap * 0.5,
gridW_final + finalGap,
gridH_final + finalGap,
Math.min(6, finalCell * 0.4)
);
ctx.fillStyle = 'transparent'; // 透明背景
ctx.fill();
ctx.strokeStyle = 'transparent'; // 透明边框
ctx.stroke();
// 计算库位在正方形背景中的居中位置
const actualGridW = gridCols * finalCell + (gridCols - 1) * finalGap;
const actualGridH = gridRows * finalCell + (gridRows - 1) * finalGap;
const offsetX = (gridW_final - actualGridW) / 2;
const offsetY = (gridH_final - actualGridH) / 2;
// 显示所有库位格子,最后一格可能显示 +N
const slots = assoc.slice(0, gridCols * gridRows);
slots.forEach((layerName, i) => {
const r = Math.floor(i / gridCols);
const c = i % gridCols;
// 库位位置应该是左上角坐标,不是中心点
const cx = gx + offsetX + c * (finalCell + finalGap);
const cy = gy + offsetY + r * (finalCell + finalGap);
ctx.beginPath();
roundedRectPath(ctx, cx, cy, finalCell, finalCell, Math.min(4, finalCell * 0.3));
// 解析状态
const state = opts?.stateResolver?.(pen.id!, layerName) ?? {};
const occupied = !!state.occupied;
const locked = !!state.locked;
// 填充颜色:占用为红色,否则透明
ctx.fillStyle = occupied ? '#ff4d4f' : 'transparent';
ctx.fill();
ctx.strokeStyle = 'transparent';
ctx.stroke();
// 若锁定,绘制锁图标角标(尽量不阻塞:未加载完成则跳过)
if (locked && lockedIcon.complete && lockedIcon.naturalWidth > 0) {
const pad = Math.max(1, Math.floor(finalCell * 0.08));
// 图标尺寸不超过 cell 内可用空间,且有最小可见阈值
const maxIcon = Math.max(0, finalCell - 2 * pad);
const iconSize = Math.min(14, Math.floor(finalCell * 0.7), maxIcon);
// 将绘制范围裁剪到当前格子,确保不会溢出
ctx.save();
ctx.beginPath();
roundedRectPath(ctx, cx, cy, cell, cell, Math.min(4, cell * 0.3));
ctx.clip();
// 放在格子左上角
ctx.drawImage(lockedIcon, cx + pad, cy + pad, iconSize, iconSize);
ctx.restore();
}
});
const overflow = Math.max(0, assoc.length - gridCols * gridRows);
if (overflow > 0) {
const i = gridCols * gridRows - 1;
const r = Math.floor(i / gridCols);
const c = i % gridCols;
// 更多按钮位置也应该是左上角坐标
const cx = gx + offsetX + c * (finalCell + finalGap);
const cy = gy + offsetY + r * (finalCell + finalGap);
ctx.beginPath();
roundedRectPath(ctx, cx, cy, finalCell, finalCell, Math.min(4, finalCell * 0.3));
ctx.fillStyle = '#e6f4ff';
ctx.fill();
ctx.strokeStyle = '#1677ff';
ctx.stroke();
ctx.fillStyle = '#1677ff';
ctx.font = `${Math.floor(finalCell * 0.6)}px/${1} ${fontFamily ?? 'sans-serif'}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('+' + overflow, cx + finalCell / 2, cy + finalCell / 2);
}
ctx.restore();
}
/**
* 绘制库位的自定义函数
* @param ctx Canvas 2D绘制上下文
* @param pen 库位图形对象
*/
export function drawStorageLocation(ctx: CanvasRenderingContext2D, pen: MapPen): void {
const { x = 0, y = 0, width: w = 0, height: h = 0 } = pen.calculative?.worldRect ?? {};
const { occupied = false, locked = false } = pen.storageLocation ?? {};
ctx.save();
// 绘制圆角矩形
const r = Math.min(4, w * 0.3);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
// 使用配置的颜色,如果没有配置则使用默认值
ctx.fillStyle = occupied
? colorConfig.getColor('storage.occupied') || '#ff4d4f'
: colorConfig.getColor('storage.default') || '#f5f5f5';
ctx.fill();
ctx.strokeStyle = '#999999';
ctx.stroke();
// 如果锁定,绘制锁图标
if (locked && lockedIcon.complete && lockedIcon.naturalWidth > 0) {
const pad = Math.max(1, Math.floor(w * 0.1));
// 图标充满整个库位,留少量边距
const iconSize = Math.max(0, w - 2 * pad);
const iconX = x + pad;
const iconY = y + pad;
// 将绘制范围裁剪到当前库位,确保不会溢出
ctx.save();
ctx.beginPath();
const r = Math.min(4, w * 0.3);
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
ctx.clip();
// 绘制锁定图标
ctx.drawImage(lockedIcon, iconX, iconY, iconSize, iconSize);
ctx.restore();
}
ctx.restore();
}
/**
* 绘制"更多"库位按钮的自定义函数
* @param ctx Canvas 2D绘制上下文
* @param pen "更多"按钮图形对象
*/
export function drawStorageMore(ctx: CanvasRenderingContext2D, pen: MapPen): void {
const { x = 0, y = 0, width: w = 0, height: h = 0 } = pen.calculative?.worldRect ?? {};
ctx.save();
// 绘制圆角矩形
const r = Math.min(4, w * 0.3);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
// 使用配置的颜色,如果没有配置则使用默认值
ctx.fillStyle = colorConfig.getColor('storage.moreButton.background') || '#e6f4ff';
ctx.fill();
ctx.strokeStyle = colorConfig.getColor('storage.moreButton.border') || '#1677ff';
ctx.stroke();
// 绘制文字
ctx.fillStyle = colorConfig.getColor('storage.moreButton.text') || '#1677ff';
ctx.font = `${Math.floor(w * 0.6)}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(pen.text || '+0', x + w / 2, y + h / 2);
ctx.restore();
}
/**
* 绘制库位区域背景的自定义函数
* @param ctx Canvas 2D绘制上下文
* @param pen 背景矩形图形对象
*/
export function drawStorageBackground(ctx: CanvasRenderingContext2D, pen: MapPen): void {
const { x = 0, y = 0, width: w = 0, height: h = 0 } = pen.calculative?.worldRect ?? {};
ctx.save();
// 绘制圆角矩形背景
const r = Math.min(6, w * 0.1);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
// 填充半透明背景 - 调整为更偏白色
ctx.fillStyle = 'transparent';
ctx.fill();
ctx.strokeStyle = 'transparent';
ctx.stroke();
ctx.restore();
}
/**
* 创建库位pen对象的工厂函数
* @param pointId 点位ID
* @param storageLocations 库位列表
* @param pointRect 点位矩形信息
* @param storageStateMap 库位状态映射
* @returns 库位相关的pen对象数组
*/
export function createStorageLocationPens(
pointId: string,
storageLocations: string[],
pointRect: { x: number; y: number; width: number; height: number },
storageStateMap: Map<string, Map<string, { occupied?: boolean; locked?: boolean }>>
): MapPen[] {
const { x = 0, y = 0, width: w = 0, height: h = 0 } = pointRect;
// 库位栅格参数 - 与drawStorageGrid保持一致
const base = Math.min(w, h);
const cell = Math.max(16, Math.min(32, base * 0.70)); // 放大库位方框
const gap = Math.max(1, Math.min(2, base * 0.03)); // 进一步减少间距
// 自适应网格布局:根据库位数量动态调整行列数,确保正方形背景
const totalSlots = storageLocations.length;
let gridCols: number;
let gridRows: number;
let isSingleSlot = false;
if (totalSlots === 1) {
gridCols = 1;
gridRows = 1; // 单个库位使用1x1布局突出显示
isSingleSlot = true;
} else if (totalSlots <= 2) {
gridCols = 2;
gridRows = 2; // 2个库位时使用2x2正方形
} else if (totalSlots <= 4) {
gridCols = 2;
gridRows = 2; // 3-4个库位时使用2x2正方形
} else if (totalSlots <= 9) {
gridCols = 3;
gridRows = 3; // 5-9个库位时使用3x3正方形
} else if (totalSlots <= 16) {
gridCols = 4;
gridRows = 4; // 10-16个库位时使用4x4正方形
} else {
gridCols = 5;
gridRows = 5; // 17+个库位时使用5x5正方形
}
// 根据库位数量调整方框大小和间距
let finalCell = cell;
let finalGap = gap;
if (isSingleSlot) {
// 单个库位时方框更大
finalCell = Math.max(20, Math.min(40, base * 0.85));
} else {
// 多个库位时边距更大,确保库位之间有明显间距
finalGap = Math.max(6, Math.min(12, base * 0.12));
}
const gridW = gridCols * finalCell + (gridCols - 1) * finalGap;
const gridH = gridRows * finalCell + (gridRows - 1) * finalGap;
// 确保背景是正方形,取较大的尺寸
// 单个库位时使用更小的正方形来突出显示
const squareSize = isSingleSlot ? Math.max(gridW, gridH) * 0.8 : Math.max(gridW, gridH);
const gridW_final = squareSize;
const gridH_final = squareSize;
// 右上角位置 - getPointRect返回的是中心点坐标需要转换为左上角坐标进一步减少外边距
const outer = Math.max(0.5, gap * 0.3);
const gx = x + w / 2 + outer;
const gy = y - h / 2 - gridH_final - outer;
const pens: MapPen[] = [];
// 创建库位区域背景矩形 - 使用正方形尺寸,透明背景
const backgroundPen: MapPen = {
id: `storage-bg-${pointId}`,
name: 'storage-background',
tags: ['storage-background', `point-${pointId}`],
x: gx - finalGap * 0.5,
y: gy - finalGap * 0.5,
width: gridW_final + finalGap,
height: gridH_final + finalGap,
lineWidth: 1,
background: 'transparent', // 透明背景
color: 'transparent', // 透明边框
locked: LockState.DisableEdit,
visible: true,
borderRadius: Math.min(6, finalCell * 0.4),
};
pens.push(backgroundPen);
// 计算库位在正方形背景中的居中位置
const actualGridW = gridCols * finalCell + (gridCols - 1) * finalGap;
const actualGridH = gridRows * finalCell + (gridRows - 1) * finalGap;
const offsetX = (gridW_final - actualGridW) / 2;
const offsetY = (gridH_final - actualGridH) / 2;
// 创建库位pen对象
storageLocations.slice(0, gridCols * gridRows).forEach((locationName, i) => {
const r = Math.floor(i / gridCols);
const c = i % gridCols;
// 计算库位在背景矩形内的相对位置,然后加上背景矩形的起始位置和居中偏移
// 库位位置应该是左上角坐标,不是中心点
const relativeX = c * (finalCell + finalGap);
const relativeY = r * (finalCell + finalGap);
const cx = gx - finalGap * 0.5 + offsetX + relativeX;
const cy = gy - finalGap * 0.5 + offsetY + relativeY;
// 从storageStateMap获取库位状态
const inner = storageStateMap.get(pointId);
const state = inner?.get(locationName) || { occupied: false, locked: false };
const occupied = !!state.occupied;
const locked = !!state.locked;
const storagePen: MapPen = {
id: `storage-${pointId}-${i}`,
name: 'storage-location',
tags: ['storage-location', `point-${pointId}`],
x: cx,
y: cy,
width: finalCell,
height: finalCell,
lineWidth: 1,
background: occupied ? '#ff4d4f' : 'transparent',
color: 'transparent',
locked: LockState.DisableEdit,
visible: true,
borderRadius: Math.min(4, finalCell * 0.3),
storageLocation: {
pointId,
locationName,
index: i,
occupied,
locked,
},
};
pens.push(storagePen);
});
// 如果有超出显示的库位,创建"更多"按钮
const overflow = Math.max(0, storageLocations.length - gridCols * gridRows);
if (overflow > 0) {
const i = gridCols * gridRows - 1;
const r = Math.floor(i / gridCols);
const c = i % gridCols;
// 计算库位在背景矩形内的相对位置,然后加上背景矩形的起始位置和居中偏移
// 更多按钮位置也应该是左上角坐标
const relativeX = c * (finalCell + finalGap);
const relativeY = r * (finalCell + finalGap);
const cx = gx - finalGap * 0.5 + offsetX + relativeX;
const cy = gy - finalGap * 0.5 + offsetY + relativeY;
const morePen: MapPen = {
id: `storage-more-${pointId}`,
name: 'storage-more',
tags: ['storage-more', `point-${pointId}`],
x: cx,
y: cy,
width: finalCell,
height: finalCell,
lineWidth: 1,
background: '#e6f4ff',
color: '#1677ff',
locked: LockState.DisableEdit,
visible: true,
text: `+${overflow}`,
fontSize: Math.floor(finalCell * 0.6),
textAlign: 'center',
textBaseline: 'middle',
storageLocation: {
pointId,
locationName: 'more',
index: i,
overflow,
},
};
pens.push(morePen);
}
return pens;
}