Skip to content

Commit 99a6db0

Browse files
committed
feat(renderer): 增强渲染逻辑以支持乐谱配置
- 更新 RenderManager 和 SvgRenderer,新增 scoreConfig 参数以支持乐谱相关配置的传递。 - 在 ElementNode 渲染过程中实现小节号的显示逻辑,支持根据配置条件渲染小节号。 - 修改 ScoreConfig,调整小节号的显示频率和位置配置,提升乐谱的可视化效果。 该变更提升了渲染器的灵活性和乐谱的可读性,确保乐谱信息的准确展示。
1 parent eed9496 commit 99a6db0

File tree

6 files changed

+230
-10
lines changed

6 files changed

+230
-10
lines changed

packages/simple-notation/src/manager/config/score-config.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,13 @@ export class ScoreConfig extends BaseConfig<SNScoreConfig> {
111111
measureGap: 10,
112112
},
113113
measureNumber: {
114-
enable: false,
115-
frequency: 4,
114+
enable: true,
115+
frequency: 1,
116116
style: {
117117
fontSize: 10,
118118
fontFamily: 'Arial',
119119
color: '#666666',
120-
position: 'top',
120+
position: 'left-top',
121121
},
122122
},
123123
},
@@ -298,4 +298,3 @@ export class ScoreConfig extends BaseConfig<SNScoreConfig> {
298298
this.set({ element: { ...this.get().element, ...config } });
299299
}
300300
}
301-

packages/simple-notation/src/manager/model/score-config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ export interface SNScoreMeasureConfig {
222222
fontSize?: number;
223223
fontFamily?: string;
224224
color?: string;
225-
position?: 'top' | 'bottom' | 'left' | 'right';
225+
position?: 'top' | 'bottom' | 'left' | 'right' | 'left-top' | 'right-top';
226226
};
227227
};
228228
}

packages/simple-notation/src/manager/render-manager.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { IRenderer, SNRendererType } from '@render/model';
22
import { SvgRenderer } from '@render/renderer/svg';
33
import type { SNLayoutNode } from '@layout/node';
44
import type { SNDebugConfig } from '@manager/model/debug-config';
5+
import type { ScoreConfig } from '@manager/config';
56

67
/**
78
* 渲染器管理器
@@ -55,16 +56,28 @@ export class RenderManager {
5556
*
5657
* @param layoutTree - 布局树根节点
5758
* @param debugConfig - 调试配置(可选)
59+
* @param scoreConfig - 乐谱配置(可选)
5860
*/
5961
render(
6062
layoutTree: SNLayoutNode,
6163
debugConfig?: Readonly<SNDebugConfig>,
64+
scoreConfig?: ScoreConfig,
6265
): void {
6366
if (!this.renderer) {
6467
throw new Error('Renderer not initialized. Call init() first.');
6568
}
6669

67-
this.renderer.render(layoutTree, debugConfig);
70+
// 如果渲染器是 SvgRenderer,传递 scoreConfig
71+
if (this.renderer instanceof SvgRenderer) {
72+
(this.renderer as SvgRenderer).render(
73+
layoutTree,
74+
debugConfig,
75+
scoreConfig,
76+
);
77+
} else {
78+
// 其他渲染器暂时不支持 scoreConfig
79+
this.renderer.render(layoutTree, debugConfig);
80+
}
6881
}
6982

