1
0
mirror of https://github.com/gsi-upm/soil synced 2024-11-22 11:12:29 +00:00

merge visualization branch

The web server is included as a submodule.
The dependencies for the web (tornado) are not installed by default, but they
can be installed as an extra:

```
pip install soil[web]
```

Once installed, the soil web can be used like this:

```
soil-web

OR

python -m soil.web
```

There are other minor changes:

* History re-connects to the sqlite database if it is used from a different
thread.
* Environment accepts additional parameters (so it can run simulations with
`visualization_params` or any other in the future).
* The simulator class is no longer necessary
* Logging is done in the same thread, and the simulation is run in a separate
one. This had to be done because it was creating some problems with tornado not
being able to find the current thread during logs, which caused hundreds of
repeated lines in the web "console".
* The player is slightly modified in this version. I noticed that when the
  visualization was playing, if you clicked somewhere it would change for a
  second, and then go back to the previous place. The code for the playback
  seemed too complex, especially speed control, so I rewrote some parts. I
  might've introduced new bugs.
This commit is contained in:
J. Fernando Sánchez 2018-12-07 18:28:19 +01:00
parent 078f8ace9e
commit 9165979b49
25 changed files with 174 additions and 129 deletions

View File

@ -1,3 +1,5 @@
FROM python:3.4-onbuild FROM python:3.4-onbuild
RUN pip install '.[web]'
ENTRYPOINT ["python", "-m", "soil"] ENTRYPOINT ["python", "-m", "soil"]

View File

@ -6,3 +6,5 @@ services:
- .:/usr/src/app - .:/usr/src/app
tty: true tty: true
entrypoint: /bin/bash entrypoint: /bin/bash
ports:
- '8001:8001'

View File

@ -40,10 +40,15 @@ setup(
'Operating System :: POSIX', 'Operating System :: POSIX',
'Programming Language :: Python :: 3'], 'Programming Language :: Python :: 3'],
install_requires=install_reqs, install_requires=install_reqs,
extras_require={
'web': ['tornado']
},
tests_require=test_reqs, tests_require=test_reqs,
setup_requires=['pytest-runner', ], setup_requires=['pytest-runner', ],
include_package_data=True, include_package_data=True,
entry_points={ entry_points={
'console_scripts': 'console_scripts':
['soil = soil.__init__:main'] ['soil = soil.__init__:main',
'soil-web = soil.web.__init__:main']
}) })

View File

@ -1 +1 @@
0.11.3 0.12.0

View File

