Source: AudioChart.js

/** @module */
/* exported AudioChart */
/* global getAudioContext FrequencyPitchMapper Sounder Player KeyboardHandler GoogleDataWrapper googleVisualCallbackMaker JSONDataWrapper HTMLTableDataWrapper htmlTableVisualCallbackMaker C3DataWrapper c3VisualCallbackMaker */

/**
 * Array index number (starts at zero).
 * Used to specify series and row in visual callbacks.
 * @typedef {integer} index
 */


/**
 * A function that highlights the current datum, in all series, visually.
 * Different callbacks must be created for different types of chart.
 * @callback VisualCallback
 * @param {index} row - The row of the cell to highlight
 */

/**
 * @typedef {Object} AudioChartOptions
 * @todo move the documentation here? (Downside is that the branching/groups
 *       of different options required for different chart types would be less
 *       clear.)
 */

/**
 * @typedef {Object} WrapperAndCallbackResults
 * @property {Class} WrapperClass - the data wrapper class
 * @property {Object|HTMLTableElement} dataSource - the data source to wrap
 * @property {VisualCallback} visualCallback - created if requested by the user
 */

/** Main object for API consumers */
class AudioChart {
	/**
	 * Create an AudioChart object.
	 * This first checks to see if the Web Audio API is available, and throws
	 * an {Error} if not. Then check the options given by the user.
	 * @param {AudioChartOptions} options - AudioChart options
	 */
	constructor(options) {
		const context = getAudioContext()
		if (context === null) {
			throw Error(
				"Sorry, your browser doesn't support the Web Audio API.")
		}
		this._setUp(context, options)
	}

	/**
	 * Passes through play/pause commands to the Player
	 */
	playPause() {
		this.player.playPause()
	}

	/**
	 * Returns the current set of options (passed in at object creation, or
	 * computed when options were updated).
	 */
	get options() {
		return this._options
	}

	/**
	 * Updates an AudioChart object to reflect new options. Can accept a subset
	 * of the standard options, so if only, for example, duration changes, then
	 * you need only specify the new duration and not the type and other
	 * paramaters.
	 * @param {AudioChartOptions} newOptions - Partial/full AudioChart options
	 */
	updateOptions(newOptions) {
		if (newOptions === undefined || Object.keys(newOptions).length === 0) {
			throw Error('No new options given')
		}
		const patchedOptions = Object.assign({}, this._options, newOptions)
		this._setUp(getAudioContext(), patchedOptions)
	}

	/**
	 * Checks options (either when a new AudioChart object is created, or when
	 * the user has asked for them to be updated) and then set everything up.
	 * @param {AudioContext} context - the Web Audio context
	 * @param {AudioChartOptions} options - AudioChart options
	 * @private
	 */
	_setUp(context, options) {
		// The testing for this next bit is a bit of a fudge as curerntly I've
		// not come up with a beter way than having the testing done on static
		// functions and checking that they've been called with appropraite
		// values, return appropriate values, or if they throw an exception.
		//
		// The thing blocking this is that I don't know how to stub out global
		// ES6 classes/functions *or* how to run each test via Karma in an
		// isolated environment where I can mock those global classes.
		//
		// TODO, as per https://github.com/matatk/audiochart/issues/37

		// Testing this separately allows us to check the options-checking code
		// without having to pass in functioning source data objects.
		AudioChart._checkOptions(options)

		// This is not currently tested; to mitigate, it doesn't make decisions.
		// Actually, it does call _assignWrapperCallback() but that /is/ tested.
		this._wireUpStuff(context, options)

		// Re _assignWrapperCallback(): that is also tested separately to avoid
		// the need for very detailed mocks for the data sources.

		this._options = Object.freeze(options)
	}

	/**
	 * Checks the passed-in options opbject for validity. This does not perform
	 * detailed checks that are covered in the various components' constructors;
	 * they run such checks themselves.
	 * @param {AudioChartOptions} options - AudioChart options
	 * @private
	 */
	static _checkOptions(options) {
		if (!options.hasOwnProperty('duration')) {
			throw Error('No duration given')
		}

		if (!options.hasOwnProperty('frequencyLow')) {
			throw Error('No minimum frequency given')
		}

		if (!options.hasOwnProperty('frequencyHigh')) {
			throw Error('No maximum frequency given')
		}

		switch (options.type) {
			case 'google':
			case 'c3':
			case 'json':
				if (!options.hasOwnProperty('data')) {
					throw Error("Options must include a 'data' key")
				}
				break
			case 'htmlTable':
				if (!options.hasOwnProperty('table')) {
					throw Error("Options must include a 'table' key")
				}
				break
			default:
				throw Error(`Invalid data type '${options.type}' given.`)
		}
	}

	/**
	 * Make a data wrapper of the appropriate class, instantiated with the
	 * appropriate data paramater (from options.data) and, optionally, make
	 * a visual callback (which may use options.chart, or other options if
	 * it's an HTML table visual callback).
	 * FIXME: Test somehow
	 * @private
	 * @param {AudioContext} context - the Web Audio context
	 * @param {AudioChartOptions} options - given by the user
	 */
	_wireUpStuff(context, options) {
		const assigned = AudioChart._assignWrapperCallback(options)
		const dataWrapper = new assigned.WrapperClass(assigned.dataSource)
		const callback = assigned.visualCallback

		const numberOfSeries = dataWrapper.numSeries()

		const seriesInfo = []
		for (let i = 0; i < numberOfSeries; i++ ) {
			seriesInfo.push({
				minimumDatum: dataWrapper.seriesMin(i),
				maximumDatum: dataWrapper.seriesMax(i),
				minimumFrequency: options.frequencyLow,
				maximumFrequency: options.frequencyHigh
			})
		}

		const frequencyPitchMapper = new FrequencyPitchMapper(seriesInfo)

		const sounder = new Sounder(context, numberOfSeries)

		this.player = new Player(
			options.duration,
			dataWrapper,
			frequencyPitchMapper,
			sounder,
			callback)

		if (options.chartContainer) {
			new KeyboardHandler(
				options.chartContainer,
				this.player)
		}
	}

	/**
	 * Works out which data source wrapper and visual callback (if requested)
	 * should be used with this chart.
	 * @param {AudioChartOptions} options - given by the user
	 * @returns {WrapperAndCallbackResults}
	 *	- data wrapper, data source and callback (if applicable) for this chart
	 * @private
	 */
	static _assignWrapperCallback(options) {
		const result = {
			'WrapperClass': null,
			'dataSource': null,
			'visualCallback': null
		}

		const chartTypeToWrapperClass = {
			google: GoogleDataWrapper,
			json: JSONDataWrapper,
			htmlTable: HTMLTableDataWrapper,
			c3: C3DataWrapper
		}

		const chartTypeToVisualCallbackMaker = {
			google: googleVisualCallbackMaker,
			c3: c3VisualCallbackMaker
		}

		switch (options.type) {
			case 'google':
			case 'json':
			case 'c3':
				result.WrapperClass = chartTypeToWrapperClass[options.type]
				result.dataSource = options.data
				if (options.hasOwnProperty('chart')) {
					result.visualCallback =
						chartTypeToVisualCallbackMaker[options.type](
							options.chart)
				}
				break
			case 'htmlTable':
				result.WrapperClass = HTMLTableDataWrapper
				result.dataSource = options.table
				if (options.hasOwnProperty('highlightClass')) {
					result.visualCallback = htmlTableVisualCallbackMaker(
						options.table,
						options.highlightClass)
				}
				break
		}

		return result
	}
}