/*
* Copyright (C) 2025 Huawei Device Co., Ltd.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { MindElixirCore } from '../controller/MindElixirCore';
import { CanvasSize, NodeObj, NodeOption, NodeStyle, LayoutDirection, Coordinate } from '../model/NodeObj';
import { MindLineRender } from '../controller/MindLineRender';
import { NodeHelper } from '../controller/NodeHelper';
import { UtilsManager } from '../utils/UtilsManager';
import { GlobalContext } from '../utils/GlobalContext';
const FOLD_BTN_WIDTH: number = 20;
export class MindMapRenderer {
private mind: MindElixirCore;
private offscreenCanvas: OffscreenCanvas;
private context: OffscreenCanvasRenderingContext2D;
private nodes: NodeObj[] = [];
private nodeHelper: NodeHelper;
private options: NodeOption | null = null;
private nodeStyles: NodeStyle = {};
/**
* 构造函数
* @param model 思维导图核心实例
*/
constructor(model: MindElixirCore) {
this.mind = model;
this.options = this.mind.getMindOptions();
this.nodeHelper = UtilsManager.getNodeHelper(this.mind.getInstanceId())!;
const canvasSize: CanvasSize = this.nodeHelper.getCanvasSize();
this.offscreenCanvas = new OffscreenCanvas(Number(canvasSize.width), Number(canvasSize.height));
const context = this.offscreenCanvas.getContext('2d');
if (!context) {
throw new Error('Failed to get 2d context from OffscreenCanvas');
}
this.context = context;
this.getStyles();
}
/**
* 获取节点样式
*/
private getStyles(): void {
const modelStyles = this.mind.getMindOptions();
this.nodeStyles.fontSize = modelStyles.nodeStyle?.fontSize || 16;
this.nodeStyles.fontFamily = modelStyles.nodeStyle?.fontFamily || 'HarmonyOS Sans';
this.nodeStyles.color = modelStyles.nodeStyle?.color || '#000';
this.nodeStyles.background = modelStyles.nodeStyle?.background || Color.Transparent;
this.nodeStyles.fontWeight = modelStyles.nodeStyle?.fontWeight || 'normal';
this.nodeStyles.border = modelStyles.nodeStyle?.border || { width: 0, radius: 20 };
}
/**
* 计算选中状态的边框样式
*/
private getBorderStyle(node: NodeObj): BorderOptions {
const isSelected = this.mind.getSelectedNodeId() === node.id;
const borderWidth = this.nodeStyles.border?.width !== undefined ? Number(this.nodeStyles.border.width) : 0;
return {
width: isSelected ? 3 : (node.level! <= 1 ? 2 : borderWidth),
color: isSelected ? '#1890FF' : this.nodeStyles?.border?.color,
radius: this.nodeStyles.border?.radius !== undefined ? Number(this.nodeStyles.border.radius) : 20
};
}
/**
* 初始化并绘制整个思维导图
* @param width 可选画布宽度
* @param height 可选画布高度
*/
public init(width?: number, height?: number): void {
this.nodes = this.nodeHelper.getFlatNodes();
// 如果指定了新的宽高,重新创建画布
if (width && height) {
this.offscreenCanvas = new OffscreenCanvas(width, height);
const context = this.offscreenCanvas.getContext('2d');
if (context) {
this.context = context;
}
}
this.clearCanvas();
this.drawLines();
this.drawNodes();
}
/**
* 清空画布
*/
private clearCanvas(): void {
const ctx = this.context;
// 首先清空画布
ctx.clearRect(0, 0, this.offscreenCanvas.width, this.offscreenCanvas.height);
// 设置白色背景
ctx.fillStyle = '#ffffff'; // 白色
ctx.fillRect(0, 0, this.offscreenCanvas.width, this.offscreenCanvas.height);
}
/**
* 绘制连接线
*/
private drawLines(): void {
MindLineRender.createInstance(this.mind!.getInstanceId()).init(this.context, this.mind!);
}
/**
* 绘制节点
*/
private drawNodes(): void {
this.nodes.forEach(node => {
if (node.level! == 0 || node.level! == 1 || (node.level! > 1 && node.parent!.expanded!)) {
this.drawNode(node);
}
});
}
/**
* 绘制单个节点
*/
private drawNode(node: NodeObj): void {
if (!node.x || !node.y) {
return;
}
const ctx = this.context;
const x = node.x!;
const y = node.y!;
const width = node.width!;
const height = node.height!;
// 保存当前状态
ctx.save();
// 绘制节点背景和边框
this.drawNodeBackground(node, x, y, width, height);
// 绘制文本
this.drawNodeText(node, x, y, width, height);
// 绘制折叠指示器
if (!node.expanded && node.children!.length > 0 && node.level! >= 1) {
this.drawFoldIndicator(node, x, y, width, height);
}
// 恢复状态
ctx.restore();
}
/**
* 绘制节点背景和边框
*/
private drawNodeBackground(node: NodeObj, x: number, y: number, width: number, height: number): void {
const ctx = this.context;
const borderStyle = this.getBorderStyle(node);
const borderRadius = borderStyle.radius || 20;
// 绘制背景
ctx.fillStyle = (node.style?.background || this.nodeStyles.background) as string || '';
this.drawRoundedRect(ctx, x, y, width, height, borderRadius as number);
ctx.fill();
// 绘制边框
if (borderStyle.width && borderStyle.width > 0) {
ctx.strokeStyle = (borderStyle.color as string) || '';
ctx.lineWidth = borderStyle.width as number;
this.drawRoundedRect(ctx, x, y, width, height, borderRadius as number);
ctx.stroke();
}
}
/**
* 绘制节点文本
*/
private drawNodeText(node: NodeObj, x: number, y: number, width: number, height: number): void {
const ctx = this.context;
const uiContext: UIContext | null = GlobalContext.getInstance().getUiContext()!;
const textColor = node.style?.color || this.nodeStyles.color || '#000';
const fontSize = uiContext.vp2px(node.style?.fontSize || this.nodeStyles.fontSize || 16);
const fontFamily = node.style?.fontFamily || this.nodeStyles.fontFamily || 'HarmonyOS Sans';
const fontWeight = node.style?.fontWeight || this.nodeStyles.fontWeight || 'normal';
ctx.fillStyle = textColor;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
// 文本溢出处理
const maxTextWidth = width - 16;
let displayText = node.topic;
if (ctx.measureText(node.topic).width > maxTextWidth) {
let truncatedText = node.topic;
while (truncatedText.length > 0 && ctx.measureText(truncatedText + '...').width > maxTextWidth) {
truncatedText = truncatedText.slice(0, -1);
}
displayText = truncatedText + '...';
}
ctx.fillText(displayText, x + width / 2, y + height / 2);
}
/**
* 绘制折叠指示器
*/
private drawFoldIndicator(node: NodeObj, x: number, y: number, width: number, height: number): void {
const ctx = this.context;
const layout = this.options?.layout ?? LayoutDirection.RIGHT;
const foldBtnPoint = this.getFoldBtnPoint(node, layout);
// 绘制连接线
ctx.beginPath();
if (layout === LayoutDirection.LEFT) {
ctx.moveTo(x, y + height / 2);
ctx.lineTo(foldBtnPoint.x + FOLD_BTN_WIDTH / 2, y + height / 2);
} else if (layout === LayoutDirection.RIGHT) {
ctx.moveTo(x + width, y + height / 2);
ctx.lineTo(foldBtnPoint.x - FOLD_BTN_WIDTH / 2, y + height / 2);
} else if (layout === LayoutDirection.TOP) {
ctx.moveTo(x + width / 2, y);
ctx.lineTo(x + width / 2, foldBtnPoint.y + FOLD_BTN_WIDTH / 2);
} else if (layout === LayoutDirection.BOTTOM) {
ctx.moveTo(x + width / 2, y + height);
ctx.lineTo(x + width / 2, foldBtnPoint.y - FOLD_BTN_WIDTH / 2);
}
ctx.lineWidth = 3;
ctx.strokeStyle = '#ffa500';
ctx.stroke();
// 绘制圆形指示器
const centerX: number = foldBtnPoint.x;
const centerY: number = foldBtnPoint.y;
const radius = 10;
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
ctx.strokeStyle = '#ffa500';
ctx.lineWidth = 3;
ctx.stroke();
// 设置文字样式
ctx.font = '12px HarmonyOS Sans';
ctx.fillStyle = '#000000';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// 在圆心位置绘制文字
const text = this.nodeHelper.getAllChildrenNumber(node) + '';
ctx.fillText(text, centerX, centerY);
}
/**
* 获取折叠按钮位置
*/
private getFoldBtnPoint(node: NodeObj, layout: LayoutDirection): Coordinate {
try {
if (layout === LayoutDirection.LEFT) {
return {
x: node.x! - FOLD_BTN_WIDTH / 2,
y: node.y! + (node.height!) / 2 - FOLD_BTN_WIDTH / 2
};
} else if (layout === LayoutDirection.RIGHT) {
return {
x: node.x! + node.width! - FOLD_BTN_WIDTH / 2,
y: node.y! + (node.height!) / 2 - FOLD_BTN_WIDTH / 2
};
} else if (layout === LayoutDirection.TOP) {
return {
x: node.x! + (node.width!) / 2 - FOLD_BTN_WIDTH / 2,
y: node.y! - FOLD_BTN_WIDTH / 2
};
} else if (layout === LayoutDirection.BOTTOM) {
return {
x: node.x! + (node.width!) / 2 - FOLD_BTN_WIDTH / 2,
y: node.y! + (node.height!) - FOLD_BTN_WIDTH / 2
};
} else {
return {
x: node.x! + node.width! - FOLD_BTN_WIDTH / 2,
y: node.y! + (node.height!) / 2 - FOLD_BTN_WIDTH / 2
};
}
} catch (e) {
console.error('node data error');
return { x: 0, y: 0 };
}
}
/**
* 绘制圆角矩形
*/
private drawRoundedRect(ctx: OffscreenCanvasRenderingContext2D, x: number, y: number, width: number, height: number,
radius: number): void {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.arcTo(x + width, y, x + width, y + radius, radius);
ctx.lineTo(x + width, y + height - radius);
ctx.arcTo(x + width, y + height, x + width - radius, y + height, radius);
ctx.lineTo(x + radius, y + height);
ctx.arcTo(x, y + height, x, y + height - radius, radius);
ctx.lineTo(x, y + radius);
ctx.arcTo(x, y, x + radius, y, radius);
ctx.closePath();
}
/**
* 导出为PNG
*/
exportToPNG(): string {
return this.context.toDataURL('image/png');
}
/**
* 转换为ImageBitmap
*/
transferToImageBitmap() {
return this.context.transferToImageBitmap();
}
/**
* 获取画布上下文
*/
getContext(): OffscreenCanvasRenderingContext2D {
return this.context;
}
/**
* 获取画布元素
*/
getCanvas(): OffscreenCanvas {
return this.offscreenCanvas;
}
/**
* 调整画布大小
*/
resize(width: number, height: number): void {
this.offscreenCanvas = new OffscreenCanvas(width, height);
const context = this.offscreenCanvas.getContext('2d');
if (context) {
this.context = context;
}
this.init();
}
/**
* 销毁渲染器
*/
destroy(): void {
this.nodes = [];
}
}