VexFlow - 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.');
    }

    if (notes.length === 1) {
      throw new Vex.RuntimeError('BadArguments', 'Too few notes 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.num_glyphs = [];
    let n = this.num_notes;
    while (n >= 1) {
      this.num_glyphs.push(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.push(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();

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.num_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
        );
      }
    }

draw numerator glyphs

    let x_offset = 0;
    this.num_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;
      });
    }
  }
}
h