Skip to content

Commit

Permalink
Keyboard replay control (#562)
Browse files Browse the repository at this point in the history
A bit of refactoring along with the introduction of keyboard control of replay (`p` to play/pause/resume and `s` to stop).


* Approximate constant replay speed

Signed-off-by: F.N. Claessen <felix@seita.nl>

* Listen to p-key presses to pause/resume

Signed-off-by: F.N. Claessen <felix@seita.nl>

* Refactor: also start replay when hitting 'p'

Signed-off-by: F.N. Claessen <felix@seita.nl>

* Refactor: clean up util functions

Signed-off-by: F.N. Claessen <felix@seita.nl>

* Use stopReplay on calendar selection, too

Signed-off-by: F.N. Claessen <felix@seita.nl>

* Refactor: simplify for-loop

Signed-off-by: F.N. Claessen <felix@seita.nl>

* Listen to s-key presses to stop

Signed-off-by: F.N. Claessen <felix@seita.nl>

* Show previous results when stopping replay

Signed-off-by: F.N. Claessen <felix@seita.nl>

* Use abort signals to resolve glitches when pressing the next key before resolving promises

Signed-off-by: F.N. Claessen <felix@seita.nl>

* Changelog entry

Signed-off-by: F.N. Claessen <felix@seita.nl>

* Add UI hints

Signed-off-by: F.N. Claessen <felix@seita.nl>

Signed-off-by: F.N. Claessen <felix@seita.nl>
  • Loading branch information
Flix6x committed Jan 27, 2023
1 parent d742749 commit a1fb871
Show file tree
Hide file tree
Showing 5 changed files with 150 additions and 96 deletions.
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>`_]
* The ``FLEXMEASURES_MAX_PLANNING_HORIZON`` config setting can also be set as an integer number of planning steps rather than just as a fixed duration, which makes it possible to schedule further ahead in coarser time steps [see `PR #583 <https://www.github.com/FlexMeasures/flexmeasures/pull/583>`_]

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() {
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

0 comments on commit a1fb871

Please sign in to comment.