UNPKG

vexflow

Version:

A JavaScript library for rendering music notation and guitar tablature.

424 lines (366 loc) 13.5 kB
// [VexFlow](https://vexflow.com) - Copyright (c) Mohit Muthanna 2010. /** * ## Description * * Create a new tuplet from the specified notes. The notes must * be part of the same voice. If they are of different rhythmic * values, then options.num_notes must be set. * * @constructor * @param {Array.<Vex.Flow.StaveNote>} A set of notes: staveNotes, * notes, etc... any class that inherits stemmableNote at some * point in its prototype chain. * @param options: object { * * num_notes: fit this many notes into... * notes_occupied: ...the space of this many notes * * Together, these two properties make up the tuplet ratio * in the form of num_notes : notes_occupied. * num_notes defaults to the number of notes passed in, so * it is important that if you omit this property, all of * the notes passed should be of the same note value. * notes_occupied defaults to 2 -- so you should almost * certainly pass this parameter for anything other than * a basic triplet. * * location: * default 1, which is above the notes: ┌─── 3 ───┐ * -1 is below the notes └─── 3 ───┘ * * bracketed: boolean, draw a bracket around the tuplet number * when true: ┌─── 3 ───┐ when false: 3 * defaults to true if notes are not beamed, false otherwise * * ratioed: boolean * when true: ┌─── 7:8 ───┐, when false: ┌─── 7 ───┐ * defaults to true if the difference between num_notes and * notes_occupied is greater than 1. * * y_offset: int, default 0 * manually offset a tuplet, for instance to avoid collisions * with articulations, etc... * } */ import { Element } from './element'; import { Formatter } from './formatter'; import { Glyph } from './glyph'; import { Note } from './note'; import { Stem } from './stem'; import { StemmableNote } from './stemmablenote'; import { Tables } from './tables'; import { Category } from './typeguard'; import { defined, RuntimeError } from './util'; export interface TupletOptions { beats_occupied?: number; bracketed?: boolean; location?: number; notes_occupied?: number; num_notes?: number; ratioed?: boolean; y_offset?: number; } export interface TupletMetrics { noteHeadOffset: number; stemOffset: number; bottomLine: number; topModifierOffset: number; } export const enum TupletLocation { BOTTOM = -1, TOP = +1, } export class Tuplet extends Element { static get CATEGORY(): string { return Category.Tuplet; } notes: Note[]; protected options: TupletOptions; protected num_notes: number; protected point: number; protected bracketed: boolean; protected y_pos: number; protected x_pos: number; protected width: number; // location is initialized by the constructor via setTupletLocation(...). protected location!: number; protected notes_occupied: number; protected ratioed: boolean; protected numerator_glyphs: Glyph[] = []; protected denom_glyphs: Glyph[] = []; static get LOCATION_TOP(): number { return TupletLocation.TOP; } static get LOCATION_BOTTOM(): number { return TupletLocation.BOTTOM; } static get NESTING_OFFSET(): number { return 15; } static get metrics(): TupletMetrics { const tupletMetrics = Tables.currentMusicFont().getMetrics().tuplet; if (!tupletMetrics) throw new RuntimeError('BadMetrics', `tuplet missing`); return tupletMetrics; } constructor(notes: Note[], options: TupletOptions = {}) { super(); if (!notes || !notes.length) { throw new RuntimeError('BadArguments', 'No notes provided for tuplet.'); } this.options = options; this.notes = notes; this.num_notes = this.options.num_notes != undefined ? this.options.num_notes : notes.length; // We accept beats_occupied, but warn that it's deprecated: // the preferred property name is now notes_occupied. if (this.options.beats_occupied) { this.beatsOccupiedDeprecationWarning(); } this.notes_occupied = this.options.notes_occupied || this.options.beats_occupied || 2; if (this.options.bracketed != undefined) { this.bracketed = this.options.bracketed; } else { this.bracketed = notes.some((note) => !note.hasBeam()); } this.ratioed = this.options.ratioed != undefined ? this.options.ratioed : Math.abs(this.notes_occupied - this.num_notes) > 1; this.point = (Tables.NOTATION_FONT_SCALE * 3) / 5; this.y_pos = 16; this.x_pos = 100; this.width = 200; this.setTupletLocation(this.options.location || Tuplet.LOCATION_TOP); Formatter.AlignRestsToNotes(notes, true, true); this.resolveGlyphs(); this.attach(); } attach(): void { for (let i = 0; i < this.notes.length; i++) { const note = this.notes[i]; note.setTuplet(this); } } detach(): void { for (let i = 0; i < this.notes.length; i++) { const note = this.notes[i]; note.resetTuplet(this); } } /** * Set whether or not the bracket is drawn. */ setBracketed(bracketed: boolean): this { this.bracketed = !!bracketed; return this; } /** * Set whether or not the ratio is shown. */ setRatioed(ratioed: boolean): this { this.ratioed = !!ratioed; return this; } /** * Set the tuplet indicator to be displayed either on the top or bottom of the stave. */ setTupletLocation(location: number): this { if (location !== Tuplet.LOCATION_TOP && location !== Tuplet.LOCATION_BOTTOM) { // eslint-disable-next-line console.warn(`Invalid tuplet location [${location}]. Using Tuplet.LOCATION_TOP.`); location = Tuplet.LOCATION_TOP; } this.location = location; return this; } getNotes(): Note[] { return this.notes; } getNoteCount(): number { return this.num_notes; } beatsOccupiedDeprecationWarning(): void { // eslint-disable-next-line console.warn( 'beats_occupied has been deprecated as an option for tuplets. Please use notes_occupied instead.', 'Calls to getBeatsOccupied / setBeatsOccupied should now be routed to getNotesOccupied / setNotesOccupied.', 'The old methods will be removed in VexFlow 5.0.' ); } getBeatsOccupied(): number { this.beatsOccupiedDeprecationWarning(); return this.getNotesOccupied(); } setBeatsOccupied(beats: number): void { this.beatsOccupiedDeprecationWarning(); return this.setNotesOccupied(beats); } getNotesOccupied(): number { return this.notes_occupied; } setNotesOccupied(notes: number): void { this.detach(); this.notes_occupied = notes; this.resolveGlyphs(); this.attach(); } resolveGlyphs(): void { this.numerator_glyphs = []; let n = this.num_notes; while (n >= 1) { this.numerator_glyphs.unshift(new Glyph('timeSig' + (n % 10), this.point)); n = parseInt((n / 10).toString(), 10); } this.denom_glyphs = []; n = this.notes_occupied; while (n >= 1) { this.denom_glyphs.unshift(new Glyph('timeSig' + (n % 10), this.point)); n = parseInt((n / 10).toString(), 10); } } // determine how many tuplets are nested within this tuplet // on the same side (above/below), to calculate a y // offset for this tuplet: getNestedTupletCount(): number { const location = this.location; const first_note = this.notes[0]; let maxTupletCount = countTuplets(first_note, location); let minTupletCount = countTuplets(first_note, location); // Count the tuplets that are on the same side (above/below) // as this tuplet: function countTuplets(note: Note, location: number) { return note.getTupletStack().filter((tuplet) => tuplet.location === location).length; } this.notes.forEach((note) => { const tupletCount = countTuplets(note, location); maxTupletCount = tupletCount > maxTupletCount ? tupletCount : maxTupletCount; minTupletCount = tupletCount < minTupletCount ? tupletCount : minTupletCount; }); return maxTupletCount - minTupletCount; } // determine the y position of the tuplet: getYPosition(): number { // offset the tuplet for any nested tuplets between // it and the notes: const nested_tuplet_y_offset = this.getNestedTupletCount() * Tuplet.NESTING_OFFSET * -this.location; // offset the tuplet for any manual y_offset: const y_offset = this.options.y_offset || 0; // now iterate through the notes and find our highest // or lowest locations, to form a base y_pos const first_note = this.notes[0]; let y_pos; if (this.location === Tuplet.LOCATION_TOP) { y_pos = first_note.checkStave().getYForLine(0) - Tuplet.metrics.topModifierOffset; // check modifiers above note to see if they will collide with tuplet beam for (let i = 0; i < this.notes.length; ++i) { const note = this.notes[i]; let modLines = 0; const mc = note.getModifierContext(); if (mc) { modLines = Math.max(modLines, mc.getState().top_text_line); } const modY = note.getYForTopText(modLines) - Tuplet.metrics.noteHeadOffset; if (note.hasStem() || note.isRest()) { const top_y = note.getStemDirection() === Stem.UP ? note.getStemExtents().topY - Tuplet.metrics.stemOffset : note.getStemExtents().baseY - Tuplet.metrics.noteHeadOffset; y_pos = Math.min(top_y, y_pos); if (modLines > 0) { y_pos = Math.min(modY, y_pos); } } } } else { let lineCheck = Tuplet.metrics.bottomLine; // tuplet default on line 4 // check modifiers below note to see if they will collide with tuplet beam this.notes.forEach((nn) => { const mc = nn.getModifierContext(); if (mc) { lineCheck = Math.max(lineCheck, mc.getState().text_line + 1); } }); y_pos = first_note.checkStave().getYForLine(lineCheck) + Tuplet.metrics.noteHeadOffset; for (let i = 0; i < this.notes.length; ++i) { if (this.notes[i].hasStem() || this.notes[i].isRest()) { const bottom_y = this.notes[i].getStemDirection() === Stem.UP ? this.notes[i].getStemExtents().baseY + Tuplet.metrics.noteHeadOffset : this.notes[i].getStemExtents().topY + Tuplet.metrics.stemOffset; if (bottom_y > y_pos) { y_pos = bottom_y; } } } } return y_pos + nested_tuplet_y_offset + y_offset; } draw(): void { const ctx = this.checkContext(); this.setRendered(); // determine x value of left bound of tuplet const first_note = this.notes[0] as StemmableNote; const last_note = this.notes[this.notes.length - 1] as StemmableNote; if (!this.bracketed) { this.x_pos = first_note.getStemX(); this.width = last_note.getStemX() - this.x_pos; } else { this.x_pos = first_note.getTieLeftX() - 5; this.width = last_note.getTieRightX() - this.x_pos + 5; } // determine y value for tuplet this.y_pos = this.getYPosition(); const addGlyphWidth = (width: number, glyph: Glyph) => width + defined(glyph.getMetrics().width); // calculate total width of tuplet notation let width = this.numerator_glyphs.reduce(addGlyphWidth, 0); if (this.ratioed) { width = this.denom_glyphs.reduce(addGlyphWidth, width); width += this.point * 0.32; } const notation_center_x = this.x_pos + this.width / 2; const notation_start_x = notation_center_x - width / 2; // draw bracket if the tuplet is not beamed if (this.bracketed) { const line_width = this.width / 2 - width / 2 - 5; // only draw the bracket if it has positive length if (line_width > 0) { ctx.fillRect(this.x_pos, this.y_pos, line_width, 1); ctx.fillRect(this.x_pos + this.width / 2 + width / 2 + 5, this.y_pos, line_width, 1); ctx.fillRect( this.x_pos, this.y_pos + (this.location === Tuplet.LOCATION_BOTTOM ? 1 : 0), 1, this.location * 10 ); ctx.fillRect( this.x_pos + this.width, this.y_pos + (this.location === Tuplet.LOCATION_BOTTOM ? 1 : 0), 1, this.location * 10 ); } } // draw numerator glyphs const shiftY = Tables.currentMusicFont().lookupMetric('digits.shiftY', 0); let x_offset = 0; this.numerator_glyphs.forEach((glyph) => { glyph.render(ctx, notation_start_x + x_offset, this.y_pos + this.point / 3 - 2 + shiftY); x_offset += defined(glyph.getMetrics().width); }); // display colon and denominator if the ratio is to be shown if (this.ratioed) { const colon_x = notation_start_x + x_offset + this.point * 0.16; const colon_radius = this.point * 0.06; ctx.beginPath(); ctx.arc(colon_x, this.y_pos - this.point * 0.08, colon_radius, 0, Math.PI * 2, false); ctx.closePath(); ctx.fill(); ctx.beginPath(); ctx.arc(colon_x, this.y_pos + this.point * 0.12, colon_radius, 0, Math.PI * 2, false); ctx.closePath(); ctx.fill(); x_offset += this.point * 0.32; this.denom_glyphs.forEach((glyph) => { glyph.render(ctx, notation_start_x + x_offset, this.y_pos + this.point / 3 - 2 + shiftY); x_offset += defined(glyph.getMetrics().width); }); } } }