@ -23,16 +23,14 @@ class History:
if backup and os.path.exists(db_path): if backup and os.path.exists(db_path):
newname = db_path + '.backup{}.sqlite'.format(time.time()) newname = db_path + '.backup{}.sqlite'.format(time.time())
os.rename(db_path, newname) os.rename(db_path, newname)
self._db_path = db_path self.db_path = db_path
if isinstance(db_path, str):
self._db = sqlite3.connect(db_path)
else:
self._db = db_path
with self._db: self.db = db_path
self._db.execute('''CREATE TABLE IF NOT EXISTS history (agent_id text, t_step int, key text, value text text)''')
self._db.execute('''CREATE TABLE IF NOT EXISTS value_types (key text, value_type text)''') with self.db:
self._db.execute('''CREATE UNIQUE INDEX IF NOT EXISTS idx_history ON history (agent_id, t_step, key);''') self.db.execute('''CREATE TABLE IF NOT EXISTS history (agent_id text, t_step int, key text, value text text)''')
self.db.execute('''CREATE TABLE IF NOT EXISTS value_types (key text, value_type text)''')
self.db.execute('''CREATE UNIQUE INDEX IF NOT EXISTS idx_history ON history (agent_id, t_step, key);''')
self._dtypes = {} self._dtypes = {}
self._tups = [] self._tups = []
@ -42,6 +40,22 @@ class History:
self.read_types() self.read_types()
return self._dtypes[key] return self._dtypes[key]
@property
def db(self):
try:
self._db.cursor()
except sqlite3.ProgrammingError:
self.db = None # Reset the database
return self._db
@db.setter
def db(self, db_path=None):
db_path = db_path or self.db_path
if isinstance(db_path, str):
self._db = sqlite3.connect(db_path)
else:
self._db = db_path
@property @property
def dtypes(self): def dtypes(self):
return {k:v[0] for k, v in self._dtypes.items()} return {k:v[0] for k, v in self._dtypes.items()}
@ -50,7 +64,7 @@ class History:
self.save_records(Record(*tup) for tup in tuples) self.save_records(Record(*tup) for tup in tuples)
def save_records(self, records): def save_records(self, records):
with self._db: with self.db:
for rec in records: for rec in records:
if not isinstance(rec, Record): if not isinstance(rec, Record):
rec = Record(*rec) rec = Record(*rec)
@ -59,8 +73,8 @@ class History:
serializer = utils.serializer(name) serializer = utils.serializer(name)
deserializer = utils.deserializer(name) deserializer = utils.deserializer(name)
self._dtypes[rec.key] = (name, serializer, deserializer) self._dtypes[rec.key] = (name, serializer, deserializer)
self._db.execute("replace into value_types (key, value_type) values (?, ?)", (rec.key, name)) self.db.execute("replace into value_types (key, value_type) values (?, ?)", (rec.key, name))
self._db.execute("replace into history(agent_id, t_step, key, value) values (?, ?, ?, ?)", (rec.agent_id, rec.t_step, rec.key, rec.value)) self.db.execute("replace into history(agent_id, t_step, key, value) values (?, ?, ?, ?)", (rec.agent_id, rec.t_step, rec.key, rec.value))
def save_record(self, *args, **kwargs): def save_record(self, *args, **kwargs):
self._tups.append(Record(*args, **kwargs)) self._tups.append(Record(*args, **kwargs))
@ -77,16 +91,16 @@ class History:
def to_tuples(self): def to_tuples(self):
self.flush_cache() self.flush_cache()
with self._db: with self.db:
res = self._db.execute("select agent_id, t_step, key, value from history ").fetchall() res = self.db.execute("select agent_id, t_step, key, value from history ").fetchall()
for r in res: for r in res:
agent_id, t_step, key, value = r agent_id, t_step, key, value = r
_, _ , des = self.conversors(key) _, _ , des = self.conversors(key)
yield agent_id, t_step, key, des(value) yield agent_id, t_step, key, des(value)
def read_types(self): def read_types(self):
with self._db: with self.db:
res = self._db.execute("select key, value_type from value_types ").fetchall() res = self.db.execute("select key, value_type from value_types ").fetchall()
for k, v in res: for k, v in res:
serializer = utils.serializer(v) serializer = utils.serializer(v)
deserializer = utils.deserializer(v) deserializer = utils.deserializer(v)
@ -143,7 +157,7 @@ class History:
h1.key = h2.key and h1.key = h2.key and
h1.t_step = h2.t_step h1.t_step = h2.t_step
'''.format(condition=condition) '''.format(condition=condition)
last_df = pd.read_sql_query(last_query, self._db) last_df = pd.read_sql_query(last_query, self.db)
filters.append("t_step >= '{}' and t_step <= '{}'".format(min_step, max(t_steps))) filters.append("t_step >= '{}' and t_step <= '{}'".format(min_step, max(t_steps)))
@ -151,7 +165,7 @@ class History:
if filters: if filters:
condition = 'where {} '.format(' and '.join(filters)) condition = 'where {} '.format(' and '.join(filters))
query = 'select * from history {} limit {}'.format(condition, limit) query = 'select * from history {} limit {}'.format(condition, limit)
df = pd.read_sql_query(query, self._db) df = pd.read_sql_query(query, self.db)
if last_df is not None: if last_df is not None:
df = pd.concat([df, last_df]) df = pd.concat([df, last_df])

View File

@ -49,7 +49,7 @@ class SoilSimulation(NetworkSimulation):
default_state=None, interval=1, dump=None, dry_run=False, default_state=None, interval=1, dump=None, dry_run=False,
dir_path=None, num_trials=1, max_time=100, dir_path=None, num_trials=1, max_time=100,
agent_module=None, load_module=None, seed=None, agent_module=None, load_module=None, seed=None,
environment_agents=None, environment_params=None): environment_agents=None, environment_params=None, **kwargs):
if topology is None: if topology is None:
topology = utils.load_network(network_params, topology = utils.load_network(network_params,

View File

@ -1,8 +1,10 @@
import io import io
import threading
import asyncio
import logging import logging
import networkx as nx import networkx as nx
import os import os
import threading import sys
import tornado.ioloop import tornado.ioloop
import tornado.web import tornado.web
import tornado.websocket import tornado.websocket
@ -14,9 +16,20 @@ from contextlib import contextmanager
from time import sleep from time import sleep
from xml.etree.ElementTree import tostring from xml.etree.ElementTree import tostring
from tornado.concurrent import run_on_executor
from concurrent.futures import ThreadPoolExecutor
from ..simulation import SoilSimulation
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
ROOT = os.path.abspath(os.path.dirname(__file__))
MAX_WORKERS = 4
LOGGING_INTERVAL = 0.5
# Workaround to let Soil load the required modules
sys.path.append(ROOT)
class PageHandler(tornado.web.RequestHandler): class PageHandler(tornado.web.RequestHandler):
""" Handler for the HTML template which holds the visualization. """ """ Handler for the HTML template which holds the visualization. """
@ -28,6 +41,7 @@ class PageHandler(tornado.web.RequestHandler):
class SocketHandler(tornado.websocket.WebSocketHandler): class SocketHandler(tornado.websocket.WebSocketHandler):
""" Handler for websocket. """ """ Handler for websocket. """
executor = ThreadPoolExecutor(max_workers=MAX_WORKERS)
def open(self): def open(self):
if self.application.verbose: if self.application.verbose:
@ -55,9 +69,10 @@ class SocketHandler(tornado.websocket.WebSocketHandler):
self.write_message({'type': 'error', self.write_message({'type': 'error',
'error': error}) 'error': error})
return return
else:
self.config = self.config[0] self.config = self.config[0]
self.send_log('INFO.' + self.application.simulator.name, 'Using config: {name}'.format(name=self.config['name'])) self.send_log('INFO.' + self.simulation_name,
'Using config: {name}'.format(name=self.config['name']))
if 'visualization_params' in self.config: if 'visualization_params' in self.config:
self.write_message({'type': 'visualization_params', self.write_message({'type': 'visualization_params',
@ -96,12 +111,12 @@ class SocketHandler(tornado.websocket.WebSocketHandler):
elif msg['type'] == 'run_simulation': elif msg['type'] == 'run_simulation':
if self.application.verbose: if self.application.verbose:
logger.info('Running new simulation for {name}'.format(name=self.config['name'])) logger.info('Running new simulation for {name}'.format(name=self.config['name']))
self.send_log('INFO.' + self.application.simulator.name, 'Running new simulation for {name}'.format(name=self.config['name'])) self.send_log('INFO.' + self.simulation_name, 'Running new simulation for {name}'.format(name=self.config['name']))
self.config['environment_params'] = msg['data'] self.config['environment_params'] = msg['data']
self.run_simulation() self.run_simulation()
elif msg['type'] == 'download_gexf': elif msg['type'] == 'download_gexf':
G = self.simulation[ int(msg['data']) ].history_to_graph() G = self.trials[ int(msg['data']) ].history_to_graph()
for node in G.nodes(): for node in G.nodes():
if 'pos' in G.node[node]: if 'pos' in G.node[node]:
G.node[node]['viz'] = {"position": {"x": G.node[node]['pos'][0], "y": G.node[node]['pos'][1], "z": 0.0}} G.node[node]['viz'] = {"position": {"x": G.node[node]['pos'][0], "y": G.node[node]['pos'][1], "z": 0.0}}
@ -113,7 +128,7 @@ class SocketHandler(tornado.websocket.WebSocketHandler):
'data': tostring(writer.xml).decode(writer.encoding) }) 'data': tostring(writer.xml).decode(writer.encoding) })
elif msg['type'] == 'download_json': elif msg['type'] == 'download_json':
G = self.simulation[ int(msg['data']) ].history_to_graph() G = self.trials[ int(msg['data']) ].history_to_graph()
for node in G.nodes(): for node in G.nodes():
if 'pos' in G.node[node]: if 'pos' in G.node[node]:
G.node[node]['viz'] = {"position": {"x": G.node[node]['pos'][0], "y": G.node[node]['pos'][1], "z": 0.0}} G.node[node]['viz'] = {"position": {"x": G.node[node]['pos'][0], "y": G.node[node]['pos'][1], "z": 0.0}}
@ -130,13 +145,13 @@ class SocketHandler(tornado.websocket.WebSocketHandler):
try: try:
if (not self.log_capture_string.closed and self.log_capture_string.getvalue()): if (not self.log_capture_string.closed and self.log_capture_string.getvalue()):
for i in range(len(self.log_capture_string.getvalue().split('\n')) - 1): for i in range(len(self.log_capture_string.getvalue().split('\n')) - 1):
self.send_log('INFO.' + self.application.simulator.name, self.log_capture_string.getvalue().split('\n')[i]) self.send_log('INFO.' + self.simulation_name, self.log_capture_string.getvalue().split('\n')[i])
self.log_capture_string.truncate(0) self.log_capture_string.truncate(0)
self.log_capture_string.seek(0) self.log_capture_string.seek(0)
finally: finally:
if self.capture_logging: if self.capture_logging:
thread = threading.Timer(0.01, self.update_logging) tornado.ioloop.IOLoop.current().call_later(LOGGING_INTERVAL, self.update_logging)
thread.start()
def on_close(self): def on_close(self):
if self.application.verbose: if self.application.verbose:
@ -147,26 +162,42 @@ class SocketHandler(tornado.websocket.WebSocketHandler):
'logger': logger, 'logger': logger,
'logging': logging}) 'logging': logging})
@property
def simulation_name(self):
return self.config.get('name', 'NoSimulationRunning')
@run_on_executor
def nonblocking(self, config):
simulation = SoilSimulation(**config)
return simulation.run()
@tornado.gen.coroutine
def run_simulation(self): def run_simulation(self):
# Run simulation and capture logs # Run simulation and capture logs
logger.info('Running simulation!')
if 'visualization_params' in self.config: if 'visualization_params' in self.config:
del self.config['visualization_params'] del self.config['visualization_params']
with self.logging(self.application.simulator.name): with self.logging(self.simulation_name):
try: try:
self.simulation = self.application.simulator.run(self.config) config = dict(**self.config)
trials = [] config['dir_path'] = os.path.join(self.application.dir_path, config['name'])
for i in range(self.config['num_trials']): config['dump'] = self.application.dump
trials.append('{}_trial_{}'.format(self.name, i)) self.trials = yield self.nonblocking(config)
self.write_message({'type': 'trials', self.write_message({'type': 'trials',
'data': trials }) 'data': list(trial.name for trial in self.trials) })
except: except Exception as ex:
error = 'Something went wrong. Please, try again.' error = 'Something went wrong:\n\t{}'.format(ex)
logging.info(error)
self.write_message({'type': 'error', self.write_message({'type': 'error',
'error': error}) 'error': error})
self.send_log('ERROR.' + self.application.simulator.name, error) self.send_log('ERROR.' + self.simulation_name, error)
def get_trial(self, trial): def get_trial(self, trial):
G = self.simulation[trial].history_to_graph() logger.info('Available trials: %s ' % len(self.trials))
logger.info('Ask for : %s' % trial)
trial = self.trials[trial]
G = trial.history_to_graph()
return nx.node_link_data(G) return nx.node_link_data(G)
@contextmanager @contextmanager
@ -193,19 +224,20 @@ class ModularServer(tornado.web.Application):
page_handler = (r'/', PageHandler) page_handler = (r'/', PageHandler)
socket_handler = (r'/ws', SocketHandler) socket_handler = (r'/ws', SocketHandler)
static_handler = (r'/(.*)', tornado.web.StaticFileHandler, static_handler = (r'/(.*)', tornado.web.StaticFileHandler,
{'path': 'templates'}) {'path': os.path.join(ROOT, 'static')})
local_handler = (r'/local/(.*)', tornado.web.StaticFileHandler, local_handler = (r'/local/(.*)', tornado.web.StaticFileHandler,
{'path': ''}) {'path': ''})
handlers = [page_handler, socket_handler, static_handler, local_handler] handlers = [page_handler, socket_handler, static_handler, local_handler]
settings = {'debug': True, settings = {'debug': True,
'template_path': os.path.dirname(__file__) + '/templates'} 'template_path': ROOT + '/templates'}
def __init__(self, simulator, name='SOIL', verbose=True, *args, **kwargs): def __init__(self, dump=False, dir_path='output', name='SOIL', verbose=True, *args, **kwargs):
self.verbose = verbose self.verbose = verbose
self.name = name self.name = name
self.simulator = simulator self.dump = dump
self.dir_path = dir_path
# Initializing the application itself: # Initializing the application itself:
super().__init__(self.handlers, **self.settings) super().__init__(self.handlers, **self.settings)
@ -220,3 +252,23 @@ class ModularServer(tornado.web.Application):
self.listen(self.port) self.listen(self.port)
# webbrowser.open(url) # webbrowser.open(url)
tornado.ioloop.IOLoop.instance().start() tornado.ioloop.IOLoop.instance().start()
def run(*args, **kwargs):
asyncio.set_event_loop(asyncio.new_event_loop())
server = ModularServer(*args, **kwargs)
server.launch()
def main():
import argparse
parser = argparse.ArgumentParser(description='Visualization of a Graph Model')
parser.add_argument('--name', '-n', nargs=1, default='SOIL', help='name of the simulation')
parser.add_argument('--dump', '-d', help='dumping results in folder output', action='store_true')
parser.add_argument('--port', '-p', nargs=1, default=8001, help='port for launching the server')
parser.add_argument('--verbose', '-v', help='verbose mode', action='store_true')
args = parser.parse_args()
run(name=args.name, port=(args.port[0] if isinstance(args.port, list) else args.port), verbose=args.verbose)

5
soil/web/__main__.py Normal file
View File

@ -0,0 +1,5 @@
from . import main
if __name__ == "__main__":
main()

View File

@ -1,36 +0,0 @@
import os
import networkx as nx
from soil.simulation import SoilSimulation
class Simulator():
""" Simulator for running simulations. Using SOIL."""
def __init__(self, dump=False, dir_path='output'):
self.name = 'soil'
self.dump = dump
self.dir_path = dir_path
def run(self, config):
name = config['name']
print('Using config(s): {name}'.format(name=name))
sim = SoilSimulation(**config)
sim.dir_path = os.path.join(self.dir_path, name)
sim.dump = self.dump
print('Dumping results to {} : {}'.format(sim.dir_path, sim.dump))
simulation_results = sim.run_simulation()
# G = simulation_results[0].history_to_graph()
# for node in G.nodes():
# if 'pos' in G.node[node]:
# G.node[node]['viz'] = {"position": {"x": G.node[node]['pos'][0], "y": G.node[node]['pos'][1], "z": 0.0}}
# del (G.node[node]['pos'])
# nx.write_gexf(G, 'test.gexf', version='1.2draft')
return simulation_results
def reset(self):
pass

View File

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

Before

Width:  |  Height:  |  Size: 8.0 MiB

After

