highcharts
Version:
JavaScript charting framework
477 lines (476 loc) • 19.1 kB
JavaScript
/* *
*
* (c) 2009-2025 Ø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<Highcharts.SonificationTimelineEvent>} 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