/* * * * (c) 2009-2021 Øystein Moseng * * Sonification functions for chart/series. * * License: www.highcharts.com/license * * !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!! * * */ 'use strict'; import Earcon from './Earcon.js'; import Instrument from './Instrument.js'; import Point from '../../Core/Series/Point.js'; import SU from './SonificationUtilities.js'; var getExtremesForInstrumentProps = SU.getExtremesForInstrumentProps, virtualAxisTranslate = SU.virtualAxisTranslate; import Timeline from './Timeline.js'; import TimelineEvent from './TimelineEvent.js'; import TimelinePath from './TimelinePath.js'; import U from '../../Core/Utilities.js'; var extend = U.extend, find = U.find, isArray = U.isArray, merge = U.merge, objectEach = U.objectEach, pick = U.pick; /* * * * Compositions * * */ var SeriesSonify; (function (SeriesSonify) { /* * * * Declarations * * */ /* * * * Constants * * */ var composedClasses = []; /* * * * Functions * * */ /* eslint-disable valid-jsdoc */ /** * @private */ function compose(SeriesClass) { if (composedClasses.indexOf(SeriesClass) === -1) { composedClasses.push(SeriesClass); var seriesProto = SeriesClass.prototype; extend(seriesProto, { sonify: sonify }); } return SeriesClass; } SeriesSonify.compose = compose; /** * Utility function to apply a master volume to a list of instrument * options. * @private * @param {Array} instruments * The instrument options. Only options with Instrument object instances * will be affected. * @param {number} masterVolume * The master volume multiplier to apply to the instruments. * @return {Array} * Array of instrument options. */ function applyMasterVolumeToInstruments(instruments, masterVolume) { instruments.forEach(function (instrOpts) { var instr = instrOpts.instrument; if (typeof instr !== 'string') { instr.setMasterVolume(masterVolume); } }); return instruments; } /** * Utility function to assemble options for creating a TimelinePath from a * series when sonifying an entire chart. * @private * @param {Highcharts.Series} series * The series to return options for. * @param {Highcharts.RangeObject} dataExtremes * Pre-calculated data extremes for the chart. * @param {Highcharts.SonificationOptions} chartSonifyOptions * Options passed in to chart.sonify. * @return {Partial} * Options for buildTimelinePathFromSeries. */ function buildChartSonifySeriesOptions(series, dataExtremes, chartSonifyOptions) { var additionalSeriesOptions = chartSonifyOptions.seriesOptions || {}, sonification = series.chart.options.sonification, pointPlayTime = (sonification && sonification.defaultInstrumentOptions && sonification.defaultInstrumentOptions.mapping && sonification.defaultInstrumentOptions.mapping.pointPlayTime || 'x'), configOptions = chartOptionsToSonifySeriesOptions(series); return merge( // Options from chart configuration configOptions, // Options passed in { // Calculated dataExtremes for chart dataExtremes: dataExtremes, // We need to get timeExtremes for each series. We pass this // in when building the TimelinePath objects to avoid // calculating twice. timeExtremes: getTimeExtremes(series, pointPlayTime), // Some options we just pass on instruments: (chartSonifyOptions.instruments || configOptions.instruments), onStart: (chartSonifyOptions.onSeriesStart || configOptions.onStart), onEnd: chartSonifyOptions.onSeriesEnd || configOptions.onEnd, earcons: chartSonifyOptions.earcons || configOptions.earcons, masterVolume: pick(chartSonifyOptions.masterVolume, configOptions.masterVolume) }, // Merge in the specific series options by ID if any are passed in isArray(additionalSeriesOptions) ? (find(additionalSeriesOptions, function (optEntry) { return optEntry.id === pick(series.id, series.options.id); }) || {}) : additionalSeriesOptions, { // Forced options pointPlayTime: pointPlayTime }); } SeriesSonify.buildChartSonifySeriesOptions = buildChartSonifySeriesOptions; /** * Create a TimelinePath from a series. Takes the same options as * seriesSonify. To intuitively allow multiple series to play simultaneously * we make copies of the instruments for each series. * @private * @param {Highcharts.Series} series * The series to build from. * @param {Highcharts.SonifySeriesOptionsObject} options * The options for building the TimelinePath. * @return {Highcharts.TimelinePath} * A timeline path with events. */ function buildTimelinePathFromSeries(series, options) { // options.timeExtremes is internal and used so that the calculations // from chart.sonify can be reused. var timeExtremes = options.timeExtremes || getTimeExtremes(series, options.pointPlayTime), // Compute any data extremes that aren't defined yet dataExtremes = getExtremesForInstrumentProps(series.chart, options.instruments, options.dataExtremes), minimumSeriesDurationMs = 10, // Get the duration of the final note finalNoteDuration = getFinalNoteDuration(series, options.instruments, dataExtremes), // Get time offset for a point, relative to duration pointToTime = function (point) { return virtualAxisTranslate(getPointTimeValue(point, options.pointPlayTime), timeExtremes, { min: 0, max: Math.max(options.duration - finalNoteDuration, minimumSeriesDurationMs) }); }, masterVolume = pick(options.masterVolume, 1), // Make copies of the instruments used for this series, to allow // multiple series with the same instrument to play together instrumentCopies = makeInstrumentCopies(options.instruments), instruments = applyMasterVolumeToInstruments(instrumentCopies, masterVolume), // Go through the points, convert to events, optionally add Earcons timelineEvents = series.points.reduce(function (events, point) { var earcons = getPointEarcons(point, options.earcons || []), time = pointToTime(point); return events.concat( // Event object for point new TimelineEvent({ eventObject: point, time: time, id: point.id, playOptions: { instruments: instruments, dataExtremes: dataExtremes, masterVolume: masterVolume } }), // Earcons earcons.map(function (earcon) { return new TimelineEvent({ eventObject: earcon, time: time, playOptions: { volume: masterVolume } }); })); }, []); // Build the timeline path return new TimelinePath({ events: timelineEvents, onStart: function () { if (options.onStart) { options.onStart(series); } }, onEventStart: function (event) { var eventObject = event.options && event.options.eventObject; if (eventObject instanceof Point) { // Check for hidden series if (!eventObject.series.visible && !eventObject.series.chart.series.some(function (series) { return series.visible; })) { // We have no visible series, stop the path. event.timelinePath.timeline.pause(); event.timelinePath.timeline.resetCursor(); return false; } // Emit onPointStart if (options.onPointStart) { options.onPointStart(event, eventObject); } } }, onEventEnd: function (eventData) { var eventObject = (eventData.event && eventData.event.options && eventData.event.options.eventObject); if (eventObject instanceof Point && options.onPointEnd) { options.onPointEnd(eventData.event, eventObject); } }, onEnd: function () { if (options.onEnd) { options.onEnd(series); } }, targetDuration: options.duration }); } SeriesSonify.buildTimelinePathFromSeries = buildTimelinePathFromSeries; /** * Utility function to translate between options set in chart configuration * and a SonifySeriesOptionsObject. * @private * @param {Highcharts.Series} series * The series to get options for. * @return {Highcharts.SonifySeriesOptionsObject} * Options for chart/series.sonify() */ function chartOptionsToSonifySeriesOptions(series) { var seriesOpts = series.options.sonification || {}, chartOpts = series.chart.options.sonification || {}, chartEvents = chartOpts.events || {}, seriesEvents = seriesOpts.events || {}; return { onEnd: seriesEvents.onSeriesEnd || chartEvents.onSeriesEnd, onStart: seriesEvents.onSeriesStart || chartEvents.onSeriesStart, onPointEnd: seriesEvents.onPointEnd || chartEvents.onPointEnd, onPointStart: seriesEvents.onPointStart || chartEvents.onPointStart, pointPlayTime: (chartOpts.defaultInstrumentOptions && chartOpts.defaultInstrumentOptions.mapping && chartOpts.defaultInstrumentOptions.mapping.pointPlayTime), masterVolume: chartOpts.masterVolume, // Deals with chart-level defaults instruments: getSeriesInstrumentOptions(series), earcons: seriesOpts.earcons || chartOpts.earcons }; } /** * Utility function to find the duration of the final note in a series. * @private * @param {Highcharts.Series} series The data series to calculate on. * @param {Array} instruments The instrument options for this series. * @param {Highcharts.Dictionary} dataExtremes Value extremes for the data series props. * @return {number} The duration of the final note in milliseconds. */ function getFinalNoteDuration(series, instruments, dataExtremes) { var finalPoint = series.points[series.points.length - 1]; return instruments.reduce(function (duration, instrument) { var mapping = instrument.instrumentMapping.duration; var instrumentDuration; if (typeof mapping === 'string') { instrumentDuration = 0; // Ignore, no easy way to map this } else if (typeof mapping === 'function') { instrumentDuration = mapping(finalPoint, dataExtremes); } else { instrumentDuration = mapping; } return Math.max(duration, instrumentDuration); }, 0); } /** * Get earcons for the point if there are any. * @private * @param {Highcharts.Point} point * The point to find earcons for. * @param {Array} earconDefinitions * Earcons to check. * @return {Array} * Array of earcons to be played with this point. */ function getPointEarcons(point, earconDefinitions) { return earconDefinitions.reduce(function (earcons, earconDefinition) { var earcon = earconDefinition.earcon; var cond; if (earconDefinition.condition) { // We have a condition. This overrides onPoint cond = earconDefinition.condition(point); if (cond instanceof Earcon) { // Condition returned an earcon earcons.push(cond); } else if (cond) { // Condition returned true earcons.push(earcon); } } else if (earconDefinition.onPoint && point.id === earconDefinition.onPoint) { // We have earcon onPoint earcons.push(earcon); } return earcons; }, []); } /** * Get the relative time value of a point. * @private * @param {Highcharts.Point} point * The point. * @param {Function|string} timeProp * The time axis data prop or the time function. * @return {number} * The time value. */ function getPointTimeValue(point, timeProp) { return typeof timeProp === 'function' ? timeProp(point) : pick(point[timeProp], point.options[timeProp]); } /** * @private * @param {Highcharts.Series} series * The series to get options for. * @param {Highcharts.SonifySeriesOptionsObject} options * Options to merge with user options on series/chart and default options. * @return {Array} * The merged options. */ function getSeriesInstrumentOptions(series, options) { if (options && options.instruments) { return options.instruments; } var defaultInstrOpts = (series.chart.options.sonification && series.chart.options.sonification.defaultInstrumentOptions || {}), seriesInstrOpts = (series.options.sonification && series.options.sonification.instruments || [{}]), removeNullsFromObject = function (obj) { objectEach(obj, function (val, key) { if (val === null) { delete obj[key]; } }); }; // Convert series options to PointInstrumentObjects and merge with // default options return (seriesInstrOpts).map(function (optionSet) { // Allow setting option to null to use default removeNullsFromObject(optionSet.mapping || {}); removeNullsFromObject(optionSet); return { instrument: optionSet.instrument || defaultInstrOpts.instrument, instrumentOptions: merge(defaultInstrOpts, optionSet, { // Instrument options are lifted to root in the API options // object, so merge all in order to avoid missing any. But // remove the following which are not instrumentOptions: mapping: void 0, instrument: void 0 }), instrumentMapping: merge(defaultInstrOpts.mapping, optionSet.mapping) }; }); } /** * @private * @param {Highcharts.Series} series * The series to get options for. * @param {Highcharts.SonifySeriesOptionsObject} options * Options to merge with user options on series/chart and default options. * @return {Highcharts.SonifySeriesOptionsObject} * The merged options. */ function getSeriesSonifyOptions(series, options) { var chartOpts = series.chart.options.sonification, seriesOpts = series.options.sonification; return merge({ duration: ((seriesOpts && seriesOpts.duration) || (chartOpts && chartOpts.duration)) }, chartOptionsToSonifySeriesOptions(series), options); } /** * Get the time extremes of this series. This is handled outside of the * dataExtremes, as we always want to just sonify the visible points, and we * always want the extremes to be the extremes of the visible points. * @private * @param {Highcharts.Series} series * The series to compute on. * @param {Function|string} timeProp * The time axis data prop or the time function. * @return {Highcharts.RangeObject} * Object with min/max extremes for the time values. */ function getTimeExtremes(series, timeProp) { // Compute the extremes from the visible points. return series.points.reduce(function (acc, point) { var value = getPointTimeValue(point, timeProp); acc.min = Math.min(acc.min, value); acc.max = Math.max(acc.max, value); return acc; }, { min: Infinity, max: -Infinity }); } /** * Utility function to get a new list of instrument options where all the * instrument references are copies. * @private * @param {Array} instruments * The instrument options. * @return {Array} * Array of copied instrument options. */ function makeInstrumentCopies(instruments) { return instruments.map(function (instrumentDef) { var instrument = instrumentDef.instrument, copy = (typeof instrument === 'string' ? Instrument.definitions[instrument] : instrument).copy(); return merge(instrumentDef, { instrument: copy }); }); } /** * Sonify a series. * * @sample highcharts/sonification/series-basic/ * Click on series to sonify * @sample highcharts/sonification/series-earcon/ * Series with earcon * @sample highcharts/sonification/point-play-time/ * Play y-axis by time * @sample highcharts/sonification/earcon-on-point/ * Earcon set on point * * @requires module:modules/sonification * * @function Highcharts.Series#sonify * * @param {Highcharts.SonifySeriesOptionsObject} [options] * The options for sonifying this series. If not provided, uses options set * on chart and series. */ function sonify(options) { var mergedOptions = getSeriesSonifyOptions(this, options), timelinePath = buildTimelinePathFromSeries(this, mergedOptions), chartSonification = this.chart.sonification; if (chartSonification) { // Only one timeline can play at a time. If we want multiple series // playing at the same time, use chart.sonify. if (chartSonification.timeline) { chartSonification.timeline.pause(); } // Store reference to duration chartSonification.duration = mergedOptions.duration; // Create new timeline for this series, and play it. chartSonification.timeline = new Timeline({ paths: [timelinePath] }); chartSonification.timeline.play(); } } })(SeriesSonify || (SeriesSonify = {})); /* * * * Default Export * * */ export default SeriesSonify;