Width:  |  Height:  |  Size: 8.0 MiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

View File

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 462 B

View File

Before

Width:  |  Height:  |  Size: 812 B

After

Width:  |  Height:  |  Size: 812 B

View File

Before

Width:  |  Height:  |  Size: 697 B

After

Width:  |  Height:  |  Size: 697 B

View File

Before

Width:  |  Height:  |  Size: 992 B

After

Width:  |  Height:  |  Size: 992 B

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -239,17 +239,19 @@ var reset_configuration = function() {
$('#download_json').off(); $('#download_json').off();
} }
var slider;
var set_timeline = function(graph) { var set_timeline = function(graph) {
// 'Timeline' slider // 'Timeline' slider
var [min, max] = get_limits(graph); var [min, max] = get_limits(graph);
var stepUnix = (max - min) / 200; var stepUnix = 1;
var minUnix = (min !== Math.min()) ? min : 0; var minUnix = (min !== Math.min()) ? min : 0;
var maxUnix = (max !== Math.max()) ? max : minUnix + 20; var maxUnix = (max !== Math.max()) ? max : minUnix + 20;
slider = d3.slider(); slider = d3.slider();
d3.select('#slider3').attr('width', width).call( d3.select('#slider3').attr('width', width).call(
slider.axis(true).min(minUnix).max(maxUnix).step(stepUnix).value(maxUnix) slider.axis(true).min(minUnix).max(maxUnix).step(stepUnix).value(minUnix)
.on('slide', function(evt, value) { .on('slide', function(evt, value) {
self.GraphVisualization.update_graph($('.config-item #properties').val(), value, function() { self.GraphVisualization.update_graph($('.config-item #properties').val(), value, function() {
update_statistics_table(); update_statistics_table();
@ -281,65 +283,64 @@ var set_timeline = function(graph) {
// Button 'Play' // Button 'Play'
$('button#button_play').on('click', function() { $('button#button_play').on('click', function() {
play();
$('button#button_play').addClass('pressed').prop("disabled", true);
$('#speed-slider').slider('disable');
slider.step( 1 / speed );
if (slider.value() >= maxUnix) {
slider.value(minUnix);
self.GraphVisualization.update_graph($('.config-item #properties').val(), slider.value(), function() {
update_statistics_table();
});
setTimeout(player, 1000);
} else {
player();
}
var i = slider.value();
function player() {
clearInterval(play);
play = setInterval(function() {
self.GraphVisualization.update_graph($('.config-item #properties').val(), slider.value(), function() {
update_statistics_table();
});
if (slider.value() + slider.step() >= maxUnix) {
slider.value(maxUnix);
slider.step(stepUnix);
clearInterval(play);
$('button#button_play').removeClass('pressed').prop("disabled", false);
$('#speed-slider').slider('enable');
} else {
slider.value(i);
i += slider.step();
}
}, 5);
}
}); });
// Button 'Pause' // Button 'Pause'
$('button#button_pause').on('click', function() { $('button#button_pause').on('click', function() {
clearInterval(play); stop();
slider.step(stepUnix);
$('button#button_play').removeClass('pressed').prop("disabled", false); $('button#button_play').removeClass('pressed').prop("disabled", false);
$('#speed-slider').slider('enable');
}); });
// Button 'Zoom to Fit' // Button 'Zoom to Fit'
$('button#button_zoomFit').click(function() { self.GraphVisualization.fit(); }); $('button#button_zoomFit').click(function() { self.GraphVisualization.fit(); });
} }
var player;
function play(){
$('button#button_play').addClass('pressed').prop("disabled", true);
if (slider.value() >= slider.max()) {
slider.value(slider.min());
}
var FRAME_INTERVAL = 100;
var speed_ratio = FRAME_INTERVAL / 1000 // speed=1 => 1 step per second
nextStep = function() {
newvalue = Math.min(slider.value() + speed*speed_ratio, slider.max());
console.log("new time value", newvalue);
slider.value(newvalue);
self.GraphVisualization.update_graph($('.config-item #properties').val(), slider.value(), function () {
update_statistics_table();
});
if (newvalue < slider.max()) {
player = setTimeout(nextStep, FRAME_INTERVAL);
} else {
$('button#button_play').removeClass('pressed').prop("disabled", false);
}
}
player = setTimeout(nextStep, FRAME_INTERVAL);
}
function stop() {
clearTimeout(player);
}
var reset_timeline = function() { var reset_timeline = function() {
// 'Timeline' slider // 'Timeline' slider
$('#slider3').html(''); $('#slider3').html('');
// 'Speed' slider // 'Speed' slider
$('#speed-slider').slider('disable').slider('setValue', 1000); // $('#speed-slider').slider('disable').slider('setValue', 1000);
// Buttons // Buttons
clearInterval(play); stop();
$('button#button_play').off().removeClass('pressed').prop("disabled", false); $('button#button_play').off().removeClass('pressed').prop("disabled", false);
$('button#button_pause').off(); $('button#button_pause').off();
$('button#button_zoomFit').off(); $('button#button_zoomFit').off();

View File

@ -41,7 +41,7 @@
var width = window.innerWidth * 0.75, var width = window.innerWidth * 0.75,
height = window.innerHeight * 3 / 5, height = window.innerHeight * 3 / 5,
speed = 1000, speed = 1,
play, play,
slider; slider;
@ -255,7 +255,7 @@
<th class="text-right max">max</th> <th class="text-right max">max</th>
</tr></tbody></table> </tr></tbody></table>
<div class="speed-slider"> <div class="speed-slider">
<input id="speed-slider" type="text" data-slider-min="1" data-slider-max="1000" data-slider-step="0.01" data-slider-value="1000" data-slider-tooltip="hide" data-slider-reversed="true" data-slider-enabled="false" data-slider-scale="logarithmic"/> <input id="speed-slider" type="text" data-slider-min="0.1" data-slider-max="100" data-slider-step="0.1" data-slider-value="1" data-slider-tooltip="hide" data-slider-enabled="false" data-slider-scale="logarithmic"/>
</div> </div>
</div> </div>
<hr /> <hr />