Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Keyboard replay control #562

Merged
merged 14 commits into from Jan 27, 2023
1 change: 1 addition & 0 deletions documentation/changelog.rst
Expand Up @@ -10,6 +10,7 @@ v0.13.0 | February XX, 2023

New features
-------------
* Keyboard control over replay [see `PR #562 <https://www.github.com/FlexMeasures/flexmeasures/pull/562>`_]

Bugfixes
-----------
Expand Down
19 changes: 19 additions & 0 deletions flexmeasures/ui/static/js/replay-utils.js
Expand Up @@ -42,3 +42,22 @@ export function updateBeliefs(oldBeliefs, newBeliefs) {

// Define the step duration for the replay (value in ms)
export var beliefTimedelta = 3600000


/**
* Timer that can be canceled using the optional AbortSignal.
* Adapted from https://www.bennadel.com/blog/4195-using-abortcontroller-to-debounce-settimeout-calls-in-javascript.htm
* MIT License: https://www.bennadel.com/blog/license.htm
*/
export function setAbortableTimeout(callback, delayInMilliseconds, signal) {
signal?.addEventListener( "abort", handleAbort );
var internalTimer = setTimeout(internalCallback, delayInMilliseconds);

function internalCallback() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit confused: Should these two functions not be declared before they are used in the event listener?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

signal?.removeEventListener( "abort", handleAbort );
callback();
}
function handleAbort() {
clearTimeout( internalTimer );
}
}
222 changes: 128 additions & 94 deletions flexmeasures/ui/templates/base.html
Expand Up @@ -231,7 +231,7 @@
<script type="module" type="text/javascript">

import { subtract, thisMonth, lastNMonths, getOffsetBetweenTimezonesForDate } from "{{ url_for('flexmeasures_ui.static', filename='js/daterange-utils.js') }}";
import { partition, updateBeliefs, beliefTimedelta} from "{{ url_for('flexmeasures_ui.static', filename='js/replay-utils.js') }}";
import { partition, updateBeliefs, beliefTimedelta, setAbortableTimeout} from "{{ url_for('flexmeasures_ui.static', filename='js/replay-utils.js') }}";

let vegaView;
let previousResult;
Expand All @@ -243,6 +243,7 @@
storeStartDate = new Date('{{ event_starts_after }}');
storeEndDate = new Date('{{ event_ends_before }}');
{% endif %}
let replaySpeed = 100

