Refactor Interruptions to Diversions & some fixes in status display

This commit is contained in:
Ruben van de Ven 2019-01-25 10:43:55 +01:00
parent eb28ce2ac9
commit a1f66a6a01
18 changed files with 287 additions and 919 deletions

View File

@ -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

View File

@ -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)
@ -242,6 +238,10 @@ class HugveyState(object):
"""Represents the state of a Hugvey client on the server.
Manages server connections & voice parsing etc.
"""
STATE_PAUSE = "paused"
STATE_GONE = "gone"
STATE_RUNNING = "running"
def __init__(self, id: int, command: CentralCommand):
@ -255,7 +255,16 @@ 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
self.hostname = hostname
@ -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)
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()
while self.notShuttingDown:
# self.isRunning.wait()
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()

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -2,3 +2,4 @@ pyzmq
pyaudio
coloredlogs
pyyaml
pyalsaaudio

View File

@ -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;

View 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

View 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

View 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

View 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

View 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

View 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

View File

@ -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>
@ -18,28 +19,33 @@
<dt>Uptime</dt>
<dd>{{uptime}}</dd>
</dl>
<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'">
{{ hv.language }} / {{ hv.msg }}
<div v-if="hv.finished != false">Finished: {{time_passed(hv,
<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"><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>

View File

@ -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', {

View File

@ -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>

View File

@ -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;