lotus / inc /staffSvg /sheetDocument.ts
k-l-lambda's picture
commit lotus dist.
d605f27
import {CM_TO_PX} from "../constants";
import {roundNumber} from "./utils";
// eslint-disable-next-line
import StaffToken from "./staffToken";
// eslint-disable-next-line
import * as LilyNotation from "../lilyNotation";
import pick from "../pick";
interface SheetMarkingData {
id: string;
text: string;
x: number;
y: number;
cls: string;
}
export interface SheetMeasure {
index: number;
tokens: StaffToken[];
headX: number;
lineX?: number;
matchedTokens?: StaffToken[]; // for baking mode
noteRange: {
begin: number,
end: number,
};
class?: {[key: string]: boolean};
};
export interface SheetStaff {
measures: SheetMeasure[];
tokens: StaffToken[];
markings?: Partial<SheetMarkingData>[];
// the third staff line Y coordinate value
// The third staff line Y supposed to be zero, but regarding to the line stroke width,
// there is some error for original values in SVG document (which erased by coordinate rounding).
yRoundOffset?: number; // 0.0657 for default
x: number;
y: number;
top?: number;
headWidth?: number;
};
export interface SheetSystem {
index?: number;
pageIndex?: number;
measureIndices?: [number, number][]; // [end_x, index]
staves: SheetStaff[];
tokens: StaffToken[];
x: number;
y: number;
width?: number;
top: number;
bottom: number;
};
export interface SheetPage {
width: string;
height: string;
viewBox: {
x: number,
y: number,
width: number,
height: number,
};
systems: SheetSystem[];
tokens: StaffToken[];
hidden?: boolean;
// DEPRECATED
rows?: SheetSystem[];
};
/*const ALTER_PREFIXES = {
[-2]: "\u266D\u266D",
[-1]: "\u266D",
[0]: "\u266E",
[1]: "\u266F",
[2]: "\uD834\uDD2A",
};*/
// char codes defined in music font
const ALTER_PREFIXES = {
[-2]: "\ue02a",
[-1]: "\ue021",
[0]: "\ue01d",
[1]: "\ue013",
[2]: "\ue01c",
};
let sheetMarkingIndex = 0;
class SheetMarking {
alter?: number;
index: number; // as v-for key
id?: string;
text?: string;
x?: number;
y?: number;
cls?: string;
constructor (fields: Partial<SheetMarkingData>) {
this.index = sheetMarkingIndex++;
Object.assign(this, fields);
}
get alterText (): string {
return Number.isInteger(this.alter) ? ALTER_PREFIXES[this.alter] : null;
}
};
const parseUnitExp = exp => {
if (/[\d.]+mm/.test(exp)) {
const [value] = exp.match(/[\d.]+/);
return Number(value) * 0.1 * CM_TO_PX;
}
return Number(exp);
};
type MeasureLocationTable = {[key: number]: {[key: number]: number}};
const cc = <T>(arrays: T[][]): T[] => [].concat(...arrays);
class SheetDocument {
pages: SheetPage[];
constructor (fields: Partial<SheetDocument>, {initialize = true} = {}) {
Object.assign(this, fields);
if (initialize)
this.updateTokenIndex();
}
get systems (): SheetSystem[] {
return [].concat(...this.pages.map(page => page.systems));
}
// DEPRECATED
get rows (): SheetSystem[] {
return this.systems;
}
get trackCount (): number{
return Math.max(...this.systems.map(system => system.staves.length), 0);
}
get pageSize (): {width: number, height: number} {
const page = this.pages && this.pages[0];
if (!page)
return null;
return {
width: parseUnitExp(page.width),
height: parseUnitExp(page.height),
};
}
updateTokenIndex () {
// remove null pages for broken document
this.pages = this.pages.filter(page => page);
this.pages.forEach((page, index) => page.systems.forEach(system => system.pageIndex = index));
let rowMeasureIndex = 1;
this.systems.forEach((system, index) => {
system.index = index;
system.width = system.tokens.concat(...system.staves.map(staff => staff.tokens))
.reduce((max, token) => Math.max(max, token.x), 0);
system.measureIndices = [];
system.staves = system.staves.filter(s => s);
system.staves.forEach((staff, t) => {
staff.measures.forEach((measure, i) => {
measure.index = rowMeasureIndex + i;
measure.class = {};
measure.tokens.forEach(token => {
token.system = index;
token.measure = measure.index;
token.endX = measure.noteRange.end;
});
measure.lineX = measure.lineX || 0;
if (i < staff.measures.length - 1)
staff.measures[i + 1].lineX = measure.noteRange.end;
if (t === 0)
system.measureIndices.push([measure.noteRange.end, measure.index]);
});
staff.markings = [];
staff.yRoundOffset = 0;
const line = staff.tokens.find(token => token.is("STAFF_LINE"));
if (line)
staff.yRoundOffset = line.y - line.ry;
});
rowMeasureIndex += Math.max(...system.staves.map(staff => staff.measures.length));
});
}
updateMatchedTokens (matchedIds: Set<string>) {
this.systems.forEach(system => {
system.staves.forEach(staff =>
staff.measures.forEach(measure => {
measure.matchedTokens = measure.tokens.filter(token => token.href && matchedIds.has(token.href));
if (!staff.yRoundOffset) {
const token = measure.matchedTokens[0];
if (token)
staff.yRoundOffset = token.y - token.ry;
}
}));
});
}
addMarking (systemIndex: number, staffIndex: number, data: Partial<SheetMarkingData>): SheetMarking {
const system = this.systems[systemIndex];
if (!system) {
console.warn("system index out of range:", systemIndex, this.systems.length);
return;
}
const staff = system.staves[staffIndex];
if (!staff) {
console.warn("staff index out of range:", staffIndex, system.staves.length);
return;
}
const marking = new SheetMarking(data);
staff.markings.push(marking);
return marking;
}
removeMarking (id: string) {
this.systems.forEach(system => system.staves.forEach(staff =>
staff.markings = staff.markings.filter(marking => marking.id !== id)));
}
clearMarkings () {
this.systems.forEach(system => system.staves.forEach(staff => staff.markings = []));
}
toJSON (): object {
return {
__prototype: "SheetDocument",
pages: this.pages,
};
}
getLocationTable (): MeasureLocationTable {
const table = {};
this.systems.forEach(system => system.staves.forEach(staff => staff.measures.forEach(measure => {
measure.tokens.forEach(token => {
if (token.href) {
const location = token.href.match(/\d+/g);
if (location) {
const [line, column] = location.map(Number);
table[line] = table[line] || {};
table[line][column] = Number.isFinite(table[line][column]) ? Math.min(table[line][column], measure.index) : measure.index;
}
else
console.warn("invalid href:", token.href);
}
});
})));
return table;
}
lookupMeasureIndex (systemIndex: number, x: number): number {
const system = this.systems[systemIndex];
if (!system || !system.measureIndices)
return null;
const [_, index] = system.measureIndices.find(([end]) => x < end) || [null, null];
return index;
}
tokensInSystem (systemIndex: number): StaffToken[] {
const system = this.systems[systemIndex];
if (!system)
return null;
return system.staves.reduce((tokens, staff) => {
const translate = token => token.translate({x: staff.x, y: staff.y});
tokens.push(...staff.tokens.map(translate));
staff.measures.forEach(measure => tokens.push(...measure.tokens.map(translate)));
return tokens;
}, [...system.tokens]);
}
tokensInPage (pageIndex: number, {withPageTokens = false} = {}): StaffToken[] {
const page = this.pages[pageIndex];
if (!page)
return null;
return page.systems.reduce((tokens, system) => {
tokens.push(...this.tokensInSystem(system.index).map(token => token.translate({x: system.x, y: system.y})));
return tokens;
}, withPageTokens ? [...page.tokens] : []);
}
fitPageViewbox ({margin = 5, verticalCropOnly = false, pageTokens = false} = {}) {
if (!this.pages || !this.pages.length)
return;
const svgScale = this.pageSize.width / this.pages[0].viewBox.width;
this.pages.forEach((page, i) => {
const rects = page.systems.filter(system => Number.isFinite(system.x + system.width + system.y + system.top + system.bottom))
.map(system => [system.x, system.x + system.width, system.y + system.top, system.y + system.bottom ]);
const tokens = this.tokensInPage(i, {withPageTokens: pageTokens}) || [];
const tokenXs = tokens.map(token => token.x).filter(Number.isFinite);
const tokenYs = tokens.map(token => token.y).filter(Number.isFinite);
//console.debug("tokens:", i, tokens, tokenXs, tokenYs);
if (!rects.length)
return;
const left = Math.min(...rects.map(rect => rect[0]), ...tokenXs);
const right = Math.max(...rects.map(rect => rect[1]), ...tokenXs);
const top = Math.min(...rects.map(rect => rect[2]), ...tokenYs);
const bottom = Math.max(...rects.map(rect => rect[3]), ...tokenYs);
const x = verticalCropOnly ? page.viewBox.x : left - margin;
const y = (verticalCropOnly && i === 0) ? page.viewBox.y : top - margin;
const width = verticalCropOnly ? page.viewBox.width : right - left + margin * 2;
const height = (verticalCropOnly && i === 0) ? bottom + margin - y : bottom - top + margin * 2;
page.viewBox = {x, y, width, height};
page.width = (page.viewBox.width * svgScale).toString();
page.height = (page.viewBox.height * svgScale).toString();
});
}
getTokensOf (symbol: string): StaffToken[] {
return this.systems.reduce((tokens, system) => {
system.staves.forEach(staff => staff.measures.forEach(measure =>
tokens.push(...measure.tokens.filter(token => token.is(symbol)))));
return tokens;
}, []);
}
getNoteHeads (): StaffToken[] {
return this.getTokensOf("NOTEHEAD");
}
getNotes (): StaffToken[] {
return this.getTokensOf("NOTE");
}
getTokenMap (): Map<string, StaffToken> {
return this.systems.reduce((tokenMap, system) => {
system.staves.forEach(staff => staff.measures.forEach(measure => measure.tokens
.filter(token => token.href)
.forEach(token => tokenMap.set(token.href, token))));
return tokenMap;
}, new Map<string, StaffToken>());
}
findTokensAround (token: StaffToken, indices: number[]): StaffToken[] {
const system = this.systems[token.system];
if (system) {
const tokens = [
...system.tokens,
...cc(system.staves.map(staff => [
...staff.tokens,
...cc(staff.measures.map(measure => measure.tokens)),
])),
];
return tokens.filter(token => indices.includes(token.index));
}
return null;
}
findTokenAround (token: StaffToken, index: number): StaffToken {
const results = this.findTokensAround(token, [index]);
return results && results[0];
}
alignTokensWithNotation (notation: LilyNotation.Notation, {partial = false, assignFlags = false} = {}) {
const shortId = (href: string): string => href.split(":").slice(0, 2).join(":");
const noteTokens = this.getNotes();
const tokenMap = noteTokens.reduce((map, token) => {
const sid = token.href && shortId(token.href);
const tokens = map.get(sid) || [];
// shift column for command chord element
if (/^\\/.test(token.source)) {
const spaceCapture = token.source.match(/(?<=\s+)(\S|$)/);
if (spaceCapture) {
const [line, column] = token.href.match(/\d+/g).map(Number);
map.set(`${line}:${column + spaceCapture.index}`, [token]);
return map;
}
else
console.warn("unresolved command chord element:", token.source, token);
}
tokens.push(token);
token.href && map.set(sid, tokens);
return map;
}, new Map<string, StaffToken[]>());
//console.assert(tokenMap.size === noteTokens.length, "tokens noteTokens count dismatch:", tokenMap.size, noteTokens.length);
const tokenTickMap = new Map<StaffToken, {measureTick: number, tick: number}>();
// assign tick & track
notation.measures.forEach((measure, mi) => {
const pendingStems = new Map<StaffToken, StaffToken>(); // stem -> beam
measure.notes.forEach(note => {
const tokens = tokenMap.get(shortId(note.id));
if (tokens) {
tokens.forEach(token => {
token.href = note.id;
if (!Number.isFinite(token.tick)) {
tokenTickMap.set(token, {measureTick: measure.tick, tick: measure.tick + note.tick});
token.pitch = note.pitch;
token.track = note.track;
if (token.stems) {
const stems = this.findTokensAround(token, token.stems);
if (stems) {
const stem = stems.find(stem => stem.division === note.division && !Number.isFinite(stem.track));
if (stem) {
stem.track = note.track;
if (stem.beam >= 0) {
const beam = this.findTokenAround(stem, stem.beam);
if (stems.length < 2 || stems[0].division !== stems[1].division)
beam.track = stem.track;
}
}
else if (!stems.find(stem => stem.division === note.division))
console.warn("missed stem:", mi, token.href, note.division, token.stems, stems.map(stem => stem.division));
stems.forEach(stem => {
tokenTickMap.set(stem, {measureTick: measure.tick, tick: measure.tick + note.tick});
if (stems.length > 1 && stem.beam >= 0) {
const beam = this.findTokenAround(stem, stem.beam);
if (beam)
pendingStems.set(stem, beam);
}
});
}
else
console.warn("stems token missing:", token.system, token.stems, mi, token.href);
}
}
});
}
else if (!partial)
note.overlapped = true;
});
//if (pendingStems.size)
// console.log("pendingStems:", mi, [...pendingStems].map(s => s.index));
for (const [stem, beam] of pendingStems) {
if (Number.isFinite(beam.track))
stem.track = beam.track;
}
});
const tokenTickMapKeys = Array.from(tokenTickMap.keys());
this.systems.forEach(system => {
system.staves.forEach(staff => staff.measures.forEach(measure => {
const tokens = measure.tokens.filter(token => tokenTickMapKeys.includes(token));
const meastureTick = tokens.reduce((tick, token) => Math.min(tokenTickMap.get(token).measureTick, tick), Infinity);
tokens.forEach(token => token.tick = tokenTickMap.get(token).tick - meastureTick);
}));
});
if (assignFlags)
this.assignFlagsTrack();
}
assignFlagsTrack () {
const flags = this.getTokensOf("FLAG");
flags.forEach(flag => {
if (Number.isFinite(flag.stem)) {
const stem = this.findTokenAround(flag, flag.stem);
if (stem && Number.isFinite(stem.track))
flag.track = stem.track;
}
});
}
pruneForBakingMode () {
const round = x => roundNumber(x, 1e-4);
this.pages.forEach(page => {
page.tokens = [];
page.systems.forEach(system => {
system.tokens = [];
system.measureIndices = system.measureIndices && system.measureIndices.map(([x, i]) => [round(x), i]);
system.staves.forEach(staff => {
staff.tokens = [];
staff.yRoundOffset = round(staff.yRoundOffset);
delete staff.top;
delete staff.headWidth;
staff.measures.forEach(measure => {
measure.headX = round(measure.headX);
measure.lineX = round(measure.lineX);
measure.noteRange = {
begin: round(measure.noteRange.begin),
end: round(measure.noteRange.end),
};
measure.tokens = measure.matchedTokens.map(token => new StaffToken(pick(token, [
"x", "y", "symbol", "href", "scale", "tied",
])));
delete measure.matchedTokens;
});
});
});
});
}
appendLinkedTokensForStaves (): void {
const doneTokens = new Set();
const appendLink = (staff: SheetStaff, oldStaff: SheetStaff, token: StaffToken): void => {
if (doneTokens.has(token.index))
return;
//console.log("appendLink:", staff, oldStaff, token);
const dy = staff.y - oldStaff.y;
const measure = staff.measures.find(measure => measure.noteRange.end >= token.x);
if (measure) {
const newToken = new StaffToken({...token, symbols: new Set(), y: token.y - dy, ry: token.ry - dy});
token.addSymbol("ACROSS_STAVES");
newToken.addSymbol("ACROSS_STAVES");
newToken.addSymbol("DUPLICATED");
measure.tokens.push(newToken);
}
else
console.warn("appendLink failed, because no fit measure:", staff.measures, token);
doneTokens.add(token.index);
};
this.pages.forEach(page => {
const tokens: StaffToken[] = (page.systems
.map(system => system.staves
.map(staff => staff.measures
.map(measure => measure.tokens))) as any).flat(3);
const tokenStaffTable: Record<number, SheetStaff> = page.systems
.reduce((table, system) => system.staves
.reduce((table, staff) => staff.measures
.reduce((table, measure) => measure.tokens
.reduce((table, token) => {
table[token.index] = staff;
return table;
}, table), table), table), {});
//console.log("tokenStaffTable:", tokenStaffTable);
tokens.forEach(token => {
if (token.stems) {
const staff = tokenStaffTable[token.index];
token.stems.forEach(stem => {
if (tokenStaffTable[stem] !== staff)
appendLink(tokenStaffTable[stem], staff, token);
});
}
});
});
}
};
export default SheetDocument;