Running a very basic Story for the hugveys
This commit is contained in:
parent
f5f08fc103
commit
1ea85dd490
13 changed files with 12828 additions and 164 deletions
|
@ -17,13 +17,33 @@ from hugvey.story import Story
|
|||
from hugvey.voice.google import GoogleVoiceClient
|
||||
from hugvey.voice.player import Player
|
||||
from hugvey.voice.streamer import AudioStreamer
|
||||
import queue
|
||||
|
||||
|
||||
logger = logging.getLogger("command")
|
||||
|
||||
# def exceptionEmitter(a):
|
||||
# print(a)
|
||||
# def decorate(func):
|
||||
# print('decorate')
|
||||
# async def call(*args, **kwargs):
|
||||
# print('call')
|
||||
# # pre(func, *args, **kwargs)
|
||||
# try:
|
||||
# result = await func(*args, **kwargs)
|
||||
# except Exception as e:
|
||||
# logger.critical(e, "in", func)
|
||||
# raise e
|
||||
# # post(func, *args, **kwargs)
|
||||
# return result
|
||||
# return call
|
||||
# return decorate
|
||||
|
||||
|
||||
class CentralCommand(object):
|
||||
"""docstring for CentralCommand."""
|
||||
def __init__(self, debug_mode = False):
|
||||
|
||||
def __init__(self, debug_mode=False):
|
||||
self.debug = debug_mode
|
||||
self.eventQueue = asyncio.Queue()
|
||||
self.commandQueue = asyncio.Queue()
|
||||
|
@ -31,39 +51,67 @@ class CentralCommand(object):
|
|||
self.hugveys = {}
|
||||
self.ctx = Context.instance()
|
||||
self.hugveyLock = asyncio.Lock()
|
||||
|
||||
self.start_time = time.time()
|
||||
|
||||
def loadConfig(self, filename):
|
||||
if hasattr(self, 'config'):
|
||||
raise Exception("Overriding config not supported yet")
|
||||
|
||||
|
||||
with open(filename, 'r') as fp:
|
||||
logger.debug('Load config from {}'.format(filename))
|
||||
self.config = yaml.safe_load(fp)
|
||||
|
||||
self.hugvey_ids = [i+1 for i in range(self.config['hugveys'])]
|
||||
|
||||
|
||||
self.hugvey_ids = [i + 1 for i in range(self.config['hugveys'])]
|
||||
|
||||
# load languages:
|
||||
self.languages = {}
|
||||
|
||||
|
||||
for lang in self.config['languages']:
|
||||
with open(lang['file'], 'r') as fp:
|
||||
self.languages[lang['code']] = yaml.load(fp)
|
||||
|
||||
|
||||
self.panopticon = Panopticon(self, self.config)
|
||||
|
||||
|
||||
|
||||
def getHugveyStatus(self, hv_id):
|
||||
status = {'id': hv_id}
|
||||
if not hv_id in self.hugveys:
|
||||
status['status'] = 'off'
|
||||
return status
|
||||
|
||||
hv = self.hugveys[hv_id]
|
||||
status['status'] = 'running' if hv.isRunning.is_set() else 'paused'
|
||||
status['language'] = hv.language_code
|
||||
status['msg'] = hv.story.currentMessage.id
|
||||
status['counts'] = hv.story.getStoryCounts()
|
||||
status['finished'] = hv.story.isFinished()
|
||||
|
||||
|
||||
return status
|
||||
|
||||
def getStatusSummary(self):
|
||||
status = {
|
||||
'uptime': time.time() - self.start_time,
|
||||
'languages': self.config['languages'],
|
||||
'hugveys': [],
|
||||
}
|
||||
|
||||
for hv_id in self.hugvey_ids:
|
||||
status['hugveys'].append(self.getHugveyStatus(hv_id))
|
||||
|
||||
return status
|
||||
|
||||
def commandHugvey(self, hv_id, msg):
|
||||
"""
|
||||
prepare command to be picked up by the sender
|
||||
"""
|
||||
if threading.current_thread().getName() != 'MainThread':
|
||||
# Threading nightmares! Adding to queue from other thread/loop (not sure which is the isse)
|
||||
# won't trigger asyncios queue.get() so we have to do this thread safe, in the right loop
|
||||
self.loop.call_soon_threadsafe( self._queueCommand, hv_id, msg )
|
||||
# won't trigger asyncios queue.get() so we have to do this thread
|
||||
# safe, in the right loop
|
||||
self.loop.call_soon_threadsafe(self._queueCommand, hv_id, msg)
|
||||
else:
|
||||
self._queueCommand(hv_id, msg)
|
||||
|
||||
|
||||
def _queueCommand(self, hv_id, msg):
|
||||
self.commandQueue.put_nowait((hv_id, msg))
|
||||
# if msg['action'] == 'play':
|
||||
|
@ -72,155 +120,166 @@ class CentralCommand(object):
|
|||
# 'msg': "This is an interrption",
|
||||
# 'id': 'test',
|
||||
# }))
|
||||
|
||||
|
||||
def commandAllHugveys(self, msg):
|
||||
for hv_id in self.hugvey_ids:
|
||||
self.commandHugvey(hv_id, msg)
|
||||
|
||||
|
||||
def commandAllActiveHugveys(self, msg):
|
||||
for hv_id in self.hugveys:
|
||||
self.commandHugvey(hv_id, msg)
|
||||
|
||||
|
||||
async def commandSender(self):
|
||||
s = self.ctx.socket(zmq.PUB)
|
||||
s.bind(self.config['events']['cmd_address'])
|
||||
|
||||
|
||||
self.commandAllHugveys({'action': 'show_yourself'})
|
||||
|
||||
|
||||
# sleep to allow pending connections to connect
|
||||
await asyncio.sleep(1)
|
||||
logger.info("Ready to publish commands on: {}".format(self.config['events']['cmd_address']))
|
||||
logger.debug('Already {} items in queue'.format(self.commandQueue.qsize()))
|
||||
|
||||
logger.info("Ready to publish commands on: {}".format(
|
||||
self.config['events']['cmd_address']))
|
||||
logger.debug('Already {} items in queue'.format(
|
||||
self.commandQueue.qsize()))
|
||||
|
||||
while self.isRunning.is_set():
|
||||
hv_id, cmd = await self.commandQueue.get()
|
||||
logger.info('Got command to send: {} {}'.format(hv_id, cmd))
|
||||
zmqSend(s, hv_id, cmd)
|
||||
|
||||
|
||||
logger.warn('Stopping command sender')
|
||||
s.close()
|
||||
|
||||
|
||||
async def instantiateHugvey(self, hugvey_id, msg):
|
||||
'''
|
||||
Start a HugveyState, according to a show_yourself reply
|
||||
|
||||
|
||||
'event': 'connection',
|
||||
'id': self.hugvey_id,
|
||||
'host': socket.gethostname(),
|
||||
'ip': self.getIp(),
|
||||
'''
|
||||
async with self.hugveyLock: # lock to prevent duplicates on creation
|
||||
async with self.hugveyLock: # lock to prevent duplicates on creation
|
||||
if not hugvey_id in self.hugveys:
|
||||
logger.info(f'Instantiate hugvey #{hugvey_id}')
|
||||
print('a')
|
||||
h = HugveyState(hugvey_id, self)
|
||||
h.config(msg['host'],msg['ip'])
|
||||
print('a')
|
||||
h.config(msg['host'], msg['ip'])
|
||||
print('b')
|
||||
self.hugveys[hugvey_id] = h
|
||||
thread = threading.Thread(target=h.start, name=f"hugvey#{hugvey_id}")
|
||||
thread = threading.Thread(
|
||||
target=h.start, name=f"hugvey#{hugvey_id}")
|
||||
thread.start()
|
||||
print('c')
|
||||
else:
|
||||
logger.info(f'Reconfigure hugvey #{hugvey_id}')
|
||||
# (re)configure exisitng hugveys
|
||||
h.config(msg['host'],msg['ip'])
|
||||
|
||||
|
||||
|
||||
h.config(msg['host'], msg['ip'])
|
||||
|
||||
async def eventListener(self):
|
||||
s = self.ctx.socket(zmq.SUB)
|
||||
s.bind(self.config['events']['listen_address'])
|
||||
logger.info("Listen for events on: {}".format(self.config['events']['listen_address']))
|
||||
|
||||
logger.info("Listen for events on: {}".format(
|
||||
self.config['events']['listen_address']))
|
||||
|
||||
for id in self.hugvey_ids:
|
||||
s.subscribe(getTopic(id))
|
||||
|
||||
|
||||
while self.isRunning.is_set():
|
||||
hugvey_id, msg = await zmqReceive(s)
|
||||
|
||||
if hugvey_id not in self.hugvey_ids:
|
||||
logger.critical("Message from alien Hugvey: {}".format(hugvey_id))
|
||||
continue
|
||||
elif hugvey_id not in self.hugveys:
|
||||
if msg['event'] == 'connection':
|
||||
# Create a hugvey
|
||||
await self.instantiateHugvey(hugvey_id, msg)
|
||||
try:
|
||||
hugvey_id, msg = await zmqReceive(s)
|
||||
|
||||
if hugvey_id not in self.hugvey_ids:
|
||||
logger.critical(
|
||||
"Message from alien Hugvey: {}".format(hugvey_id))
|
||||
continue
|
||||
elif hugvey_id not in self.hugveys:
|
||||
if msg['event'] == 'connection':
|
||||
# Create a hugvey
|
||||
await self.instantiateHugvey(hugvey_id, msg)
|
||||
else:
|
||||
logger.warning(
|
||||
"Message from uninstantiated Hugvey {}".format(hugvey_id))
|
||||
logger.debug("Message contains: {}".format(msg))
|
||||
continue
|
||||
else:
|
||||
logger.warning("Message from uninstantiated Hugvey {}".format(hugvey_id))
|
||||
logger.debug("Message contains: {}".format(msg))
|
||||
continue
|
||||
else:
|
||||
await self.hugveys[hugvey_id].eventQueue.put(msg)
|
||||
pass
|
||||
|
||||
# def getPanopticon(self):
|
||||
# self.panopticon =
|
||||
|
||||
await self.hugveys[hugvey_id].eventQueue.put(msg)
|
||||
except Exception as e:
|
||||
logger.critical(f"Exception while running event loop:")
|
||||
logger.exception(e)
|
||||
|
||||
|
||||
def start(self):
|
||||
self.isRunning.set()
|
||||
self.loop = asyncio.get_event_loop()
|
||||
# self.panopticon_loop = asyncio.new_event_loop()
|
||||
|
||||
self.tasks = {} # collect tasks so we can cancel in case of error
|
||||
self.tasks['eventListener'] = self.loop.create_task(self.eventListener())
|
||||
self.tasks['commandSender'] = self.loop.create_task(self.commandSender())
|
||||
|
||||
print(threading.current_thread())
|
||||
|
||||
self.tasks = {} # collect tasks so we can cancel in case of error
|
||||
self.tasks['eventListener'] = self.loop.create_task(
|
||||
self.eventListener())
|
||||
self.tasks['commandSender'] = self.loop.create_task(
|
||||
self.commandSender())
|
||||
|
||||
# we want the web interface in a separate thread
|
||||
self.panopticon_thread = threading.Thread(target=self.panopticon.start, name="Panopticon")
|
||||
self.panopticon_thread = threading.Thread(
|
||||
target=self.panopticon.start, name="Panopticon")
|
||||
self.panopticon_thread.start()
|
||||
print(threading.current_thread())
|
||||
|
||||
self.loop.run_forever()
|
||||
|
||||
|
||||
def stop(self):
|
||||
self.isRunning.clear()
|
||||
|
||||
|
||||
|
||||
class HugveyState(object):
|
||||
"""Represents the state of a Hugvey client on the server.
|
||||
Manages server connections & voice parsing etc.
|
||||
"""
|
||||
|
||||
def __init__(self, id: int, command: CentralCommand):
|
||||
|
||||
self.id = id
|
||||
self.command = command
|
||||
self.logger = logging.getLogger(f"hugvey{self.id}")
|
||||
self.loop = asyncio.new_event_loop()
|
||||
self.isConfigured = False
|
||||
self.isRunning = threading.Event()
|
||||
self.eventQueue = None
|
||||
self.language_code = 'en-GB'
|
||||
self.story = Story(self)
|
||||
self.story.setStoryData(self.command.languages[self.language_code])
|
||||
|
||||
|
||||
def config(self, hostname, ip):
|
||||
self.ip = ip
|
||||
self.hostname = hostname
|
||||
self.logger.info(f"Hugvey {self.id} at {self.ip}, host: {self.hostname}")
|
||||
|
||||
self.logger.info(
|
||||
f"Hugvey {self.id} at {self.ip}, host: {self.hostname}")
|
||||
|
||||
if self.isConfigured == True:
|
||||
# a reconfiguration/reconnection
|
||||
pass
|
||||
|
||||
|
||||
self.isConfigured = True
|
||||
|
||||
|
||||
def sendCommand(self, msg):
|
||||
"""
|
||||
Send message or command to hugvey
|
||||
@param msg: The message to be sent. Probably a dict()
|
||||
"""
|
||||
self.command.commandHugvey(self.id, msg)
|
||||
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
Start the tasks
|
||||
"""
|
||||
# stop on isRunning.is_set() or wait()
|
||||
# self.loop.create_task(self.processAudio())
|
||||
tasks = asyncio.gather(
|
||||
self.catchException(self.processAudio()),
|
||||
self.catchException(self.handleEvents()),
|
||||
self.catchException(self.playStory()),
|
||||
loop=self.loop)
|
||||
self.loop.run_until_complete(tasks)
|
||||
# asyncio.run_coroutine_threadsafe(self._start(), self.loop)
|
||||
self.isRunning.set()
|
||||
|
||||
async def catchException(self, awaitable):
|
||||
try:
|
||||
|
@ -228,7 +287,7 @@ class HugveyState(object):
|
|||
except Exception as e:
|
||||
logger.exception(e)
|
||||
logger.critical(f"Hugvey restart required but not implemented yet")
|
||||
|
||||
|
||||
# TODO: restart
|
||||
|
||||
def queueEvent(self, msg):
|
||||
|
@ -243,51 +302,74 @@ class HugveyState(object):
|
|||
self.story.events.append(msg)
|
||||
|
||||
async def handleEvents(self):
|
||||
self.eventQueue = asyncio.Queue() # start event queue here, to avoid loop issues
|
||||
self.eventQueue = asyncio.Queue() # start event queue here, to avoid loop issues
|
||||
while self.command.isRunning.is_set():
|
||||
event = await self.eventQueue.get()
|
||||
self.logger.info("Received: {}".format(event))
|
||||
|
||||
if event['event'] =='language':
|
||||
|
||||
if event['event'] == 'language':
|
||||
self.setLanguage(event['code'])
|
||||
|
||||
if event['event'] == 'pause':
|
||||
self.pause()
|
||||
if event['event'] == 'restart':
|
||||
self.restart()
|
||||
if event['event'] == 'resume':
|
||||
self.resume()
|
||||
|
||||
self.eventQueue = None
|
||||
|
||||
|
||||
def setLanguage(self, language_code):
|
||||
if language_code not in self.command.languages:
|
||||
raise Exception("Invalid language {}".format(language_code))
|
||||
|
||||
|
||||
self.language_code = language_code
|
||||
self.google.setLanguage(language_code)
|
||||
|
||||
|
||||
self.story.reset()
|
||||
self.story.setStoryData(self.command.languages[language_code])
|
||||
|
||||
def pause(self):
|
||||
self.google.pause()
|
||||
self.story.pause()
|
||||
self.isRunning.clear()
|
||||
|
||||
def resume(self):
|
||||
self.google.resume()
|
||||
self.story.resume()
|
||||
self.isRunning.set()
|
||||
|
||||
def restart(self):
|
||||
self.story.reset()
|
||||
self.resume()
|
||||
self.isRunning.set()
|
||||
|
||||
async def playStory(self):
|
||||
await self.story.start()
|
||||
|
||||
|
||||
async def processAudio(self):
|
||||
'''
|
||||
Start the audio streamer service
|
||||
'''
|
||||
self.logger.info("Start audio stream")
|
||||
streamer = AudioStreamer(
|
||||
self.command.config['voice']['chunk'],
|
||||
self.command.config['voice']['chunk'],
|
||||
self.ip,
|
||||
int(self.command.config['voice']['port']))
|
||||
|
||||
|
||||
if self.command.debug:
|
||||
self.logger.warn("Debug on: Connecting Audio player")
|
||||
self.player = Player(self.command.config['voice']['src_rate'], self.command.config['voice']['out_rate'])
|
||||
self.player = Player(
|
||||
self.command.config['voice']['src_rate'], self.command.config['voice']['out_rate'])
|
||||
streamer.addConsumer(self.player)
|
||||
|
||||
|
||||
self.logger.info("Start Speech")
|
||||
self.google = GoogleVoiceClient(
|
||||
hugvey=self,
|
||||
src_rate=self.command.config['voice']['src_rate'],
|
||||
credential_file=self.command.config['voice']['google_credentials'],
|
||||
language_code=self.language_code
|
||||
)
|
||||
)
|
||||
streamer.addConsumer(self.google)
|
||||
|
||||
|
||||
await streamer.run()
|
||||
|
|
|
@ -11,28 +11,73 @@ import tornado.ioloop
|
|||
import os
|
||||
from pytz.reference import Central
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
logger = logging.getLogger("panopticon")
|
||||
|
||||
web_dir = os.path.join(os.path.split(__file__)[0], '..','www')
|
||||
web_dir = os.path.join(os.path.split(__file__)[0], '..', 'www')
|
||||
print(web_dir)
|
||||
|
||||
class WebSocketHandler(tornado.websocket.WebSocketHandler):
|
||||
connections = set()
|
||||
|
||||
# the client connected
|
||||
def open(self):
|
||||
self.connections.add(self)
|
||||
print("New client connected")
|
||||
def getWebSocketHandler(central_command):
|
||||
class WebSocketHandler(tornado.websocket.WebSocketHandler):
|
||||
connections = set()
|
||||
|
||||
# the client sent the message
|
||||
def on_message(self, message):
|
||||
[con.write_message(message) for con in self.connections]
|
||||
# the client connected
|
||||
def open(self):
|
||||
self.connections.add(self)
|
||||
logger.info("New client connected")
|
||||
|
||||
# client disconnected
|
||||
def on_close(self):
|
||||
self.connections.remove(self)
|
||||
print("Client disconnected")
|
||||
# the client sent the message
|
||||
def on_message(self, message):
|
||||
try:
|
||||
msg = json.loads(message)
|
||||
if msg['action'] == 'init':
|
||||
self.msgInit()
|
||||
if msg['action'] == 'get_status':
|
||||
self.msgStatus()
|
||||
if msg['action'] == 'resume':
|
||||
self.msgResume(msg['hugvey'])
|
||||
if msg['action'] == 'pause':
|
||||
self.msgPause(msg['hugvey'])
|
||||
if msg['action'] == 'restart':
|
||||
self.msgRestart(msg['hugvey'])
|
||||
|
||||
except Exception as e:
|
||||
self.send({'alert': 'Invalid request: {}'.format(e)})
|
||||
|
||||
def send(self, message):
|
||||
j = json.dumps(message)
|
||||
[con.write_message(j) for con in self.connections]
|
||||
|
||||
# client disconnected
|
||||
def on_close(self):
|
||||
self.connections.remove(self)
|
||||
logger.info("Client disconnected")
|
||||
|
||||
def getStatusMsg(self):
|
||||
msg = central_command.getStatusSummary()
|
||||
msg['action'] = 'status'
|
||||
|
||||
return msg
|
||||
|
||||
def msgStatus(self):
|
||||
self.send(self.getStatusMsg())
|
||||
|
||||
def msgInit(self):
|
||||
msg = self.getStatusMsg()
|
||||
self.send(msg)
|
||||
|
||||
def msgResume(self, hv_id):
|
||||
central_command.hugveys[hv_id].eventQueue.put({'event': 'resume'})
|
||||
|
||||
def msgPause(self, hv_id):
|
||||
central_command.hugveys[hv_id].eventQueue.put({'event': 'pause'})
|
||||
|
||||
def msgRestart(self, hv_id):
|
||||
central_command.hugveys[hv_id].eventQueue.put({'event': 'restart'})
|
||||
|
||||
return WebSocketHandler
|
||||
|
||||
|
||||
class Panopticon(object):
|
||||
|
@ -40,22 +85,23 @@ class Panopticon(object):
|
|||
self.command = central_command
|
||||
self.config = config
|
||||
self.application = tornado.web.Application([
|
||||
(r"/ws", WebSocketHandler),
|
||||
(r"/uploads/(.*)", tornado.web.StaticFileHandler, {"path": config['web']['files_dir']}),
|
||||
(r"/(.*)", tornado.web.StaticFileHandler, {"path": web_dir, "default_filename": 'index.html'}),
|
||||
(r"/ws", getWebSocketHandler(self.command)),
|
||||
(r"/uploads/(.*)", tornado.web.StaticFileHandler,
|
||||
{"path": config['web']['files_dir']}),
|
||||
(r"/(.*)", tornado.web.StaticFileHandler,
|
||||
{"path": web_dir, "default_filename": 'index.html'}),
|
||||
], debug=True)
|
||||
|
||||
|
||||
|
||||
self.application.listen(config['web']['port'])
|
||||
# self.loop.configure(evt_loop)
|
||||
|
||||
def start(self):
|
||||
evt_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(evt_loop)
|
||||
|
||||
|
||||
self.loop = tornado.ioloop.IOLoop.current()
|
||||
logger.info(f"Start Panopticon on port {self.config['web']['port']}")
|
||||
self.loop.start()
|
||||
|
||||
|
||||
def stop(self):
|
||||
self.loop.stop()
|
||||
|
|
181
hugvey/story.py
181
hugvey/story.py
|
@ -7,6 +7,7 @@ import asyncio
|
|||
|
||||
logger = logging.getLogger("narrative")
|
||||
|
||||
|
||||
class Message(object):
|
||||
def __init__(self, id, text):
|
||||
self.id = id
|
||||
|
@ -16,9 +17,9 @@ class Message(object):
|
|||
|
||||
@classmethod
|
||||
def initFromJson(message, data, story):
|
||||
msg = message(data['@id'], data['text'])
|
||||
msg = message(data['@id'], data['text'])
|
||||
msg.isStart = data['start'] if 'start' in data else False
|
||||
return msg;
|
||||
return msg
|
||||
|
||||
def setReply(self, text):
|
||||
self.reply = text
|
||||
|
@ -28,7 +29,8 @@ class Message(object):
|
|||
|
||||
def getReply(self):
|
||||
if self.reply is None:
|
||||
raise Exception("Getting reply while there is none! {0}".format(self.id))
|
||||
raise Exception(
|
||||
"Getting reply while there is none! {0}".format(self.id))
|
||||
|
||||
return self.reply
|
||||
|
||||
|
@ -38,6 +40,7 @@ class Condition(object):
|
|||
A condition, basic conditions are built in, custom condition can be given by
|
||||
providing a custom method.
|
||||
"""
|
||||
|
||||
def __init__(self, id):
|
||||
self.id = id
|
||||
self.method = None
|
||||
|
@ -45,7 +48,7 @@ class Condition(object):
|
|||
|
||||
@classmethod
|
||||
def initFromJson(conditionClass, data, story):
|
||||
condition = conditionClass(data['@id'])
|
||||
condition = conditionClass(data['@id'])
|
||||
# TODO: should Condition be subclassed?
|
||||
if data['type'] == "replyContains":
|
||||
condition.method = condition._hasMetReplyContains
|
||||
|
@ -55,7 +58,7 @@ class Condition(object):
|
|||
if 'vars' in data:
|
||||
condition.vars = data['vars']
|
||||
|
||||
return condition;
|
||||
return condition
|
||||
|
||||
def isMet(self, story):
|
||||
"""
|
||||
|
@ -98,6 +101,7 @@ class Direction(object):
|
|||
"""
|
||||
A condition based edge in the story graph
|
||||
"""
|
||||
|
||||
def __init__(self, id, msgFrom: Message, msgTo: Message):
|
||||
self.id = id
|
||||
self.msgFrom = msgFrom
|
||||
|
@ -111,18 +115,19 @@ class Direction(object):
|
|||
def initFromJson(direction, data, story):
|
||||
msgFrom = story.get(data['source'])
|
||||
msgTo = story.get(data['target'])
|
||||
direction = direction(data['@id'], msgFrom, msgTo)
|
||||
direction = direction(data['@id'], msgFrom, msgTo)
|
||||
if 'conditions' in data:
|
||||
for conditionId in data['conditions']:
|
||||
c = story.get(conditionId)
|
||||
direction.addCondition(c)
|
||||
return direction;
|
||||
return direction
|
||||
|
||||
|
||||
class Interruption(object):
|
||||
"""
|
||||
An Interruption. Used to catch events outside of story flow.
|
||||
"""
|
||||
|
||||
def __init__(self, id):
|
||||
self.id = id
|
||||
self.conditions = []
|
||||
|
@ -132,12 +137,13 @@ class Interruption(object):
|
|||
|
||||
@classmethod
|
||||
def initFromJson(interruptionClass, data, story):
|
||||
interrupt = interruptionClass(data['@id'])
|
||||
interrupt = interruptionClass(data['@id'])
|
||||
if 'conditions' in data:
|
||||
for conditionId in data['conditions']:
|
||||
c = story.get(conditionId)
|
||||
interrupt.addCondition(c)
|
||||
return interrupt;
|
||||
return interrupt
|
||||
|
||||
|
||||
storyClasses = {
|
||||
'Msg': Message,
|
||||
|
@ -146,34 +152,103 @@ storyClasses = {
|
|||
'Interruption': Interruption,
|
||||
}
|
||||
|
||||
|
||||
class Stopwatch(object):
|
||||
"""
|
||||
Keep track of elapsed time. Use multiple markers, but a single pause/resume button
|
||||
"""
|
||||
def __init__(self):
|
||||
self.isRunning = asyncio.Event()
|
||||
self.reset()
|
||||
|
||||
def getElapsed(self, since_mark='start'):
|
||||
t = time.time()
|
||||
if self.paused_at != 0:
|
||||
pause_duration = t - self.paused_at
|
||||
else:
|
||||
pause_duration = 0
|
||||
return t - self.marks[since_mark] - pause_duration
|
||||
|
||||
def pause(self):
|
||||
self.paused_at = time.time()
|
||||
self.isRunning.clear()
|
||||
|
||||
def resume(self):
|
||||
if self.paused_at == 0:
|
||||
return
|
||||
|
||||
pause_duration = time.time() - self.paused_at
|
||||
for m in self.marks:
|
||||
self.marks[m] += pause_duration
|
||||
|
||||
self.paused_at = 0
|
||||
self.isRunning.set()
|
||||
|
||||
def reset(self):
|
||||
self.marks = {}
|
||||
self.setMark('start')
|
||||
self.paused_at = 0
|
||||
self.isRunning.set()
|
||||
|
||||
def setMark(self, name):
|
||||
self.marks[name] = time.time()
|
||||
|
||||
|
||||
def clearMark(self, name):
|
||||
if name in self.marks:
|
||||
self.marks.pop(name)
|
||||
|
||||
class Story(object):
|
||||
"""Story represents and manages a story/narrative flow"""
|
||||
#TODO should we separate 'narrative' (the graph) from the story (the current user flow)
|
||||
# TODO should we separate 'narrative' (the graph) from the story (the
|
||||
# current user flow)
|
||||
|
||||
def __init__(self, hugvey_state):
|
||||
super(Story, self).__init__()
|
||||
self.hugvey = hugvey_state
|
||||
|
||||
self.events = [] # queue of received events
|
||||
self.commands = [] # queue of commands to send
|
||||
self.log = [] # all nodes/elements that are triggered
|
||||
self.currentMessage = None
|
||||
|
||||
self.events = [] # queue of received events
|
||||
self.commands = [] # queue of commands to send
|
||||
self.log = [] # all nodes/elements that are triggered
|
||||
self.currentMessage = None
|
||||
self.timer = Stopwatch()
|
||||
|
||||
def pause(self):
|
||||
logger.debug('pause hugvey')
|
||||
self.timer.pause()
|
||||
|
||||
def resume(self):
|
||||
logger.debug('resume hugvey')
|
||||
self.timer.resume()
|
||||
|
||||
def getStoryCounts(self):
|
||||
# counts = {}
|
||||
# for item in self.log:
|
||||
# n =item.__class__.__name__
|
||||
# if n not in counts:
|
||||
# counts[n] = 0
|
||||
# counts[n] += 1
|
||||
# return counts
|
||||
return {
|
||||
'messages': len([e for e in self.log if isinstance(e, Message)]),
|
||||
'interruptions': len([e for e in self.log if isinstance(e, Interruption)])
|
||||
}
|
||||
|
||||
def setStoryData(self, story_data):
|
||||
"""
|
||||
Parse self.data into a working story engine
|
||||
"""
|
||||
self.data = story_data
|
||||
|
||||
|
||||
# keep to be able to reset it in the end
|
||||
currentId = self.currentMessage.id if self.currentMessage else None
|
||||
|
||||
|
||||
self.elements = {}
|
||||
self.interruptions = []
|
||||
self.directionsPerMsg = {}
|
||||
self.startMessage = None # The entrypoint to the graph
|
||||
self.startMessage = None # The entrypoint to the graph
|
||||
self.reset()
|
||||
|
||||
|
||||
for el in self.data:
|
||||
className = storyClasses[el['@type']]
|
||||
obj = className.initFromJson(el, self)
|
||||
|
@ -181,22 +256,31 @@ class Story(object):
|
|||
|
||||
logger.debug(self.elements)
|
||||
logger.debug(self.directionsPerMsg)
|
||||
|
||||
|
||||
if currentId:
|
||||
self.currentMessage = self.get(currentId)
|
||||
if self.currentMessage:
|
||||
logger.info(f"Reinstantiated current message: {self.currentMessage.id}")
|
||||
logger.info(
|
||||
f"Reinstantiated current message: {self.currentMessage.id}")
|
||||
else:
|
||||
logger.warn("Could not reinstatiate current message. Starting over")
|
||||
logger.warn(
|
||||
"Could not reinstatiate current message. Starting over")
|
||||
|
||||
def reset(self):
|
||||
self.startTime = time.time()
|
||||
self.currentMessage = None # currently active message, determines active listeners etc.
|
||||
self.timer.reset()
|
||||
# self.startTime = time.time()
|
||||
# currently active message, determines active listeners etc.
|
||||
self.currentMessage = None
|
||||
self.lastMsgTime = None
|
||||
self.lastSpeechStartTime = None
|
||||
self.lastSpeechEndTime = None
|
||||
self.variables = {} # captured variables from replies
|
||||
self.variables = {} # captured variables from replies
|
||||
self.finish_time = False
|
||||
|
||||
self.events = [] # queue of received events
|
||||
self.commands = [] # queue of commands to send
|
||||
self.log = [] # all nodes/elements that are triggered
|
||||
|
||||
def add(self, obj):
|
||||
if obj.id in self.elements:
|
||||
# print(obj)
|
||||
|
@ -223,25 +307,24 @@ class Story(object):
|
|||
return self.elements[id]
|
||||
return None
|
||||
|
||||
|
||||
def stop(self):
|
||||
logger.info("Stop Story")
|
||||
if self.isRunning:
|
||||
self.isRunning = False
|
||||
|
||||
|
||||
def _processPendingEvents(self):
|
||||
# Gather events:
|
||||
nr = len(self.events)
|
||||
for i in range(nr):
|
||||
e = self.events.pop(0)
|
||||
logger.info("handle '{}'".format( e ))
|
||||
logger.info("handle '{}'".format(e))
|
||||
if e['event'] == "exit":
|
||||
self.stop()
|
||||
if e['event'] == 'connect':
|
||||
# a client connected. Shold only happen in the beginning or in case of error
|
||||
# that is, until we have a 'reset' or 'start' event.
|
||||
self.setCurrentMessage(self.currentMessage) # reinitiate current message
|
||||
# reinitiate current message
|
||||
self.setCurrentMessage(self.currentMessage)
|
||||
|
||||
if e['event'] == "playbackFinish":
|
||||
if e['msgId'] == self.currentMessage.id:
|
||||
|
@ -254,6 +337,7 @@ class Story(object):
|
|||
|
||||
if e['event'] == 'speech':
|
||||
# log if somebody starts speaking
|
||||
# TODO: use pausing timer
|
||||
if self.lastSpeechStartTime is None or self.lastSpeechStartTime < self.lastMsgTime:
|
||||
self.lastSpeechStartTime = e['time']
|
||||
|
||||
|
@ -266,7 +350,8 @@ class Story(object):
|
|||
for direction in directions:
|
||||
for condition in direction.conditions:
|
||||
if condition.isMet(self):
|
||||
logger.info("Condition is met: {0}, going to {1}".format(condition.id, direction.msgTo.id))
|
||||
logger.info("Condition is met: {0}, going to {1}".format(
|
||||
condition.id, direction.msgTo.id))
|
||||
self.log.append(condition)
|
||||
self.log.append(direction)
|
||||
self.setCurrentMessage(direction.msgTo)
|
||||
|
@ -276,20 +361,21 @@ class Story(object):
|
|||
"""
|
||||
every 1/10 sec. determine what needs to be done based on the current story state
|
||||
"""
|
||||
loopDuration = 0.1 # Configure fps
|
||||
loopDuration = 0.1 # Configure fps
|
||||
lastTime = time.time()
|
||||
logger.info("Start renderer")
|
||||
while self.isRunning:
|
||||
if self.isRunning is False:
|
||||
break
|
||||
|
||||
# pause on timer paused
|
||||
await self.timer.isRunning.wait() # wait for un-pause
|
||||
|
||||
for i in range(len(self.events)):
|
||||
self._processPendingEvents()
|
||||
|
||||
if self.currentMessage.id not in self.directionsPerMsg:
|
||||
# TODO: finish!
|
||||
|
||||
pass
|
||||
self.finish()
|
||||
|
||||
directions = self.getCurrentDirections()
|
||||
self._processDirections(directions)
|
||||
|
@ -307,9 +393,11 @@ class Story(object):
|
|||
def setCurrentMessage(self, message):
|
||||
self.currentMessage = message
|
||||
self.lastMsgTime = time.time()
|
||||
self.lastMsgFinishTime = None # to be filled in by the event
|
||||
self.lastMsgFinishTime = None # to be filled in by the event
|
||||
|
||||
logger.info("Current message: ({0}) \"{1}\"".format(message.id, message.text))
|
||||
logger.info("Current message: ({0}) \"{1}\"".format(
|
||||
message.id, message.text))
|
||||
self.log.append(message)
|
||||
# TODO: prep events & timer etc.
|
||||
self.hugvey.sendCommand({
|
||||
'action': 'play',
|
||||
|
@ -321,8 +409,8 @@ class Story(object):
|
|||
|
||||
for direction in self.getCurrentDirections():
|
||||
conditions = [c.id for c in direction.conditions]
|
||||
logger.debug("- {0} -> {1} (when: {2}) ".format(direction.msgFrom.id, direction.msgTo.id, conditions))
|
||||
|
||||
logger.debug(
|
||||
"- {0} -> {1} (when: {2}) ".format(direction.msgFrom.id, direction.msgTo.id, conditions))
|
||||
|
||||
def getCurrentDirections(self):
|
||||
if self.currentMessage.id not in self.directionsPerMsg:
|
||||
|
@ -332,10 +420,21 @@ class Story(object):
|
|||
|
||||
async def start(self):
|
||||
logger.info("Starting story")
|
||||
self.startTime = time.time()
|
||||
self.timer.reset()
|
||||
# self.startTime = time.time()
|
||||
self.isRunning = True
|
||||
self.setCurrentMessage(self.startMessage)
|
||||
await self._renderer()
|
||||
|
||||
|
||||
|
||||
def isFinished(self):
|
||||
if hasattr(self, 'finish_time'):
|
||||
return self.finish_time
|
||||
|
||||
return False
|
||||
|
||||
def finish(self):
|
||||
logger.info(f"Finished story for {self.hugvey.id}")
|
||||
self.hugvey.pause()
|
||||
self.finish_time = time.time()
|
||||
self.timer.pause()
|
||||
|
||||
|
|
|
@ -32,17 +32,26 @@ class GoogleVoiceClient(object):
|
|||
|
||||
# Create a thread-safe buffer of audio data
|
||||
self.buffer = queue.Queue()
|
||||
self.isRunning = False
|
||||
self.isRunning = threading.Event()
|
||||
self.toBeShutdown = False
|
||||
self.target_rate = 16000
|
||||
self.cv_laststate = None
|
||||
self.restart = False
|
||||
|
||||
|
||||
self.task = threading.Thread(target=self.run, name=f"hugvey#{self.hugvey.id}v")
|
||||
self.task.setDaemon(True)
|
||||
self.task.start()
|
||||
|
||||
def pause(self):
|
||||
self.isRunning.clear()
|
||||
self.restart = True
|
||||
|
||||
def resume(self):
|
||||
self.isRunning.set()
|
||||
|
||||
def generator(self):
|
||||
while self.isRunning:
|
||||
while not self.toBeShutdown:
|
||||
yield self.buffer.get()
|
||||
|
||||
def setLanguage(self, language_code):
|
||||
|
@ -54,11 +63,13 @@ class GoogleVoiceClient(object):
|
|||
self.restart = True
|
||||
|
||||
def run(self):
|
||||
self.isRunning = True
|
||||
self.isRunning.set()
|
||||
|
||||
|
||||
while self.isRunning:
|
||||
while not self.toBeShutdown:
|
||||
try:
|
||||
self.isRunning.wait()
|
||||
|
||||
self.speech_client = speech.SpeechClient()
|
||||
config = types.RecognitionConfig(
|
||||
encoding=enums.RecognitionConfig.AudioEncoding.LINEAR16,
|
||||
|
@ -122,7 +133,7 @@ class GoogleVoiceClient(object):
|
|||
self.restart = False
|
||||
raise RequireRestart("Restart required")
|
||||
|
||||
if not self.isRunning:
|
||||
if self.toBeShutdown:
|
||||
logger.warn("Stopping voice loop")
|
||||
break
|
||||
except RequireRestart as e:
|
||||
|
@ -142,7 +153,7 @@ class GoogleVoiceClient(object):
|
|||
self.buffer.put_nowait(data)
|
||||
|
||||
def shutdown(self):
|
||||
self.isRunning = False
|
||||
self.toBeShutdown = True
|
||||
|
||||
|
||||
|
||||
|
|
3
local/.gitignore
vendored
Normal file
3
local/.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -7,7 +7,7 @@ voice:
|
|||
port: 4444
|
||||
chunk: 2972
|
||||
google_credentials: "/home/ruben/Documents/Projecten/2018/Hugvey/test_googlespeech/My First Project-0c7833e0d5fa.json"
|
||||
hugveys: 3
|
||||
hugveys: 25
|
||||
languages:
|
||||
- code: en-GB
|
||||
file: story_en.json
|
||||
|
|
100
www/hugvey_console.js
Normal file
100
www/hugvey_console.js
Normal file
|
@ -0,0 +1,100 @@
|
|||
var panopticon;
|
||||
|
||||
class Panopticon {
|
||||
constructor() {
|
||||
console.log( "Init panopticon" );
|
||||
this.hugveys = new Vue( {
|
||||
el: "#status",
|
||||
data: {
|
||||
uptime: 0,
|
||||
languages: [],
|
||||
hugveys: []
|
||||
},
|
||||
methods: {
|
||||
time_passed: function (hugvey, property) {
|
||||
console.log("property!", Date(hugvey[property] * 1000));
|
||||
return moment(Date(hugvey[property] * 1000)).fromNow();
|
||||
}
|
||||
}
|
||||
} );
|
||||
|
||||
|
||||
this.socket = new ReconnectingWebSocket( "ws://localhost:8888/ws", null, { debug: true, reconnectInterval: 3000 } );
|
||||
|
||||
|
||||
this.socket.addEventListener( 'open', ( e ) => {
|
||||
this.send( { action: 'init' } );
|
||||
} );
|
||||
|
||||
this.socket.addEventListener( 'close', function( e ) {
|
||||
console.log( 'Closed connection' );
|
||||
} );
|
||||
this.socket.addEventListener( 'message', ( e ) => {
|
||||
let msg = JSON.parse( e.data );
|
||||
if ( typeof msg['alert'] !== 'undefined' ) {
|
||||
alert(msg['alert']);
|
||||
}
|
||||
|
||||
if ( typeof msg['action'] === 'undefined' ) {
|
||||
console.error( "not a valid message: " + e.data );
|
||||
return;
|
||||
}
|
||||
|
||||
switch ( msg['action'] ) {
|
||||
|
||||
case 'status':
|
||||
this.hugveys.uptime = this.stringToHHMMSS(msg['uptime']);
|
||||
this.hugveys.languages = msg['languages'];
|
||||
this.hugveys.hugveys = msg['hugveys'];
|
||||
break;
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
send( msg ) {
|
||||
this.socket.send( JSON.stringify( msg ) );
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
// console.log('get status', this, panopticon);
|
||||
panopticon.send( { action: 'get_status' } );
|
||||
}
|
||||
|
||||
init() {
|
||||
setInterval( this.getStatus, 3000 );
|
||||
}
|
||||
|
||||
stringToHHMMSS (string) {
|
||||
var sec_num = parseInt(string, 10); // don't forget the second param
|
||||
var hours = Math.floor(sec_num / 3600);
|
||||
var minutes = Math.floor((sec_num - (hours * 3600)) / 60);
|
||||
var seconds = sec_num - (hours * 3600) - (minutes * 60);
|
||||
|
||||
if (hours < 10) {hours = "0"+hours;}
|
||||
if (minutes < 10) {minutes = "0"+minutes;}
|
||||
if (seconds < 10) {seconds = "0"+seconds;}
|
||||
return hours+':'+minutes+':'+seconds;
|
||||
}
|
||||
|
||||
|
||||
loadNarrative(code, file) {
|
||||
|
||||
}
|
||||
|
||||
resume(hv_id) {
|
||||
this.send({ action: 'resume', hugvey: hv_id })
|
||||
}
|
||||
pause(hv_id) {
|
||||
this.send({ action: 'play', hugvey: hv_id })
|
||||
}
|
||||
restart(hv_id) {
|
||||
this.send({ action: 'restart', hugvey: hv_id })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
window.addEventListener( 'load', function() {
|
||||
panopticon = new Panopticon();
|
||||
panopticon.init();
|
||||
})
|
|
@ -1,17 +1,43 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Pillow Talk Control Interface</title>
|
||||
<title>Pillow Talk Control Interface</title>
|
||||
<!-- development version, includes helpful console warnings -->
|
||||
<script src="/vue.js"></script>
|
||||
<script src="/reconnecting-websocket.js"></script>
|
||||
<script src="/moment.min.js"></script>
|
||||
<link rel="stylesheet" href="styles.css"></link>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id='status'>
|
||||
</div>
|
||||
<div id='story'>
|
||||
</div>
|
||||
<div id='hugvey'>
|
||||
</div>
|
||||
<div id='app'>{{message}}</div>
|
||||
<script src="/hugvey_console.js"></script>
|
||||
<div id='status'>
|
||||
<div id='overview'>
|
||||
<dl>
|
||||
<dt>Uptime</dt>
|
||||
<dd>{{uptime}}</dd>
|
||||
<dt>Languages</dt>
|
||||
<dd v-for="lang in languages" :title="lang.file"
|
||||
@click="panopticon.loadNarrative(lang.code, lang.file)">{{lang.code}}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class='hugvey' v-for="hv in hugveys"
|
||||
:class="[{'hugvey--off': hv.status == 'off'},{'hugvey--on': hv.status != 'off'},{'hugvey--paused': hv.status == 'paused'},{'hugvey--running': hv.status == 'running'}]">
|
||||
<h1>
|
||||
{{ hv.id }}
|
||||
<!-- / {{ hv.status }} -->
|
||||
</h1>
|
||||
<div v-if="hv.status != 'off'">
|
||||
{{ hv.language }} / {{ hv.msg }}
|
||||
<div v-if="hv.finished != false">
|
||||
Finished: {{time_passed(hv, 'finished')}}
|
||||
</div>
|
||||
<div v-for="c, key in hv.counts"><dt>{{key}}</dt><dd>{{c}}</dd></div>
|
||||
<div v-if="hv.status != 'running'" @click="panopticon.pause(hv.id)">Pause</div>
|
||||
<div v-if="hv.status != 'paused'" @click="panopticon.resume(hv.id)">Resume</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id='story'></div>
|
||||
<div id='hugvey'></div>
|
||||
<script type='application/javascript' src="/hugvey_console.js"></script>
|
||||
</body>
|
||||
</html>
|
1
www/moment.min.js
vendored
Normal file
1
www/moment.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
832
www/narrative_builder.html
Normal file
832
www/narrative_builder.html
Normal file
|
@ -0,0 +1,832 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Pillow Talk - Narrative Builder</title>
|
||||
<script src="https://d3js.org/d3.v5.min.js"></script>
|
||||
<script type="text/javascript">
|
||||
// https://raw.githubusercontent.com/KoryNunn/crel/master/crel.min.js
|
||||
!function(n,e){"object"==typeof exports?module.exports=e():"function"==typeof define&&define.amd?define(e):n.crel=e()}(this,function(){function n(a){var d,s=arguments,p=s[1],y=2,m=s.length,x=n[o];if(a=n[u](a)?a:c.createElement(a),m>1){if((!e(p,r)||n[f](p)||Array.isArray(p))&&(--y,p=null),m-y==1&&e(s[y],"string"))a.textContent=s[y];else for(;y<m;++y)null!==(d=s[y])&&l(a,d);for(var v in p)if(x[v]){var A=x[v];e(A,t)?A(a,p[v]):a[i](A,p[v])}else e(p[v],t)?a[v]=p[v]:a[i](v,p[v])}return a}var e=function(n,e){return typeof n===e},t="function",r="object",i="setAttribute",o="attrMap",f="isNode",u="isElement",c=document,a=function(n){return n instanceof Node},d=function(n){return n instanceof Element},l=function(e,t){if(Array.isArray(t))return void t.map(function(n){l(e,n)});n[f](t)||(t=c.createTextNode(t)),e.appendChild(t)};return n[o]={},n[u]=d,n[f]=a,e(Proxy,"undefined")||(n.proxy=new Proxy(n,{get:function(e,t){return!(t in n)&&(n[t]=n.bind(null,t)),n[t]}})),n});
|
||||
|
||||
crel.attrMap['on'] = function(element, value) {
|
||||
for (var eventName in value) {
|
||||
element.addEventListener(eventName, value[eventName]);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style media="screen">
|
||||
body{
|
||||
margin:0;
|
||||
overflow: hidden;
|
||||
font-family: "Noto Sans", sans-serif;
|
||||
}
|
||||
svg{
|
||||
width:100vw;
|
||||
height: 100vh;
|
||||
cursor: grab;
|
||||
}
|
||||
svg:active{
|
||||
cursor: grabbing;
|
||||
}
|
||||
circle{
|
||||
cursor: pointer;
|
||||
fill: rgb(119, 97, 142);
|
||||
}
|
||||
.startMsg circle{
|
||||
fill: lightseagreen;
|
||||
}
|
||||
.endMsg circle{
|
||||
fill: lightslategray;
|
||||
}
|
||||
|
||||
.orphanedMsg{
|
||||
fill: lightcoral;
|
||||
}
|
||||
text{
|
||||
text-anchor: middle;
|
||||
font-size: 11pt;
|
||||
font-family: sans-serif;
|
||||
fill: white;
|
||||
}
|
||||
line{
|
||||
marker-end: url('#arrowHead');
|
||||
stroke-width: 2px;
|
||||
stroke: black;
|
||||
}
|
||||
line.link--noconditions{
|
||||
stroke-dasharray: 5 4;
|
||||
stroke: red;
|
||||
}
|
||||
label::after {
|
||||
content: '';
|
||||
clear: both;
|
||||
display: block;
|
||||
}
|
||||
label{
|
||||
width:100%;
|
||||
font-weight:bold;
|
||||
display: block;
|
||||
margin: 0 -10px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
label input,label select{
|
||||
float: right;
|
||||
}
|
||||
label:nth-child(odd){
|
||||
background-color: rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
#msg{
|
||||
position: absolute;
|
||||
top:0;
|
||||
right:0;
|
||||
width: 30%;
|
||||
max-height:100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
#msg .msg__info, #msg .directions > div{
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
background:lightgray;
|
||||
}
|
||||
#opener{
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#nodes g:hover circle,
|
||||
.selectedMsg circle {
|
||||
stroke: lightgreen;
|
||||
stroke-width: 8;
|
||||
}
|
||||
|
||||
.controlDown #nodes g:hover circle,
|
||||
.secondaryMsg circle {
|
||||
stroke: lightgreen;
|
||||
stroke-width: 5;
|
||||
stroke-dasharray: 10 3;
|
||||
}
|
||||
|
||||
.condition h4{
|
||||
text-align: center;
|
||||
}
|
||||
.condition + .condition::before {
|
||||
content: "OR";
|
||||
display: block;
|
||||
border-bottom: solid 2px;
|
||||
height: 10px;
|
||||
margin-bottom: 15px;
|
||||
text-align: center;
|
||||
text-shadow: 2px 2px 2px lightgray,-2px 2px 2px lightgray,2px -2px 2px lightgray,-2px -2px 2px lightgray;
|
||||
|
||||
}
|
||||
.condition--add{
|
||||
/* text-align: center; */
|
||||
}
|
||||
.btn{
|
||||
padding: 5px;
|
||||
background:lightgray;
|
||||
border-radius: 5px;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
|
||||
}
|
||||
.btn:hover{
|
||||
background: lightblue;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="opener">
|
||||
<h1>Hugvey</h1>
|
||||
<h3>Select a narrative json file</h3>
|
||||
<input id='fileOpener' type="file" />
|
||||
<div>
|
||||
<input id='fileLoad' type="submit" value="Load file" />
|
||||
<input id='stateLoad' type="button" value="Load last state" />
|
||||
</div>
|
||||
</div>
|
||||
<div id="msg"></div>
|
||||
<div id="controls">
|
||||
<div id='btn-save' class='btn'>Save JSON</div>
|
||||
<div id='btn-addMsg' class='btn'>New Message</div>
|
||||
</div>
|
||||
<svg id='graph' viewbox="0 0 1280 1024" preserveAspectRatio="xMidYMid">
|
||||
<defs>
|
||||
<marker markerHeight="8" markerWidth="8" refY="0" refX="12" viewBox="0 -6 16 12" preserveAspectRatio="none" orient="auto" id="arrowHead"><path d="M0,-6L16,0L0,6" fill="black"></path></marker>
|
||||
</defs>
|
||||
<g id='container'>
|
||||
</g>
|
||||
</svg>
|
||||
</body>
|
||||
<script type="text/javascript">
|
||||
class Graph{
|
||||
constructor() {
|
||||
this.width = 1280;
|
||||
this.height = 1024;
|
||||
this.nodeSize = 80;
|
||||
this.maxChars = 16;
|
||||
this.svg = d3.select('#graph');
|
||||
this.container = d3.select('#container');
|
||||
this.selectedMsg = null;
|
||||
this.messages = []; // initialise empty array. For the simulation, make sure we keep the same array object
|
||||
this.directions = []; // initialise empty array. For the simulation, make sure we keep the same array object
|
||||
this.conditions = []; // initialise empty array. For the simulation, make sure we keep the same array object
|
||||
this.interruptions = []; // initialise empty array. For the simulation, make sure we keep the same array object
|
||||
|
||||
let graph = this;
|
||||
this.controlDown = false;
|
||||
document.addEventListener('keydown', function(e){
|
||||
console.log(e);
|
||||
if(e.which == "17") {
|
||||
graph.controlDown = true;
|
||||
document.body.classList.add('controlDown');
|
||||
}
|
||||
});
|
||||
document.addEventListener('keyup', function(e){
|
||||
console.log(e);
|
||||
if(e.which == "17") {
|
||||
graph.controlDown = false;
|
||||
document.body.classList.remove('controlDown');
|
||||
}
|
||||
});
|
||||
|
||||
let c = this.container;
|
||||
let zoomed = function(){
|
||||
c.attr("transform", d3.event.transform);
|
||||
}
|
||||
this.svg.call(d3.zoom()
|
||||
.scaleExtent([1 / 2, 8])
|
||||
.on("zoom", zoomed));
|
||||
|
||||
this.nodesG = this.container.append("g")
|
||||
.attr("id", "nodes")
|
||||
|
||||
this.linkG = this.container.append("g")
|
||||
.attr("id", "links");
|
||||
|
||||
document.getElementById('btn-save').addEventListener('click', function(e){ graph.saveJson(); });
|
||||
document.getElementById('btn-addMsg').addEventListener('click', function(e){ graph.createMsg(); });
|
||||
}
|
||||
|
||||
clickMsg(msg) {
|
||||
// event when a message is clicked.
|
||||
console.log(msg);
|
||||
|
||||
if(this.controlDown) {
|
||||
this.secondarySelectMsg(msg);
|
||||
} else {
|
||||
this.selectMsg(msg);
|
||||
}
|
||||
}
|
||||
|
||||
secondarySelectMsg(msg) {
|
||||
if(this.selectedMsg !== null) {
|
||||
this.addDirection(this.selectedMsg, msg);
|
||||
} else {
|
||||
console.error('No message selected as Source');
|
||||
}
|
||||
}
|
||||
|
||||
selectMsg(msg) {
|
||||
let selectedEls = document.getElementsByClassName('selectedMsg');
|
||||
while(selectedEls.length > 0){
|
||||
selectedEls[0].classList.remove('selectedMsg');
|
||||
}
|
||||
document.getElementById(msg['@id']).classList.add('selectedMsg');
|
||||
|
||||
this.selectedMsg = msg;
|
||||
|
||||
this.showMsg(msg);
|
||||
}
|
||||
|
||||
updateMsg() {
|
||||
// used eg. after a condition creation.
|
||||
this.showMsg(this.selectedMsg);
|
||||
}
|
||||
|
||||
showMsg(msg) {
|
||||
let msgEl = document.getElementById('msg');
|
||||
msgEl.innerHTML = "";
|
||||
let startAttributes = {
|
||||
'name': msg['@id'] + '-start',
|
||||
'disabled': true,
|
||||
'type': 'checkbox',
|
||||
'on': {
|
||||
'change': this.getEditEventListener()
|
||||
}
|
||||
}
|
||||
if(msg['start'] == true) {
|
||||
startAttributes['checked'] = 'checked';
|
||||
}
|
||||
let msgInfoEl = crel('div', {'class': 'msg__info'},
|
||||
crel('h1', {'class':'msg__id'}, msg['@id']),
|
||||
crel('label',
|
||||
crel('span', 'Text'),
|
||||
crel('input', {
|
||||
'name': msg['@id'] + '-text',
|
||||
'value': msg['text'],
|
||||
'on': {
|
||||
'change': this.getEditEventListener()
|
||||
}
|
||||
} )
|
||||
),
|
||||
crel('label',
|
||||
crel('span', 'Start'),
|
||||
crel('input', startAttributes)
|
||||
)
|
||||
);
|
||||
msgEl.appendChild(msgInfoEl);
|
||||
|
||||
// let directionHEl = document.createElement('h2');
|
||||
// directionHEl.innerHTML = "Directions";
|
||||
|
||||
let fromDirections =[] , toDirections = [];
|
||||
|
||||
for(let direction of this.getDirectionsTo(msg)) {
|
||||
toDirections.push(this.getDirectionEl(direction, msg));
|
||||
}
|
||||
|
||||
for(let direction of this.getDirectionsFrom(msg)) {
|
||||
fromDirections.push(this.getDirectionEl(direction, msg));
|
||||
}
|
||||
let directionsEl = crel('div', {'class': 'directions'},
|
||||
crel('h2', 'Directions'),
|
||||
...toDirections, ...fromDirections
|
||||
);
|
||||
msgEl.appendChild(directionsEl);
|
||||
|
||||
}
|
||||
|
||||
getDirectionEl(direction, msg) {
|
||||
let directionEl = document.createElement('div');
|
||||
if(direction['source'] == msg) {
|
||||
directionEl.innerHTML = `<h3>To ${direction['target']['@id']}</h3>`;
|
||||
} else {
|
||||
directionEl.innerHTML = `<h3>From ${direction['source']['@id']}</h3>`;
|
||||
}
|
||||
let del = document.createElement('div');
|
||||
del.innerHTML = "delete";
|
||||
del.classList.add("deleteBtn");
|
||||
let g = this;
|
||||
del.addEventListener('click', (e) => g.rmDirection(direction));
|
||||
directionEl.appendChild(del);
|
||||
|
||||
// TODO; conditions
|
||||
|
||||
for(let conditionId of direction['conditions']) {
|
||||
let condition = this.getNodeById(conditionId);
|
||||
directionEl.appendChild(this.getEditConditionFormEl(condition, direction));
|
||||
}
|
||||
|
||||
directionEl.appendChild(this.getAddConditionFormEl(direction));
|
||||
|
||||
return directionEl;
|
||||
}
|
||||
|
||||
getEditConditionFormEl(condition, direction) {
|
||||
let conditionEl = crel('div', {'class': 'condition condition--edit'},
|
||||
crel('h4', {'title': condition['@id']}, condition['type'])
|
||||
)
|
||||
let labelLabel = document.createElement('label');
|
||||
labelLabel.innerHTML = "Description";
|
||||
let labelInput = crel('input',{
|
||||
'name': `${condition['@id']}-label`,
|
||||
'value': typeof condition['label'] == 'undefined' ? "" : condition['label'],
|
||||
'on': {
|
||||
'change': this.getEditEventListener()
|
||||
}
|
||||
});
|
||||
labelLabel.appendChild(labelInput);
|
||||
conditionEl.appendChild(labelLabel);
|
||||
|
||||
for(let v in condition['vars']) {
|
||||
let varLabel = document.createElement('label');
|
||||
varLabel.innerHTML = v;
|
||||
let varInput = document.createElement('input');
|
||||
if(v == 'seconds') {
|
||||
varInput.type = 'number';
|
||||
}
|
||||
varInput.name = `${condition['@id']}-vars.${v}`;
|
||||
varInput.value = condition['vars'][v];
|
||||
varInput.addEventListener('change', this.getEditEventListener());
|
||||
varLabel.appendChild(varInput);
|
||||
conditionEl.appendChild(varLabel);
|
||||
}
|
||||
return conditionEl;
|
||||
}
|
||||
|
||||
getConditionTypes() {
|
||||
if(typeof this.conditionTypes === 'undefined') {
|
||||
// type: vars: attribtes for crel()
|
||||
this.conditionTypes = {
|
||||
'timeout': {
|
||||
'seconds': {'type': 'number', 'value': 10, 'min':0, 'step': 0.1}
|
||||
},
|
||||
'replyContains': {
|
||||
'regex': {'value': '.+'}
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.conditionTypes;
|
||||
}
|
||||
|
||||
fillConditionFormForType(conditionForm, type) {
|
||||
conditionForm.innerHTML = "";
|
||||
let vars = this.getConditionTypes()[type];
|
||||
for(let v in vars){
|
||||
let attr = vars[v];
|
||||
attr['name'] = v;
|
||||
conditionForm.appendChild(
|
||||
crel('label',
|
||||
crel('span', v),
|
||||
crel('input', attr)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getAddConditionFormEl(direction) {
|
||||
|
||||
let optionEls = [];
|
||||
let types = this.getConditionTypes();
|
||||
for(let type in types) {
|
||||
optionEls.push(crel('option', type));
|
||||
}
|
||||
|
||||
let conditionForm = crel('div', {'class': 'condition--vars'});
|
||||
let g = this;
|
||||
let addConditionEl = crel('div', {'class': 'condition condition--add'},
|
||||
crel('form', {
|
||||
'on': {
|
||||
'submit': function(e) {
|
||||
e.preventDefault();
|
||||
let form = new FormData(e.target);
|
||||
console.log('submit', form);
|
||||
let type = form.get('type');
|
||||
form.delete('type');
|
||||
let label = form.get('label');
|
||||
form.delete('label');
|
||||
let vars = {};
|
||||
for(var pair of form.entries()) {
|
||||
vars[pair[0]] = pair[1];
|
||||
}
|
||||
g.addConditionForDirection(type, label, vars, direction);
|
||||
}
|
||||
}
|
||||
},
|
||||
crel("h4", "Create New Condition"),
|
||||
crel("label",
|
||||
crel('span', "Type"),
|
||||
crel('select', {
|
||||
'name': 'type',
|
||||
'on': {
|
||||
'change': function(e){
|
||||
g.fillConditionFormForType(conditionForm, e.target.value);
|
||||
}
|
||||
}}, optionEls),
|
||||
),
|
||||
crel("label",
|
||||
crel('span', "Description"),
|
||||
crel('input', {'name': 'label'})
|
||||
),
|
||||
conditionForm,
|
||||
crel('input', {
|
||||
'type':'submit',
|
||||
'value':'create'
|
||||
})
|
||||
)
|
||||
);
|
||||
this.fillConditionFormForType(conditionForm, optionEls[0].value);
|
||||
|
||||
return addConditionEl;
|
||||
}
|
||||
|
||||
rmConditionFromDirection(condition, direction) {
|
||||
let id = condition['@id'];
|
||||
// TODO
|
||||
if(typeof direction != 'undefined') {
|
||||
|
||||
}
|
||||
this._rmNode(id);
|
||||
}
|
||||
|
||||
getConditionEl(condition) {
|
||||
let conditionEl = document.createElement('div');
|
||||
|
||||
return conditionEl;
|
||||
}
|
||||
|
||||
getDirectionsFrom(msg) {
|
||||
return this.directions.filter(d => d['source'] == msg);
|
||||
}
|
||||
|
||||
getDirectionsTo(msg) {
|
||||
return this.directions.filter(d => d['target'] == msg);
|
||||
}
|
||||
|
||||
|
||||
addMsg() {
|
||||
let msg = {
|
||||
"@id": "n" + Date.now().toString(36),
|
||||
"@type": "Msg",
|
||||
"text": "New",
|
||||
"start": false
|
||||
}
|
||||
this.data.push(msg);
|
||||
this.updateFromData();
|
||||
this.build();
|
||||
return msg;
|
||||
}
|
||||
|
||||
rmMsg(msg) {
|
||||
let invalidatedDirections = this.directions.filter(d => d['source'] == msg || d['target'] == msg);
|
||||
console.log('invalidated', invalidatedDirections);
|
||||
for(let dir of invalidatedDirections) {
|
||||
let i = this.data.indexOf(dir);
|
||||
this.data.splice(i, 1);
|
||||
}
|
||||
this._rmNode(msg);
|
||||
}
|
||||
|
||||
_rmNode(node) {
|
||||
// remove msg/direction/condition/etc
|
||||
let i = this.data.indexOf(node);
|
||||
this.data.splice(i, 1);
|
||||
this.updateFromData();
|
||||
this.build();
|
||||
return this.data;
|
||||
}
|
||||
|
||||
addConditionForDirection(type, label, vars, direction) {
|
||||
let con = this.addCondition(type, label, vars, true);
|
||||
direction['conditions'].push(con['@id']);
|
||||
this.updateFromData();
|
||||
this.build();
|
||||
|
||||
this.updateMsg();
|
||||
}
|
||||
|
||||
addCondition(type, label, vars, skip) {
|
||||
let con = {
|
||||
"@id": "c" + Date.now().toString(36),
|
||||
"@type": "Condition",
|
||||
"type": type,
|
||||
"label": label,
|
||||
"vars": vars
|
||||
}
|
||||
this.data.push(con);
|
||||
if(skip !== true) {
|
||||
this.updateFromData();
|
||||
this.build();
|
||||
}
|
||||
return con;
|
||||
}
|
||||
|
||||
addDirection(source, target) {
|
||||
let dir = {
|
||||
"@id": "d" + Date.now().toString(36),
|
||||
"@type": "Direction",
|
||||
"source": source,
|
||||
"target": target,
|
||||
"conditions": []
|
||||
}
|
||||
this.data.push(dir);
|
||||
this.updateFromData();
|
||||
this.build();
|
||||
return dir;
|
||||
}
|
||||
|
||||
rmDirection(dir) {
|
||||
this._rmNode(dir);
|
||||
}
|
||||
|
||||
createMsg() {
|
||||
this.addMsg();
|
||||
this.build();
|
||||
}
|
||||
|
||||
getNodeById(id) {
|
||||
return this.data.filter(node => node['@id'] == id)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Use wrapper method, because for event handlers 'this' will refer to
|
||||
* the input object
|
||||
*/
|
||||
getEditEventListener(){
|
||||
let graph = this;
|
||||
let el = function(e){
|
||||
let parts = e.srcElement.name.split('-');
|
||||
let id = parts[0], field = parts[1];
|
||||
console.log(this, graph);
|
||||
let node = graph.getNodeById(id);
|
||||
let path = field.split('.'); // use vars.test to set ['vars']['test'] = value
|
||||
var res=node;
|
||||
for (var i=0;i<path.length;i++){
|
||||
if(i == (path.length -1)) {
|
||||
console.log('last', path[i]);
|
||||
res[path[i]] = e.srcElement.value;
|
||||
} else {
|
||||
res=res[path[i]];
|
||||
}
|
||||
}
|
||||
|
||||
// node[field] = e.srcElement.value;
|
||||
|
||||
graph.build();
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
getJsonString() {
|
||||
// recreate array to have the right order of items.
|
||||
this.data = [...this.messages, ...this.conditions,
|
||||
...this.directions, ...this.interruptions]
|
||||
let d = [];
|
||||
let toRemove = ['sourceX', 'sourceY', 'targetX', 'targetY', 'x','y', 'vx','vy']
|
||||
for(let node of this.data) {
|
||||
let n = {};
|
||||
console.log(node['source']);
|
||||
for (let e in node) {
|
||||
if (node.hasOwnProperty(e) && toRemove.indexOf(e) == -1 ) {
|
||||
if(this.data.indexOf(node[e]) != -1) {
|
||||
n[e] = node[e]['@id'];
|
||||
} else {
|
||||
n[e] = node[e];
|
||||
}
|
||||
}
|
||||
}
|
||||
d.push(n);
|
||||
}
|
||||
return JSON.stringify(d);
|
||||
}
|
||||
|
||||
saveJson() {
|
||||
var blob = new Blob([this.getJsonString()], {type: 'application/json'});
|
||||
if(window.navigator.msSaveOrOpenBlob) {
|
||||
window.navigator.msSaveBlob(blob, "pillow_talk.json");
|
||||
}
|
||||
else{
|
||||
var elem = window.document.createElement('a');
|
||||
elem.href = window.URL.createObjectURL(blob);
|
||||
elem.download = "pillow_talk.json";
|
||||
document.body.appendChild(elem);
|
||||
elem.click();
|
||||
document.body.removeChild(elem);
|
||||
}
|
||||
}
|
||||
|
||||
loadData(data) {
|
||||
this.data = data;
|
||||
this.updateFromData();
|
||||
this.build(true);
|
||||
}
|
||||
|
||||
updateFromData() {
|
||||
this.messages = this.data.filter((node) => node['@type'] == 'Msg');
|
||||
this.directions = this.data.filter((node) => node['@type'] == 'Direction');
|
||||
this.conditions = this.data.filter((node) => node['@type'] == 'Condition');
|
||||
this.interruptions = this.data.filter((node) => node['@type'] == 'Interruption');
|
||||
|
||||
// save state;
|
||||
this.saveState();
|
||||
}
|
||||
|
||||
saveState() {
|
||||
window.localStorage.setItem("lastState", this.getJsonString());
|
||||
}
|
||||
|
||||
hasSavedState() {
|
||||
return window.localStorage.getItem("lastState") !== null;
|
||||
}
|
||||
|
||||
loadFromState() {
|
||||
this.loadData(JSON.parse(window.localStorage.getItem("lastState")));
|
||||
}
|
||||
|
||||
build(isInit) {
|
||||
this.simulation = d3.forceSimulation(this.messages)
|
||||
.force("link", d3.forceLink(this.directions).id(d => d['@id']))
|
||||
.force("charge", d3.forceManyBody().strength(-1000))
|
||||
.force("center", d3.forceCenter(this.width / 2, this.height / 2))
|
||||
.force("collide", d3.forceCollide(this.nodeSize*2))
|
||||
;
|
||||
|
||||
// Update existing nodes
|
||||
let node = this.nodesG
|
||||
.selectAll("g")
|
||||
.data(this.messages, n => n['@id'])
|
||||
;
|
||||
|
||||
// Update existing nodes
|
||||
let newNode = node.enter();
|
||||
|
||||
let newNodeG = newNode.append("g")
|
||||
.attr('id', d => d['@id'])
|
||||
.call(d3.drag(this.simulation))
|
||||
.on('click', function(d){
|
||||
this.clickMsg(d);
|
||||
}.bind(this))
|
||||
;
|
||||
console.log('a');
|
||||
let circle = newNodeG.append("circle")
|
||||
.attr('r', this.nodeSize)
|
||||
// .text(d => d.id)
|
||||
;
|
||||
let text = newNodeG.append("text")
|
||||
;
|
||||
|
||||
// remove
|
||||
node.exit().remove();
|
||||
node = node.merge(newNodeG);
|
||||
|
||||
// for all existing nodes:
|
||||
node.attr('class', msg => {
|
||||
let classes = [];
|
||||
if( this.selectedMsg == msg) classes.push('selectedMsg');
|
||||
if( msg['start'] == true ) classes.push('startMsg');
|
||||
if(this.getDirectionsFrom(msg).length < 1) {
|
||||
classes.push('endMsg');
|
||||
if(this.getDirectionsTo(msg).length < 1) classes.push('orphanedMsg');
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
})
|
||||
|
||||
let link = this.linkG
|
||||
.selectAll("line")
|
||||
.data(this.directions)
|
||||
;
|
||||
let newLink = link.enter()
|
||||
.append("line")
|
||||
;
|
||||
//remove
|
||||
link.exit().remove();
|
||||
link = link.merge(newLink);
|
||||
|
||||
link.attr('class', l => { return `link ` + (l['conditions'].length == 0 ? "link--noconditions" : "link--withconditions"); });
|
||||
|
||||
// console.log('c');
|
||||
let formatText = (t) => {
|
||||
if(t.length > this.maxChars) {
|
||||
return t.substr(0, this.maxChars - 3) + '...';
|
||||
} else {
|
||||
return t;
|
||||
}
|
||||
};
|
||||
|
||||
node.selectAll("text").text(d => formatText(`(${d['@id']}) ${d['text']}`));
|
||||
// console.log('q');
|
||||
// // TODO: update text
|
||||
// let text = newNodeG.append("text")
|
||||
// // .attr('stroke', "black")
|
||||
// .text(d => formatText(`(${d['@id']}) ${d['text']}`))
|
||||
// // .attr('title', d => d.label)
|
||||
// ;
|
||||
|
||||
let n = this.nodesG;
|
||||
this.simulation.on("tick", () => {
|
||||
link
|
||||
.each(function(d){
|
||||
let sourceX, targetX, midX, dx, dy, angle;
|
||||
// This mess makes the arrows exactly perfect.
|
||||
// thanks to http://bl.ocks.org/curran/9b73eb564c1c8a3d8f3ab207de364bf4
|
||||
if( d.source.x < d.target.x ){
|
||||
sourceX = d.source.x;
|
||||
targetX = d.target.x;
|
||||
} else if( d.target.x < d.source.x ){
|
||||
targetX = d.target.x;
|
||||
sourceX = d.source.x;
|
||||
} else if (d.target.isCircle) {
|
||||
targetX = sourceX = d.target.x;
|
||||
} else if (d.source.isCircle) {
|
||||
targetX = sourceX = d.source.x;
|
||||
} else {
|
||||
midX = (d.source.x + d.target.x) / 2;
|
||||
if(midX > d.target.x){
|
||||
midX = d.target.x;
|
||||
} else if(midX > d.source.x){
|
||||
midX = d.source.x;
|
||||
} else if(midX < d.target.x){
|
||||
midX = d.target.x;
|
||||
} else if(midX < d.source.x){
|
||||
midX = d.source.x;
|
||||
}
|
||||
targetX = sourceX = midX;
|
||||
}
|
||||
|
||||
dx = targetX - sourceX;
|
||||
dy = d.target.y - d.source.y;
|
||||
angle = Math.atan2(dx, dy);
|
||||
|
||||
// Compute the line endpoint such that the arrow
|
||||
// is touching the edge of the node rectangle perfectly.
|
||||
d.sourceX = sourceX + Math.sin(angle) * this.nodeSize;
|
||||
d.targetX = targetX - Math.sin(angle) * this.nodeSize;
|
||||
d.sourceY = d.source.y + Math.cos(angle) * this.nodeSize;
|
||||
d.targetY = d.target.y - Math.cos(angle) * this.nodeSize;
|
||||
}.bind(this))
|
||||
.attr("x1", function(d) { return d.sourceX; })
|
||||
.attr("y1", function(d) { return d.sourceY; })
|
||||
.attr("x2", function(d) { return d.targetX; })
|
||||
.attr("y2", function(d) { return d.targetY; });
|
||||
|
||||
node.attr("transform", d => `translate(${d.x},${d.y})`);
|
||||
// .attr("cy", d => d.y);
|
||||
});
|
||||
|
||||
// this.simulation.alpha(1);
|
||||
// this.simulation.restart();
|
||||
if(typeof isInit != 'undefined' && isInit) {
|
||||
for (let i = 0, n = Math.ceil(Math.log(this.simulation.alphaMin()) / Math.log(1 - this.simulation.alphaDecay())); i < n; ++i) {
|
||||
this.simulation.tick();
|
||||
}
|
||||
}
|
||||
return this.svg.node();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var graph = new Graph();
|
||||
|
||||
var openerDiv = document.getElementById("opener");
|
||||
var fileOpenerBtn = document.getElementById('fileOpener');
|
||||
var openerSubmit = document.getElementById('fileLoad');
|
||||
var loadFileFromInput = function (inputEl) {
|
||||
if(inputEl.files && inputEl.files[0]){
|
||||
var reader = new FileReader();
|
||||
reader.onload = function (e) {
|
||||
var output=e.target.result;
|
||||
let j = JSON.parse(output);
|
||||
graph.loadData(j);
|
||||
openerDiv.parentElement.removeChild(openerDiv);
|
||||
};//end onload()
|
||||
reader.readAsText(inputEl.files[0]);
|
||||
}
|
||||
}
|
||||
fileOpenerBtn.addEventListener('change', function(){ loadFileFromInput(this); })
|
||||
|
||||
if(fileOpenerBtn.files.length) {
|
||||
openerSubmit.addEventListener('click', function() { loadFileFromInput(fileOpener); });
|
||||
} else {
|
||||
openerSubmit.parentElement.removeChild(openerSubmit);
|
||||
}
|
||||
|
||||
let loadStateBtn = document.getElementById('stateLoad');
|
||||
if(graph.hasSavedState()) {
|
||||
loadStateBtn.addEventListener('click', function() {
|
||||
graph.loadFromState();
|
||||
|
||||
openerDiv.parentElement.removeChild(openerDiv);
|
||||
});
|
||||
} else {
|
||||
loadStateBtn.parentElement.removeChild(loadStateBtn);
|
||||
}
|
||||
|
||||
</script>
|
||||
</html>
|
365
www/reconnecting-websocket.js
Normal file
365
www/reconnecting-websocket.js
Normal file
|
@ -0,0 +1,365 @@
|
|||
// MIT License:
|
||||
//
|
||||
// Copyright (c) 2010-2012, Joe Walnes
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
/**
|
||||
* This behaves like a WebSocket in every way, except if it fails to connect,
|
||||
* or it gets disconnected, it will repeatedly poll until it successfully connects
|
||||
* again.
|
||||
*
|
||||
* It is API compatible, so when you have:
|
||||
* ws = new WebSocket('ws://....');
|
||||
* you can replace with:
|
||||
* ws = new ReconnectingWebSocket('ws://....');
|
||||
*
|
||||
* The event stream will typically look like:
|
||||
* onconnecting
|
||||
* onopen
|
||||
* onmessage
|
||||
* onmessage
|
||||
* onclose // lost connection
|
||||
* onconnecting
|
||||
* onopen // sometime later...
|
||||
* onmessage
|
||||
* onmessage
|
||||
* etc...
|
||||
*
|
||||
* It is API compatible with the standard WebSocket API, apart from the following members:
|
||||
*
|
||||
* - `bufferedAmount`
|
||||
* - `extensions`
|
||||
* - `binaryType`
|
||||
*
|
||||
* Latest version: https://github.com/joewalnes/reconnecting-websocket/
|
||||
* - Joe Walnes
|
||||
*
|
||||
* Syntax
|
||||
* ======
|
||||
* var socket = new ReconnectingWebSocket(url, protocols, options);
|
||||
*
|
||||
* Parameters
|
||||
* ==========
|
||||
* url - The url you are connecting to.
|
||||
* protocols - Optional string or array of protocols.
|
||||
* options - See below
|
||||
*
|
||||
* Options
|
||||
* =======
|
||||
* Options can either be passed upon instantiation or set after instantiation:
|
||||
*
|
||||
* var socket = new ReconnectingWebSocket(url, null, { debug: true, reconnectInterval: 4000 });
|
||||
*
|
||||
* or
|
||||
*
|
||||
* var socket = new ReconnectingWebSocket(url);
|
||||
* socket.debug = true;
|
||||
* socket.reconnectInterval = 4000;
|
||||
*
|
||||
* debug
|
||||
* - Whether this instance should log debug messages. Accepts true or false. Default: false.
|
||||
*
|
||||
* automaticOpen
|
||||
* - Whether or not the websocket should attempt to connect immediately upon instantiation. The socket can be manually opened or closed at any time using ws.open() and ws.close().
|
||||
*
|
||||
* reconnectInterval
|
||||
* - The number of milliseconds to delay before attempting to reconnect. Accepts integer. Default: 1000.
|
||||
*
|
||||
* maxReconnectInterval
|
||||
* - The maximum number of milliseconds to delay a reconnection attempt. Accepts integer. Default: 30000.
|
||||
*
|
||||
* reconnectDecay
|
||||
* - The rate of increase of the reconnect delay. Allows reconnect attempts to back off when problems persist. Accepts integer or float. Default: 1.5.
|
||||
*
|
||||
* timeoutInterval
|
||||
* - The maximum time in milliseconds to wait for a connection to succeed before closing and retrying. Accepts integer. Default: 2000.
|
||||
*
|
||||
*/
|
||||
(function (global, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
define([], factory);
|
||||
} else if (typeof module !== 'undefined' && module.exports){
|
||||
module.exports = factory();
|
||||
} else {
|
||||
global.ReconnectingWebSocket = factory();
|
||||
}
|
||||
})(this, function () {
|
||||
|
||||
if (!('WebSocket' in window)) {
|
||||
return;
|
||||
}
|
||||
|
||||
function ReconnectingWebSocket(url, protocols, options) {
|
||||
|
||||
// Default settings
|
||||
var settings = {
|
||||
|
||||
/** Whether this instance should log debug messages. */
|
||||
debug: false,
|
||||
|
||||
/** Whether or not the websocket should attempt to connect immediately upon instantiation. */
|
||||
automaticOpen: true,
|
||||
|
||||
/** The number of milliseconds to delay before attempting to reconnect. */
|
||||
reconnectInterval: 1000,
|
||||
/** The maximum number of milliseconds to delay a reconnection attempt. */
|
||||
maxReconnectInterval: 30000,
|
||||
/** The rate of increase of the reconnect delay. Allows reconnect attempts to back off when problems persist. */
|
||||
reconnectDecay: 1.5,
|
||||
|
||||
/** The maximum time in milliseconds to wait for a connection to succeed before closing and retrying. */
|
||||
timeoutInterval: 2000,
|
||||
|
||||
/** The maximum number of reconnection attempts to make. Unlimited if null. */
|
||||
maxReconnectAttempts: null,
|
||||
|
||||
/** The binary type, possible values 'blob' or 'arraybuffer', default 'blob'. */
|
||||
binaryType: 'blob'
|
||||
}
|
||||
if (!options) { options = {}; }
|
||||
|
||||
// Overwrite and define settings with options if they exist.
|
||||
for (var key in settings) {
|
||||
if (typeof options[key] !== 'undefined') {
|
||||
this[key] = options[key];
|
||||
} else {
|
||||
this[key] = settings[key];
|
||||
}
|
||||
}
|
||||
|
||||
// These should be treated as read-only properties
|
||||
|
||||
/** The URL as resolved by the constructor. This is always an absolute URL. Read only. */
|
||||
this.url = url;
|
||||
|
||||
/** The number of attempted reconnects since starting, or the last successful connection. Read only. */
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
/**
|
||||
* The current state of the connection.
|
||||
* Can be one of: WebSocket.CONNECTING, WebSocket.OPEN, WebSocket.CLOSING, WebSocket.CLOSED
|
||||
* Read only.
|
||||
*/
|
||||
this.readyState = WebSocket.CONNECTING;
|
||||
|
||||
/**
|
||||
* A string indicating the name of the sub-protocol the server selected; this will be one of
|
||||
* the strings specified in the protocols parameter when creating the WebSocket object.
|
||||
* Read only.
|
||||
*/
|
||||
this.protocol = null;
|
||||
|
||||
// Private state variables
|
||||
|
||||
var self = this;
|
||||
var ws;
|
||||
var forcedClose = false;
|
||||
var timedOut = false;
|
||||
var eventTarget = document.createElement('div');
|
||||
|
||||
// Wire up "on*" properties as event handlers
|
||||
|
||||
eventTarget.addEventListener('open', function(event) { self.onopen(event); });
|
||||
eventTarget.addEventListener('close', function(event) { self.onclose(event); });
|
||||
eventTarget.addEventListener('connecting', function(event) { self.onconnecting(event); });
|
||||
eventTarget.addEventListener('message', function(event) { self.onmessage(event); });
|
||||
eventTarget.addEventListener('error', function(event) { self.onerror(event); });
|
||||
|
||||
// Expose the API required by EventTarget
|
||||
|
||||
this.addEventListener = eventTarget.addEventListener.bind(eventTarget);
|
||||
this.removeEventListener = eventTarget.removeEventListener.bind(eventTarget);
|
||||
this.dispatchEvent = eventTarget.dispatchEvent.bind(eventTarget);
|
||||
|
||||
/**
|
||||
* This function generates an event that is compatible with standard
|
||||
* compliant browsers and IE9 - IE11
|
||||
*
|
||||
* This will prevent the error:
|
||||
* Object doesn't support this action
|
||||
*
|
||||
* http://stackoverflow.com/questions/19345392/why-arent-my-parameters-getting-passed-through-to-a-dispatched-event/19345563#19345563
|
||||
* @param s String The name that the event should use
|
||||
* @param args Object an optional object that the event will use
|
||||
*/
|
||||
function generateEvent(s, args) {
|
||||
var evt = document.createEvent("CustomEvent");
|
||||
evt.initCustomEvent(s, false, false, args);
|
||||
return evt;
|
||||
};
|
||||
|
||||
this.open = function (reconnectAttempt) {
|
||||
ws = new WebSocket(self.url, protocols || []);
|
||||
ws.binaryType = this.binaryType;
|
||||
|
||||
if (reconnectAttempt) {
|
||||
if (this.maxReconnectAttempts && this.reconnectAttempts > this.maxReconnectAttempts) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
eventTarget.dispatchEvent(generateEvent('connecting'));
|
||||
this.reconnectAttempts = 0;
|
||||
}
|
||||
|
||||
if (self.debug || ReconnectingWebSocket.debugAll) {
|
||||
console.debug('ReconnectingWebSocket', 'attempt-connect', self.url);
|
||||
}
|
||||
|
||||
var localWs = ws;
|
||||
var timeout = setTimeout(function() {
|
||||
if (self.debug || ReconnectingWebSocket.debugAll) {
|
||||
console.debug('ReconnectingWebSocket', 'connection-timeout', self.url);
|
||||
}
|
||||
timedOut = true;
|
||||
localWs.close();
|
||||
timedOut = false;
|
||||
}, self.timeoutInterval);
|
||||
|
||||
ws.onopen = function(event) {
|
||||
clearTimeout(timeout);
|
||||
if (self.debug || ReconnectingWebSocket.debugAll) {
|
||||
console.debug('ReconnectingWebSocket', 'onopen', self.url);
|
||||
}
|
||||
self.protocol = ws.protocol;
|
||||
self.readyState = WebSocket.OPEN;
|
||||
self.reconnectAttempts = 0;
|
||||
var e = generateEvent('open');
|
||||
e.isReconnect = reconnectAttempt;
|
||||
reconnectAttempt = false;
|
||||
eventTarget.dispatchEvent(e);
|
||||
};
|
||||
|
||||
ws.onclose = function(event) {
|
||||
clearTimeout(timeout);
|
||||
ws = null;
|
||||
if (forcedClose) {
|
||||
self.readyState = WebSocket.CLOSED;
|
||||
eventTarget.dispatchEvent(generateEvent('close'));
|
||||
} else {
|
||||
self.readyState = WebSocket.CONNECTING;
|
||||
var e = generateEvent('connecting');
|
||||
e.code = event.code;
|
||||
e.reason = event.reason;
|
||||
e.wasClean = event.wasClean;
|
||||
eventTarget.dispatchEvent(e);
|
||||
if (!reconnectAttempt && !timedOut) {
|
||||
if (self.debug || ReconnectingWebSocket.debugAll) {
|
||||
console.debug('ReconnectingWebSocket', 'onclose', self.url);
|
||||
}
|
||||
eventTarget.dispatchEvent(generateEvent('close'));
|
||||
}
|
||||
|
||||
var timeout = self.reconnectInterval * Math.pow(self.reconnectDecay, self.reconnectAttempts);
|
||||
setTimeout(function() {
|
||||
self.reconnectAttempts++;
|
||||
self.open(true);
|
||||
}, timeout > self.maxReconnectInterval ? self.maxReconnectInterval : timeout);
|
||||
}
|
||||
};
|
||||
ws.onmessage = function(event) {
|
||||
if (self.debug || ReconnectingWebSocket.debugAll) {
|
||||
console.debug('ReconnectingWebSocket', 'onmessage', self.url, event.data);
|
||||
}
|
||||
var e = generateEvent('message');
|
||||
e.data = event.data;
|
||||
eventTarget.dispatchEvent(e);
|
||||
};
|
||||
ws.onerror = function(event) {
|
||||
if (self.debug || ReconnectingWebSocket.debugAll) {
|
||||
console.debug('ReconnectingWebSocket', 'onerror', self.url, event);
|
||||
}
|
||||
eventTarget.dispatchEvent(generateEvent('error'));
|
||||
};
|
||||
}
|
||||
|
||||
// Whether or not to create a websocket upon instantiation
|
||||
if (this.automaticOpen == true) {
|
||||
this.open(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transmits data to the server over the WebSocket connection.
|
||||
*
|
||||
* @param data a text string, ArrayBuffer or Blob to send to the server.
|
||||
*/
|
||||
this.send = function(data) {
|
||||
if (ws) {
|
||||
if (self.debug || ReconnectingWebSocket.debugAll) {
|
||||
console.debug('ReconnectingWebSocket', 'send', self.url, data);
|
||||
}
|
||||
return ws.send(data);
|
||||
} else {
|
||||
throw 'INVALID_STATE_ERR : Pausing to reconnect websocket';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Closes the WebSocket connection or connection attempt, if any.
|
||||
* If the connection is already CLOSED, this method does nothing.
|
||||
*/
|
||||
this.close = function(code, reason) {
|
||||
// Default CLOSE_NORMAL code
|
||||
if (typeof code == 'undefined') {
|
||||
code = 1000;
|
||||
}
|
||||
forcedClose = true;
|
||||
if (ws) {
|
||||
ws.close(code, reason);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Additional public API method to refresh the connection if still open (close, re-open).
|
||||
* For example, if the app suspects bad data / missed heart beats, it can try to refresh.
|
||||
*/
|
||||
this.refresh = function() {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* An event listener to be called when the WebSocket connection's readyState changes to OPEN;
|
||||
* this indicates that the connection is ready to send and receive data.
|
||||
*/
|
||||
ReconnectingWebSocket.prototype.onopen = function(event) {};
|
||||
/** An event listener to be called when the WebSocket connection's readyState changes to CLOSED. */
|
||||
ReconnectingWebSocket.prototype.onclose = function(event) {};
|
||||
/** An event listener to be called when a connection begins being attempted. */
|
||||
ReconnectingWebSocket.prototype.onconnecting = function(event) {};
|
||||
/** An event listener to be called when a message is received from the server. */
|
||||
ReconnectingWebSocket.prototype.onmessage = function(event) {};
|
||||
/** An event listener to be called when an error occurs. */
|
||||
ReconnectingWebSocket.prototype.onerror = function(event) {};
|
||||
|
||||
/**
|
||||
* Whether all instances of ReconnectingWebSocket should log debug messages.
|
||||
* Setting this to true is the equivalent of setting all instances of ReconnectingWebSocket.debug to true.
|
||||
*/
|
||||
ReconnectingWebSocket.debugAll = false;
|
||||
|
||||
ReconnectingWebSocket.CONNECTING = WebSocket.CONNECTING;
|
||||
ReconnectingWebSocket.OPEN = WebSocket.OPEN;
|
||||
ReconnectingWebSocket.CLOSING = WebSocket.CLOSING;
|
||||
ReconnectingWebSocket.CLOSED = WebSocket.CLOSED;
|
||||
|
||||
return ReconnectingWebSocket;
|
||||
});
|
44
www/styles.css
Normal file
44
www/styles.css
Normal file
|
@ -0,0 +1,44 @@
|
|||
body{
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
#status{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
width: 430px;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
#status > div{
|
||||
width: 33.3333333%;
|
||||
height: 150px;
|
||||
border: solid 1px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hugvey{
|
||||
background-image: linear-gradient(to top, #587457, #35a589);
|
||||
color:white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.hugvey.hugvey--off{
|
||||
background-image: linear-gradient(to top, #575d74, #3572a5);
|
||||
}
|
||||
|
||||
.hugvey h1{
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
.hugvey.hugvey--off h1::after{
|
||||
content: '[off]'
|
||||
}
|
||||
.hugvey.hugvey--on h1 {
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 5px;
|
||||
|
||||
}
|
11055
www/vue.js
Normal file
11055
www/vue.js
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue