511 lines
17 KiB
TypeScript
511 lines
17 KiB
TypeScript
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;
|
||
}
|