|
1 | 1 | import type { SNLayoutNode } from '@layout/node'; |
2 | 2 | import type { SvgRenderer } from '../svg-renderer'; |
3 | 3 | import type { SNDebugConfig } from '@manager/model/debug-config'; |
| 4 | +import type { ScoreConfig } from '@manager/config'; |
4 | 5 | import type { SNPitch } from '@core/model/music'; |
5 | 6 | import { SNAccidental } from '@core/model/music'; |
6 | 7 | import type { SNVoiceMetaClef } from '@data/model/parser'; |
@@ -135,12 +136,14 @@ export class ElementNode extends SvgRenderNode { |
135 | 136 | * @param node - ELEMENT 布局节点 |
136 | 137 | * @param renderer - SVG 渲染器实例 |
137 | 138 | * @param debugConfig - 调试配置(可选) |
| 139 | + * @param scoreConfig - 乐谱配置(可选) |
138 | 140 | */ |
139 | 141 | static render( |
140 | 142 | parent: SVGElement, |
141 | 143 | node: SNLayoutNode, |
142 | 144 | renderer: SvgRenderer, |
143 | 145 | debugConfig?: Readonly<SNDebugConfig>, |
| 146 | + scoreConfig?: ScoreConfig, |
144 | 147 | ): void { |
145 | 148 | const layout = node.layout; |
146 | 149 | if (!layout) return; |
@@ -266,6 +269,95 @@ export class ElementNode extends SvgRenderNode { |
266 | 269 | l.setAttribute('stroke-width', '1'); |
267 | 270 | g.appendChild(l); |
268 | 271 | } |
| 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 | + } |
269 | 361 | } else if (dataType === 'note') { |
270 | 362 | // 获取音符数据 |
271 | 363 | const noteData = node.data as any; |
@@ -792,6 +884,109 @@ export class ElementNode extends SvgRenderNode { |
792 | 884 | ElementNode.renderBeamGroup(parent, noteNodes, beamCount, noteGroupNode); |
793 | 885 | } |
794 | 886 |
|
| 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 | + |
795 | 990 | /** |
796 | 991 | * 渲染单个符杠组 |
797 | 992 | * |
|
0 commit comments