7083
/**

packages/simple-notation/src/render/renderer/svg/node/element.ts

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { SNLayoutNode } from '@layout/node';
22
import type { SvgRenderer } from '../svg-renderer';
33
import type { SNDebugConfig } from '@manager/model/debug-config';
4+
import type { ScoreConfig } from '@manager/config';
45
import type { SNPitch } from '@core/model/music';
56
import { SNAccidental } from '@core/model/music';
67
import type { SNVoiceMetaClef } from '@data/model/parser';
@@ -135,12 +136,14 @@ export class ElementNode extends SvgRenderNode {
135136
* @param node - ELEMENT 布局节点
136137
* @param renderer - SVG 渲染器实例
137138
* @param debugConfig - 调试配置(可选)
139+
* @param scoreConfig - 乐谱配置(可选)
138140
*/
139141
static render(
140142
parent: SVGElement,
141143
node: SNLayoutNode,
142144
renderer: SvgRenderer,
143145
debugConfig?: Readonly<SNDebugConfig>,
146+
scoreConfig?: ScoreConfig,
144147
): void {
145148
const layout = node.layout;
146149
if (!layout) return;
@@ -266,6 +269,95 @@ export class ElementNode extends SvgRenderNode {
266269
l.setAttribute('stroke-width', '1');
267270
g.appendChild(l);
268271
}
272+
273+
// 渲染小节号(仅在第一个声部显示)
274+
if (scoreConfig) {
275+
const measureConfig = scoreConfig.getMeasure();
276+
const measureNumberConfig = measureConfig.measureNumber;
277+
278+
// 检查是否启用小节号显示
279+
if (measureNumberConfig?.enable !== false) {
280+
// 判断是否是第一个声部
281+
// 通过向上查找 VoiceGroup,检查当前 Line 是否是第一个
282+
const isFirstVoice = ElementNode.isFirstVoiceInGroup(node);
283+
284+
if (isFirstVoice) {
285+
// 获取小节索引
286+
const measureData = node.data as any;
287+
const measureIndex = measureData?.index as number | undefined;
288+
289+
if (measureIndex !== undefined) {
290+
// 检查显示频率(默认每小节显示一次)
291+
const frequency = measureNumberConfig?.frequency ?? 1;
292+
const shouldShow = (measureIndex - 1) % frequency === 0;
293+
294+
if (shouldShow) {
295+
// 获取样式配置
296+
const style = measureNumberConfig?.style || {};
297+
const fontSize = style.fontSize ?? 12;
298+
const fontFamily =
299+
style.fontFamily ?? 'Arial, "DejaVu Sans", sans-serif';
300+
const color = style.color ?? '#000';
301+
const position = style.position ?? 'left-top';
302+
303+
// 计算小节号位置
304+
let textX = 0;
305+
let textY = 0;
306+
let textAnchor = 'middle';
307+
let dominantBaseline = 'middle';
308+
309+
if (position === 'left') {
310+
// 左侧:在小节线左侧(垂直居中)
311+
textX = -8;
312+
textY = staffTop + staffHeight / 2;
313+
textAnchor = 'end';
314+
} else if (position === 'left-top') {
315+
// 左侧上方(默认):在左侧小节线的上方
316+
textX = 0;
317+
textY = staffTop - 8;
318+
textAnchor = 'start';
319+
dominantBaseline = 'baseline';
320+
} else if (position === 'right') {
321+
// 右侧:在小节线右侧(垂直居中)
322+
textX = width + 8;
323+
textY = staffTop + staffHeight / 2;
324+
textAnchor = 'start';
325+
} else if (position === 'right-top') {
326+
// 右侧上方:在右侧小节线的上方
327+
textX = width;
328+
textY = staffTop - 8;
329+
textAnchor = 'end';
330+
dominantBaseline = 'baseline';
331+
} else if (position === 'bottom') {
332+
// 底部:在五线谱下方
333+
textX = width / 2;
334+
textY = staffBottom + 12;
335+
} else {
336+
// 顶部(旧默认):在五线谱上方中间
337+
textX = width / 2;
338+
textY = staffTop - 8;
339+
}
340+
341+
// 创建小节号文本
342+
const text = document.createElementNS(
343+
'http://www.w3.org/2000/svg',
344+
'text',
345+
);
346+
text.setAttribute('x', String(textX));
347+
text.setAttribute('y', String(textY));
348+
text.setAttribute('font-size', String(fontSize));
349+
text.setAttribute('font-family', fontFamily);
350+
text.setAttribute('fill', color);
351+
text.setAttribute('text-anchor', textAnchor);
352+
text.setAttribute('dominant-baseline', dominantBaseline);
353+
text.textContent = String(measureIndex);
354+
355+
g.appendChild(text);
356+
}
357+
}
358+
}
359+
}
360+
}
269361
} else if (dataType === 'note') {
270362
// 获取音符数据
271363
const noteData = node.data as any;
@@ -792,6 +884,109 @@ export class ElementNode extends SvgRenderNode {
792884
ElementNode.renderBeamGroup(parent, noteNodes, beamCount, noteGroupNode);
793885
}
794886

887+
/**
888+
* 判断当前小节是否属于第一个声部
889+
*
890+
* 通过向上查找布局树,找到 VoiceGroup,然后检查当前 Line 是否是同一行中的第一个声部
891+
*
892+
* @param node - 小节布局节点
893+
* @returns 是否是第一个声部
894+
*/
895+
private static isFirstVoiceInGroup(node: SNLayoutNode): boolean {
896+
// 向上查找,找到 Line 节点
897+
let current: SNLayoutNode | undefined = node.parent;
898+
let lineNode: SNLayoutNode | undefined;
899+
900+
while (current) {
901+
// 检查是否是 Line 节点
902+
// Line 节点的 type 应该是 'line',或者 data 中的 type 是 'voice'
903+
const nodeType = (current as any).type;
904+
const dataType = (current.data as any)?.type;
905+
if (nodeType === 'line' || dataType === 'voice') {
906+
lineNode = current;
907+
break;
908+
}
909+
current = current.parent;
910+
}
911+
912+
if (!lineNode || !lineNode.parent) {
913+
// 如果找不到 Line 或父节点,默认认为是第一个声部(单声部情况)
914+
return true;
915+
}
916+
917+
// 查找 VoiceGroup(父节点应该是 Block 类型)
918+
const voiceGroup = lineNode.parent;
919+
const voiceGroupChildren = voiceGroup.children;
920+
921+
if (!voiceGroupChildren || voiceGroupChildren.length === 0) {
922+
return true;
923+
}
924+
925+
// 获取当前 Line 的数据,查找 voiceNumber
926+
const lineData = lineNode.data as any;
927+
const currentVoiceNumber = lineData?.meta?.voiceNumber;
928+
929+
// 检查是否是主声部
930+
const isPrimary = lineData?.isPrimary;
931+
if (isPrimary) {
932+
return true;
933+
}
934+
935+
// 如果当前声部编号是 "1",认为是第一个声部
936+
if (currentVoiceNumber === '1') {
937+
return true;
938+
}
939+
940+
// 查找同一行中的其他声部,判断当前是否是第一个
941+
// 从 build-voice-groups.ts 的逻辑来看,同一行的多个声部会按顺序排列
942+
// 我们需要找到同一行(通过检查 Line 的 y 坐标或 lineIndex)中的第一个声部
943+
944+
// 获取当前 Line 的位置信息
945+
const currentLineY =
946+
typeof lineNode.layout?.y === 'number' ? lineNode.layout.y : 0;
947+
948+
// 查找同一行(y 坐标相近)的所有 Line,然后找到 voiceNumber 最小的
949+
const linesInSameRow: Array<{
950+
node: SNLayoutNode;
951+
voiceNumber: string;
952+
y: number;
953+
}> = [];
954+
955+
for (const child of voiceGroupChildren) {
956+
const childDataType = (child.data as any)?.type;
957+
const childNodeType = (child as any).type;
958+
if (childNodeType === 'line' || childDataType === 'voice') {
959+
const childY = typeof child.layout?.y === 'number' ? child.layout.y : 0;
960+
// 判断是否在同一行(y 坐标相差小于 5 像素)
961+
if (Math.abs(childY - currentLineY) < 5) {
962+
const childData = child.data as any;
963+
const voiceNumber = childData?.meta?.voiceNumber || '999';
964+
linesInSameRow.push({
965+
node: child,
966+
voiceNumber,
967+
y: childY,
968+
});
969+
}
970+
}
971+
}
972+
973+
if (linesInSameRow.length === 0) {
974+
// 如果找不到同一行的 Line,默认认为是第一个声部
975+
return true;
976+
}
977+
978+
// 按 voiceNumber 排序,找到最小的(第一个声部)
979+
linesInSameRow.sort((a, b) => {
980+
const numA = parseInt(a.voiceNumber, 10) || 999;
981+
const numB = parseInt(b.voiceNumber, 10) || 999;
982+
return numA - numB;
983+
});
984+
985+
// 检查当前 Line 是否是排序后的第一个
986+
const firstLineInRow = linesInSameRow[0];
987+
return firstLineInRow.node === lineNode;
988+
}
989+
795990
/**
796991
* 渲染单个符杠组
797992
*

packages/simple-notation/src/render/renderer/svg/svg-renderer.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { BaseRenderer } from '../base-renderer';
22
import type { SNLayoutNode } from '@layout/node';
33
import { SNLayoutNodeType } from '@layout/model';
44
import type { SNDebugConfig } from '@manager/model/debug-config';
5+
import type { ScoreConfig } from '@manager/config';
56
import { RootNode, PageNode, BlockNode, LineNode, ElementNode } from './node';
67

78
/**
@@ -12,6 +13,8 @@ import { RootNode, PageNode, BlockNode, LineNode, ElementNode } from './node';
1213
export class SvgRenderer extends BaseRenderer {
1314
/** 当前调试配置 */
1415
private debugConfig?: Readonly<SNDebugConfig>;
16+
/** 当前乐谱配置 */
17+
private scoreConfig?: ScoreConfig;
1518
/**
1619
* 创建 SVG 输出节点
1720
*
@@ -31,13 +34,16 @@ export class SvgRenderer extends BaseRenderer {
3134
*
3235
* @param layoutTree - 布局树根节点
3336
* @param debugConfig - 调试配置(可选)
37+
* @param scoreConfig - 乐谱配置(可选)
3438
*/
3539
render(
3640
layoutTree: SNLayoutNode,
3741
debugConfig?: Readonly<SNDebugConfig>,
42+
scoreConfig?: ScoreConfig,
3843
): void {
39-
// 存储调试配置,供子节点渲染函数使用
44+
// 存储配置,供子节点渲染函数使用
4045
this.debugConfig = debugConfig;
46+
this.scoreConfig = scoreConfig;
4147
if (!this.outputNode) {
4248
throw new Error('Renderer not mounted. Call mount() first.');
4349
}
@@ -141,7 +147,13 @@ export class SvgRenderer extends BaseRenderer {
141147
LineNode.render(parent, node, this, this.debugConfig);
142148
break;
143149
case SNLayoutNodeType.ELEMENT:
144-
ElementNode.render(parent, node, this, this.debugConfig);
150+
ElementNode.render(
151+
parent,
152+
node,
153+
this,
154+
this.debugConfig,
155+
this.scoreConfig,
156+
);
145157
break;
146158
default:
147159
console.warn(`Unknown node type: ${node.type}`);

packages/simple-notation/src/sn.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,9 +166,10 @@ export class SimpleNotation {
166166

167167
console.log(layoutTree);
168168

169-
// 4. 渲染布局树(传递 debug 配置)
169+
// 4. 渲染布局树(传递 debug 和 score 配置)
170170
const debugConfig = this.configManager.getDebugConfig();
171-
this.renderManager.render(layoutTree, debugConfig);
171+
const scoreConfig = this.configManager.getScore();
172+
this.renderManager.render(layoutTree, debugConfig, scoreConfig);
172173
} catch (error) {
173174
console.error('Failed to load and render data:', error);
174175
throw error;

0 commit comments

Comments
 (0)