smoosic
Version:
<sub>[Github site](https://github.com/Smoosic/smoosic) | [source documentation](https://smoosic.github.io/Smoosic/release/docs/modules.html) | [change notes](https://aarondavidnewman.github.io/Smoosic/changes.html) | [application](https://smoosic.github.i
916 lines (892 loc) • 40.1 kB
text/typescript
// [Smoosic](https://github.com/AaronDavidNewman/Smoosic)
// Copyright (c) Aaron David Newman 2021.
/**
* Utilities for formatting the music by estimating the geometry of the music.
* @module /render/sui/formatter
*/
import { SvgHelpers } from './svgHelpers';
import { SmoMusic } from '../../smo/data/music';
import { vexGlyph } from '../vex/glyphDimensions';
import { SmoDynamicText, SmoLyric, SmoArticulation, SmoOrnament } from '../../smo/data/noteModifiers';
import { SmoNote } from '../../smo/data/note';
import { SmoBeamer } from '../../smo/xform/beamers';
import { SmoSelector } from '../../smo/xform/selections';
import { SmoScore } from '../../smo/data/score';
import { SmoStaffHairpin, SmoStaffTextBracket, SmoTabStave } from '../../smo/data/staffModifiers';
import { layoutDebug } from './layoutDebug';
import { ScaledPageLayout, SmoLayoutManager, SmoPageLayout } from '../../smo/data/scoreModifiers';
import { SmoMeasure, ISmoBeamGroup } from '../../smo/data/measure';
import { TimeSignature, SmoTempoText } from '../../smo/data//measureModifiers';
import { SvgPageMap } from './svgPageMap';
import { VexFlow, defaultMeasurePadding } from '../../common/vex';
import { TextFormatter } from '../../common/textformatter';
const VF = VexFlow;
/**
* @category SuiRender
*/
export interface SuiTickContext {
widths: number[],
tickCounts: number[]
}
/**
* Estimated x, y position of the measure
* @category SuiRender
*/
export interface MeasureEstimate {
measures: SmoMeasure[], x: number, y: number
}
/**
* @category SuiRender
*/
export interface LineRender {
systems: Record<number, SmoMeasure[]>
}
/**
* Keep track of start/end measures on a page. If the page
* content doesn't change, and the measures don't change, we don't
* need to re-render the content
* @category SuiRender
*/
export interface RenderedPage {
startMeasure: number,
endMeasure: number
}
/**
* Utilities for estimating measure/system/page width and height
* @category SuiRender
*/
export class SuiLayoutFormatter {
score: SmoScore;
systems: Record<number, LineRender> = {};
columnMeasureMap: Record<number, SmoMeasure[]>;
currentPage: number = 0;
svg: SvgPageMap;
renderedPages: Record<number,RenderedPage | null>;
lines: number[] = [];
constructor(score: SmoScore, svg: SvgPageMap, renderedPages: Record<number, RenderedPage | null>) {
this.score = score;
this.svg = svg;
this.columnMeasureMap = {};
this.renderedPages = renderedPages;
this.score.staves.forEach((staff) => {
staff.measures.forEach((measure) => {
if (!this.columnMeasureMap[measure.measureNumber.measureIndex]) {
this.columnMeasureMap[measure.measureNumber.measureIndex] = [];
}
this.columnMeasureMap[measure.measureNumber.measureIndex].push(measure);
});
});
}
/**
* Once we know which line a measure is going on, make a map for it for easy
* looking during rendering
* @param measures
* @param lineIndex
* @param systemIndex
*/
updateSystemMap(measures: SmoMeasure[], lineIndex: number, systemIndex: number) {
if (!this.systems[lineIndex]) {
const nextLr: LineRender = {
systems: {}
};
this.systems[lineIndex] = nextLr;
}
const systemRender = this.systems[lineIndex];
if (!systemRender.systems[systemIndex]) {
systemRender.systems[systemIndex] = measures;
}
}
trimPages(startPageCount: number): boolean {
let pl: SmoPageLayout[] | undefined = this.score?.layoutManager?.pageLayouts;
if (pl) {
if (this.currentPage < pl.length - 1) {
this.score!.layoutManager!.trimPages(this.currentPage);
pl = this.score?.layoutManager?.pageLayouts;
}
if (pl && pl.length !== startPageCount) {
return true;
}
}
return false;
}
/**
* see if page breaks this boundary. If it does, bump the current page and move the system down
* to the new page
* @param scoreLayout
* @param currentLine
* @param bottomMeasure
* @returns
*/
checkPageBreak(scoreLayout: ScaledPageLayout, currentLine: SmoMeasure[], bottomMeasure: SmoMeasure): ScaledPageLayout {
let pageAdj = 0;
const lm: SmoLayoutManager = this.score!.layoutManager!;
// See if this measure breaks a page.
const maxY = bottomMeasure.lowestY;
if (maxY > ((this.currentPage + 1) * scoreLayout.pageHeight) - scoreLayout.bottomMargin) {
this.currentPage += 1;
// If this is a new page, make sure there is a layout for it.
lm.addToPageLayouts(this.currentPage);
scoreLayout = lm.getScaledPageLayout(this.currentPage);
// When adjusting the page, make it so the top staff of the system
// clears the bottom of the page.
const topMeasure = currentLine.reduce((a, b) =>
a.svg.logicalBox.y < b.svg.logicalBox.y ? a : b
);
const minMaxY = topMeasure.svg.logicalBox.y;
pageAdj = (this.currentPage * scoreLayout.pageHeight) - minMaxY;
pageAdj = pageAdj + scoreLayout.topMargin;
// For each measure on the current line, move it down past the page break;
currentLine.forEach((measure) => {
measure.adjustY(pageAdj);
measure.setY(measure.staffY + pageAdj, '_checkPageBreak');
measure.svg.pageIndex = this.currentPage;
});
}
return scoreLayout;
}
measureToLeft(measure: SmoMeasure) {
const j = measure.measureNumber.staffId;
const i = measure.measureNumber.measureIndex;
return (i > 0 ? this.score!.staves[j].measures[i - 1] : measure);
}
measureAbove(measure: SmoMeasure) {
const j = measure.measureNumber.staffId;
const i = measure.measureNumber.measureIndex;
return (j > 0 ? this.score!.staves[j - 1].measures[i] : measure);
}
// {measures,y,x} the x and y at the left/bottom of the render
/**
* Estimate the dimensions of a column when it's rendered.
* @param scoreLayout
* @param measureIx
* @param systemIndex
* @param lineIndex
* @param x
* @param y
* @returns { MeasureEstimate } - the measures in the column and the x, y location
*/
estimateColumn(scoreLayout: ScaledPageLayout, measureIx: number, systemIndex: number, lineIndex: number, x: number, y: number): MeasureEstimate {
const s: any = {};
const measures = this.columnMeasureMap[measureIx];
let rowInSystem = 0;
let voiceCount = 0;
let unalignedCtxCount = 0;
let wsum = 0;
let dsum = 0;
let maxCfgWidth = 0;
let isPickup = false;
// Keep running tab of accidental widths for justification
const contextMap: Record<number, SuiTickContext> = {};
let measureToSkip = false;
let maxColumnStartX = 0;
measures.forEach((measure) => {
// use measure to left to figure out whether I need to render key signature, etc.
// If I am the first measure, just use self and we always render them on the first measure.
const measureToLeft = this.measureToLeft(measure);
const measureAbove = this.measureAbove(measure);
s.measureKeySig = SmoMusic.vexKeySignatureTranspose(measure.keySignature, 0);
s.keySigLast = SmoMusic.vexKeySignatureTranspose(measureToLeft.keySignature, 0);
s.tempoLast = measureToLeft.getTempo();
if (measure.measureNumber.staffId > 0) {
s.tempoLast = measureAbove.getTempo();
}
s.timeSigLast = measureToLeft.timeSignature;
s.clefLast = measureToLeft.getLastClef();
this.calculateBeginningSymbols(systemIndex, measure, s.clefLast, s.keySigLast, s.timeSigLast, s.tempoLast);
const startX = SuiLayoutFormatter.estimateStartSymbolWidth(measure);
measure.svg.adjX = startX;
maxColumnStartX = Math.max(maxColumnStartX, startX);
});
measures.forEach((measure) => {
let tabHeight = 0;
measure.svg.maxColumnStartX = maxColumnStartX;
SmoBeamer.applyBeams(measure);
voiceCount += measure.voices.length;
if (measure.isPickup()) {
isPickup = true;
}
if (measure.format.skipMeasureCount) {
measureToSkip = true;
}
measure.measureNumber.systemIndex = systemIndex;
measure.svg.rowInSystem = rowInSystem;
measure.svg.lineIndex = lineIndex;
measure.svg.pageIndex = this.currentPage;
// calculate vertical offsets from the baseline
const stave = this.score.staves[measure.measureNumber.staffId];
const tabStave = stave.getTabStaveForMeasure({ staff: measure.measureNumber.staffId, measure: measure.measureNumber.measureIndex,
voice: 0, tick: 0, pitches: [] });
const offsets = this.estimateMeasureHeight(measure);
measure.setYTop(offsets.aboveBaseline, 'render:estimateColumn');
measure.setY(y - measure.yTop, 'estimateColumns height');
measure.setX(x, 'render:estimateColumn');
// Add custom width to measure:
measure.setBox(SvgHelpers.boxPoints(measure.staffX, y, measure.staffWidth, offsets.belowBaseline - offsets.aboveBaseline), 'render: estimateColumn');
this.estimateMeasureWidth(measure, scoreLayout, contextMap);
// account for the extra stave for tablature in the height, also set the dimensions of the stave tab
if (tabStave) {
const stemHeight = tabStave.showStems ? vexGlyph.dimensions['stem'].height : 0;
tabHeight = stemHeight + tabStave.numLines * tabStave.spacing;
measure.svg.tabStaveBox = { x, y: measure.svg.logicalBox.y + measure.svg.logicalBox.height,
width: measure.svg.logicalBox.width, height: tabHeight };
offsets.belowBaseline += measure.svg.tabStaveBox.height;
}
y = y + measure.svg.logicalBox.height + scoreLayout.intraGap + tabHeight;
maxCfgWidth = Math.max(maxCfgWidth, measure.staffWidth);
rowInSystem += 1;
});
// justify this column to the maximum width.
const startX = measures[0].staffX;
const adjX = measures[0].svg.maxColumnStartX;
const contexts = Object.keys(contextMap);
const widths: number[] = [];
const durations: number[] = [];
let minTotalWidth = 0;
contexts.forEach((strIx) => {
const ix = parseInt(strIx);
let tickWidth = 0;
const context = contextMap[ix];
if (context.tickCounts.length < voiceCount) {
unalignedCtxCount += 1;
}
context.widths.forEach((w, ix) => {
wsum += w;
dsum += context.tickCounts[ix];
widths.push(w);
durations.push(context.tickCounts[ix]);
tickWidth = Math.max(tickWidth, w);
});
minTotalWidth += tickWidth;
});
// Vex formatter adjusts location of ticks based to keep the justified music aligned. It does this
// by moving notes to the right. We try to add padding to each tick context based on the 'entropy' of the
// music. 4 quarter notes with no accidentals in all voices will have 0 entropy. All the notes need the same
// amount of space, so they don't need additional space to align.
// wvar - the std deviation in the widths or 'width entropy'
// dvar - the std deviation in the duration between voices or 'duration entropy'
const sumArray = (arr: number[]) => arr.reduce((a, b) => a + b, 0);
const wavg = wsum > 0 ? wsum / widths.length : 1 / widths.length;
const wvar = sumArray(widths.map((ll) => Math.pow(ll - wavg, 2)));
const wpads = Math.pow(wvar / widths.length, 0.5) / wavg;
const davg = dsum / durations.length;
const dvar = sumArray(durations.map((ll) => Math.pow(ll - davg, 2)));
const dpads = Math.pow(dvar / durations.length, 0.5) / davg;
const unalignedPadding = 2;
const padmax = Math.max(dpads, wpads) * contexts.length * unalignedPadding;
const unalignedPad = unalignedPadding * unalignedCtxCount;
let maxWidth = Math.max(adjX + minTotalWidth + Math.max(unalignedPad, padmax), maxCfgWidth);
if (scoreLayout.maxMeasureSystem > 0 && !isPickup && !measureToSkip) {
// Add 1 because there is some overhead in each measure,
// so there can never be (width/max) measures in the system
const defaultWidth = (scoreLayout.pageWidth / (scoreLayout.maxMeasureSystem + 1));
maxWidth = Math.max(maxWidth, defaultWidth);
}
const maxX = startX + maxWidth;
measures.forEach((measure) => {
measure.setWidth(maxWidth, 'render:estimateColumn');
// measure.svg.adjX = adjX;
});
const rv = { measures, y, x: maxX };
return rv;
}
/**
* return true if this is the last measure, taking into account multimeasure rest
* @param measureIx
* @returns
*/
isLastVisibleMeasure(measureIx: number) {
if (measureIx >= this.score.staves[0].measures.length) {
return true;
}
if (this.score.staves[0].partInfo.expandMultimeasureRests) {
return false;
}
let i = 0;
for (i = measureIx; i < this.score.staves[0].measures.length; ++i) {
const mm = this.score.staves[0].measures[i];
if (!mm.svg.hideMultimeasure) {
return false;
}
}
return true;
}
/**
* Calculate the geometry for the entire score, based on estimated measure width and height.
* @returns
*/
layout() {
let measureIx = 0;
let systemIndex = 0;
if (!this.score.layoutManager) {
return;
}
let scoreLayout = this.score.layoutManager.getScaledPageLayout(0);
let y = 0;
let x = 0;
let lineIndex = 0;
this.lines = [];
let pageCheck = 0;
// let firstMeasureOnPage = 0;
this.lines.push(lineIndex);
let currentLine: SmoMeasure[] = []; // the system we are esimating
let measureEstimate: MeasureEstimate | null = null;
layoutDebug.clearDebugBoxes(layoutDebug.values.pre);
layoutDebug.clearDebugBoxes(layoutDebug.values.system);
const timestamp = new Date().valueOf();
y = scoreLayout.topMargin;
x = scoreLayout.leftMargin;
while (measureIx < this.score.staves[0].measures.length) {
if (this.score.isPartExposed()) {
if (this.score.staves[0].measures[measureIx].svg.hideMultimeasure) {
measureIx += 1;
continue;
}
}
measureEstimate = this.estimateColumn(scoreLayout, measureIx, systemIndex, lineIndex, x, y);
x = measureEstimate.x;
if (systemIndex > 0 &&
(measureEstimate.measures[0].format.systemBreak || measureEstimate.x > (scoreLayout.pageWidth - scoreLayout.leftMargin))) {
this.justifyY(scoreLayout, measureEstimate.measures.length, currentLine, false);
// find the measure with the lowest y extend (greatest y value), not necessarily one with lowest
// start of staff.
const bottomMeasure: SmoMeasure = currentLine.reduce((a, b) =>
a.lowestY > b.lowestY ? a : b
);
this.checkPageBreak(scoreLayout, currentLine, bottomMeasure);
const renderedPage: RenderedPage | null = this.renderedPages[pageCheck];
if (renderedPage) {
if (pageCheck !== this.currentPage) {
// The last measure in the last system of the previous page
const previousSystem = currentLine[0].measureNumber.measureIndex - 1;
if (renderedPage.endMeasure !== previousSystem) {
this.renderedPages[pageCheck] = null;
}
const nextPage = this.renderedPages[this.currentPage];
if (nextPage && nextPage.startMeasure !== previousSystem + 1) {
this.renderedPages[this.currentPage] = null;
}
}
}
pageCheck = this.currentPage;
const ld = layoutDebug;
const sh = SvgHelpers;
if (layoutDebug.mask & layoutDebug.values.system) {
currentLine.forEach((measure) => {
if (measure.svg.logicalBox) {
const context = this.svg.getRenderer(measure.svg.logicalBox);
if (context) {
ld.debugBox(context.svg, measure.svg.logicalBox, layoutDebug.values.system);
}
}
});
}
// Now start rendering on the next system.
y = bottomMeasure.lowestY + scoreLayout.interGap;
currentLine = [];
systemIndex = 0;
x = scoreLayout.leftMargin;
lineIndex += 1;
this.lines.push(lineIndex);
measureEstimate = this.estimateColumn(scoreLayout, measureIx, systemIndex, lineIndex, x, y);
x = measureEstimate.x;
}
measureEstimate?.measures.forEach((measure) => {
const context = this.svg.getRenderer(measure.svg.logicalBox);
if (context) {
layoutDebug.debugBox(context.svg, measure.svg.logicalBox, layoutDebug.values.pre);
}
});
this.updateSystemMap(measureEstimate.measures, lineIndex, systemIndex);
currentLine = currentLine.concat(measureEstimate.measures);
measureIx += 1;
systemIndex += 1;
// If this is the last measure but we have not filled the x extent,
// still justify the vertical staves and check for page break.
if (this.isLastVisibleMeasure(measureIx) && measureEstimate !== null) {
this.justifyY(scoreLayout, measureEstimate.measures.length, currentLine, true);
const bottomMeasure = currentLine.reduce((a, b) =>
a.svg.logicalBox.y + a.svg.logicalBox.height > b.svg.logicalBox.y + b.svg.logicalBox.height ? a : b
);
scoreLayout = this.checkPageBreak(scoreLayout, currentLine, bottomMeasure);
}
}
// If a measure was added to the last page, make sure we re-render the page
const renderedPage: RenderedPage | null = this.renderedPages[this.currentPage];
if (renderedPage) {
if (renderedPage.endMeasure !== currentLine[0].measureNumber.measureIndex) {
this.renderedPages[this.currentPage] = null;
}
}
layoutDebug.setTimestamp(layoutDebug.codeRegions.COMPUTE, new Date().valueOf() - timestamp);
}
static estimateMusicWidth(smoMeasure: SmoMeasure, tickContexts: Record<number, SuiTickContext>): number {
const widths: number[] = [];
// Add up the widths of the music glyphs for each voice, including accidentals etc. We save the widths in a hash by duration
// and later consider overlapping/colliding ticks in each voice
const tmObj = smoMeasure.createMeasureTickmaps();
smoMeasure.voices.forEach((voice) => {
let width = 0;
let duration = 0;
const noteCount = voice.notes.length;
voice.notes.forEach((note) => {
let noteWidth = 0;
const dots: number = (note.dots ? note.dots : 0);
let headWidth: number = vexGlyph.width(vexGlyph.dimensions.noteHead);
// Maybe not the best place for this...ideally we'd get the note head glyph from
// the ntoe.
if (note.tickCount >= 4096 * 4 && note.noteType === 'n') {
headWidth *= 2;
}
const dotWidth: number = vexGlyph.width(vexGlyph.dimensions.dot);
noteWidth += headWidth +
vexGlyph.dimensions.noteHead.spacingRight;
// TODO: Consider engraving font and adjust grace note size?
noteWidth += (headWidth + vexGlyph.dimensions.noteHead.spacingRight) * note.graceNotes.length;
noteWidth += dotWidth * dots + vexGlyph.dimensions.dot.spacingRight * dots;
// This could be better if we actually did the beaming before formatting
if (!note.isRest() && note.beamState === SmoNote.beamStates.end) {
noteWidth += vexGlyph.dimensions.flag.width;
}
note.pitches.forEach((pitch) => {
const keyAccidental = SmoMusic.getAccidentalForKeySignature(pitch, smoMeasure.keySignature);
const accidentals = tmObj.accidentalArray.filter((ar) =>
(ar.duration as number) < duration && ar.pitches[pitch.letter]);
const acLen = accidentals.length;
const declared = acLen > 0 ?
accidentals[acLen - 1].pitches[pitch.letter].pitch.accidental : keyAccidental;
const ornaments = note.getOrnaments();
// Allocate extra space if there are ornaments, which tend to be larger than the note width
// or offset
if (ornaments.length) {
noteWidth += headWidth;
}
if (declared !== pitch.accidental || pitch.cautionary) {
noteWidth += vexGlyph.accidentalWidth(pitch.accidental) * 2;
}
});
let verse = 0;
let lyricBase = note.getLyricForVerse(verse, SmoLyric.parsers.lyric);
while (lyricBase.length) {
let lyric = lyricBase[0] as SmoLyric;
let lyricWidth = 0;
let i = 0;
// TODO: kerning and all that...
if (!lyric.text.length) {
break;
}
// why did I make this return an array?
// oh...because of voices
const textFont =
TextFormatter.create({ family: lyric.fontInfo.family,
size: lyric.fontInfo.size, weight: 'normal' });
const lyricText = lyric.getText();
for (i = 0; i < lyricText.length; ++i) {
lyricWidth += textFont.getWidthForTextInPx(lyricText[i])
}
if (lyric.isHyphenated()) {
lyricWidth += 2 * textFont.getWidthForTextInPx('-');
} else {
lyricWidth += 2 * textFont.getWidthForTextInPx('H');
}
noteWidth = Math.max(lyricWidth, noteWidth);
verse += 1;
lyricBase = note.getLyricForVerse(verse, SmoLyric.parsers.lyric);
}
if (!tickContexts[duration]) {
tickContexts[duration] = {
widths: [],
tickCounts: []
}
}
if (smoMeasure.repeatSymbol) {
noteWidth = vexGlyph.repeatSymbolWidth() / noteCount;
}
tickContexts[duration].widths.push(noteWidth);
tickContexts[duration].tickCounts.push(note.tickCount);
duration += Math.round(note.tickCount);
width += noteWidth;
});
widths.push(width);
});
widths.sort((a, b) => a > b ? -1 : 1);
return widths[0];
}
static estimateStartSymbolWidth(smoMeasure: SmoMeasure): number {
let width = 0;
// the variables starts and digits used to be in the if statements. I moved them here to fix the resulting error
var starts = smoMeasure.getStartBarline();
var digits = smoMeasure.timeSignature.timeSignature.split('/')[0].length;
if (smoMeasure.svg.forceKeySignature) {
if (smoMeasure.canceledKeySignature) {
width += vexGlyph.keySignatureLength(smoMeasure.canceledKeySignature);
}
width += vexGlyph.keySignatureLength(smoMeasure.keySignature);
}
if (smoMeasure.svg.forceClef) {
const clefGlyph = vexGlyph.clef(smoMeasure.clef);
width += clefGlyph.width + clefGlyph.spacingRight;
}
if (smoMeasure.svg.forceTimeSignature) {
width += vexGlyph.width(vexGlyph.dimensions.timeSignature) * digits + vexGlyph.dimensions.timeSignature.spacingRight;
}
if (starts) {
width += vexGlyph.barWidth(starts);
}
return width;
}
static estimateEndSymbolWidth(smoMeasure: SmoMeasure) {
var width = 0;
var ends = smoMeasure.getEndBarline();
if (ends) {
width += vexGlyph.barWidth(ends);
}
return width;
}
estimateMeasureWidth(measure: SmoMeasure, scoreLayout: ScaledPageLayout, tickContexts: Record<number, SuiTickContext>) {
// Calculate the existing staff width, based on the notes and what we expect to be rendered.
let measureWidth = SuiLayoutFormatter.estimateMusicWidth(measure, tickContexts) + defaultMeasurePadding;
// measure.svg.adjX already set based on max column adjX
measure.svg.adjRight = SuiLayoutFormatter.estimateEndSymbolWidth(measure);
measureWidth += measure.svg.adjX + measure.svg.adjRight + measure.format.customStretch + measure.format.padLeft;
const y = measure.svg.logicalBox.y;
// For systems that start with padding, add width for the padding
measure.setWidth(measureWidth, 'estimateMeasureWidth adjX adjRight');
// Calculate the space for left/right text which displaces the measure.
// measure.setX(measure.staffX + textOffsetBox.x,'estimateMeasureWidth');
measure.setBox(SvgHelpers.boxPoints(measure.staffX, y, measure.staffWidth, measure.svg.logicalBox.height),
'estimate measure width');
}
static _beamGroupForNote(measure: SmoMeasure, note: SmoNote): ISmoBeamGroup | null {
let rv: ISmoBeamGroup | null = null;
if (!note.beam_group) {
return null;
}
measure.beamGroups.forEach((bg) => {
if (!rv) {
if (bg.notes.findIndex((note) => note.beam_group && note.beam_group.id === bg.attrs.id) >= 0) {
rv = bg;
}
}
});
return rv;
}
/**
* Format a full system:
* 1. Lop the last measure off the end and move it to the first measure of the
* next system, if it doesn't fit
* 2. Justify the measures vertically
* 3. Justify the columns horizontally
* 4. Hide lines if they don't contain music
* @param scoreLayout
* @param measureEstimate
* @param currentLine
* @param columnCount
* @param lastSystem
*/
justifyY(scoreLayout: ScaledPageLayout, rowCount: number, currentLine: SmoMeasure[], lastSystem: boolean) {
const sh = SvgHelpers;
// If there are fewer measures in the system than the max, don't justify.
// We estimate the staves at the same absolute y value.
// Now, move them down so the top of the staves align for all measures in a row.
const measuresToHide: SmoMeasure[] = [];
const rows: Array<SmoMeasure[]> = [];
let anyNotes = false;
for (let i = 0; i < rowCount; ++i) {
// lowest staff has greatest staffY value.
const rowAdj = currentLine.filter((mm) => mm.svg.rowInSystem === i);
rows.push(rowAdj);
let lowestTabStaff = rowAdj.reduce((a, b) =>
a.svg.tabStaveBox && b.svg.tabStaveBox &&
a.svg.tabStaveBox.y + a.svg.tabStaveBox.height > b.svg.tabStaveBox.y + b.svg.tabStaveBox.height ?
a : b
);
const lowestStaff = rowAdj.reduce((a, b) =>
a.staffY > b.staffY ? a : b
);
const hasNotes = rowAdj.findIndex((x) => x.isRest() === false) >= 0;
if (hasNotes) {
anyNotes = true;
}
rowAdj.forEach((measure) => {
measure.svg.hideEmptyMeasure = false;
if (this.score.preferences.hideEmptyLines && !hasNotes && !this.score.isPartExposed()) {
measuresToHide.push(measure);
}
const adj = lowestStaff.staffY - measure.staffY;
measure.setY(measure.staffY + adj, 'justifyY');
measure.setBox(sh.boxPoints(measure.svg.logicalBox.x, measure.svg.logicalBox.y + adj, measure.svg.logicalBox.width, measure.svg.logicalBox.height), 'justifyY');
if (lowestTabStaff.svg.tabStaveBox && measure.svg.tabStaveBox) {
measure.svg.tabStaveBox.y = measure.svg.tabStaveBox.y + lowestTabStaff.svg.tabStaveBox.y - measure.svg.tabStaveBox.y;
}
});
const rightStaff = rowAdj.reduce((a, b) =>
a.staffX + a.staffWidth > b.staffX + b.staffWidth ? a : b);
const ld = layoutDebug;
let justifyX = 0;
let columnCount = rowAdj.length;
// missing offset is for systems that have fewer measures than the default (due to section break or score ending)
let missingOffset = 0;
if (scoreLayout.maxMeasureSystem > 1 &&
columnCount < scoreLayout.maxMeasureSystem
&& lastSystem) {
missingOffset = (scoreLayout.pageWidth / (scoreLayout.maxMeasureSystem + 1)) * (scoreLayout.maxMeasureSystem - columnCount);
columnCount = scoreLayout.maxMeasureSystem;
}
if (scoreLayout.maxMeasureSystem > 1 || !lastSystem) {
justifyX = Math.round((scoreLayout.pageWidth - (scoreLayout.leftMargin + scoreLayout.rightMargin + rightStaff.staffX + rightStaff.staffWidth + missingOffset))
/ columnCount);
}
let justOffset = 0;
rowAdj.forEach((measure) => {
measure.setWidth(measure.staffWidth + justifyX, '_estimateMeasureDimensions justify');
measure.setX(measure.staffX + justOffset, 'justifyY');
measure.setBox(sh.boxPoints(measure.svg.logicalBox.x + justOffset,
measure.svg.logicalBox.y, measure.staffWidth, measure.svg.logicalBox.height), 'justifyY');
const context = this.svg.getRenderer(measure.svg.logicalBox);
if (context) {
ld.debugBox(context.svg, measure.svg.logicalBox, layoutDebug.values.adjust);
}
justOffset += justifyX;
});
}
// If a full line doesn't contain any music, hide it.
if (this.score.preferences.hideEmptyLines && anyNotes) {
let adjY = 0;
for (let i = 0; i < rowCount; ++i) {
const rowAdj = measuresToHide.filter((mm) => mm.svg.rowInSystem === i);
if (rowAdj.length) {
adjY += rowAdj[0].svg.logicalBox.height;
rowAdj.forEach((mm) => {
mm.svg.logicalBox.height = 0;
mm.svg.hideEmptyMeasure = true;
});
} else {
const rowAdj = currentLine.filter((mm) => mm.svg.rowInSystem === i);
rowAdj.forEach((row) => {
row.setY(row.svg.staffY - adjY, 'format-hide');
});
}
}
}
// If a hidden measure has tempo or time signature, move it to the
// first visible measure
for (let i = 0; i < rowCount; ++i) {
const row = rows[i];
if (!row[0].svg.hideEmptyMeasure) {
break;
}
for (let j = 0; j < row.length; ++j) {
const mm: SmoMeasure = row[j];
if (mm.svg.hideEmptyMeasure && rows.length > i) {
const nextmm = rows[i + 1][j];
nextmm.svg.forceTimeSignature = mm.svg.forceTimeSignature;
nextmm.svg.forceKeySignature = mm.svg.forceKeySignature;
nextmm.svg.forceTempo = mm.svg.forceKeySignature;
}
}
}
}
/**
* highest value is actually the one lowest on the page
* @param measure
* @param note
* @returns
*/
static _highestLowestHead(measure: SmoMeasure, note: SmoNote) {
// note...er warning: Notes always have at least 1 pitch, even a rest
// or glyph has a pitch to indicate the placement
const hilo = { hi: 0, lo: 99999999 };
note.pitches.forEach((pitch) => {
const line = 5 - SmoMusic.pitchToStaffLine(measure.clef, pitch);
// TODO: use actual note head/rest/glyph. 10 px is space between staff lines
const noteHeight = 10;
const px = (noteHeight * line);
hilo.lo = Math.min(hilo.lo, px - noteHeight / 2);
hilo.hi = Math.max(hilo.hi, px + noteHeight / 2);
});
return hilo;
}
static textFont(lyric: SmoLyric) {
return TextFormatter.create(lyric.fontInfo);
}
/**
* Calculate the dimensions of symbols based on where in a system we are, like whether we need to show
* the key signature, clef etc.
* @param systemIndex
* @param measure
* @param clefLast
* @param keySigLast
* @param timeSigLast
* @param tempoLast
* @param score
*/
calculateBeginningSymbols(systemIndex: number, measure: SmoMeasure,
clefLast: string, keySigLast: string, timeSigLast: TimeSignature, tempoLast: SmoTempoText) {
// The key signature is set based on the transpose index already, i.e. an Eb part in concert C already has 3 sharps.
const xposeScore = this.score?.preferences?.transposingScore && (this.score?.isPartExposed() === false);
const xposeOffset = xposeScore ? measure.transposeIndex : 0;
const measureKeySig = SmoMusic.vexKeySignatureTranspose(measure.keySignature, xposeOffset);
measure.svg.forceClef = (systemIndex === 0 || measure.clef !== clefLast);
measure.svg.forceTimeSignature = (measure.measureNumber.measureIndex === 0 ||
(!SmoMeasure.timeSigEqual(timeSigLast, measure.timeSignature)) || measure.timeSignature.displayString.length > 0);
if (measure.timeSignature.display === false) {
measure.svg.forceTimeSignature = false;
}
measure.svg.forceTempo = false;
const tempo = measure.getTempo();
// always print tempo for the first measure, if indicated
if (tempo && measure.measureNumber.measureIndex === 0 && measure.measureNumber.staffId === 0) {
measure.svg.forceTempo = tempo.display && measure.svg.rowInSystem === 0;
} else if (tempo && tempoLast) {
// otherwise get tempo from the measure prior. But only one tempo per system.
if (!SmoTempoText.eq(tempo, tempoLast) && measure.svg.rowInSystem === 0) {
measure.svg.forceTempo = tempo.display;
}
} else if (tempo) {
measure.svg.forceTempo = tempo.display && measure.svg.rowInSystem === 0;
}
if (measureKeySig !== keySigLast && measure.measureNumber.measureIndex > 0) {
measure.canceledKeySignature = SmoMusic.vexKeySigWithOffset(keySigLast, xposeOffset);
measure.svg.forceKeySignature = true;
} else if (systemIndex === 0 && measureKeySig !== 'C') {
measure.svg.forceKeySignature = true;
} else {
measure.svg.forceKeySignature = false;
}
}
/**
* The baseline is the top line of the staff. aboveBaseline is a negative number
* that indicates how high above the baseline the measure goes. belowBaseline
* is a positive number that indicates how far below the baseline the measure goes.
* the height of the measure is below-above. Vex always renders a staff such that
* the y coordinate passed in for the stave is on the baseline.
*
* Note to past self: this was a really useful comment. Thank you.
* **/
estimateMeasureHeight(measure: SmoMeasure): { aboveBaseline: number, belowBaseline: number } {
let yTop = 0; // highest point, smallest Y value
let yBottom = measure.lines * 10; // lowest point, largest Y value.
let flag: number = -1;
let lyricOffset = 0;
const measureIndex = measure.measureNumber.measureIndex;
const staffIndex = measure.measureNumber.staffId;
const stave = this.score.staves[staffIndex];
stave.renderableModifiers.forEach((mm) => {
if (mm.startSelector.staff === staffIndex && (mm.startSelector.measure <= measureIndex && mm.endSelector.measure >= measureIndex) ||
mm.endSelector.staff === staffIndex &&
(mm.endSelector.measure <= measureIndex && mm.endSelector.measure >= measureIndex && mm.endSelector.measure !== mm.startSelector.measure)) {
if (mm.ctor === 'SmoHairpin') {
const hp = mm as SmoStaffHairpin;
if (hp.position === SmoStaffHairpin.positions.ABOVE) {
yTop = yTop - hp.height;
} else {
yBottom = yBottom + hp.height;
}
} else if (mm.ctor === 'SmoStaffTextBracket') {
const tb = mm as SmoStaffTextBracket;
const tbHeight = 14 + (10 * Math.abs(tb.line - 1)); // 14 default font size
if (tb.position === SmoStaffTextBracket.positions.TOP) {
yTop = yTop - tbHeight;
} else {
yBottom = yBottom + tbHeight;
}
}
}
});
if (measure.svg.forceClef) {
yBottom += vexGlyph.clef(measure.clef).yTop + vexGlyph.clef(measure.clef).yBottom;
yTop = yTop - vexGlyph.clef(measure.clef).yTop;
}
if (measure.svg.forceTempo) {
yTop = Math.min(-1 * vexGlyph.tempo.yTop, yTop);
}
let yBottomOffset = 0;
let yBottomVoiceZero = 0;
measure.voices.forEach((voice, voiceIx) => {
voice.notes.forEach((note) => {
const bg = SuiLayoutFormatter._beamGroupForNote(measure, note);
flag = SmoNote.flagStates.auto;
if (bg && note.noteType === 'n') {
flag = bg.notes[0].flagState;
// an auto-flag note is up if the 1st note is middle line
if (flag === SmoNote.flagStates.auto) {
const pitch = bg.notes[0].pitches[0];
flag = SmoMusic.pitchToStaffLine(measure.clef, pitch)
>= 3 ? SmoNote.flagStates.down : SmoNote.flagStates.up;
}
} else {
flag = note.flagState;
// odd-numbered voices flip default up/down
const voiceMod = voiceIx % 2;
// an auto-flag note is up if the 1st note is middle line
if (flag === SmoNote.flagStates.auto) {
const pitch = note.pitches[0];
flag = SmoMusic.pitchToStaffLine(measure.clef, pitch)
>= 3 ? SmoNote.flagStates.down : SmoNote.flagStates.up;
if (voiceMod === 1) {
flag = (flag === SmoNote.flagStates.down) ? SmoNote.flagStates.up : SmoNote.flagStates.down;
}
}
}
const hiloHead = SuiLayoutFormatter._highestLowestHead(measure, note);
if (flag === SmoNote.flagStates.down) {
yTop = Math.min(hiloHead.lo, yTop);
yBottom = Math.max(hiloHead.hi + vexGlyph.stem.height, yBottom);
} else {
yTop = Math.min(hiloHead.lo - vexGlyph.stem.height, yTop);
yBottom = Math.max(hiloHead.hi, yBottom);
}
// Lyrics will be rendered below the lowest thing on the staff, so add to
// belowBaseline value based on the max number of verses and font size
// it will extend
});
// Vex won't adjust for music in voices > 0 when placing lyrics.
// So we need to adjust here, if voices > 0 have music below lyrics.
if (voiceIx > 0 && yBottomVoiceZero < yBottom) {
yBottomOffset = yBottom - yBottomVoiceZero;
} else {
yBottomVoiceZero = yBottom;
}
});
let lyricsToAdjust: SmoLyric[] = [];
// get the lowest music part, then consider the lyrics
measure.voices.forEach((voice, voiceIx) => {
voice.notes.forEach((note) => {
const lyrics = note.getTrueLyrics();
lyricsToAdjust = lyricsToAdjust.concat(lyrics);
if (lyrics.length) {
const maxLyric = lyrics.reduce((a, b) => a.verse > b.verse ? a : b);
const fontInfo = SuiLayoutFormatter.textFont(maxLyric);
lyricOffset = Math.max((maxLyric.verse + 2) * fontInfo.maxHeight, lyricOffset);
}
const dynamics = note.getModifiers('SmoDynamicText') as SmoDynamicText[];
dynamics.forEach((dyn) => {
yBottom = Math.max((10 * dyn.yOffsetLine - 50) + 11, yBottom);
yTop = Math.min(10 * dyn.yOffsetLine - 50, yTop);
});
note.articulations.forEach((articulation) => {
if (articulation.position === SmoArticulation.positions.above) {
yTop -= 10;
} else {
yBottom += 10;
}
});
note.ornaments.forEach((ornament) => {
if (ornament.position === SmoOrnament.positions.above) {
yTop -= 10;
} else {
yBottom += 10;
}
})
});
});
yBottom += lyricOffset;
if (lyricsToAdjust.length > 0) {
lyricsToAdjust.forEach((lyric: SmoLyric) => {
lyric.musicYOffset = yBottomOffset;
});
}
const mmsel = SmoSelector.measureSelector(stave.staffId, measure.measureNumber.measureIndex);
return { belowBaseline: yBottom, aboveBaseline: yTop };
}
}