satie
Version:
A sheet music renderer for the web
185 lines (162 loc) • 7.45 kB
text/typescript
/**
* This file is part of Satie music engraver <https://github.com/jnetterf/satie>.
* Copyright (C) Joshua Netterfield <joshua.ca> 2015 - present.
*
* Satie is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Satie is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Satie. If not, see <http://www.gnu.org/licenses/>.
*/
import {reduce, map, max, times, last, forEach} from "lodash";
import {IMeasureLayout} from "./private_measureLayout";
import {ILayoutOptions} from "./private_layoutOptions";
import {ILineBounds} from "./private_lineBounds";
import {MAX_SAFE_INTEGER} from "./private_util";
import {barDivisions} from "./private_chordUtil";
import {scoreParts} from "./private_part";
const UNDERFILLED_EXPANSION_WEIGHT = 0.1;
/**
* Evaluates S(t), the logistic function. Used to create aesthetic transitions.
* For example, the upper half of the logistic function is used to compute how much
* spacing should be on the final line of a song.
*/
function logistic(t: number) {
return 1 / (1 + Math.exp(-t));
}
/**
* Lays out measures within a bar & justifies.
*
* @returns new end of line
*/
function justify(options: ILayoutOptions, bounds: ILineBounds,
measures: IMeasureLayout[]): IMeasureLayout[] {
if (options.singleLineMode && !options.fixedMeasureWidth) {
// Skip: note that in this case, we set the shortestCount in each measure to half what it
// needs to be to fake justification in each measure.
return measures;
}
const x = bounds.left + reduce(measures, (sum, measure) => sum + measure.width, 0);
// Check for underfilled bars
const underfilled = map(measures, (measure, idx) => {
let attr = measures[idx].attributes;
let firstPart = scoreParts(options.header.partList)[0].id;
let divs = barDivisions(attr[firstPart][1]);
let maxDivs = measure.maxDivisions;
return maxDivs < divs;
});
let smallest = Number.POSITIVE_INFINITY;
forEach(measures, function(measure, measureIdx) {
let maxIdx = max(map(measure.elements, el => el.length));
times(maxIdx, function(j) {
for (let i = 0; i < measure.elements.length; ++i) {
if (measure.elements[i][j].expandPolicy !== "none") {
if (measure.elements[i][j].model && measure.elements[i][j].model.divCount) {
smallest = Math.min(measure.elements[i][j].model.divCount, smallest);
}
}
}
});
});
// x > enX is possible if a single bar's minimum size exceeds maxX, or if our
// guess for a measure width was too liberal. In either case, we're shortening
// the measure width here, and our partial algorithm doesn't work with negative
// padding.
let partial = x < bounds.right && options.lineIndex + 1 === options.lineCount;
let underfilledCount = 0;
let expandableCount = reduce(measures, function(memo, measure$, idx) {
// Precondition: all layouts at a given index have the same "expandable" value.
return reduce(last(measure$.elements), function(memo, element$) {
if (underfilled[idx] && element$.expandPolicy !== "none") {
++underfilledCount;
}
if (!element$.model || !element$.model.divCount) {
return memo;
}
let expandBy = 0;
if (element$.expandPolicy !== "none") {
expandBy = (Math.log(element$.model.divCount) - Math.log(smallest) + 1);
}
return memo + expandBy * (underfilled[idx] ? UNDERFILLED_EXPANSION_WEIGHT : 1.0);
}, memo);
}, 0);
let avgExpansion: number;
if (!expandableCount) { // case 1: nothing to expand
avgExpansion = 0;
} else if (partial) { // case 2: expanding, but not full width
let expansionRemainingGuess = bounds.right - 3 - x;
let avgExpansionGuess = expansionRemainingGuess /
(expandableCount + (1 - UNDERFILLED_EXPANSION_WEIGHT) * underfilledCount);
let weight = logistic((avgExpansionGuess - bounds.right / 80) / 20) * 2 / 3;
avgExpansion = (1 - weight) * avgExpansionGuess;
} else { // case 3: expanding or contracting to full width
let exp = bounds.right - x;
avgExpansion = exp / expandableCount;
}
let anyExpandable = false;
let totalExpCount = 0;
let lineExpansion = 0;
forEach(measures, function(measure, measureIdx) {
measure.originX += lineExpansion;
let measureExpansion = 0;
let maxIdx = max(map(measure.elements, el => el.length));
if (options.fixedMeasureWidth) {
let expandable = times(maxIdx, function(j) {
let expand = false;
for (let i = 0; i < measure.elements.length; ++i) {
if (measure.elements[i][j].expandPolicy !== "none") {
expand = true;
}
}
return expand;
});
let count = expandable.filter(n => n).length;
let expansionPerElement = (options.fixedMeasureWidth - measure.width) / count;
times(maxIdx, function(j) {
for (let i = 0; i < measure.elements.length; ++i) {
measure.elements[i][j].x += measureExpansion;
}
if (expandable[j]) {
measureExpansion += expansionPerElement;
}
});
} else {
times(maxIdx, function(j) {
for (let i = 0; i < measure.elements.length; ++i) {
measure.elements[i][j].x += measureExpansion;
}
let expandOne = false;
let minRatio = MAX_SAFE_INTEGER;
for (let i = 0; i < measure.elements.length; ++i) {
if (measure.elements[i][j].expandPolicy !== "none") {
anyExpandable = true;
if (!measure.elements[i][j].model || !measure.elements[i][j].model.divCount) {
continue;
}
let divCount = measure.elements[i][j].model.divCount;
let ratio = (Math.log(divCount) - Math.log(smallest) + 1) *
(underfilled[measureIdx] ? UNDERFILLED_EXPANSION_WEIGHT : 1.0);
minRatio = Math.min(minRatio, ratio);
expandOne = true;
}
}
if (expandOne) {
// FIXME: We can overshoot, like on Lily 23f.
measureExpansion += avgExpansion * minRatio;
totalExpCount += minRatio;
}
});
}
measure.width += measureExpansion;
lineExpansion += measureExpansion;
});
return measures;
}
export default justify;