Refactor Interruptions to Diversions & some fixes in status display
This commit is contained in:
parent
eb28ce2ac9
commit
a1f66a6a01
18 changed files with 287 additions and 919 deletions
|
@ -6,3 +6,7 @@ voice:
|
|||
target_rate: 16000
|
||||
port: 4444
|
||||
input_name: null
|
||||
file_address: "http://192.168.178.185:8888"
|
||||
play_device: null
|
||||
play_volume: 80
|
||||
|
||||
|
|
|
@ -74,7 +74,6 @@ class CentralCommand(object):
|
|||
self.languageFiles[lang['code']] = lang['file']
|
||||
with open(lang_filename, 'r') as fp:
|
||||
self.languages[lang['code']] = json.load(fp)
|
||||
print(self.languages)
|
||||
|
||||
self.panopticon = Panopticon(self, self.config)
|
||||
|
||||
|
@ -85,9 +84,9 @@ class CentralCommand(object):
|
|||
return status
|
||||
|
||||
hv = self.hugveys[hv_id]
|
||||
status['status'] = 'running' if hv.isRunning.is_set() else 'paused'
|
||||
status['status'] = hv.getStatus()
|
||||
status['language'] = hv.language_code
|
||||
status['msg'] = hv.story.currentMessage.id
|
||||
status['msg'] = hv.story.currentMessage.id if hv.story.currentMessage else None
|
||||
status['counts'] = hv.story.getStoryCounts()
|
||||
status['finished'] = hv.story.isFinished()
|
||||
|
||||
|
@ -168,16 +167,12 @@ class CentralCommand(object):
|
|||
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)
|
||||
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.start()
|
||||
print('c')
|
||||
else:
|
||||
logger.info(f'Reconfigure hugvey #{hugvey_id}')
|
||||
# (re)configure exisitng hugveys
|
||||
|
@ -210,7 +205,8 @@ class CentralCommand(object):
|
|||
logger.debug("Message contains: {}".format(msg))
|
||||
continue
|
||||
else:
|
||||
await self.hugveys[hugvey_id].eventQueue.put(msg)
|
||||
self.hugveys[hugvey_id].queueEvent(msg)
|
||||
# await self.hugveys[hugvey_id].eventQueue.put(msg)
|
||||
except Exception as e:
|
||||
logger.critical(f"Exception while running event loop:")
|
||||
logger.exception(e)
|
||||
|
@ -243,6 +239,10 @@ class HugveyState(object):
|
|||
Manages server connections & voice parsing etc.
|
||||
"""
|
||||
|
||||
STATE_PAUSE = "paused"
|
||||
STATE_GONE = "gone"
|
||||
STATE_RUNNING = "running"
|
||||
|
||||
def __init__(self, id: int, command: CentralCommand):
|
||||
|
||||
self.id = id
|
||||
|
@ -255,6 +255,15 @@ class HugveyState(object):
|
|||
self.language_code = 'en-GB'
|
||||
self.story = Story(self)
|
||||
self.story.setStoryData(self.command.languages[self.language_code])
|
||||
self.streamer = None
|
||||
self.status = self.STATE_PAUSE
|
||||
self.google = None
|
||||
self.notShuttingDown = True # TODO: allow shutdown of object
|
||||
|
||||
def getStatus(self):
|
||||
if self.story.isFinished():
|
||||
return "finished"
|
||||
return self.status
|
||||
|
||||
def config(self, hostname, ip):
|
||||
self.ip = ip
|
||||
|
@ -279,13 +288,17 @@ class HugveyState(object):
|
|||
"""
|
||||
Start the tasks
|
||||
"""
|
||||
self.isRunning.set()
|
||||
self.status = self.STATE_RUNNING
|
||||
|
||||
tasks = asyncio.gather(
|
||||
self.catchException(self.processAudio()),
|
||||
self.catchException(self.handleEvents()),
|
||||
self.catchException(self.playStory()),
|
||||
loop=self.loop)
|
||||
# self.pause()
|
||||
self.loop.run_until_complete(tasks)
|
||||
self.isRunning.set()
|
||||
|
||||
|
||||
async def catchException(self, awaitable):
|
||||
try:
|
||||
|
@ -336,46 +349,73 @@ class HugveyState(object):
|
|||
self.story.setStoryData(self.command.languages[language_code])
|
||||
|
||||
def pause(self):
|
||||
self.google.pause()
|
||||
self.logger.info('Pause')
|
||||
if self.google:
|
||||
self.google.pause()
|
||||
self.story.pause()
|
||||
self.isRunning.clear()
|
||||
self.status = self.STATE_PAUSE
|
||||
|
||||
def resume(self):
|
||||
self.google.resume()
|
||||
self.logger.info('Resume')
|
||||
if self.google:
|
||||
self.google.resume()
|
||||
self.story.resume()
|
||||
self.isRunning.set()
|
||||
self.status = self.STATE_RUNNING
|
||||
|
||||
def restart(self):
|
||||
self.logger.info('Restart')
|
||||
self.story.reset()
|
||||
self.resume()
|
||||
self.isRunning.set()
|
||||
|
||||
def gone(self):
|
||||
'''Status to 'gone' as in, shutdown/crashed/whatever
|
||||
'''
|
||||
self.pause()
|
||||
self.logger.info('Gone')
|
||||
self.status = self.STATE_GONE
|
||||
|
||||
|
||||
async def playStory(self):
|
||||
await self.story.start()
|
||||
|
||||
def getStreamer(self):
|
||||
if not self.streamer:
|
||||
self.streamer = AudioStreamer(
|
||||
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.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
|
||||
)
|
||||
self.streamer.addConsumer(self.google)
|
||||
return self.streamer
|
||||
|
||||
async def processAudio(self):
|
||||
'''
|
||||
Start the audio streamer service
|
||||
'''
|
||||
|
||||
self.logger.info("Start audio stream")
|
||||
streamer = AudioStreamer(
|
||||
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'])
|
||||
streamer.addConsumer(self.player)
|
||||
while self.notShuttingDown:
|
||||
# self.isRunning.wait()
|
||||
|
||||
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()
|
||||
self.logger.info("Start audio stream")
|
||||
await self.getStreamer().run()
|
||||
self.logger.warn("stream has left the building")
|
||||
# if we end up here, the streamer finished, probably meaning hte hugvey shutdown
|
||||
self.gone()
|
||||
|
|
|
@ -7,6 +7,7 @@ import socket
|
|||
import threading
|
||||
import time
|
||||
import yaml
|
||||
import alsaaudio
|
||||
import zmq
|
||||
from zmq.asyncio import Context
|
||||
|
||||
|
@ -112,13 +113,14 @@ class VoiceServer(object):
|
|||
r = await future
|
||||
|
||||
class CommandHandler(object):
|
||||
def __init__(self, hugvey_id, cmd_address = "tcp://127.0.0.1:5555", publish_address = "tcp://0.0.0.0:5555"):
|
||||
def __init__(self, hugvey_id, cmd_address, publish_address, file_address):
|
||||
self.eventQueue = []
|
||||
self.ctx = Context.instance()
|
||||
self.hugvey_id = hugvey_id
|
||||
self.cmd_address = cmd_address
|
||||
self.publish_address = publish_address
|
||||
self.playPopen = None
|
||||
self.file_address = file_address
|
||||
# self.showMyself() # queue message for connection request
|
||||
|
||||
def handle(self, cmd):
|
||||
|
@ -132,20 +134,34 @@ class CommandHandler(object):
|
|||
if cmd['action'] == 'show_yourself':
|
||||
self.showMyself()
|
||||
if cmd['action'] == 'play':
|
||||
self.cmdPlay(cmd['id'], cmd['msg'])
|
||||
self.cmdPlay(cmd)
|
||||
|
||||
|
||||
def cmdPlay(self, msgId, msgText, pitch=50):
|
||||
logger.info("Play: {}".format(msgText))
|
||||
self.playPopen = subprocess.Popen(['espeak', '-p','{0}'.format(pitch), msgText], stdout=subprocess.PIPE)
|
||||
returnCode = self.playPopen.wait()
|
||||
self.playPopen = None
|
||||
def cmdPlay(self, cmd):
|
||||
msgId= cmd['id']
|
||||
pitch = cmd['pitch'] if 'pitch' in cmd else 50
|
||||
file = cmd['file'] if 'file' in cmd else None
|
||||
text = cmd['msg'] if 'msg' in cmd else None
|
||||
|
||||
if returnCode:
|
||||
logger.warn("Had returncode on play: {}".format(returnCode))
|
||||
if file is None and text is None:
|
||||
logger.critical("No file nor text given: {}".format(cmd))
|
||||
else:
|
||||
logger.debug("Finished playback. Return code: {}".format(returnCode))
|
||||
if file is not None:
|
||||
logger.info("Play: {}".format(file))
|
||||
file = self.file_address + "/" + file
|
||||
self.playPopen = subprocess.Popen(['play', file], stdout=subprocess.PIPE)
|
||||
returnCode = self.playPopen.wait()
|
||||
self.playPopen = None
|
||||
else:
|
||||
logger.info("Speak: {}".format(text))
|
||||
self.playPopen = subprocess.Popen(['espeak', '-p','{0}'.format(pitch), text], stdout=subprocess.PIPE)
|
||||
returnCode = self.playPopen.wait()
|
||||
self.playPopen = None
|
||||
|
||||
if returnCode:
|
||||
logger.warn("Had returncode on play: {}".format(returnCode))
|
||||
else:
|
||||
logger.debug("Finished playback. Return code: {}".format(returnCode))
|
||||
|
||||
self.sendMessage({
|
||||
'event': 'playbackFinish',
|
||||
|
@ -237,6 +253,9 @@ class Hugvey(object):
|
|||
def start(self):
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
if self.config['voice']['play_device']:
|
||||
alsaaudio.Mixer(self.config['voice']['play_device']).setvolume(self.config['voice']['play_volume'])
|
||||
|
||||
self.voice_server = VoiceServer(
|
||||
loop = loop,
|
||||
voice_port = int(self.config['voice']['port']),
|
||||
|
@ -248,6 +267,7 @@ class Hugvey(object):
|
|||
hugvey_id = self.id,
|
||||
cmd_address = self.config['events']['cmd_address'],
|
||||
publish_address = self.config['events']['publish_address'],
|
||||
file_address = self.config['voice']['file_address']
|
||||
)
|
||||
logger.info('start')
|
||||
# self.voice_server.asyncStart(loop)
|
||||
|
|
|
@ -56,6 +56,7 @@ def getWebSocketHandler(central_command):
|
|||
|
||||
except Exception as e:
|
||||
self.send({'alert': 'Invalid request: {}'.format(e)})
|
||||
logger.exception(e)
|
||||
|
||||
def send(self, message):
|
||||
j = json.dumps(message)
|
||||
|
@ -80,13 +81,13 @@ def getWebSocketHandler(central_command):
|
|||
self.send(msg)
|
||||
|
||||
def msgResume(self, hv_id):
|
||||
central_command.hugveys[hv_id].eventQueue.put({'event': 'resume'})
|
||||
central_command.hugveys[hv_id].eventQueue.put_nowait({'event': 'resume'})
|
||||
|
||||
def msgPause(self, hv_id):
|
||||
central_command.hugveys[hv_id].eventQueue.put({'event': 'pause'})
|
||||
central_command.hugveys[hv_id].eventQueue.put_nowait({'event': 'pause'})
|
||||
|
||||
def msgRestart(self, hv_id):
|
||||
central_command.hugveys[hv_id].eventQueue.put({'event': 'restart'})
|
||||
central_command.hugveys[hv_id].eventQueue.put_nowait({'event': 'restart'})
|
||||
|
||||
return WebSocketHandler
|
||||
|
||||
|
|
|
@ -14,11 +14,14 @@ class Message(object):
|
|||
self.text = text
|
||||
self.isStart = False
|
||||
self.reply = None
|
||||
self.audioFile= None
|
||||
|
||||
@classmethod
|
||||
def initFromJson(message, data, story):
|
||||
msg = message(data['@id'], data['text'])
|
||||
msg.isStart = data['start'] if 'start' in data else False
|
||||
if 'audio' in data:
|
||||
msg.audioFile = data['audio']['file']
|
||||
return msg
|
||||
|
||||
def setReply(self, text):
|
||||
|
@ -123,9 +126,9 @@ class Direction(object):
|
|||
return direction
|
||||
|
||||
|
||||
class Interruption(object):
|
||||
class Diversion(object):
|
||||
"""
|
||||
An Interruption. Used to catch events outside of story flow.
|
||||
An Diversion. Used to catch events outside of story flow.
|
||||
"""
|
||||
|
||||
def __init__(self, id):
|
||||
|
@ -136,20 +139,20 @@ class Interruption(object):
|
|||
self.conditions.append(condition)
|
||||
|
||||
@classmethod
|
||||
def initFromJson(interruptionClass, data, story):
|
||||
interrupt = interruptionClass(data['@id'])
|
||||
def initFromJson(diversionClass, data, story):
|
||||
diversion = diversionClass(data['@id'])
|
||||
if 'conditions' in data:
|
||||
for conditionId in data['conditions']:
|
||||
c = story.get(conditionId)
|
||||
interrupt.addCondition(c)
|
||||
return interrupt
|
||||
diversion.addCondition(c)
|
||||
return diversion
|
||||
|
||||
|
||||
storyClasses = {
|
||||
'Msg': Message,
|
||||
'Direction': Direction,
|
||||
'Condition': Condition,
|
||||
'Interruption': Interruption,
|
||||
'Diversion': Diversion,
|
||||
}
|
||||
|
||||
|
||||
|
@ -231,7 +234,7 @@ class Story(object):
|
|||
# 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)])
|
||||
'diversions': len([e for e in self.log if isinstance(e, Diversion)])
|
||||
}
|
||||
|
||||
def setStoryData(self, story_data):
|
||||
|
@ -244,7 +247,7 @@ class Story(object):
|
|||
currentId = self.currentMessage.id if self.currentMessage else None
|
||||
|
||||
self.elements = {}
|
||||
self.interruptions = []
|
||||
self.diversions = []
|
||||
self.directionsPerMsg = {}
|
||||
self.startMessage = None # The entrypoint to the graph
|
||||
self.reset()
|
||||
|
@ -291,8 +294,8 @@ class Story(object):
|
|||
|
||||
self.elements[obj.id] = obj
|
||||
|
||||
if type(obj) == Interruption:
|
||||
self.interruptions.append(obj)
|
||||
if type(obj) == Diversion:
|
||||
self.diversions.append(obj)
|
||||
|
||||
if type(obj) == Direction:
|
||||
if obj.msgFrom.id not in self.directionsPerMsg:
|
||||
|
@ -399,11 +402,18 @@ class Story(object):
|
|||
message.id, message.text))
|
||||
self.log.append(message)
|
||||
# TODO: prep events & timer etc.
|
||||
self.hugvey.sendCommand({
|
||||
'action': 'play',
|
||||
'msg': message.text,
|
||||
'id': message.id,
|
||||
})
|
||||
if message.audioFile:
|
||||
self.hugvey.sendCommand({
|
||||
'action': 'play',
|
||||
'file': message.audioFile,
|
||||
'id': message.id,
|
||||
})
|
||||
else:
|
||||
self.hugvey.sendCommand({
|
||||
'action': 'play',
|
||||
'msg': message.text,
|
||||
'id': message.id,
|
||||
})
|
||||
|
||||
logger.debug("Pending directions: ")
|
||||
|
||||
|
@ -426,8 +436,8 @@ class Story(object):
|
|||
await self._renderer()
|
||||
|
||||
def isFinished(self):
|
||||
if hasattr(self, 'finish_time'):
|
||||
return self.finish_time
|
||||
if hasattr(self, 'finish_time') and self.finish_time:
|
||||
return time.time() - self.finish_time
|
||||
|
||||
return False
|
||||
|
||||
|
|
|
@ -27,6 +27,7 @@ class AudioStreamer(object):
|
|||
address = "tcp://{}:{}".format(self.address, self.port)
|
||||
self.ctx = Context.instance()
|
||||
self.socket = self.ctx.socket(zmq.SUB)
|
||||
self.socket.setsockopt(zmq.RCVTIMEO, 4000) # timeout: 8 sec
|
||||
self.socket.subscribe('')
|
||||
# self.socket.setsockopt(zmq.CONFLATE, 1)
|
||||
self.socket.connect(address)
|
||||
|
@ -35,13 +36,16 @@ class AudioStreamer(object):
|
|||
logger.info("Attempt connection on {}:{}".format(self.address, self.port))
|
||||
# s.connect((self.address, self.port))
|
||||
#
|
||||
while self.isRunning:
|
||||
data = await self.socket.recv()
|
||||
# logger.debug('chunk received')
|
||||
self.process(data)
|
||||
|
||||
logger.info("Close socket on {}:{}".format(self.address, self.port))
|
||||
self.socket.close()
|
||||
try:
|
||||
while self.isRunning:
|
||||
data = await self.socket.recv()
|
||||
# logger.debug('chunk received')
|
||||
self.process(data)
|
||||
except zmq.error.Again as timeout_e:
|
||||
logger.warn("Timeout of audiostream. Hugvey shutdown?")
|
||||
finally:
|
||||
logger.info("Close socket on {}:{}".format(self.address, self.port))
|
||||
self.socket.close()
|
||||
|
||||
def stop(self):
|
||||
self.isRunning = False
|
||||
|
|
|
@ -2,3 +2,4 @@ pyzmq
|
|||
pyaudio
|
||||
coloredlogs
|
||||
pyyaml
|
||||
pyalsaaudio
|
||||
|
|
|
@ -19,6 +19,10 @@ body {
|
|||
to {
|
||||
stroke-dashoffset: -1000; } }
|
||||
|
||||
img.icon {
|
||||
height: .9em;
|
||||
width: .9em; }
|
||||
|
||||
#interface {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
@ -40,6 +44,14 @@ body {
|
|||
position: relative; }
|
||||
#status > div#overview {
|
||||
width: 66.66667%; }
|
||||
#status .counts dd, #status .counts dt {
|
||||
display: inline-block;
|
||||
width: 30px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margiN: 0; }
|
||||
#status .counts dd:hover, #status .counts dt:hover {
|
||||
width: auto; }
|
||||
#status .hugvey {
|
||||
background-image: linear-gradient(to top, #587457, #35a589);
|
||||
color: white;
|
||||
|
@ -57,6 +69,27 @@ body {
|
|||
#status .hugvey.hugvey--off {
|
||||
background-image: linear-gradient(to top, #575d74, #3572a5); }
|
||||
#status .hugvey.hugvey--off::after {
|
||||
content: 'off';
|
||||
font-style: italic;
|
||||
color: gray;
|
||||
text-align: center; }
|
||||
#status .hugvey.hugvey--gone {
|
||||
background-image: linear-gradient(to top, orange, #ce5c00); }
|
||||
#status .hugvey.hugvey--gone::after {
|
||||
content: 'disconnected';
|
||||
font-style: italic;
|
||||
color: gray;
|
||||
text-align: center; }
|
||||
#status .hugvey.hugvey--paused {
|
||||
background-image: linear-gradient(to top, #888a85, #555753); }
|
||||
#status .hugvey.hugvey--paused::after {
|
||||
content: 'disconnected';
|
||||
font-style: italic;
|
||||
color: gray;
|
||||
text-align: center; }
|
||||
#status .hugvey.hugvey--finished {
|
||||
background-image: linear-gradient(to top, #888a85, #35a589); }
|
||||
#status .hugvey.hugvey--finished::after {
|
||||
content: 'disconnected';
|
||||
font-style: italic;
|
||||
color: gray;
|
||||
|
|
1
www/images/icon-diversions.svg
Normal file
1
www/images/icon-diversions.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path d="M384 320H256c-17.67 0-32 14.33-32 32v128c0 17.67 14.33 32 32 32h128c17.67 0 32-14.33 32-32V352c0-17.67-14.33-32-32-32zM192 32c0-17.67-14.33-32-32-32H32C14.33 0 0 14.33 0 32v128c0 17.67 14.33 32 32 32h95.72l73.16 128.04C211.98 300.98 232.4 288 256 288h.28L192 175.51V128h224V64H192V32zM608 0H480c-17.67 0-32 14.33-32 32v128c0 17.67 14.33 32 32 32h128c17.67 0 32-14.33 32-32V32c0-17.67-14.33-32-32-32z"/></svg>
|
After Width: | Height: | Size: 479 B |
1
www/images/icon-finished.svg
Normal file
1
www/images/icon-finished.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M466.515 66.928C487.731 57.074 512 72.551 512 95.944v243.1c0 10.526-5.161 20.407-13.843 26.358-35.837 24.564-74.335 40.858-122.505 40.858-67.373 0-111.63-34.783-165.217-34.783-50.853 0-86.124 10.058-114.435 22.122V488c0 13.255-10.745 24-24 24H56c-13.255 0-24-10.745-24-24V101.945C17.497 91.825 8 75.026 8 56 8 24.296 34.345-1.254 66.338.048c28.468 1.158 51.779 23.968 53.551 52.404.52 8.342-.81 16.31-3.586 23.562C137.039 68.384 159.393 64 184.348 64c67.373 0 111.63 34.783 165.217 34.783 40.496 0 82.612-15.906 116.95-31.855zM96 134.63v70.49c29-10.67 51.18-17.83 73.6-20.91v-71.57c-23.5 2.17-40.44 9.79-73.6 21.99zm220.8 9.19c-26.417-4.672-49.886-13.979-73.6-21.34v67.42c24.175 6.706 47.566 16.444 73.6 22.31v-68.39zm-147.2 40.39v70.04c32.796-2.978 53.91-.635 73.6 3.8V189.9c-25.247-7.035-46.581-9.423-73.6-5.69zm73.6 142.23c26.338 4.652 49.732 13.927 73.6 21.34v-67.41c-24.277-6.746-47.54-16.45-73.6-22.32v68.39zM96 342.1c23.62-8.39 47.79-13.84 73.6-16.56v-71.29c-26.11 2.35-47.36 8.04-73.6 17.36v70.49zm368-221.6c-21.3 8.85-46.59 17.64-73.6 22.47v71.91c27.31-4.36 50.03-14.1 73.6-23.89V120.5zm0 209.96v-70.49c-22.19 14.2-48.78 22.61-73.6 26.02v71.58c25.07-2.38 48.49-11.04 73.6-27.11zM316.8 212.21v68.16c25.664 7.134 46.616 9.342 73.6 5.62v-71.11c-25.999 4.187-49.943 2.676-73.6-2.67z"/></svg>
|
After Width: | Height: | Size: 1.3 KiB |
1
www/images/icon-interruptions.svg
Normal file
1
www/images/icon-interruptions.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M532 386.2c27.5-27.1 44-61.1 44-98.2 0-80-76.5-146.1-176.2-157.9C368.3 72.5 294.3 32 208 32 93.1 32 0 103.6 0 192c0 37 16.5 71 44 98.2-15.3 30.7-37.3 54.5-37.7 54.9-6.3 6.7-8.1 16.5-4.4 25 3.6 8.5 12 14 21.2 14 53.5 0 96.7-20.2 125.2-38.8 9.2 2.1 18.7 3.7 28.4 4.9C208.1 407.6 281.8 448 368 448c20.8 0 40.8-2.4 59.8-6.8C456.3 459.7 499.4 480 553 480c9.2 0 17.5-5.5 21.2-14 3.6-8.5 1.9-18.3-4.4-25-.4-.3-22.5-24.1-37.8-54.8zm-392.8-92.3L122.1 305c-14.1 9.1-28.5 16.3-43.1 21.4 2.7-4.7 5.4-9.7 8-14.8l15.5-31.1L77.7 256C64.2 242.6 48 220.7 48 192c0-60.7 73.3-112 160-112s160 51.3 160 112-73.3 112-160 112c-16.5 0-33-1.9-49-5.6l-19.8-4.5zM498.3 352l-24.7 24.4 15.5 31.1c2.6 5.1 5.3 10.1 8 14.8-14.6-5.1-29-12.3-43.1-21.4l-17.1-11.1-19.9 4.6c-16 3.7-32.5 5.6-49 5.6-54 0-102.2-20.1-131.3-49.7C338 339.5 416 272.9 416 192c0-3.4-.4-6.7-.7-10C479.7 196.5 528 238.8 528 288c0 28.7-16.2 50.6-29.7 64z"/></svg>
|
After Width: | Height: | Size: 971 B |
1
www/images/icon-laughs.svg
Normal file
1
www/images/icon-laughs.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path d="M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm141.4 389.4c-37.8 37.8-88 58.6-141.4 58.6s-103.6-20.8-141.4-58.6S48 309.4 48 256s20.8-103.6 58.6-141.4S194.6 56 248 56s103.6 20.8 141.4 58.6S448 202.6 448 256s-20.8 103.6-58.6 141.4zM328 224c17.7 0 32-14.3 32-32s-14.3-32-32-32-32 14.3-32 32 14.3 32 32 32zm-160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32-32 14.3-32 32 14.3 32 32 32zm194.4 64H133.6c-8.2 0-14.5 7-13.5 15 7.5 59.2 58.9 105 121.1 105h13.6c62.2 0 113.6-45.8 121.1-105 1-8-5.3-15-13.5-15z"/></svg>
|
After Width: | Height: | Size: 595 B |
1
www/images/icon-messages.svg
Normal file
1
www/images/icon-messages.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M144 208c-17.7 0-32 14.3-32 32s14.3 32 32 32 32-14.3 32-32-14.3-32-32-32zm112 0c-17.7 0-32 14.3-32 32s14.3 32 32 32 32-14.3 32-32-14.3-32-32-32zm112 0c-17.7 0-32 14.3-32 32s14.3 32 32 32 32-14.3 32-32-14.3-32-32-32zM256 32C114.6 32 0 125.1 0 240c0 47.6 19.9 91.2 52.9 126.3C38 405.7 7 439.1 6.5 439.5c-6.6 7-8.4 17.2-4.6 26S14.4 480 24 480c61.5 0 110-25.7 139.1-46.3C192 442.8 223.2 448 256 448c141.4 0 256-93.1 256-208S397.4 32 256 32zm0 368c-26.7 0-53.1-4.1-78.4-12.1l-22.7-7.2-19.5 13.8c-14.3 10.1-33.9 21.4-57.5 29 7.3-12.1 14.4-25.7 19.9-40.2l10.6-28.1-20.6-21.8C69.7 314.1 48 282.2 48 240c0-88.2 93.3-160 208-160s208 71.8 208 160-93.3 160-208 160z"/></svg>
|
After Width: | Height: | Size: 733 B |
1
www/images/icon-timeouts.svg
Normal file
1
www/images/icon-timeouts.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path d="M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm0 448c-110.3 0-200-89.7-200-200S137.7 56 248 56s200 89.7 200 200-89.7 200-200 200zm-80-280c-17.7 0-32 14.3-32 32s14.3 32 32 32 32-14.3 32-32-14.3-32-32-32zm160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32 32-14.3 32-32-14.3-32-32-32z"/></svg>
|
After Width: | Height: | Size: 376 B |
|
@ -7,6 +7,7 @@
|
|||
<script src="/js/moment.min.js"></script>
|
||||
<script src="/js/d3.v5.min.js"></script>
|
||||
<script src="/js/crel.min.js"></script>
|
||||
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.6.3/css/all.css" integrity="sha384-UHRtZLI+pbxtHCWp1t77Bi1L4ZtiqrqD80Kn4Z8NTSRyMA2Fd33n5dQ8lWUE00s/" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="/css/styles.css"></link>
|
||||
|
||||
</head>
|
||||
|
@ -21,25 +22,30 @@
|
|||
|
||||
<ul id='languages'>
|
||||
<li v-for="lang in languages" :title="lang.file"
|
||||
class="btn lang--btn" @click="loadNarrative(lang.code, lang.file)"><span :class="['flag-icon', lang.code]"></span> {{lang.code}}</li>
|
||||
:id="'lang-' + lang.code" class="btn lang--btn"
|
||||
@click="loadNarrative(lang.code, lang.file)"><span
|
||||
:class="['flag-icon', lang.code]"></span> {{lang.code}}</li>
|
||||
</ul>
|
||||
</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'}]">
|
||||
:class="[{'hugvey--on': hv.status != 'off'},'hugvey--' + hv.status]">
|
||||
<h1>
|
||||
{{ hv.id }}
|
||||
<!-- / {{ hv.status }} -->
|
||||
</h1>
|
||||
<div v-if="hv.status != 'off'">
|
||||
<div class='status'>{{ hv.status }}</div>
|
||||
<div v-if="hv.status != 'off' && hv.status != 'gone'">
|
||||
{{ hv.language }} / {{ hv.msg }}
|
||||
<div v-if="hv.finished != false">Finished: {{time_passed(hv,
|
||||
<div v-if="hv.finished != false"><img class='icon' :src="'/images/icon-finished.svg'" title="Finished"> {{timer(hv,
|
||||
'finished')}}</div>
|
||||
<div v-for="c, key in hv.counts">
|
||||
<dt>{{key}}</dt>
|
||||
<dd>{{c}}</dd>
|
||||
<div class='counts'>
|
||||
<div class='count' v-for="c, key in hv.counts">
|
||||
<img class='icon' :src="'/images/icon-' + key + '.svg'" :title="key">
|
||||
{{c}}
|
||||
</div>
|
||||
</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 class='btn' v-if="hv.status == 'running'" @click="pause(hv.id)">Pause</div>
|
||||
<div class='btn' v-if="hv.status == 'paused'" @click="resume(hv.id)">Resume</div>
|
||||
<div class='btn' v-if="hv.status == 'finished'" @click="restart(hv.id)">Restart</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -3,6 +3,7 @@ var panopticon;
|
|||
class Panopticon {
|
||||
constructor() {
|
||||
console.log( "Init panopticon" );
|
||||
this.languages = []
|
||||
this.hugveys = new Vue( {
|
||||
el: "#status",
|
||||
data: {
|
||||
|
@ -15,15 +16,27 @@ class Panopticon {
|
|||
console.log( "property!", Date( hugvey[property] * 1000 ) );
|
||||
return moment( Date( hugvey[property] * 1000 ) ).fromNow();
|
||||
},
|
||||
timer: function(hugvey, property) {
|
||||
return panopticon.stringToHHMMSS( hugvey[property] );
|
||||
},
|
||||
loadNarrative: function( code, file ) {
|
||||
return panopticon.loadNarrative( code, file );
|
||||
},
|
||||
pause: function(hv_id) {
|
||||
return panopticon.pause(hv_id);
|
||||
},
|
||||
resume: function(hv_id) {
|
||||
return panopticon.resume(hv_id);
|
||||
},
|
||||
restart: function(hv_id) {
|
||||
return panopticon.restart(hv_id);
|
||||
}
|
||||
}
|
||||
} );
|
||||
|
||||
|
||||
|
||||
this.socket = new ReconnectingWebSocket( "ws://localhost:8888/ws", null, { debug: true, reconnectInterval: 3000 } );
|
||||
this.socket = new ReconnectingWebSocket( "ws://localhost:8888/ws", null, { debug: false, reconnectInterval: 3000 } );
|
||||
this.graph = new Graph();
|
||||
|
||||
|
||||
|
@ -48,8 +61,10 @@ class Panopticon {
|
|||
switch ( msg['action'] ) {
|
||||
|
||||
case 'status':
|
||||
console.debug(msg);
|
||||
this.hugveys.uptime = this.stringToHHMMSS( msg['uptime'] );
|
||||
this.hugveys.languages = msg['languages'];
|
||||
this.languages = msg['languages'];
|
||||
this.hugveys.hugveys = msg['hugveys'];
|
||||
break;
|
||||
}
|
||||
|
@ -85,8 +100,14 @@ class Panopticon {
|
|||
return hours + ':' + minutes + ':' + seconds;
|
||||
}
|
||||
|
||||
|
||||
loadNarrative( code, file ) {
|
||||
if(typeof file == 'undefined') {
|
||||
for (let lang of this.languages) {
|
||||
if (lang['code'] == code) {
|
||||
file = lang['file'];
|
||||
}
|
||||
}
|
||||
}
|
||||
let req = new XMLHttpRequest();
|
||||
let graph = this.graph;
|
||||
req.addEventListener( "load", function( e ) {
|
||||
|
@ -128,7 +149,7 @@ class Graph {
|
|||
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
|
||||
this.diversions = []; // initialise empty array. For the simulation, make sure we keep the same array object
|
||||
|
||||
let graph = this;
|
||||
this.controlDown = false;
|
||||
|
@ -259,6 +280,9 @@ class Graph {
|
|||
panopticon.graph.saveJson(msg['@id'], e.target, function(e2){
|
||||
console.log(e);
|
||||
audioSpan.innerHTML = e.target.files[0].name + "<sup>*</sup>";
|
||||
// reload graph:
|
||||
console.log('reload', panopticon.graph.language_code);
|
||||
panopticon.loadNarrative(panopticon.graph.language_code);
|
||||
});
|
||||
// console.log(this,e);
|
||||
}
|
||||
|
@ -596,7 +620,7 @@ class Graph {
|
|||
getJsonString() {
|
||||
// recreate array to have the right order of items.
|
||||
this.data = [...this.messages, ...this.conditions,
|
||||
...this.directions, ...this.interruptions]
|
||||
...this.directions, ...this.diversions]
|
||||
let d = [];
|
||||
let toRemove = ['sourceX', 'sourceY', 'targetX', 'targetY', 'x', 'y', 'vx', 'vy']
|
||||
for ( let node of this.data ) {
|
||||
|
@ -673,7 +697,7 @@ class Graph {
|
|||
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' );
|
||||
this.diversions = this.data.filter(( node ) => node['@type'] == 'Diversion' );
|
||||
|
||||
document.getElementById('current_lang').innerHTML = "";
|
||||
document.getElementById('current_lang').appendChild(crel('span', {
|
||||
|
|
|
@ -1,832 +0,0 @@
|
|||
<!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>
|
|
@ -28,6 +28,11 @@ body{
|
|||
}
|
||||
}
|
||||
|
||||
img.icon{
|
||||
height: .9em;
|
||||
width: .9em;
|
||||
}
|
||||
|
||||
#interface{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
@ -58,6 +63,19 @@ body{
|
|||
}
|
||||
}
|
||||
|
||||
.counts{
|
||||
dd, dt{
|
||||
display: inline-block;
|
||||
width: 30px;
|
||||
overflow:hidden;
|
||||
text-overflow: ellipsis;
|
||||
margiN: 0;
|
||||
&:hover{
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hugvey{
|
||||
background-image: linear-gradient(to top, #587457, #35a589);
|
||||
color:white;
|
||||
|
@ -81,6 +99,39 @@ body{
|
|||
|
||||
&.hugvey--off{
|
||||
background-image: linear-gradient(to top, #575d74, #3572a5);
|
||||
&::after{
|
||||
content: 'off';
|
||||
font-style: italic;
|
||||
color: gray;
|
||||
text-align:center;
|
||||
// font-size: 30pt;
|
||||
}
|
||||
}
|
||||
|
||||
&.hugvey--gone{
|
||||
background-image: linear-gradient(to top, orange, rgb(206, 92, 0));
|
||||
&::after{
|
||||
content: 'disconnected';
|
||||
font-style: italic;
|
||||
color: gray;
|
||||
text-align:center;
|
||||
// font-size: 30pt;
|
||||
}
|
||||
}
|
||||
|
||||
&.hugvey--paused{
|
||||
background-image: linear-gradient(to top, rgb(136, 138, 133), rgb(85, 87, 83));
|
||||
&::after{
|
||||
content: 'disconnected';
|
||||
font-style: italic;
|
||||
color: gray;
|
||||
text-align:center;
|
||||
// font-size: 30pt;
|
||||
}
|
||||
}
|
||||
|
||||
&.hugvey--finished{
|
||||
background-image: linear-gradient(to top, rgb(136, 138, 133), #35a589);
|
||||
&::after{
|
||||
content: 'disconnected';
|
||||
font-style: italic;
|
||||
|
|
Loading…
Reference in a new issue