package.es-modules.Extensions.Sonification.SonificationTimeline.js Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of highcharts Show documentation
Show all versions of highcharts Show documentation
JavaScript charting framework
The newest version!
/* *
*
* (c) 2009-2024 Øystein Moseng
*
* Class representing a Timeline with sonification events to play.
*
* License: www.highcharts.com/license
*
* !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!!
*
* */
'use strict';
import TimelineChannel from './TimelineChannel.js';
import toMIDI from './MIDI.js';
import DU from '../DownloadURL.js';
const { downloadURL } = DU;
import U from '../../Core/Utilities.js';
const { defined, find, merge } = U;
/**
* Get filtered channels. Timestamps are compensated, so that the first
* event starts immediately.
* @private
*/
function filterChannels(filter, channels) {
const filtered = channels.map((channel) => {
channel.cancel();
return {
channel,
filteredEvents: channel.muted ?
[] : channel.events.filter(filter)
};
}), minTime = filtered.reduce((acc, cur) => Math.min(acc, cur.filteredEvents.length ?
cur.filteredEvents[0].time : Infinity), Infinity);
return filtered.map((c) => (new TimelineChannel(c.channel.type, c.channel.engine, c.channel.showPlayMarker, c.filteredEvents.map((e) => merge(e, { time: e.time - minTime })), c.channel.muted)));
}
/**
* The SonificationTimeline class. This class represents a timeline of
* audio events scheduled to play. It provides functionality for manipulating
* and navigating the timeline.
* @private
*/
class SonificationTimeline {
constructor(options, chart) {
this.chart = chart;
this.isPaused = false;
this.isPlaying = false;
this.channels = [];
this.scheduledCallbacks = [];
this.playTimestamp = 0;
this.resumeFromTime = 0;
this.options = options || {};
}
// Add a channel, optionally with events, to be played.
// Note: Only one speech channel is supported at a time.
addChannel(type, engine, showPlayMarker = false, events) {
if (type === 'instrument' &&
!engine.scheduleEventAtTime ||
type === 'speech' &&
!engine.sayAtTime) {
throw new Error('Highcharts Sonification: Invalid channel engine.');
}
const channel = new TimelineChannel(type, engine, showPlayMarker, events);
this.channels.push(channel);
return channel;
}
// Play timeline, optionally filtering out only some of the events to play.
// Note that if not all instrument parameters are updated on each event,
// parameters may update differently depending on the events filtered out,
// since some of the events that update parameters can be filtered out too.
// The filterPersists argument determines whether or not the filter persists
// after e.g. pausing and resuming. Usually this should be true.
play(filter, filterPersists = true, resetAfter = true, onEnd) {
if (this.isPlaying) {
this.cancel();
}
else {
this.clearScheduledCallbacks();
}
this.onEndArgument = onEnd;
this.playTimestamp = Date.now();
this.resumeFromTime = 0;
this.isPaused = false;
this.isPlaying = true;
const skipThreshold = this.options.skipThreshold || 2, onPlay = this.options.onPlay, showTooltip = this.options.showTooltip, showCrosshair = this.options.showCrosshair, channels = filter ?
filterChannels(filter, this.playingChannels || this.channels) :
this.channels, getEventKeysSignature = (e) => Object.keys(e.speechOptions || {})
.concat(Object.keys(e.instrumentEventOptions || {}))
.join(), pointsPlayed = [];
if (filterPersists) {
this.playingChannels = channels;
}
if (onPlay) {
onPlay({ chart: this.chart, timeline: this });
}
let maxTime = 0;
channels.forEach((channel) => {
if (channel.muted) {
return;
}
const numEvents = channel.events.length;
let lastCallbackTime = -Infinity, lastEventTime = -Infinity, lastEventKeys = '';
maxTime = Math.max(channel.events[numEvents - 1] &&
channel.events[numEvents - 1].time || 0, maxTime);
for (let i = 0; i < numEvents; ++i) {
const e = channel.events[i], keysSig = getEventKeysSignature(e);
// Optimize by skipping extremely close events (<2ms apart by
// default), as long as they don't introduce new event options
if (keysSig === lastEventKeys &&
e.time - lastEventTime < skipThreshold) {
continue;
}
lastEventKeys = keysSig;
lastEventTime = e.time;
if (channel.type === 'instrument') {
channel.engine
.scheduleEventAtTime(e.time / 1000, e.instrumentEventOptions || {});
}
else {
channel.engine.sayAtTime(e.time, e.message || '', e.speechOptions || {});
}
const point = e.relatedPoint, chart = point && point.series && point.series.chart, needsCallback = e.callback ||
point && (showTooltip || showCrosshair) &&
channel.showPlayMarker !== false &&
(e.time - lastCallbackTime > 50 || i === numEvents - 1);
if (point) {
pointsPlayed.push(point);
}
if (needsCallback) {
this.scheduledCallbacks.push(setTimeout(() => {
if (e.callback) {
e.callback();
}
if (point) {
if (showCrosshair) {
const s = point.series;
if (s && s.xAxis && s.xAxis.crosshair) {
s.xAxis.drawCrosshair(void 0, point);
}
if (s && s.yAxis && s.yAxis.crosshair) {
s.yAxis.drawCrosshair(void 0, point);
}
}
if (showTooltip && !(
// Don't re-hover if shared tooltip
chart && chart.hoverPoints &&
chart.hoverPoints.length > 1 &&
find(chart.hoverPoints, (p) => p === point) &&
// Stock issue w/Navigator
point.onMouseOver)) {
point.onMouseOver();
}
}
}, e.time));
lastCallbackTime = e.time;
}
}
});
const onEndOpt = this.options.onEnd, onStop = this.options.onStop;
this.scheduledCallbacks.push(setTimeout(() => {
const chart = this.chart, context = { chart, timeline: this, pointsPlayed };
this.isPlaying = false;
if (resetAfter) {
this.resetPlayState();
}
if (onStop) {
onStop(context);
}
if (onEndOpt) {
onEndOpt(context);
}
if (onEnd) {
onEnd(context);
}
if (chart) {
if (chart.tooltip) {
chart.tooltip.hide(0);
}
if (chart.hoverSeries) {
chart.hoverSeries.onMouseOut();
}
chart.axes.forEach((a) => a.hideCrosshair());
}
}, maxTime + 250));
this.resumeFromTime = filterPersists ? maxTime : this.getLength();
}
// Pause for later resuming. Returns current timestamp to resume from.
pause() {
this.isPaused = true;
this.cancel();
this.resumeFromTime = Date.now() - this.playTimestamp - 10;
return this.resumeFromTime;
}
// Get current time
getCurrentTime() {
return this.isPlaying ?
Date.now() - this.playTimestamp :
this.resumeFromTime;
}
// Get length of timeline in milliseconds
getLength() {
return this.channels.reduce((maxTime, channel) => {
const lastEvent = channel.events[channel.events.length - 1];
return lastEvent ? Math.max(lastEvent.time, maxTime) : maxTime;
}, 0);
}
// Resume from paused
resume() {
if (this.playingChannels) {
const resumeFrom = this.resumeFromTime - 50;
this.play((e) => e.time > resumeFrom, false, false, this.onEndArgument);
this.playTimestamp -= resumeFrom;
}
else {
this.play(void 0, false, false, this.onEndArgument);
}
}
// Play a short moment, then pause, setting the cursor to the final
// event's time.
anchorPlayMoment(eventFilter, onEnd) {
if (this.isPlaying) {
this.pause();
}
let finalEventTime = 0;
this.play((e, ix, arr) => {
// We have to keep track of final event time ourselves, since
// play() messes with the time internally upon filtering.
const res = eventFilter(e, ix, arr);
if (res && e.time > finalEventTime) {
finalEventTime = e.time;
}
return res;
}, false, false, onEnd);
this.playingChannels = this.playingChannels || this.channels;
this.isPaused = true;
this.isPlaying = false;
this.resumeFromTime = finalEventTime;
}
// Play event(s) occurring next/prev from paused state.
playAdjacent(next, onEnd, onBoundaryHit, eventFilter) {
if (this.isPlaying) {
this.pause();
}
const fromTime = this.resumeFromTime, closestTime = this.channels.reduce((time, channel) => {
// Adapted binary search since events are sorted by time
const events = eventFilter ?
channel.events.filter(eventFilter) : channel.events;
let s = 0, e = events.length, lastValidTime = time;
while (s < e) {
const mid = (s + e) >> 1, t = events[mid].time, cmp = t - fromTime;
if (cmp > 0) { // Ahead
if (next && t < lastValidTime) {
lastValidTime = t;
}
e = mid;
}
else if (cmp < 0) { // Behind
if (!next && t > lastValidTime) {
lastValidTime = t;
}
s = mid + 1;
}
else { // Same as from time
if (next) {
s = mid + 1;
}
else {
e = mid;
}
}
}
return lastValidTime;
}, next ? Infinity : -Infinity), margin = 0.02;
if (closestTime === Infinity || closestTime === -Infinity) {
if (onBoundaryHit) {
onBoundaryHit({
chart: this.chart, timeline: this, attemptedNext: next
});
}
return;
}
this.anchorPlayMoment((e, ix, arr) => {
const withinTime = next ?
e.time > fromTime && e.time <= closestTime + margin :
e.time < fromTime && e.time >= closestTime - margin;
return eventFilter ? withinTime && eventFilter(e, ix, arr) :
withinTime;
}, onEnd);
}
// Play event with related point, where the value of a prop on the
// related point is closest to a target value.
// Note: not very efficient.
playClosestToPropValue(prop, targetVal, onEnd, onBoundaryHit, eventFilter) {
const filter = (e, ix, arr) => !!(eventFilter ?
eventFilter(e, ix, arr) && e.relatedPoint :
e.relatedPoint);
let closestValDiff = Infinity, closestEvent = null;
(this.playingChannels || this.channels).forEach((channel) => {
const events = channel.events;
let i = events.length;
while (i--) {
if (!filter(events[i], i, events)) {
continue;
}
const val = events[i].relatedPoint[prop], diff = defined(val) && Math.abs(targetVal - val);
if (diff !== false && diff < closestValDiff) {
closestValDiff = diff;
closestEvent = events[i];
}
}
});
if (closestEvent) {
this.play((e) => !!(closestEvent &&
e.time < closestEvent.time + 1 &&
e.time > closestEvent.time - 1 &&
e.relatedPoint === closestEvent.relatedPoint), false, false, onEnd);
this.playingChannels = this.playingChannels || this.channels;
this.isPaused = true;
this.isPlaying = false;
this.resumeFromTime = closestEvent.time;
}
else if (onBoundaryHit) {
onBoundaryHit({ chart: this.chart, timeline: this });
}
}
// Get timeline events that are related to a certain point.
// Note: Point grouping may cause some points not to have a
// related point in the timeline.
getEventsForPoint(point) {
return this.channels.reduce((events, channel) => {
const pointEvents = channel.events
.filter((e) => e.relatedPoint === point);
return events.concat(pointEvents);
}, []);
}
// Divide timeline into 100 parts of equal time, and play one of them.
// Used for scrubbing.
// Note: Should be optimized?
playSegment(segment, onEnd) {
const numSegments = 100;
const eventTimes = {
first: Infinity,
last: -Infinity
};
this.channels.forEach((c) => {
if (c.events.length) {
eventTimes.first = Math.min(c.events[0].time, eventTimes.first);
eventTimes.last = Math.max(c.events[c.events.length - 1].time, eventTimes.last);
}
});
if (eventTimes.first < Infinity) {
const segmentSize = (eventTimes.last - eventTimes.first) / numSegments, fromTime = eventTimes.first + segment * segmentSize, toTime = fromTime + segmentSize;
// Binary search, do we have any events within time range?
if (!this.channels.some((c) => {
const events = c.events;
let s = 0, e = events.length;
while (s < e) {
const mid = (s + e) >> 1, t = events[mid].time;
if (t < fromTime) { // Behind
s = mid + 1;
}
else if (t > toTime) { // Ahead
e = mid;
}
else {
return true;
}
}
return false;
})) {
return; // If not, don't play - avoid cancelling current play
}
this.play((e) => e.time >= fromTime && e.time <= toTime, false, false, onEnd);
this.playingChannels = this.playingChannels || this.channels;
this.isPaused = true;
this.isPlaying = false;
this.resumeFromTime = toTime;
}
}
// Get last played / current point
// Since events are scheduled we can't just store points as we play them
getLastPlayedPoint(filter) {
const curTime = this.getCurrentTime(), channels = this.playingChannels || this.channels;
let closestDiff = Infinity, closestPoint = null;
channels.forEach((c) => {
const events = c.events.filter((e, ix, arr) => !!(e.relatedPoint && e.time <= curTime &&
(!filter || filter(e, ix, arr)))), closestEvent = events[events.length - 1];
if (closestEvent) {
const closestTime = closestEvent.time, diff = Math.abs(closestTime - curTime);
if (diff < closestDiff) {
closestDiff = diff;
closestPoint = closestEvent.relatedPoint;
}
}
});
return closestPoint;
}
// Reset play/pause state so that a later call to resume() will start over
reset() {
if (this.isPlaying) {
this.cancel();
}
this.resetPlayState();
}
cancel() {
const onStop = this.options.onStop;
if (onStop) {
onStop({ chart: this.chart, timeline: this });
}
this.isPlaying = false;
this.channels.forEach((c) => c.cancel());
if (this.playingChannels && this.playingChannels !== this.channels) {
this.playingChannels.forEach((c) => c.cancel());
}
this.clearScheduledCallbacks();
this.resumeFromTime = 0;
}
destroy() {
this.cancel();
if (this.playingChannels && this.playingChannels !== this.channels) {
this.playingChannels.forEach((c) => c.destroy());
}
this.channels.forEach((c) => c.destroy());
}
setMasterVolume(vol) {
this.channels.forEach((c) => c.engine.setMasterVolume(vol));
}
getMIDIData() {
return toMIDI(this.channels.filter((c) => c.type === 'instrument'));
}
downloadMIDI(filename) {
const data = this.getMIDIData(), name = (filename ||
this.chart &&
this.chart.options.title &&
this.chart.options.title.text ||
'chart') + '.mid', blob = new Blob([data], { type: 'application/octet-stream' }), url = window.URL.createObjectURL(blob);
downloadURL(url, name);
window.URL.revokeObjectURL(url);
}
resetPlayState() {
delete this.playingChannels;
delete this.onEndArgument;
this.playTimestamp = this.resumeFromTime = 0;
this.isPaused = false;
}
clearScheduledCallbacks() {
this.scheduledCallbacks.forEach(clearTimeout);
this.scheduledCallbacks = [];
}
}
/* *
*
* Default Export
*
* */
export default SonificationTimeline;
/* *
*
* API declarations
*
* */
/**
* Filter callback for filtering timeline events on a SonificationTimeline.
*
* @callback Highcharts.SonificationTimelineFilterCallback
*
* @param {Highcharts.SonificationTimelineEvent} e TimelineEvent being filtered
*
* @param {number} ix Index of TimelineEvent in current event array
*
* @param {Array} arr The current event array
*
* @return {boolean}
* The function should return true if the TimelineEvent should be included,
* false otherwise.
*/
(''); // Keep above doclets in JS file