|
@@ -0,0 +1,368 @@
|
|
|
+// [VexFlow](http://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 { Vex } from './vex';
|
|
|
+ import { Element } from './element';
|
|
|
+ import { Formatter } from './formatter';
|
|
|
+ import { Glyph } from './glyph';
|
|
|
+ import { Stem } from './stem';
|
|
|
+
|
|
|
+ export class Tuplet extends Element {
|
|
|
+ static get LOCATION_TOP() {
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ static get LOCATION_BOTTOM() {
|
|
|
+ return -1;
|
|
|
+ }
|
|
|
+ static get NESTING_OFFSET() {
|
|
|
+ return 15;
|
|
|
+ }
|
|
|
+
|
|
|
+ constructor(notes, options) {
|
|
|
+ super();
|
|
|
+ this.setAttribute('type', 'Tuplet');
|
|
|
+ if (!notes || !notes.length) {
|
|
|
+ throw new Vex.RuntimeError('BadArguments', 'No notes provided for tuplet.');
|
|
|
+ }
|
|
|
+
|
|
|
+ this.options = Vex.Merge({}, options);
|
|
|
+ this.notes = notes;
|
|
|
+ this.num_notes = 'num_notes' in this.options ?
|
|
|
+ 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 ('bracketed' in this.options) {
|
|
|
+ this.bracketed = this.options.bracketed;
|
|
|
+ } else {
|
|
|
+ this.bracketed =
|
|
|
+ notes.some(note => note.beam === null);
|
|
|
+ }
|
|
|
+
|
|
|
+ this.ratioed = 'ratioed' in this.options ?
|
|
|
+ this.options.ratioed :
|
|
|
+ (Math.abs(this.notes_occupied - this.num_notes) > 1);
|
|
|
+ this.point = 28;
|
|
|
+ this.y_pos = 16;
|
|
|
+ this.x_pos = 100;
|
|
|
+ this.width = 200;
|
|
|
+ this.location = this.options.location || Tuplet.LOCATION_TOP;
|
|
|
+
|
|
|
+ Formatter.AlignRestsToNotes(notes, true, true);
|
|
|
+ this.resolveGlyphs();
|
|
|
+ this.attach();
|
|
|
+ }
|
|
|
+
|
|
|
+ attach() {
|
|
|
+ for (let i = 0; i < this.notes.length; i++) {
|
|
|
+ const note = this.notes[i];
|
|
|
+ note.setTuplet(this);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ detach() {
|
|
|
+ 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) {
|
|
|
+ this.bracketed = !!bracketed;
|
|
|
+ return this;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Set whether or not the ratio is shown.
|
|
|
+ */
|
|
|
+ setRatioed(ratioed) {
|
|
|
+ this.ratioed = !!ratioed;
|
|
|
+ return this;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Set the tuplet to be displayed either on the top or bottom of the stave
|
|
|
+ */
|
|
|
+ setTupletLocation(location) {
|
|
|
+ if (!location) {
|
|
|
+ location = Tuplet.LOCATION_TOP;
|
|
|
+ } else if (location !== Tuplet.LOCATION_TOP && location !== Tuplet.LOCATION_BOTTOM) {
|
|
|
+ throw new Vex.RERR('BadArgument', 'Invalid tuplet location: ' + location);
|
|
|
+ }
|
|
|
+
|
|
|
+ this.location = location;
|
|
|
+ return this;
|
|
|
+ }
|
|
|
+
|
|
|
+ getNotes() {
|
|
|
+ return this.notes;
|
|
|
+ }
|
|
|
+
|
|
|
+ getNoteCount() {
|
|
|
+ return this.num_notes;
|
|
|
+ }
|
|
|
+
|
|
|
+ beatsOccupiedDeprecationWarning() {
|
|
|
+ const msg = [
|
|
|
+ 'beats_occupied has been deprecated as an ',
|
|
|
+ 'option for tuplets. Please use notes_occupied ',
|
|
|
+ 'instead. Calls to getBeatsOccupied and ',
|
|
|
+ 'setBeatsOccupied should now be routed to ',
|
|
|
+ 'getNotesOccupied and setNotesOccupied instead',
|
|
|
+ ].join('');
|
|
|
+
|
|
|
+ if (console && console.warn) { // eslint-disable-line no-console
|
|
|
+ console.warn(msg); // eslint-disable-line no-console
|
|
|
+ } else if (console) {
|
|
|
+ console.log(msg); // eslint-disable-line no-console
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ getBeatsOccupied() {
|
|
|
+ this.beatsOccupiedDeprecationWarning();
|
|
|
+ return this.getNotesOccupied();
|
|
|
+ }
|
|
|
+
|
|
|
+ setBeatsOccupied(beats) {
|
|
|
+ this.beatsOccupiedDeprecationWarning();
|
|
|
+ return this.setNotesOccupied(beats);
|
|
|
+ }
|
|
|
+
|
|
|
+ getNotesOccupied() {
|
|
|
+ return this.notes_occupied;
|
|
|
+ }
|
|
|
+
|
|
|
+ setNotesOccupied(notes) {
|
|
|
+ this.detach();
|
|
|
+ this.notes_occupied = notes;
|
|
|
+ this.resolveGlyphs();
|
|
|
+ this.attach();
|
|
|
+ }
|
|
|
+
|
|
|
+ resolveGlyphs() {
|
|
|
+ this.numerator_glyphs = [];
|
|
|
+ let n = this.num_notes;
|
|
|
+ while (n >= 1) {
|
|
|
+ this.numerator_glyphs.unshift(new Glyph('v' + (n % 10), this.point));
|
|
|
+ n = parseInt(n / 10, 10);
|
|
|
+ }
|
|
|
+
|
|
|
+ this.denom_glyphs = [];
|
|
|
+ n = this.notes_occupied;
|
|
|
+ while (n >= 1) {
|
|
|
+ this.denom_glyphs.unshift(new Glyph('v' + (n % 10), this.point));
|
|
|
+ n = parseInt(n / 10, 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() {
|
|
|
+ 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, location) {
|
|
|
+ return note.tupletStack.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() {
|
|
|
+ // 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.getStave().getYForLine(0) - 15;
|
|
|
+ // y_pos = first_note.getStemExtents().topY - 10;
|
|
|
+
|
|
|
+ for (let i = 0; i < this.notes.length; ++i) {
|
|
|
+ const top_y = this.notes[i].getStemDirection() === Stem.UP
|
|
|
+ ? this.notes[i].getStemExtents().topY - 10
|
|
|
+ : this.notes[i].getStemExtents().baseY - 20;
|
|
|
+
|
|
|
+ if (top_y < y_pos) {
|
|
|
+ y_pos = top_y;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ y_pos = first_note.getStave().getYForLine(4) + 20;
|
|
|
+
|
|
|
+ for (let i = 0; i < this.notes.length; ++i) {
|
|
|
+ const bottom_y = this.notes[i].getStemDirection() === Stem.UP
|
|
|
+ ? this.notes[i].getStemExtents().baseY + 20
|
|
|
+ : this.notes[i].getStemExtents().topY + 10;
|
|
|
+ if (bottom_y > y_pos) {
|
|
|
+ y_pos = bottom_y;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return y_pos + nested_tuplet_y_offset + y_offset;
|
|
|
+ }
|
|
|
+
|
|
|
+ draw() {
|
|
|
+ this.checkContext();
|
|
|
+ this.setRendered();
|
|
|
+
|
|
|
+ // determine x value of left bound of tuplet
|
|
|
+ const first_note = this.notes[0];
|
|
|
+ const last_note = this.notes[this.notes.length - 1];
|
|
|
+
|
|
|
+ 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, glyph) => width + 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) {
|
|
|
+ this.context.fillRect(this.x_pos, this.y_pos, line_width, 1);
|
|
|
+ this.context.fillRect(
|
|
|
+ this.x_pos + this.width / 2 + width / 2 + 5,
|
|
|
+ this.y_pos,
|
|
|
+ line_width,
|
|
|
+ 1
|
|
|
+ );
|
|
|
+ this.context.fillRect(
|
|
|
+ this.x_pos,
|
|
|
+ this.y_pos + (this.location === Tuplet.LOCATION_BOTTOM),
|
|
|
+ 1,
|
|
|
+ this.location * 10
|
|
|
+ );
|
|
|
+ this.context.fillRect(
|
|
|
+ this.x_pos + this.width,
|
|
|
+ this.y_pos + (this.location === Tuplet.LOCATION_BOTTOM),
|
|
|
+ 1,
|
|
|
+ this.location * 10
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // VexFlowPatch: add option to not render tuplet numbers
|
|
|
+ if (this.RenderTupletNumber !== false) {
|
|
|
+ // draw numerator glyphs
|
|
|
+ let x_offset = 0;
|
|
|
+ this.numerator_glyphs.forEach(glyph => {
|
|
|
+ glyph.render(this.context, notation_start_x + x_offset, this.y_pos + (this.point / 3) - 2);
|
|
|
+ x_offset += 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;
|
|
|
+ this.context.beginPath();
|
|
|
+ this.context.arc(colon_x, this.y_pos - this.point * 0.08, colon_radius, 0, Math.PI * 2, true);
|
|
|
+ this.context.closePath();
|
|
|
+ this.context.fill();
|
|
|
+ this.context.beginPath();
|
|
|
+ this.context.arc(colon_x, this.y_pos + this.point * 0.12, colon_radius, 0, Math.PI * 2, true);
|
|
|
+ this.context.closePath();
|
|
|
+ this.context.fill();
|
|
|
+ x_offset += this.point * 0.32;
|
|
|
+ this.denom_glyphs.forEach(glyph => {
|
|
|
+ glyph.render(this.context, notation_start_x + x_offset, this.y_pos + (this.point / 3) - 2);
|
|
|
+ x_offset += glyph.getMetrics().width;
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|