async function embedAndLoad(chartSpecsPath, elementId, datasetName, previousResult, startDate, endDate) {

Expand Down Expand Up @@ -325,12 +326,6 @@
format: 'YYYY-MM-DD\\T00:00:00',
});
picker.on('selected', (startDate, endDate) => {
// Stop replay
let toggle = document.querySelector('#replay');
toggle.classList.remove('playing');
toggle.classList.remove('paused');
toggle.classList.add('stopped');

startDate = startDate.toJSDate();
endDate = endDate.toJSDate();
endDate.setDate(endDate.getDate() + 1);
Expand All @@ -339,10 +334,7 @@
var queryStartDate = (startDate != null) ? (startDate.toISOString()) : (null);
var queryEndDate = (endDate != null) ? (endDate.toISOString()) : (null);

// Abort previous request and create abort controller for new request
controller.abort();
controller = new AbortController();
signal = controller.signal;
stopReplay()

$("#spinner").show();
Promise.all([
Expand All @@ -359,7 +351,7 @@
fetch(dataPath + '/chart_annotations/?event_starts_after=' + queryStartDate + '&event_ends_before=' + queryEndDate, {
method: "GET",
headers: {"Content-Type": "application/json"},
signal: signal,
signal: signal,
})
.then(function(response) { return response.json(); }),
*/
Expand Down Expand Up @@ -445,93 +437,135 @@
let toggle = document.querySelector('#replay');
toggle.addEventListener('click', function(e) {
e.preventDefault();
if (toggle.classList.contains('playing')) {
// Pause replay
toggle.classList.remove('playing');
toggle.classList.add('paused');
} else if (toggle.classList.contains('stopped')) {
// Start replay
toggle.classList.remove('stopped');
toggle.classList.add('playing');
var beliefTime = new Date(storeStartDate);
var numReplaySteps = Math.ceil((storeEndDate - storeStartDate) / beliefTimedelta);
queryStartDate = (storeStartDate != null) ? (storeStartDate.toISOString()) : (null);
queryEndDate = (storeEndDate != null) ? (storeEndDate.toISOString()) : (null);

$("#spinner").show();
Promise.all([
// Fetch time series data (all data, not only the most recent beliefs)
fetch(dataPath + '/chart_data/?event_starts_after=' + queryStartDate + '&event_ends_before=' + queryEndDate + '&most_recent_beliefs_only=false', {
method: "GET",
headers: {"Content-Type": "application/json"},
})
.then(function(response) { return response.json(); }),
]).then(function(result) {
$("#spinner").hide();
replayBeliefsData(result[0]);
}).catch(console.error);
toggleReplay();
});

const timer = ms => new Promise(res => setTimeout(res, ms));
window.addEventListener('keypress', function(e) {
// Start/pause/resume replay with 'p'
if (e.key==='p') {
toggleReplay();
} else if (e.key==='s') {
stopReplay();
}
}, false);

/**
* Replays beliefs data.
*
* As we go forward in time in steps, replayedData is updated with newData that was known at beliefTime,
* by splitting off newData from remainingData.
* Then, replayedData is loaded into the chart.
*
* @param {Array} remainingData Array containing beliefs.
*/
async function replayBeliefsData (remainingData) {
var replayedData = [];
for (var i = 0; i < numReplaySteps + 1; i++) {
while (document.getElementById('replay').classList.contains('paused') ) {
await timer(1000);
}
if (document.getElementById('replay').classList.contains('stopped') ) {
break;
}
beliefTime = new Date(beliefTime.getTime() + beliefTimedelta);

// Split off one replay step of new data from the remaining data
var newData;
[newData, remainingData] = partition(
remainingData,
(item) => item.belief_time <= beliefTime.getTime(),
);

// Update beliefs in the replayed data given the new data
replayedData = updateBeliefs(replayedData, newData);

/** When selecting a longer time periode (more than a week), the replay slows down a bit. This
* seems to be mainly from reloading the data into the graph. Slicing the data takes 10-30 ms, and
* loading that data into the graph takes 30-200 ms, depending on how much data is shown in the
* graph. After trying different approaches, we fell back to the original approach of telling vega
* to remove all previous data and to insert a completely new dataset at each iteration. Updating
* the view with removing only a few data points (representing obsolete beliefs) and inserting only
* a few data points (representing the most recent new beliefs) actually made it slower.
*/
vegaView.change(datasetName, vega.changeset().remove(vega.truthy).insert(replayedData));
vegaView.change('replay', vega.changeset().remove(vega.truthy).insert({'belief_time': beliefTime}));
vegaView.run().finalize();

document.getElementById('replay-time').innerHTML = beliefTime;
await timer(25);
}
// Remove replay ruler
vegaView.change('replay', vega.changeset().remove(vega.truthy).insert({'belief_time': null})).run().finalize();
function toggleReplay() {
if (toggle.classList.contains('stopped')) {
startReplay();
} else if (toggle.classList.contains('playing')) {
pauseReplay();
} else {
resumeReplay();
}
}
function pauseReplay() {
toggle.classList.remove('playing');
toggle.classList.add('paused');
}
function resumeReplay() {
toggle.classList.remove('paused');
toggle.classList.add('playing');
}
async function stopReplay() {
if (toggle.classList.contains('stopped')) {
return;
}
toggle.classList.remove('playing');
toggle.classList.remove('paused');
toggle.classList.add('stopped');

// Abort previous request and create abort controller for new request
controller.abort();
controller = new AbortController();
signal = controller.signal;

// Remove replay ruler and replay time
vegaView.change('replay', vega.changeset().remove(vega.truthy).insert({'belief_time': null})).run().finalize();
document.getElementById('replay-time').innerHTML = '';

// Stop replay when finished
toggle.classList.remove('playing');
toggle.classList.add('stopped');
document.getElementById('replay-time').innerHTML = '';
// Show previous results
$("#spinner").show();
await embedAndLoad(chartSpecsPath + 'event_starts_after=' + storeStartDate.toISOString() + '&event_ends_before=' + storeEndDate.toISOString() + '&', elementId, datasetName, previousResult, storeStartDate, storeEndDate);
$("#spinner").hide();
}
function startReplay() {
toggle.classList.remove('stopped');
toggle.classList.add('playing');
var beliefTime = new Date(storeStartDate);
var numReplaySteps = Math.ceil((storeEndDate - storeStartDate) / beliefTimedelta);
queryStartDate = (storeStartDate != null) ? (storeStartDate.toISOString()) : (null);
queryEndDate = (storeEndDate != null) ? (storeEndDate.toISOString()) : (null);

$("#spinner").show();
Promise.all([
// Fetch time series data (all data, not only the most recent beliefs)
fetch(dataPath + '/chart_data/?event_starts_after=' + queryStartDate + '&event_ends_before=' + queryEndDate + '&most_recent_beliefs_only=false', {
method: "GET",
headers: {"Content-Type": "application/json"},
signal: signal,
})
.then(function(response) { return response.json(); }),
]).then(function(result) {
$("#spinner").hide();
replayBeliefsData(result[0]);
}).catch(console.error);

const timer = ms => new Promise(res => setAbortableTimeout(res, Math.max(ms, 0), signal));

/**
* Replays beliefs data.
*
* As we go forward in time in steps, replayedData is updated with newData that was known at beliefTime,
* by splitting off newData from remainingData.
* Then, replayedData is loaded into the chart.
*
* @param {Array} remainingData Array containing beliefs.
*/
async function replayBeliefsData (remainingData) {
var replayedData = [];
for (var beliefTime = new Date(storeStartDate); beliefTime <= storeEndDate; beliefTime = new Date(beliefTime.getTime() + beliefTimedelta)) {
while (document.getElementById('replay').classList.contains('paused') ) {
await timer(1000);
}
if (document.getElementById('replay').classList.contains('stopped') ) {
break;
}
var s = performance.now();

// Split off one replay step of new data from the remaining data
var newData;
[newData, remainingData] = partition(
remainingData,
(item) => item.belief_time <= beliefTime.getTime(),
);

// Update beliefs in the replayed data given the new data
replayedData = updateBeliefs(replayedData, newData);

/** When selecting a longer time periode (more than a week), the replay slows down a bit. This
* seems to be mainly from reloading the data into the graph. Slicing the data takes 10-30 ms, and
* loading that data into the graph takes 30-200 ms, depending on how much data is shown in the
* graph. After trying different approaches, we fell back to the original approach of telling vega
* to remove all previous data and to insert a completely new dataset at each iteration. Updating
* the view with removing only a few data points (representing obsolete beliefs) and inserting only
* a few data points (representing the most recent new beliefs) actually made it slower.
*/
vegaView.change(datasetName, vega.changeset().remove(vega.truthy).insert(replayedData));
vegaView.change('replay', vega.changeset().remove(vega.truthy).insert({'belief_time': beliefTime}));
vegaView.run().finalize();

document.getElementById('replay-time').innerHTML = beliefTime;

// Approximate constant speed
var e = performance.now();
var throttle = e - s;
await timer(replaySpeed - throttle);
}
} else {
// Resume replay
toggle.classList.remove('paused');
toggle.classList.add('playing');

// Stop replay when finished
stopReplay()
}
});
}
</script>

{% endblock sensorChartSetup %}
Expand Down
2 changes: 1 addition & 1 deletion flexmeasures/ui/templates/crud/asset.html
Expand Up @@ -158,7 +158,7 @@ <h3>All sensors for {{ asset.name }}</h3>
</div>
<div class="col-sm-2">
<div class="replay-container">
<div id="replay" class="stopped"></div>
<div id="replay" title="Press 'p' to play/pause/resume or 's' to stop." class="stopped"></div>
<div id="replay-time"></div>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion flexmeasures/ui/templates/views/sensors.html
Expand Up @@ -26,7 +26,7 @@
</div>
<div class="col-sm-2">
<div class="replay-container">
<div id="replay" class="stopped"></div>
<div id="replay" title="Press 'p' to play/pause/resume or 's' to stop." class="stopped"></div>
<div id="replay-time"></div>
</div>
</div>
Expand Down