This class definition lets you create a Data Model from a specific JSON object which describes a n-dimensional system. The created model allows you to query and navigate through the data via an API, UI control, and/or mouse interaction.
That model will handle all the data management and delegate the data processing or rendering to your application.
This documentation will focus on its API and its usage.
JSON Structure
The core of that Query Data Model is the JSON structure that it loads and works with. The following JSON structure illustrate what this module is expecting:
A string that can be used to uniquely identify the given model. If none is provided, the QueryDataModel will automatically generate one.
type
Describe the composition of the given model. As we describe a Query Data Model it must contain ‘tonic-query-data-model’ but can be augmented with any additional data which could also then be used to drive the querying/processing/rendering.
What is described here is what we expect as a ‘tonic-query-data-model’ type. Additional data structures can be added but they should not interfere or redefine the existing core structure.
arguments
List of arguments that can be used to describe the query. Each argument must have at least a list of values.
The rest of the options will then be derived from their default. Here is the list of defaults that will be picked if they are not provided.
{ arguments: { argId: { label : 'argId', // The actual name of the argument default : 0, // The first item in the values list ui : 'list', // The UI will use a drop down loop : undefined, // No looping on this parameter bind : undefined// No binding will occur on mouse/key/? } } }
Here is the possible set of values for each argument and what they are:
label : Human readable name for UI.
default : Index within the values for initial value. (default: 0)
ui : Type of UI component to use. Can either be [ undefined, ‘slider’, ‘list‘].
loop : Specify a periodic/looping behavior. Can either be [ undefined, ‘modulo’, ‘reverse’]
bind : Specify how to bind interactions to attribute.
arguments_order
Order of the arguments that should be used to build the user interface. This is an array of strings with the name of all the attributes you want to see inside the user interface.
data
List of data to be fetched. Each element in the list must have a name, type and a pattern. Then if the type is ‘blob’, a mimeType must be specified.
pattern must be defined is using the {} to surround the name of the parameter that can compose the query.
Optionally, data can be grouped by categories to simplify the way they’re fetched. This allows you to exclusively fetch the data you are currently interested in. If no category is provided, that means the full data list is mandatory.
metadata
User data that can easily be accessed via the API.
constructor(jsonObject, basepath)
The jsonObject content has been previously described but the basepath is used to fetch the related resource relative to that basepath.
getQuery() : { argA: valueA, argB: valueB }
Returns a simple map where the keys are the arguments attribute names and the values are the actual value for the given attribute.
fetchData(category)
Triggers the download of the data associated with the current query.
If no category is provided, then only the data with no category will be downloaded while if a category is provided all the data that belong to that category will be downloaded.
In order to be aware when that set of data is available for processing you can attach a listener by calling the following method.
// For the default category instance.onDataChange( (data, envelope) => {} );
// For custom category instance.on( categoryName, (data, envelope) => {});
// Data layout will look like that var data = { dataName1: dataContentFromDataManager, dataName2: dataContentFromDataManager }
lazyFetchData(category)
Similar to fetchData(category) except that it won’t make a network request until the previous one is fully done. Moreover any lazyFetchData() call made during that pending period will be ignored and only the latest one will be triggered.
Very useful when interacting with a slider and/or mouse interaction for changing the underlying query.
{action}(attributeName) : changeDetected
Several actions can be performed on a given attribute to change the query. Sometimes an action won’t result in any change as we may have reached the end of the range of a non-looping attribute. Hence we provide feedback to the user to let them know if their method call affected the underline query or not.
Here is the list of possible actions:
first : Move to the first value of the values list.
previous : Move to the previous value in the values list.
next : Move to the next value in the values list.
last : Move to the last value in the values list.
This will trigger the following events:
state.change.first
state.change.previous
state.change.next
state.change.last
Those events could be capture by adding a listener using the onStateChange method.
setValue(attributeName, value) : changeDetected
Assigns a new value to the attribute, but the value must be part of the values list.
This will trigger the following event:
state.change.value
That event can be capture by adding a listener using the onStateChange method.
setIndex(attributeName, index) : changeDetected
Assigns a new value to the attribute base on the value that is at provided index inside the values list.
This will trigger the following event:
state.change.idx
That event can be capture by adding a listener using the onStateChange method.
getValue(attributeName) : value
Returns the current value inside the values list.
getValues(attributeName) : values
Returns the full list of possible values.
getIndex(attributeName) : index
Returns the current value index inside the values list.
getSize(attributeName) : index
Returns the number of values inside the values list or null if the attribute was not found.
getUiType(attributeName) : String
Returns the type of the UI that should be created. It can either be slider or list.
label(attributeName) : String
Returns the label that should be used for the given attribute.
setAnimationFlag(attributeName, flagState)
Tags or untags a given attribute to be part of the animation process.
This will trigger the following event:
state.change.animation
That event can be capture by adding a listener using the onStateChange method.
getAnimationFlag(attributeName)
Returns the current state of the animation flag for a given attribute.
toggleAnimationFlag(attributeName) : currentState
Toggles the animation flag state and return its actual value.
This will trigger the following event:
state.change.animation
That event can be capture by adding a listener using the onStateChange method.
hasAnimationFlag() : Boolean
Returns true if at least one argument has been toggled for animation.
isAnimating() : Boolean
Returns true if an animation is currently playing.
animate( start[, deltaT=500] )
Start or stop an animation where a deltaT can be provided in milliseconds. For example:
// Will start the animation with a wait time of 750ms // before going to the next data query. animate(true, 750);
To stop a running animation.
animate(false);
On an animation stop, the following event will be triggered:
state.change.play
That event can be capture by adding a listener using the onStateChange method.
getDataMetaData(dataName) : { data metadata }
Helper function used to retrieved any metadata that could be associated to a data entry.
onDataChange( listener ) : subscriptionObject
Add a data listener to be aware when the expected set of data will be ready. The data object that is provided to the listener will contains all the cachedDataObject from dataManager instance that is part of the category that was used to register the listener, using their name from the original JSON data model.
functionlistener(data, envelope) { // Where data is // { // data-name: {data-manager}cacheObject // } }
More information on subscriptionObject and listeners can be found on monologue.js.
For instance, to listen for a specific category, you should use the following method:
instance.on(categoryName, listener);
In fact, the onDataChange is calling the this.on(‘_’, listener) as the ‘_’ is the default category name.
Link any attribute change from the given queryDataModel to your local instance. Optionally, you can provide a list of arguments that you want to synchronize as well as any change could also trigger a fetchData() call on your instance. The returned object let you unsubscribe of the change update.
Source
index.js
import hasOwn from'mout/object/hasOwn'; import max from'mout/object/max'; import min from'mout/object/min'; import Monologue from'monologue.js'; import now from'mout/src/time/now'; import omit from'mout/object/omit'; import size from'mout/object/size';
// ============================================================================ const dataManager = new DataManager(); const DEFAULT_KEY_NAME = '_'; // ============================================================================ let queryDataModelCounter = 0; // ============================================================================
// Helper function used to handle next/previous when the loop function is 'reverse' functiondeltaReverse(arg, increment) { let newIdx = arg.idx + arg.direction * increment; if (newIdx >= arg.values.length) { arg.direction *= -1; // Reverse direction newIdx = arg.values.length - 2; }
if (newIdx < 0) { arg.direction *= -1; // Reverse direction newIdx = 1; }
// Helper function used to handle next/previous when the loop function is 'modulo' functiondeltaModulo(arg, increment) { arg.idx = (arg.values.length + arg.idx + increment) % arg.values.length; returntrue; }
// Helper function used to handle next/previous when the loop function is 'none' functiondeltaNone(arg, increment) { let newIdx = arg.idx + increment;
this.playNext = () => { if (this.keepAnimating) { let changeDetected = false; this.lastPlay = +newDate();
// Move all flagged arg to next() Object.keys(this.args).forEach((argName) => { if (this.args[argName].anime) { changeDetected = this.next(argName) || changeDetected; } });
// Keep moving if change detected if (changeDetected) { // Get new data this.lazyFetchData(); // FIXME may need a category } else { // Auto stop as nothing change this.keepAnimating = false; this.emit('state.change.play', { instance: this, }); } } else { this.emit('state.change.play', { instance: this, }); } };
const processRequest = (request) => { const dataToBroadcast = {}; let count = request.urls.length; let hasPending = false; let hasError = false;
if (this.animationTimerId !== 0) { clearTimeout(this.animationTimerId); this.animationTimerId = 0; }
if (hasPending) { // put the request back in the queue setTimeout(() => { this.requests.push(request); }, 0); } elseif (!hasError) { // We are good to go // Broadcast data to the category this.emit(request.category, dataToBroadcast);
// Trigger new fetch data if any lazyFetchData is pending if (this.requests.length === 0 && this.lazyFetchRequest) { this.fetchData(this.lazyFetchRequest); this.lazyFetchRequest = null; } }
// Handle animation if any if (this.keepAnimating) { const ts = +newDate(); this.animationTimerId = setTimeout( this.playNext, ts - this.lastPlay > this.deltaT ? 0 : this.deltaT ); } };
// Add external args to the query too Object.keys(this.externalArgs).forEach((eKey) => { query[eKey] = this.externalArgs[eKey]; });
return query; }
// Fetch data for a given category or _ if none provided fetchData(category = DEFAULT_KEY_NAME) { let dataToFetch = []; const query = this.getQuery(); const request = { urls: [] };
// fill the data to fetch if (category.name) { request.category = category.name; category.categories.forEach((cat) => { if (this.categories[cat]) { dataToFetch = dataToFetch.concat(this.categories[cat]); } }); } elseif (this.categories[category]) { request.category = category; dataToFetch = dataToFetch.concat(this.categories[category]); }
// Decrease the count and record the category request + trigger fetch if (dataToFetch.length) { this.requests.push(request); }
// Got to the last value of a given attribute and return true if data has changed last(attributeName) { const arg = this.args[attributeName]; const last = arg.values.length - 1;
// Got to the next value of a given attribute and return true if data has changed next(attributeName) { const arg = this.args[attributeName]; if (arg && arg.delta(arg, +1)) { this.emit('state.change.next', { delta: 1, value: arg.values[arg.idx], idx: arg.idx, name: attributeName, instance: this, }); returntrue; } returnfalse; }
// Got to the previous value of a given attribute and return true if data has changed previous(attributeName) { const arg = this.args[attributeName]; if (arg && arg.delta(arg, -1)) { this.emit('state.change.previous', { delta: -1, value: arg.values[arg.idx], idx: arg.idx, name: attributeName, instance: this, }); returntrue; } returnfalse; }
// Set a value to an argument (must be in values) and return true if data has changed // If argument is not in the argument list. This will be added inside the external argument list. setValue(attributeName, value) { const arg = this.args[attributeName]; const newIdx = arg ? arg.values.indexOf(value) : 0;
// Set a new index to an argument (must be in values range) and return true if data has changed setIndex(attributeName, idx) { const arg = this.args[attributeName];
// Return the argument value or null if the argument was not found // If argument is not in the argument list. // We will also search inside the external argument list. getValue(attributeName) { const arg = this.args[attributeName]; return arg ? arg.values[arg.idx] : this.externalArgs[attributeName]; }
// Return the argument values list or null if the argument was not found getValues(attributeName) { const arg = this.args[attributeName]; return arg ? arg.values : null; }
// Return the argument index or null if the argument was not found getIndex(attributeName) { const arg = this.args[attributeName]; return arg ? arg.idx : null; }
// Return the argument index or null if the argument was not found getUiType(attributeName) { const arg = this.args[attributeName]; return arg ? arg.ui : null; }
// Return the argument size or null if the argument was not found getSize(attributeName) { const arg = this.args[attributeName]; return arg ? arg.values.length : null; }
// Return the argument label or null if the argument was not found label(attributeName) { const arg = this.args[attributeName]; return arg ? arg.label : null; }
// Return the argument animation flag or false if the argument was not found getAnimationFlag(attributeName) { const arg = this.args[attributeName]; return arg ? arg.anime : false; }
// Set the argument animation flag and return true if the value changed setAnimationFlag(attributeName, state) { const arg = this.args[attributeName];
// Toggle the argument animation flag state and return the current state or // null if not found. toggleAnimationFlag(attributeName) { const arg = this.args[attributeName];
// Check if one of the argument is currently active for the animation hasAnimationFlag() { let flag = false; Object.keys(this.args).forEach((key) => { if (this.args[key].anime) { flag = true; } }); return flag; }
// Return true if an animation is currently running isAnimating() { returnthis.keepAnimating; }
// Move to next step const idxs = this.exploreState.idxs; const sizes = this.exploreState.sizes; let count = idxs.length;
// May overshoot idxs[count - 1] += 1;
// Handle overshoot while (count) { count -= 1; if (idxs[count] < sizes[count]) { // We are good /* eslint-disable no-continue */ continue; /* eslint-enable no-continue */ } elseif (count > 0) { // We need to move the index back up idxs[count] = 0; idxs[count - 1] += 1; } else { this.exploreState.animate = false; this.emit('state.change.exploration', { exploration: this.exploreState, instance: this, }); returnthis.exploreState.animate; // We are done } }
// Trigger the fetchData this.lazyFetchData(); } returnthis.exploreState.animate; }