Skip to content

Commit 0333054

Browse files
committed
feat(layout): 优化小节布局计算和渲染逻辑
- 在 SNLayoutBuilder 中引入基于 ticks 的小节宽度计算方法,确保小节宽度更准确。 - 更新 buildMeasures 方法,支持小节宽度的拉伸以适应可用空间,提升布局的灵活性。 - 修改 MeasureTransformer,移除小节间隔设置,确保小节之间无间隔。 - 在 SVG 渲染中优化小节线绘制逻辑,确保第一个小节绘制左线以避免重叠。 该变更提升了布局和渲染的准确性与美观性,增强了代码的可维护性和可扩展性。
1 parent da4ce04 commit 0333054

File tree

3 files changed

+213
-77
lines changed

3 files changed

+213
-77
lines changed

packages/simple-notation/src/layout/builder.ts

Lines changed: 188 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
type SNLayoutNode,
99
} from '@layout/node';
1010
import { LayoutConfig, ScoreConfig } from '@manager/config';
11+
import { getTimeUnitFromNode, measureDuration } from '@core/utils/time-unit';
1112
import {
1213
RootTransformer,
1314
ScoreTransformer,
@@ -253,9 +254,7 @@ export class SNLayoutBuilder {
253254

254255
if (voiceMeasures.length === 0) return;
255256

256-
// 计算小节间距
257-
const measureConfig = this.scoreConfig.getMeasure();
258-
const measureGap = measureConfig.spacing.measureGap || 10;
257+
// 小节之间不应该有间隔(measureGap = 0)
259258

260259
// 1) 计算每个小节在每个 Voice 中的实际宽度,并取同一小节索引的最大值,确保跨声部对齐
261260
const maxMeasureCount = Math.max(
@@ -267,7 +266,7 @@ export class SNLayoutBuilder {
267266
for (const { measures } of voiceMeasures) {
268267
const m = measures[i];
269268
if (!m) continue;
270-
const w = this.computeMeasureLayoutWidth(m);
269+
const w = this.computeMeasureWidthByTicks(m);
271270
if (w > maxWidth) maxWidth = w;
272271
}
273272
// 后备:至少给一个基础宽度,避免0
@@ -283,9 +282,8 @@ export class SNLayoutBuilder {
283282
const start = cursor;
284283
while (cursor < maxMeasureCount) {
285284
const nextWidth = maxWidthsByIndex[cursor];
286-
const addWidth = (lineWidth === 0 ? 0 : measureGap) + nextWidth;
287-
if (lineWidth + addWidth <= availableWidth) {
288-
lineWidth += addWidth;
285+
if (lineWidth + nextWidth <= availableWidth) {
286+
lineWidth += nextWidth;
289287
cursor++;
290288
} else {
291289
break;
@@ -326,7 +324,8 @@ export class SNLayoutBuilder {
326324

327325
this.calculateNodeWidth(line);
328326
if (lineMeasures.length > 0) {
329-
this.buildMeasures(lineMeasures, line);
327+
// 对于非最后一行,需要拉伸小节以撑满整行
328+
this.buildMeasures(lineMeasures, line, !isLastLine, availableWidth);
330329
}
331330
this.calculateNodePosition(line);
332331
});
@@ -357,6 +356,41 @@ export class SNLayoutBuilder {
357356
return Math.max(0, width - padding.left - padding.right);
358357
}
359358

359+
/**
360+
* 向上查找拍号(若未设置则返回 4/4)
361+
*/
362+
private getTimeSignatureFromNode(node: SNParserNode): {
363+
numerator: number;
364+
denominator: number;
365+
} {
366+
let current: SNParserNode | undefined = node;
367+
while (current) {
368+
const props = (current.props as any) || {};
369+
if (
370+
props.timeSignature &&
371+
typeof props.timeSignature.numerator === 'number'
372+
) {
373+
return props.timeSignature;
374+
}
375+
current = current.parent as SNParserNode | undefined;
376+
}
377+
return { numerator: 4, denominator: 4 };
378+
}
379+
380+
/**
381+
* 基于 ticks 计算小节理想宽度(像素)
382+
*/
383+
private computeMeasureWidthByTicks(
384+
measure: SNParserNode,
385+
pxPerBeat = 40,
386+
): number {
387+
const timeUnit = getTimeUnitFromNode(measure);
388+
const timeSignature = this.getTimeSignatureFromNode(measure);
389+
const totalTicks: number = measureDuration(timeSignature, timeUnit);
390+
const pxPerTick = pxPerBeat / timeUnit.ticksPerBeat;
391+
return Math.max(20, Math.round(totalTicks * pxPerTick));
392+
}
393+
360394
/**
361395
* 计算单个 Measure 的布局宽度(基于其子元素宽度汇总)
362396
*
@@ -365,34 +399,7 @@ export class SNLayoutBuilder {
365399
* finalizeNodeLayout 计算得到该 Measure Element 的宽度。
366400
* 该临时节点不会加入实际的布局树。
367401
*/
368-
private computeMeasureLayoutWidth(measure: SNParserNode): number {
369-
// 创建临时 Line(不挂到树上)
370-
const tempLine = new SNLayoutLine('temp-line');
371-
tempLine.updateLayout({
372-
x: 0,
373-
y: 0,
374-
width: 0,
375-
height: 0,
376-
padding: { top: 0, right: 0, bottom: 0, left: 0 },
377-
});
378-
379-
// 使用现有转换器构建临时 Measure Element
380-
const tempElement = this.measureTransformer.transform(measure, tempLine);
381-
if (!tempElement) return 0;
382-
383-
// 构建子元素
384-
this.buildMeasureElements(
385-
(measure.children || []) as SNParserNode[],
386-
tempElement,
387-
);
388-
389-
// 完成临时 Element 的布局计算
390-
this.finalizeNodeLayout(tempElement);
391-
392-
return typeof tempElement.layout?.width === 'number'
393-
? tempElement.layout.width
394-
: 0;
395-
}
402+
// 已废弃:改为使用 computeMeasureWidthByTicks
396403

397404
/**
398405
* 构建 Measure 节点
@@ -401,17 +408,49 @@ export class SNLayoutBuilder {
401408
*
402409
* @param measures - Measure 节点数组
403410
* @param parentNode - 父节点(Line)
411+
* @param shouldStretch - 是否拉伸小节以撑满整行(非最后一行时)
412+
* @param availableWidth - 可用宽度(用于拉伸计算)
404413
*/
405414
private buildMeasures(
406415
measures: SNParserNode[],
407416
parentNode: SNLayoutLine,
417+
shouldStretch = false,
418+
availableWidth = 0,
408419
): void {
420+
// 先计算所有小节的基础宽度
421+
const baseWidths: number[] = [];
409422
for (const measure of measures) {
423+
const baseWidth = this.computeMeasureWidthByTicks(measure);
424+
baseWidths.push(baseWidth);
425+
}
426+
427+
// 计算总宽度
428+
const totalBaseWidth = baseWidths.reduce((sum, w) => sum + w, 0);
429+
430+
// 如果需要拉伸且总宽度小于可用宽度,计算拉伸比例
431+
let stretchRatio = 1;
432+
if (
433+
shouldStretch &&
434+
totalBaseWidth > 0 &&
435+
availableWidth > totalBaseWidth
436+
) {
437+
stretchRatio = availableWidth / totalBaseWidth;
438+
}
439+
440+
// 构建每个小节
441+
for (let i = 0; i < measures.length; i++) {
442+
const measure = measures[i];
410443
// 使用 MeasureTransformer 转换 Measure 为 Element
411444
const element = this.measureTransformer.transform(measure, parentNode);
412445

413446
if (!element) continue;
414447

448+
// 应用拉伸后的宽度
449+
const finalWidth = Math.round(baseWidths[i] * stretchRatio);
450+
if (element.layout) {
451+
element.layout.width = finalWidth;
452+
}
453+
415454
// 构建 Measure 内部的元素(Note/Rest/Lyric等)
416455
this.buildMeasureElements(
417456
(measure.children || []) as SNParserNode[],
@@ -426,26 +465,105 @@ export class SNLayoutBuilder {
426465
/**
427466
* 构建 Measure 内部的元素(叶子节点)
428467
*
429-
* 这些是最底层的元素,有固定的宽高,不需要计算
468+
* 按照元素的tick(duration)按比例分配小节宽度,并添加左右padding避免元素顶在小节线上
430469
*
431470
* @param elements - Measure 的子元素(Note/Rest/Lyric/Tuplet)
432-
* @param parentNode - 父节点(Element)
471+
* @param parentNode - 父节点(Element,即小节
433472
*/
434473
private buildMeasureElements(
435474
elements: SNParserNode[],
436475
parentNode: SNLayoutElement,
437476
): void {
438-
for (const dataElement of elements) {
477+
if (!elements || elements.length === 0) return;
478+
479+
// 获取小节的总duration(通过小节节点获取timeUnit和timeSignature)
480+
const measureNode = parentNode.data as SNParserNode;
481+
if (!measureNode) return;
482+
483+
const timeUnit = getTimeUnitFromNode(measureNode);
484+
const timeSignature = this.getTimeSignatureFromNode(measureNode);
485+
const measureTotalTicks = measureDuration(timeSignature, timeUnit);
486+
487+
// 获取小节的实际宽度(已经设置好的)
488+
const measureWidth =
489+
typeof parentNode.layout?.width === 'number'
490+
? parentNode.layout.width
491+
: 0;
492+
493+
// 左右padding,避免元素顶在小节线上
494+
const horizontalPadding = 8; // 可后续做成配置项
495+
const usableWidth = Math.max(0, measureWidth - horizontalPadding * 2);
496+
497+
// 过滤出有 duration 的元素(note、rest等),忽略没有 duration 的元素(如 tie、某些装饰元素)
498+
const elementsWithDuration = elements.filter(
499+
(el) => el.duration && el.duration > 0,
500+
);
501+
502+
// 计算所有有 duration 的元素的总 ticks
503+
const totalElementsTicks = elementsWithDuration.reduce(
504+
(sum, el) => sum + (el.duration || 0),
505+
0,
506+
);
507+
508+
// 如果元素的总 ticks 不等于小节的总 ticks,需要调整比例
509+
// 使用元素实际的总 ticks 来计算比例,确保元素能正确分布
510+
const ticksForRatio =
511+
totalElementsTicks > 0 ? totalElementsTicks : measureTotalTicks;
512+
513+
// 计算每个元素的起始位置和宽度(基于tick比例)
514+
let currentTickOffset = 0;
515+
for (let i = 0; i < elements.length; i++) {
516+
const dataElement = elements[i];
517+
const elementDuration = dataElement.duration || 0;
518+
439519
// 使用 MeasureTransformer 转换元素
440520
const layoutElement = this.measureTransformer.transformElement(
441521
dataElement,
442522
parentNode,
443523
);
444524

445-
if (!layoutElement) continue;
525+
if (!layoutElement || !layoutElement.layout) continue;
526+
527+
// 对于没有 duration 的元素(如 tie),跳过位置计算,保持默认位置
528+
if (elementDuration <= 0) {
529+
// Y坐标使用父节点的padding.top
530+
if (parentNode.layout) {
531+
const parentPadding = parentNode.layout.padding || {
532+
top: 0,
533+
right: 0,
534+
bottom: 0,
535+
left: 0,
536+
};
537+
layoutElement.layout.y = parentPadding.top;
538+
}
539+
continue;
540+
}
541+
542+
// 计算元素在小节内的位置(基于tick比例)
543+
const startRatio = currentTickOffset / ticksForRatio;
544+
const durationRatio = elementDuration / ticksForRatio;
446545

447-
// 叶子节点:直接计算位置(宽高已在转换器中设置)
448-
this.calculateNodePosition(layoutElement);
546+
// 计算元素的实际位置和宽度
547+
const elementX = horizontalPadding + startRatio * usableWidth;
548+
const elementWidth = durationRatio * usableWidth;
549+
550+
// 更新元素的布局信息
551+
layoutElement.layout.x = elementX;
552+
layoutElement.layout.width = Math.max(10, elementWidth); // 最小宽度10px
553+
554+
// 更新累计的tick偏移
555+
currentTickOffset += elementDuration;
556+
557+
// Y坐标使用父节点的padding.top(由布局计算)
558+
if (parentNode.layout) {
559+
const parentPadding = parentNode.layout.padding || {
560+
top: 0,
561+
right: 0,
562+
bottom: 0,
563+
left: 0,
564+
};
565+
layoutElement.layout.y = parentPadding.top;
566+
}
449567
}
450568
}
451569

@@ -501,27 +619,34 @@ export class SNLayoutBuilder {
501619
// Block和Line:撑满父级宽度
502620
node.calculateWidth();
503621
} else if (node instanceof SNLayoutElement) {
504-
// Element:根据子节点计算宽度
505-
if (node.children && node.children.length > 0) {
506-
const childrenMaxWidth = node.calculateChildrenMaxWidth();
507-
const padding = node.layout.padding || {
508-
top: 0,
509-
right: 0,
510-
bottom: 0,
511-
left: 0,
512-
};
513-
node.layout.width =
514-
childrenMaxWidth > 0
515-
? childrenMaxWidth + padding.left + padding.right
516-
: 20;
517-
} else {
518-
// 叶子元素:使用已有宽度或默认值
519-
if (
520-
!node.layout.width ||
521-
typeof node.layout.width !== 'number' ||
522-
node.layout.width === 0
523-
) {
524-
node.layout.width = 20;
622+
// Element:如果已有固定宽度,尊重之;否则根据子节点计算
623+
const hasFixedWidth =
624+
node.layout.width !== null &&
625+
typeof node.layout.width === 'number' &&
626+
node.layout.width > 0;
627+
628+
if (!hasFixedWidth) {
629+
if (node.children && node.children.length > 0) {
630+
const childrenMaxWidth = node.calculateChildrenMaxWidth();
631+
const padding = node.layout.padding || {
632+
top: 0,
633+
right: 0,
634+
bottom: 0,
635+
left: 0,
636+
};
637+
node.layout.width =
638+
childrenMaxWidth > 0
639+
? childrenMaxWidth + padding.left + padding.right
640+
: 20;
641+
} else {
642+
// 叶子元素:使用已有宽度或默认值
643+
if (
644+
!node.layout.width ||
645+
typeof node.layout.width !== 'number' ||
646+
node.layout.width === 0
647+
) {
648+
node.layout.width = 20;
649+
}
525650
}
526651
}
527652
}

packages/simple-notation/src/layout/trans/measure-transformer.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,22 +31,19 @@ export class MeasureTransformer {
3131
return null;
3232
}
3333

34-
const measureConfig = this.scoreConfig.getMeasure();
35-
3634
// 创建元素节点
3735
const element = new SNLayoutElement(`layout-${measure.id}`);
3836
element.data = measure;
3937

40-
// 设置配置
41-
const measureGap = measureConfig.spacing.measureGap || 10;
38+
// 设置配置(小节之间不应该有间隔,所以 margin 全为 0)
4239
const measureWidth = 100; // 临时值,后续会根据实际音符宽度计算
4340

4441
element.updateLayout({
4542
x: 0, // 初始位置,由布局计算填充
4643
y: 0, // 初始位置,由布局计算填充
4744
width: measureWidth,
4845
height: 0, // 自适应行高
49-
margin: { top: 0, right: measureGap, bottom: 0, left: 0 },
46+
margin: { top: 0, right: 0, bottom: 0, left: 0 },
5047
});
5148

5249
// 建立父子关系

0 commit comments

Comments
 (0)