From 7bfcb40689ed1a9e49e9f6df1777c7b9801706e5 Mon Sep 17 00:00:00 2001 From: Anson Bridges Date: Tue, 12 Nov 2024 20:44:04 -0500 Subject: workington in progress --- .../__pycache__/datastructs.cpython-312.pyc | Bin 7899 -> 9061 bytes dashboard_website/__pycache__/db.cpython-312.pyc | Bin 11486 -> 12755 bytes .../__pycache__/router.cpython-312.pyc | Bin 15791 -> 18499 bytes dashboard_website/dashboard.py | 89 +++++++---- dashboard_website/datastructs.py | 52 +++--- dashboard_website/db.py | 174 ++++++++++++--------- dashboard_website/router.py | 161 ++++++++++++------- dashboard_website/savefile.csv | 55 +++++++ dashboard_website/static/css/dashboard.css | 7 + dashboard_website/static/js/controls.js | 7 + dashboard_website/static/js/dashboard.js | 94 +++++++++-- dashboard_website/templates/controls.html | 14 ++ dashboard_website/templates/index.html | 9 +- 13 files changed, 470 insertions(+), 192 deletions(-) diff --git a/dashboard_website/__pycache__/datastructs.cpython-312.pyc b/dashboard_website/__pycache__/datastructs.cpython-312.pyc index ff4cf5d..4d2a713 100644 Binary files a/dashboard_website/__pycache__/datastructs.cpython-312.pyc and b/dashboard_website/__pycache__/datastructs.cpython-312.pyc differ diff --git a/dashboard_website/__pycache__/db.cpython-312.pyc b/dashboard_website/__pycache__/db.cpython-312.pyc index 2172d4d..1d7188c 100644 Binary files a/dashboard_website/__pycache__/db.cpython-312.pyc and b/dashboard_website/__pycache__/db.cpython-312.pyc differ diff --git a/dashboard_website/__pycache__/router.cpython-312.pyc b/dashboard_website/__pycache__/router.cpython-312.pyc index 2611148..c55cd00 100644 Binary files a/dashboard_website/__pycache__/router.cpython-312.pyc and b/dashboard_website/__pycache__/router.cpython-312.pyc differ diff --git a/dashboard_website/dashboard.py b/dashboard_website/dashboard.py index 8ec53e0..77832aa 100644 --- a/dashboard_website/dashboard.py +++ b/dashboard_website/dashboard.py @@ -19,25 +19,17 @@ app = Flask(__name__) # responds with your next route @app.route("/enableTeam", methods=['POST']) # Expected JSON -# {"team_name" : "XXXX", str -# "longitude" : xx.xxxxxx, float //current team location -# "latitude" : xx.xxxxxx, float } +# {"team_index" : 1,2,3,4; int } # Returns JSON # {"status" : "OK"/"ERROR XX", str } expect something like: http://acetyl.net:5000/route/v1/bike/-71.0553792,42.3688272;-71.0688746,42.3576234 without the waypoints section # ERROR CODES: 1 = missing fields, 2 = invalid coordinates, 4 = team ALREADY exists, def enableTeam(): content = request.get_json() - if not ('team_name' in content and 'longitude' in content and 'latitude' in content): + if not ('team_index' in content): status = "ERROR 1" return jsonify({'status' : status}) - - if not ( (type(content['longitude']) is float ) and (type(content['latitude']) is float )): - status = "ERROR 2" - return jsonify({'status' : status}) - if db.addBike(content['team_name'], content['latitude'], content['longitude']) == 4: - status = "ERROR 4" - return jsonify({'status' : status}) + db.setBikeEnabled(content['team_index'], True) return jsonify({'status' : "OK"}) @@ -45,26 +37,24 @@ def enableTeam(): # responds with your next route @app.route("/disableTeam", methods=['POST']) # Expected JSON -# {"team_name" : "XXXX", str } +# {"team_index" : 1,2,3,4; int } # Returns JSON # {"status" : "OK"/"ERROR XX", str } # ERROR CODES: 1 = missing fields, 4 = team does not exist, def disableTeam(): content = request.get_json() - if not ('team_name' in content): + if not ('team_index' in content): status = "ERROR 1" return jsonify({'status' : status}) - if db.deleteBike(content['team_name']) == 4: - status = "ERROR 4" - return jsonify({'status' : status}) + db.setBikeEnabled(content['team_index'], False) return jsonify({'status' : "OK"}) # requests a route to the best clue given the team's current coordinates @app.route("/requestRoute", methods=['POST']) # Expected JSON -# {"team_name" : "XXXX", str +# {"team_index" : 1,2,3,4; int # "longitude" : xx.xxxxxx, float //current team location # "latitude" : xx.xxxxxx, float } # Returns JSON @@ -78,19 +68,19 @@ def disableTeam(): def requestRoute(): content = request.get_json() # verify request - if not ('team_name' in content and 'longitude' in content and 'latitude' in content): + if not ('team_index' in content and 'longitude' in content and 'latitude' in content): return jsonify({'status' : "ERROR 1"}) if not ( (type(content['longitude']) is float ) and (type(content['latitude']) is float)): return jsonify({'status' : "ERROR 2"}) - if db.pingBike(content['team_name'], content['latitude'], content['longitude']) == 4: + if db.pingBike(content['team_index'], content['latitude'], content['longitude']) == 4: return jsonify({'status' : "ERROR 4"}) if db.currently_updating: return jsonify({'status' : "ERROR 3"}) - bike, clue = db.getBikeCluePair(content['team_name']) + bike, clue = db.getBikeCluePair(content['team_index']) if clue == None: return jsonify({'status' : "ERROR 5"}) @@ -111,7 +101,7 @@ def requestRoute(): # periodically called to update team location in the management dashboard @app.route("/updateTeamLocation", methods=['POST']) # Expected JSON -# {"team_name" : "XXXX", str +# {"team_index" : 1,2,3,4; int # "longitude" : xx.xxxxxx, float # "latitude" : xx.xxxxxx, float } # Returns JSON @@ -120,7 +110,7 @@ def requestRoute(): def updateTeamLocation(): status = "OK" content = request.get_json() - if not ('team_name' in content and 'longitude' in content and 'latitude' in content): + if not ('team_index' in content and 'longitude' in content and 'latitude' in content): status = "ERROR 1" return jsonify({'status' : status}) @@ -128,16 +118,30 @@ def updateTeamLocation(): status = "ERROR 2" return jsonify({'status' : status}) - if db.pingBike(content['team_name'], content['latitude'], content['longitude']) == 4: + if db.pingBike(content['team_index'], content['latitude'], content['longitude']) == 4: status = "ERROR 4" return jsonify({'status' : status}) return jsonify({'status' : "OK"}) +# from website +# {"team_name"} +@app.route("/updateTeamDeadline", methods=['POST']) +def updateTeamDeadline(): + content = request.get_json() + if not ('team_index' in content and 'new_deadline' in content): + status = "ERROR 1" + return jsonify({'status' : status}) + db.setBikeDeadline(content['team_index'], content['new_deadline']) + return jsonify({'status' : "OK"}) + + + + # mark clue as visited from app @app.route("/visitClueTeam", methods=['POST']) # Expected JSON -# {"team_name" : xxxx, str +# {"team_index" : 1,2,3,4; int # "clue_name" : xxxx, str # "longitude" : xx.xxxxxx, float # "latitude" : xx.xxxxxx, float } @@ -146,17 +150,17 @@ def updateTeamLocation(): # ERROR CODES: 1 = missing fields, 2 = invalid coordinates, 3 = too far from clue location, 4 = no such team, 5 = no such clue, 6 = already visited def visitTeam(): content = request.get_json() - if not ('team_name' in content and 'longitude' in content and 'latitude' in content and 'clue_name' in content): + if not ('team_index' in content and 'longitude' in content and 'latitude' in content and 'clue_name' in content): return jsonify({'status' : "ERROR 1"}) if not ( (type(content['longitude']) is float ) and (type(content['latitude']) is float)): status = "ERROR 2" return jsonify({'status' : status}) - if db.pingBike(content['team_name'], content['latitude'], content['longitude']) == 4: + if db.pingBike(content['team_index'], content['latitude'], content['longitude']) == 4: return jsonify({'status' : "ERROR 4"}) - result = db.visitClueTeam(content['team_name'], content['clue_name']) + result = db.visitClueTeam(content['team_index'], content['clue_name']) if result != 0: return jsonify({'status' : f"ERROR {result}"}) return jsonify({'status' : "OK"}) @@ -212,13 +216,29 @@ def requireClue(): def enableClueToggle(): content = request.get_json() if not ('clue_name' in content): - return jsonify({'status' : "ERROR 1"}) + return jsonify({'status' : "ERROR: NO CLUE PROVIDED"}) result = db.toggleEnableClue(content['clue_name']) if result != 0: return jsonify({'status' : f"ERROR {result}"}) return jsonify({'status' : "OK"}) +@app.route("/setClueTeam", methods=['POST']) +# Expected JSON +# {"clue_name" : xxxx, str, +# "bike_team" : 0-4, int} +# Returns JSON +# {"status" : "OK"/"ERROR XX" } +def setClueTeam(): + content = request.get_json() + print("setting clue team", content) + if not ('clue_name' in content and 'bike_team' in content): + return jsonify({'status' : "ERROR: NO CLUE AND/OR TEAM PROVIDED"}) + result = db.assignClueToTeam(content['clue_name'], content['bike_team']) + if result != 0: + return jsonify({'status' : "ERROR: CLUE NOT FOUND"}) + return jsonify({'status' : "OK"}) + # # WEB / DASHBOARD API # @@ -236,7 +256,8 @@ def getLatestInfo(): data = {'timestamp' : db.getTime(), 'clues_changed' : False, 'home_changed' : False, - 'routes_changed' : False} + 'routes_changed' : False, + 'route_update_staged' : db.is_route_update_required() } cl = db.getCluesJSON(last_timestamp) if cl != False: data['clues_changed'] = True @@ -262,7 +283,7 @@ def addClueWeb(): print("adding clue:", content) if not ('clue_name' in content and 'longitude' in content and 'latitude' in content and 'clue_info' in content): return jsonify({'status' : "ERROR: INVALID CLUE JSON FORMAT"}) - res = db.addClue(content['clue_name'], content['clue_info'], content['latitude'], content['longitude']) + res = db.addClue(content['clue_name'], content['clue_info'], content['longitude'], content['latitude']) if res == 0: return jsonify({'status' : "OK",}) elif res == -1: @@ -300,6 +321,14 @@ def siteControls(): if db.load("clean.csv") != 0: return jsonify({"status" : "ERROR"}) return jsonify({"status" : "OK"}) + elif cmd == "setHome": + if db.setHomeBase(request.form.get('latitude'), request.form.get('longitude')) != 0: + return jsonify({"status" : "ERROR"}) + return jsonify({"status" : "OK"}) + elif cmd == "generateRoutes": + if db.updateRoutes() != 0: + return jsonify({"status" : "ERROR"}) + return jsonify({"status" : "OK"}) return render_template("controls.html") diff --git a/dashboard_website/datastructs.py b/dashboard_website/datastructs.py index 1b4b92b..4fe5038 100644 --- a/dashboard_website/datastructs.py +++ b/dashboard_website/datastructs.py @@ -1,10 +1,11 @@ import math import time +import datetime # time since last ping before deactivating/deleting BIKE_TIMEOUT = 60000 # 3 minutes -BIKE_DELETE = 360000 # time before bike deletes itself +endtime = datetime.datetime(2024, 11, 16, hour=18, minute=45) # data structures class Point: @@ -71,6 +72,9 @@ class Clue(Point): def toggle_required(self): self.required = False if self.required else True + + def set_team(self, team): + self.assigned_team = team def toJSON(self): json_dict = {'longitude' : self.longitude, @@ -85,14 +89,17 @@ class Clue(Point): class Bike(Point): - def __init__(self, lat, long, name, status): + def __init__(self, lat, long, name): self.longitude = long self.latitude = lat self.name = name + self.deadline = endtime self.last_contact = time.time() - self.target_name = "N/A" + self.target_clue = None + self.time_modifier = 1 # factor by which to modulate expected arrival time self.cluster = [] # list of clues this bike team is responsible for - self.status = status # ACTIVE | INACTIVE + self.status = "DISABLED" # DISABLED | ACTIVE | INACTIVE + self.clue_assignment_time = 0 # when clue was assigned, so that upon clue visitation we can determine speed def setTarget(self, clue_name): self.target_name = clue_name @@ -101,14 +108,24 @@ class Bike(Point): self.cluster = clue_cluster self.updateTarget() + def setDeadline(self, new_deadline): + self.deadline = new_deadline + + def timeTilDeadline(self): + return (self.deadline - datetime.datetime.now()).total_seconds() + def updateTarget(self): + self.clue_assignment_time = time.time() if len(self.cluster) <= 0: - self.target_name = "N/A" + self.target_clue = None + self.status = "INACTIVE" else: - self.target_name = self.cluster[0].name + self.status = "ACTIVE" + self.target_clue = self.cluster[0] def visitTarget(self): - self.cluster[0].visit() + print("clue visit time: ", time.time() - self.clue_assignment_time) + self.target_clue.visit() self.cluster.pop(0) self.updateTarget() while len(self.cluster) > 0 and self.cluster[0].status == "VISITED": @@ -120,25 +137,24 @@ class Bike(Point): self.last_contact = time.time() def disable(self): + self.status = "DISABLED" + self.target = None + + def enable(self): self.status = "INACTIVE" - self.target = "N/A" - self.last_contact = 0 - - def checkStatus(self): - if time.time() - self.last_contact > BIKE_TIMEOUT: - self.status = "INACTIVE" - self.target = "N/A" - if time.time() - self.last_contact > BIKE_DELETE: - return -1 - return 0 + self.target = None + + def tripTime(self): + return datetime.datetime.now() - self.deadline def toJSON(self): json_dict = { "longitude": self.longitude, "latitude": self.latitude, "time_since_last_contact": time.time() - self.last_contact, + "team_deadline" : str(self.deadline), "team_name": self.name, "team_status": self.status, - "target_clue": self.target_name, + "target_clue": self.target_clue.name if self.target_clue else "N/A", } return json_dict diff --git a/dashboard_website/db.py b/dashboard_website/db.py index 7bb5a18..eec587d 100644 --- a/dashboard_website/db.py +++ b/dashboard_website/db.py @@ -2,10 +2,12 @@ # also manages currently available bike teams import csv from threading import Thread +import datetime import router from datastructs import * + # constants CLUE_MIN_DISTANCE = 0.1 # minimum distance between clue and bike to be considered valid visitation @@ -16,33 +18,63 @@ bikes = [] clusters = [] routes = {"clusters": [], "cluster_times": {}, "individual_routes": []} # geojson polylines, both between all the clusters +preview_routes = {"clusters": [], "cluster_times": {}, # geojson polylines, both between all the clusters + "individual_routes": [], "clue_clusters" : []} # except clue clusters which is a list of a list of clues assigned_clues = [] clues_last_changed = time.time() home_last_changed = time.time() routes_last_changed = time.time() +route_update_required = False currently_updating = False +minimalRouting = False # if false, currently assigned clues + +_bikes_changed = False # In case bikes or clues are enabled/disabled +_clues_changed = False # during route calculation startup = False # +def should_cancel_routing(): + global _bikes_changed, _clues_changed + if _bikes_changed or _clues_changed: + _bikes_changed = True + _clues_changed = True + return True + return False + +def is_route_update_required(): + return route_update_required # called every time a node is added # a bike is added/removed # determines/assigns clusters, and assigns routes to bikes def updateRoutes_background(): # run in thread due to long runtime - global currently_updating, routes_last_changed, routes, clusters + global currently_updating, routes_last_changed, route_update_required, preview_routes, clusters, routes print("Calculating clusters...") - routes = {"clusters": [], "cluster_times": {}, "individual_routes": []} # reset - clusters, paths, times = router.getClusters(bikes, clues, homeBase) + preview_routes = {"clusters": [], "cluster_times": {}, "individual_routes": [], "clue_clusters" : []} # reset + status, clusters, paths, times = router.getClusters(bikes, clues, homeBase, minimalRouting) + if status != 0: + print("Aborted routing.") + + preview_routes['individual_routes'] = paths.copy() + preview_routes['cluster_times'] = times + preview_routes['clusters'] = paths + preview_routes['clue_clusters'] = clusters + for i in range(len(bikes)): + if bikes[i].status != "DISABLED": + preview_routes['individual_routes'][i] = router.getRouteHDPolyline(bikes[i], clusters[i][0]) + + # set routes - TESTING routes['individual_routes'] = paths.copy() routes['cluster_times'] = times routes['clusters'] = paths - for i in range(len(bikes)): - if bikes[i].status == "ACTIVE": - routes['individual_routes'][i] = router.getRouteHDPolyline(bikes[i], clusters[i][0]) + for i, bike in enumerate(bikes): + if bike.status == "DISABLED": continue + bike.setCluster(clusters[i]) routes_last_changed = time.time() print("Finished calculating clusters/routes.") currently_updating = False + route_update_required = False def updateRoutes(): # if necessary @@ -52,6 +84,7 @@ def updateRoutes(): # if necessary currently_updating = True t = Thread(target=updateRoutes_background) t.start() + return 0 # interface functions @@ -73,21 +106,20 @@ def getHomeBaseJSON(timestamp): def setHomeBase(latitude, longitude): - homeBase.setCoords(latitude, longitude) - home_last_changed = time.time() + global home_last_changed + try: + latitude = float(latitude) + longitude = float(longitude) + homeBase.setCoords(latitude, longitude) + home_last_changed = time.time() + return 0 + except: + return -1 -def getBikeCluePair(team_name): - b = None; - c = None - for bike in bikes: - if bike.name == team_name: - b = bike - for clue in clues: - if clue.name == b.target_name: - c = clue - break - break +def getBikeCluePair(team_index): + b = bikes[team_index-1] + c = b.target_clue return b, c @@ -97,41 +129,31 @@ def getRoutesJSON(timestamp): return False -def deleteBike(team_name): - for bike in bikes: - if bike.name == team_name: # already exists - bike.disable() - updateRoutes() - return 0 # OK - return 4 # bike does not exist - - -def addBike(team_name, latitude, longitude): - for bike in bikes: - if bike.name == team_name: # already exists - return 4 # already exists - newBike = Bike(latitude, longitude, team_name, "ACTIVE") - bikes.append(newBike) - updateRoutes() +def pingBike(team_index, latitude, longitude): + bike = bikes[team_index-1] + bike.ping() + bike.setCoords(latitude, longitude) return 0 +def setBikeEnabled(team_index, enabled): + global route_update_required + bike = bikes[team_index-1] + route_update_required = True + if enabled: + bike.enable() + else: + bike.disable() + return 0 -def pingBike(team_name, latitude, longitude): - for bike in bikes: - if bike.name == team_name: - bike.ping() - bike.setCoords(latitude, longitude) - return 0 - return 4 # team not found - +def setBikeDeadline(team_index, datetime_string): + global route_update_required + new_deadline = datetime.datetime.strptime(datetime_string, "%Y-%m-%dT%H:%M") + bikes[team_index-1].setDeadline(new_deadline) + route_update_required = True + return 0 def getBikesJSON(): - global bikes - old_length = len(bikes) - bikes = [x for x in bikes if x.checkStatus() >= 0] - if old_length != len(bikes): - updateRoutes() - return [x.toJSON() for x in bikes] + return [ x.toJSON() for x in bikes ] def getCluesJSON(timestamp): @@ -141,27 +163,29 @@ def getCluesJSON(timestamp): def addClue(clue_name, clue_info, longitude, latitude, status="UNVISITED"): - global clues_last_changed + global clues_last_changed, route_update_required for clue in clues: if clue.name == clue_name: return -1 # clue already exists newClue = Clue(latitude, longitude, clue_name, clue_info, status) clues.append(newClue) clues_last_changed = time.time() + route_update_required = True return 0 def deleteClue(clue_name): + global route_update_required for i, clue in enumereate(clues): if clue.name == clue_name: clues.pop(i) clues_last_changed = time.time() - updateRoutes() + route_update_required = True break def visitClue(clue_name, unvisit=False): - global clues_last_changed + global clues_last_changed, route_update_required for clue in clues: if clue.name == clue_name: if clue.status == "VISITED": @@ -172,41 +196,45 @@ def visitClue(clue_name, unvisit=False): return 3 # already visited clue.visit() clues_last_changed = time.time() - updateRoutes() + route_update_required return 0 # OK return 2 # no clue def toggleEnableClue(clue_name): - global clues_last_changed + global clues_last_changed, route_update_required for clue in clues: if clue.name == clue_name: clue.toggle_enable() clues_last_changed = time.time() - updateRoutes() + route_update_required = True return 0 # OK return 2 # no clue def toggleClueRequired(clue_name): - global clues_last_changed + global clues_last_changed, route_update_required for clue in clues: if clue.name == clue_name: clue.toggle_required() clues_last_changed = time.time() - updateRoutes() + route_update_required + return 0 # OK + return 2 # no clue + +def assignClueToTeam(clue_name, team_index): + global clues_last_changed, route_update_required + for clue in clues: + if clue.name == clue_name and clue.status != "ASSIGNED": + clue.set_team(team_index) + clues_last_changed = time.time() + route_update_required = True return 0 # OK return 2 # no clue +def visitClueTeam(team_index, clue_name): + global clues_last_changed, route_update_required + b = bikes[team_index-1] # Team 1 -> index 0 -def visitClueTeam(team_name, clue_name): - global clues_last_changed - b = None - for bike in bikes: - if bike.name == team_name: - b = bike - break - else: - return 4 # team not found c = None for clue in clues: if clue.name == clue_name: @@ -219,9 +247,9 @@ def visitClueTeam(team_name, clue_name): # if visited clue is the expected one (current target) # no need to recalculate - if clue_name == b.target_name: - if c.distanceTo(b) < CLUE_MIN_DISTANCE: - return 3 # too far away + if c == b.target_clue: + #if c.distanceTo(b) < CLUE_MIN_DISTANCE: + # return 3 # too far away b.visitTarget() clues_last_changed = time.time() return 0 @@ -229,7 +257,7 @@ def visitClueTeam(team_name, clue_name): # otherwise c.visit() clues_last_changed = time.time() - updateRoutes() # must be updated due to unexpected visitation + route_update_required def load(filename=None): @@ -284,6 +312,7 @@ def loadDirty(filename=None): if len(latlong) != 2: continue latitude = float(latlong[0]) longitude = float(latlong[1]) + print(longitude) clues.append(Clue(latitude, longitude, name, info, "UNVISITED")) except: @@ -305,5 +334,8 @@ def save(): for clue in clues: csvwriter.writerow([clue.name, clue.latitude, clue.longitude, clue.info, clue.status]) - +bikes = [ Bike(homeBase.latitude, homeBase.longitude, "Team 1"), + Bike(homeBase.latitude, homeBase.longitude, "Team 2"), + Bike(homeBase.latitude, homeBase.longitude, "Team 3"), + Bike(homeBase.latitude, homeBase.longitude, "Team 4") ] #load("all_clues.csv") \ No newline at end of file diff --git a/dashboard_website/router.py b/dashboard_website/router.py index e0e0720..4840c8d 100644 --- a/dashboard_website/router.py +++ b/dashboard_website/router.py @@ -5,10 +5,11 @@ import requests from sklearn.cluster import KMeans from datastructs import * +import db host = "http://acetyl.net:5000" # queries acetyl.net:5000, the OSRM engine -endtime = datetime.datetime(2023, 11, 18, hour=18, minute=45) # 11/18/2023 6:35pm +endtime = datetime.datetime(2024, 11, 16, hour=18, minute=45) # 11/18/2023 6:35pm # external facing functions @@ -42,38 +43,36 @@ def getRouteFastPolyline(bike, clue): return p -def getZSP(bike, home, clue_cluster): - pass - - # determines clusters based on current bikes and clues -def getClusters(bikes, clues, endpoint): +def getClusters(bikes, clues, endpoint, minimal=True): + status = 0 clusters = [[] for bike in bikes] route_geos = [[] for bike in bikes] times = {} - active_indices = [i for i in range(len(bikes)) if bikes[i].status == "ACTIVE"] - active_bikes = [bike for bike in bikes if bike.status == "ACTIVE"] + active_indices = [i for i in range(len(bikes)) if bikes[i].status != "DISABLED"] + active_bikes = [bike for bike in bikes if bike.status != "DISABLED"] if len(active_bikes) == 0: - return clusters, route_geos, times - active_clues = [clue for clue in clues if clue.status == "UNVISITED"] + return status, clusters, route_geos, times + active_clues = [ clue for clue in clues if (clue.status != "VISITED" and clue.status != "DISABLED") ] # select only active bikes # select only unvisited clues - clusters_t, route_geos_t, times_t = cluster_and_optimize( - active_clues, active_bikes, endpoint + status_c, clusters_t, route_geos_t, times_t = cluster_and_optimize( + active_clues, bikes, endpoint, minimal ) - for i in range(len(active_indices)): - route_geos[active_indices[i]] = route_geos_t[i] - clusters[active_indices[i]] = clusters_t[i] - bikes[active_indices[i]].setCluster(clusters_t[i]) - times[bikes[active_indices[i]].name] = times_t[i] + if status_c != 0: # routing canceled + return -1, [], [], [] + for i in range(len(bikes)): + route_geos[i] = route_geos_t[i] + clusters[i] = clusters_t[i] + times[i] = times_t[i] # return list of clue clusters corresponding to bikes - return clusters, route_geos, times + return status, clusters, route_geos, times # utility functions (internal) def cluster_and_optimize( - clues: [Clue], bikes: [Bike], end: Point, time_diff=0.25, max_time=24, n=2 + clues: [Clue], bikes: [Bike], end: Point, minimal : bool, time_diff=0.25, max_time=24 ): """ Takes a dataframe of gps coordinates, a list of centroids, and an end point and returns a dataframe with a cluster @@ -85,25 +84,43 @@ def cluster_and_optimize( :param n: the number of routes to create :return: a list of lists of clues (ordered by position on route), and a list of json route geojsons """ + status = 0 # OVERRIDE MAX TIME max_time = datetime.datetime.now() - endtime max_time = max_time.seconds / 3600 - routes = [ - clues - ] # one bike = one set of routes. only need to remove the faraway waypoints - if len(bikes) > 1: - # Create a new column with normalized gps coordinates and centroids - normalized_points, norm_centroids = __normalize_points(clues, bikes) - # Cluster the coordinates - kmeans = KMeans(n_clusters=len(norm_centroids), init=norm_centroids) - kmeans.fit(normalized_points) - - # Split the clues into clusters based on the cluster labels - routes = [[] for i in range(len(norm_centroids))] - for i, label in enumerate(kmeans.labels_): - routes[label].append(clues[i]) - - routes = __minimize_route_time_diff(routes, bikes, end, time_diff, n) + + # Create a new column with normalized gps coordinates and centroids + active_indices = [i for i in range(len(bikes)) if bikes[i].status != "DISABLED"] + active_bikes = [bike for bike in bikes if bike.status != "DISABLED"] + + normalized_points, norm_centroids = __normalize_points(clues, active_bikes) + # Cluster the coordinates + kmeans = KMeans(n_clusters=len(norm_centroids), init=norm_centroids) + kmeans.fit(normalized_points) + + # assign pre-determined clues to bikes + already_assigned_clues = [] + routes = [[] for i in bikes] + for clue in clues: + if clue.assigned_team != 0 and ((clue.assigned_team-1) in active_indices): + routes[clue.assigned_team - 1].append(clue) + already_assigned_clues.append(clue) + + # if we are not doing a hard reset, keep the current target clues + if minimal: + for i, bike in enumerate(bikes): + if (bike.status != "DISABLED") and (bike.target_clue != None) and (bike.target_clue not in routes[i]): + routes[i].append(bike.target_clue) + already_assigned_clues.append(clue) + + # Split the remaining clues into clusters based on the cluster labels + for i, label in enumerate(kmeans.labels_): + if clues[i].assigned_team == 0 and (clues[i] not in already_assigned_clues): + routes[active_indices[label]].append(clues[i]) + + routes = __minimize_route_time_diff(routes, bikes, end, minimal, time_diff) + if routes == -1: + return -1, [], [], [] # Remove waypoints from the longest route until the trip time is less than the max time for i in range(len(routes)): @@ -116,7 +133,7 @@ def cluster_and_optimize( for i, route in enumerate(routes): route_json = __get_json( __clues_to_string(route), - __clues_to_string([bikes[i]]), + __clues_to_string([bikes[i].target_clue if (minimal and (bikes[i].target_clue != None)) else bikes[i]]), __clues_to_string([end])[:-1], ) geometries.append(route_json["trips"][0]["geometry"]["coordinates"]) @@ -126,13 +143,15 @@ def cluster_and_optimize( times.append(eta_str) # Use the waypoint_index to reorder each route + for i, route in enumerate(routes): route2 = ["" for x in route] - for j, k in enumerate(route_waypoints[i][1:-1]): + start_index = 0 if (minimal and (bikes[i].target_clue != None)) else 1 # if starting route with first clue, first index of trip must be included + for j, k in enumerate(route_waypoints[i][start_index:-1]): route2[k["waypoint_index"] - 1] = route[j] routes[i] = route2 - return routes, geometries, times + return status, routes, geometries, times def __clues_to_string(points: [Clue]): @@ -199,7 +218,7 @@ def __get_trip_time( def __minimize_route_time_diff( - routes: [Clue], starts: [Point], end: Point, time_diff, n + routes: [Clue], bikes: [Bike], end: Point, minimal: bool, time_diff ): """ Takes a list of lists of coordinates, a list of start points, an end point, a time difference, and a number of routes @@ -210,17 +229,25 @@ def __minimize_route_time_diff( :param n: the number of routes :return: a list of lists of coordinates """ - times = [] + + if db.should_cancel_routing(): return -1 + + active_indices = [i for i in range(len(bikes)) if bikes[i].status != "DISABLED"] + starts = [ bike.target_clue if (minimal and (bike.target_clue != None)) else bike for bike in bikes ] # all bikes regardless of enabled + max_times = [ bike.timeTilDeadline()/3600 for bike in bikes if bike.status != "DISABLED"] + times = [ ] for i, route in enumerate(routes): + if bikes[i].status == "DISABLED": continue times.append( __get_trip_time( __clues_to_string(route), len(route), __clues_to_string([starts[i]]), __clues_to_string([end])[:-1], - ) + ) * bikes[i].time_modifier ) + if db.should_cancel_routing(): return -1 # Find the average trip time average_time = np.mean(times) @@ -237,18 +264,18 @@ def __minimize_route_time_diff( closest_coordinate = __find_closest_coordinate( routes[sorted_indices[-1]], __mean_center(routes[sorted_indices[0]]) ) - routes[sorted_indices[0]].append(closest_coordinate) - routes[sorted_indices[-1]].remove(closest_coordinate) + routes[active_indices[sorted_indices[0]]].append(closest_coordinate) + routes[active_indices[sorted_indices[-1]]].remove(closest_coordinate) # Recursively minimize the time difference between the routes - return __minimize_route_time_diff(routes, starts, end, time_diff, n) + return __minimize_route_time_diff(routes, bikes, end, minimal, time_diff) # If the difference of the longest trip time from average is less than the time difference, return the routes return routes def __remove_longest_waypoints( - route_coordinates: [Clue], start: Bike, end: Point, max_time + route_coordinates: [Clue], start: Bike, end: Point, max_time, start_already_set=False ): """ Takes a list of coordinates, a start point, an end point, and a maximum time and returns a list of coordinates @@ -258,6 +285,11 @@ def __remove_longest_waypoints( :param max_time: the maximum time :return: a list of coordinates """ + if len(route_coordinates) < 1: + return [] + + if start.status != "DISABLED" and start.target_clue != None: # already assigned a clue + start = start.target_clue # Find the trip time for the route route_time = __get_trip_time( __clues_to_string(route_coordinates), @@ -269,10 +301,11 @@ def __remove_longest_waypoints( # If the trip time is greater than the max time, remove the waypoint with the longest distance from the mean if route_time > max_time: route_mean = __mean_center(route_coordinates) - furthest_coordinate = __find_farthest_coordinate(route_coordinates, route_mean) - route_coordinates.remove(furthest_coordinate) + farthest_coordinates = __find_farthest_coordinates(route_coordinates, route_mean) + for farthest_coordinate in farthest_coordinates: + route_coordinates.remove(farthest_coordinate) - return __remove_longest_waypoints(route_coordinates, start, end, max_time) + return __remove_longest_waypoints(route_coordinates, start, end, max_time, start_already_set=True) return route_coordinates @@ -348,22 +381,34 @@ def __find_closest_coordinate(clues: [Clue], centroid: Point): return closest_coordinate -def __find_farthest_coordinate(clues: [Clue], centroid: Point): +def __find_farthest_coordinates(clues: [Clue], centroid: Point, n:int=1): """ Takes a list of coordinates and a centroid and returns the clue in the list that is farthest from the centroid :param clues: the list of coordinates :param centroid: the centroid :return: the clue in the list that is farthest from the centroid """ - farthest_coordinate = clues[0] - farthest_coordinate_distance = __distance(farthest_coordinate, centroid) - - for clue in clues: - if __distance(clue, centroid) > farthest_coordinate_distance: - farthest_coordinate = clue - farthest_coordinate_distance = __distance(clue, centroid) - - return farthest_coordinate + + farthest_coordinates = [] + + for _ in range(n): + farthest_coordinate = clues[0] + # remove only unrequired clues + i = 1 + while farthest_coordinate.required or (farthest_coordinate in farthest_coordinates): + farthest_coordinate = clues[i] + i += 1 + + farthest_coordinate_distance = __distance(farthest_coordinate, centroid) + + for clue in clues: + if __distance(clue, centroid) > farthest_coordinate_distance and (not clue.required) and (clue not in farthest_coordinates): + farthest_coordinate = clue + farthest_coordinate_distance = __distance(clue, centroid) + + farthest_coordinates.append(farthest_coordinate) + + return farthest_coordinates def __mean_center(clues: [Clue]): diff --git a/dashboard_website/savefile.csv b/dashboard_website/savefile.csv index 84b6c30..314a385 100644 --- a/dashboard_website/savefile.csv +++ b/dashboard_website/savefile.csv @@ -1 +1,56 @@ name,latitude,longitude,info,status +A1,42.3430402738135,-71.0675801105357,Peters Park,UNVISITED +A3,42.340735169280684,-71.0822142949978,Wally's Cafe Jazz Club,UNVISITED +A4,42.340965725249276,-71.07004200293923,Cathedral of the Holy Cross,UNVISITED +A5,42.34236478201366,-71.07314991031272,Betances Mural (80%),UNVISITED +A6,42.34355826498339,-71.0779098454636,Harriet Tubman Memorial,UNVISITED +A7,42.343917124461065,-71.06620549483627,Myers+Chang,UNVISITED +A8,42.33836232114676,-71.0667887997758,Hidden Kitchen,UNVISITED +A10,42.363845469421676,-71.10129788828729,La Fabrica Central Restaurant,UNVISITED +A11,42.33000580911709,-71.08345739566941,"Dudley Cafe , Dudley Square",UNVISITED +A12,42.321503645221576,-71.0863561083245,Malcolm X House,UNVISITED +A13,42.329719158213535,-71.08393017974034,"Nubian Station, Roxbury",UNVISITED +A14,42.32995937830152,-71.08425706494715,soleil,UNVISITED +A15,42.31163492909362,-71.0944883387719,old bear pens,UNVISITED +A17,42.33240052352865,-71.10001875238213,Lily's Gourmet Pasta Express,UNVISITED +A18,42.30940190779075,-71.11503165360074,Loring Greenough House,UNVISITED +A19,42.31599303331773,-71.0660247,Strand Theatre,UNVISITED +A20,42.31145648101414,-71.11454193944768,noodle barn (?),UNVISITED +A21,42.30758360954152,-71.11986574437587,Harvard Arboretum,UNVISITED +A22,42.31703238067602,-71.1172176327592,JAMACIA POND BENCH,UNVISITED +A23,42.32234399461923,-71.10765946762163,Lucy Parsons Center (Jamaica Plain),UNVISITED +A24,42.312828161837565,-71.1142521044243,fire station turned JP Licks,UNVISITED +A26,42.35772690789592,-71.0636244852727,Massachusetts State House,UNVISITED +A27,42.35296368437526,-71.13261242438084,Brighton Music Hall,UNVISITED +A28,42.3632259246665,-71.12855843461686,SWISS BAKERY ALSTON,UNVISITED +A29,42.35298420423782,-71.13089261430888,Bonchon Boston,UNVISITED +A30,42.36898885010578,-71.12306457136677,"Anderson bridge: a small brass plaque, the size of one brick, that is located on the +brick wall of the Eastern (Weld Boathouse) side of the bridge, just +north of the middle of the bridge span, about eighteen inches from the +ground in a small alcove. It reads: +""QUENTIN COMPSON +Drowned in the odour of honeysuckle. +1891-1910""",UNVISITED +A31,42.353802,-71.136116,Boston Fire Dept. Station 41,UNVISITED +A32,42.34038115716941,-71.16265204351588,McMullen Museum of art,UNVISITED +A33,42.35037729554579,-71.14555952509022,Hal Connolly Statue,UNVISITED +A34,42.3434052435984,-71.14279038706111,Tasca Restaurant,UNVISITED +A35,42.34965767795442,-71.14606621964128,Brighton High School,UNVISITED +A36,42.36580905967968,-71.12374919164488,Harvard Class of 1959 Chapel,UNVISITED +A38,42.34341395011007,-71.09317746727483,agassiz road duck house,UNVISITED +A39,42.35036326253947,-71.10650807481497,MLK statue on BU campus,UNVISITED +A40,42.348465223671944,-71.09776617622576,Boston Hotel Buckminster,UNVISITED +A41,42.34453026994564,-71.09720444433195,The Viridian (Apartment Building that has Blaze Pizza),UNVISITED +A42,42.34138871428725,-71.14657093175214,Land of Fire Pizzeria,UNVISITED +A43,42.3485725720217,-71.0942113926108,India Quality Restaurant,UNVISITED +A44,42.34605480640728,-71.0960390033173,MAYBE Ted Williams Statue ,UNVISITED +A46,42.35116095548625,-71.10606134524427,BU Beach,UNVISITED +A47,42.3518271762187,-71.11983863137432,Paradise Rock Club (Commonwealth),UNVISITED +A48,42.34517137203154,-71.09676153399613,The Verb Hotel,UNVISITED +A49,42.3315920224197,-71.12571559999999,Cypress Street Playground,UNVISITED +A50,42.335982955313646,-71.11231902990166,The Dutch House,UNVISITED +A51,42.32590914866024,-71.13228373433928,Frederick Law Olmstead National Historic Site,UNVISITED +A52,42.342764551151554,-71.12168301640884,Brookline Booksmith,UNVISITED +A53,42.31487124359676,-71.22693938547769,Echo Bridge,UNVISITED +A54,42.32485823589608,-71.16189103410989,Longyear Museum,UNVISITED +A55,42.3320010747946,-71.15563010527421,Waterworks Museum,UNVISITED diff --git a/dashboard_website/static/css/dashboard.css b/dashboard_website/static/css/dashboard.css index b4110a7..8afb839 100644 --- a/dashboard_website/static/css/dashboard.css +++ b/dashboard_website/static/css/dashboard.css @@ -11,3 +11,10 @@ font-family:HWYGOTH; color: lightgray; } + +.disabled-class > * > * { + color: darkgray; +} +.disabled-class > * { + color: darkgray; +} \ No newline at end of file diff --git a/dashboard_website/static/js/controls.js b/dashboard_website/static/js/controls.js index 3d81032..3f8b8a5 100644 --- a/dashboard_website/static/js/controls.js +++ b/dashboard_website/static/js/controls.js @@ -15,6 +15,13 @@ function loadSave(){ call("loadSave", "clean"); } +function setHome(){ + call("setHome", "home"); +} +function generateRoutes(){ + call("generateRoutes"); +} + function call(command, formid=""){ var formData; if (formid == "") { diff --git a/dashboard_website/static/js/dashboard.js b/dashboard_website/static/js/dashboard.js index 43c07f3..a816327 100644 --- a/dashboard_website/static/js/dashboard.js +++ b/dashboard_website/static/js/dashboard.js @@ -34,19 +34,20 @@ var homeIcon = new baseIcon({iconUrl: 'static/img/marker-home.png'}), var teamIcons = [ unvisitedIcon, new baseIcon({iconUrl: 'static/img/marker-icon-orange.png'}), new baseIcon({iconUrl: 'static/img/marker-icon-red.png'}), - new baseIcon({iconUrl: 'static/img/marker-icon-green.png'}), + new baseIcon({iconUrl: 'static/img/marker-icon-blue.png'}), new baseIcon({iconUrl: 'static/img/marker-icon-yellow.png'}) ]; -var teamIconsReq = [ disabledIconReq, +var teamIconsReq = [ unvisitedIconReq, new baseIcon({iconUrl: 'static/img/marker-icon-orange-req.png'}), new baseIcon({iconUrl: 'static/img/marker-icon-red-req.png'}), - new baseIcon({iconUrl: 'static/img/marker-icon-green-req.png'}), + new baseIcon({iconUrl: 'static/img/marker-icon-blue-req.png'}), new baseIcon({iconUrl: 'static/img/marker-icon-yellow-req.png'}) ]; team1IconReq = new baseIcon({iconUrl: 'static/img/marker-icon-orange.png'}), team2IconReq = new baseIcon({iconUrl: 'static/img/marker-icon-red.png'}), team3IconReq = new baseIcon({iconUrl: 'static/img/marker-icon-green.png'}), team4Icon = new baseIcon({iconUrl: 'static/img/marker-icon-yellow.png'}); -var bikeIcons = {'ACTIVE' : activeBikeIcon, 'INACTIVE' : inactiveBikeIcon} +var bikeIcons = {'ACTIVE' : activeBikeIcon, 'INACTIVE' : inactiveBikeIcon, 'DISABLED' : inactiveBikeIcon} +var bikeStatusOpacities = {"ACTIVE" : 1.0, "INACTIVE" : 0.75, "DISABLED" : 0.0}; function zoomToBike(team_name){ map.panTo(bikes[team_name]['marker'].getLatLng()); @@ -57,14 +58,23 @@ function zoomToClue(clue_name){ function previewZoom(){ var long = parseFloat(document.getElementById("new_clue_longitude").value); var lat = parseFloat(document.getElementById("new_clue_latitude").value); - console.log(long); - console.log(document.getElementById("new_clue_longitude").value); if (!isNaN(long) && !isNaN(lat)){ previewmarker.setLatLng([lat, long]); previewmap.panTo(previewmarker.getLatLng()); } } +function filterClues(text){ + for(var i = 0; i < clues.length; i++){ + var clue = clues[i]['clue_name']; + if((text == "") || (clue.includes(text))){ + clue_rels[clue].setOpacity(1.0); + } else { + clue_rels[clue].setOpacity(0.25); + } + } +} + function drawRoute(route_coords_osrm, team_color) { //osrm lat/long are swapped for (var i = 0; i < route_coords_osrm.length; i++){ @@ -75,6 +85,7 @@ function drawRoute(route_coords_osrm, team_color) { var route = new L.polyline(route_coords_osrm, {color: team_color}); routes_m.addLayer(route); } + function drawRoutes() { routes_m.clearLayers(); for (var i = 0; i < routes['clusters'].length; i++){ @@ -98,20 +109,23 @@ function drawRoutes() { function updateBikeStatus(){ var table = document.getElementById("bike-teams-table"); table.innerHTML = ''; + var index = 1; for (const [key, value] of Object.entries(bikes)) { var name = key; var bike = value; var row = document.createElement("tr"); + if(bike['team_status'] == "DISABLED")row.classList.add("disabled-class"); var namecell = document.createElement("td"); namecell.innerHTML = ""+name+""; - var statuscell = document.createElement("td"); statuscell.innerHTML = "" + bike['team_status'] + " ("+parseInt(bike['time_since_last_contact']).toString()+"s)"; + var statuscell = document.createElement("td"); statuscell.innerHTML = "" + bike['team_status'] + " ("+parseInt(bike['time_since_last_contact']).toString()+"s)"; var targetcell = document.createElement("td"); targetcell.innerHTML = ""+bike['target_clue']+""; - var etacell = document.createElement("td"); etacell.innerHTML = "ETA: "+routes['cluster_times'][key]; + var etacell = document.createElement("td"); etacell.innerHTML = " "; row.appendChild(namecell); row.appendChild(statuscell); row.appendChild(targetcell); row.appendChild(etacell); table.appendChild(row); + index += 1; } } @@ -154,8 +168,8 @@ function drawClues(){ popupdiv.innerHTML += ""; popupdiv.innerHTML += ""; popupdiv.innerHTML += "
"; - popupdiv.innerHTML += "
Assigned team: "; - var clueMarker = L.marker([clues[i]['longitude'], clues[i]['latitude']], {icon: tempIcon}).bindPopup(popupdiv); + popupdiv.innerHTML += "
Assigned team: "; + var clueMarker = L.marker([clues[i]['latitude'], clues[i]['longitude']], {icon: tempIcon}).bindPopup(popupdiv); clue_rels[clues[i]['clue_name']] = clueMarker; if (clues[i]['clue_status'] == "UNVISITED") unvisited_clues_m.addLayer(clueMarker); else visited_clues_m.addLayer(clueMarker); @@ -163,6 +177,18 @@ function drawClues(){ } } +function assignedTeamChanged(clue_name, team){ + fetch(host+'/setClueTeam', { + method: "POST", + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ "clue_name" : clue_name, "bike_team": parseInt(team)})}) // unvisit (if appropriate) + .then((response) => response.json()) + .then((json) => console.log(json)); +} + function toggle_visit_clue(clue_name){ if(!confirm("Are you sure you want to visit/unvisit this clue?")) return; console.log("toggling visited status for "+clue_name); @@ -224,6 +250,25 @@ function toggle_enable_clue(clue_name){ .then((json) => console.log(json)); } +function toggleBikeEnable(team_index){ + var bike = bikes["Team "+team_index]; + team_index = parseInt(team_index); + var page = "/disableTeam"; + if(bike['team_status'] == "DISABLED"){ + page = "/enableTeam"; + } + if(page == "/disableTeam") { + if(!confirm("Are you sure you want to disable this team?"))return; + } + fetch(host+page, { + method: "POST", + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ "team_index" : team_index })}).then((response) => requestLatestInfo()); +} + function addClue(){ var clue_name = document.getElementById("new_clue_name").value; var clue_info = document.getElementById("new_clue_info").value; @@ -273,13 +318,14 @@ function requestLatestInfo(){ } // process bikes if(true || json['bikes_changed']){ // always true since we need constant updates for bikes - bikes_t = json['bikes']; + var bikes_t = json['bikes']; for (var i = 0; i < bikes_t.length; i++){ var name = bikes_t[i]['team_name']; if(name in bikes) { bikes[name]['marker'].setLatLng([bikes_t[i]['latitude'],bikes_t[i]['longitude']]); - if(bikes_t[i]['team_status'] != [name]['team_status'])bikes[name]['marker'].setIcon(bikeIcons[bikes_t[i]['team_status']]); + bikes[name]['marker'].setIcon(bikeIcons[bikes_t[i]['team_status']]); + bikes[name]['marker'].setOpacity(bikeStatusOpacities[bikes_t[i]['team_status']]); for (const [key, value] of Object.entries(bikes_t[i])) { bikes[name][key] = value; } @@ -288,6 +334,7 @@ function requestLatestInfo(){ bikes[name] = bikes_t[i]; bikes[name]['marker'] = bikeMarker; bikeMarker.setIcon(bikeIcons[bikes[name]['team_status']]); + bikeMarker.setOpacity(bikeStatusOpacities[bikes_t[i]['team_status']]); bikes_m.addLayer(bikeMarker); } } @@ -325,11 +372,32 @@ function requestLatestInfo(){ .then((response) => response.json()) .then((json) => handleLatestInfo(json)); } + +function onDeadlineChange(element){ + var team_index = parseInt(element.id.split("_")[1]); + var new_deadline = element.value; + + fetch(host+'/updateTeamDeadline', { + method: "POST", + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ "team_index" : team_index, "new_deadline" : new_deadline})}) + .then((response) => response.json()) + .then((json) => { + console.log(json); + if(json['status'] != "OK") + alert(json['status']); + }); + +} + var intervalId = window.setInterval(function(){ requestLatestInfo(); }, 5000); -var clockINterval = window.setInterval(function(){ +var clockInterval = window.setInterval(function(){ var d = new Date(); document.getElementById("titletime").innerText = d.toLocaleString(); }, 1000); diff --git a/dashboard_website/templates/controls.html b/dashboard_website/templates/controls.html index 97b3b06..be5f659 100644 --- a/dashboard_website/templates/controls.html +++ b/dashboard_website/templates/controls.html @@ -10,8 +10,10 @@ Back
+

+
@@ -23,6 +25,18 @@
+
+
+ + + + + +
+
+
+ +
\ No newline at end of file diff --git a/dashboard_website/templates/index.html b/dashboard_website/templates/index.html index fc61472..9c604e0 100644 --- a/dashboard_website/templates/index.html +++ b/dashboard_website/templates/index.html @@ -136,8 +136,11 @@
-

CLUBHOUSE HQ | +

+ CLUBHOUSE HQ | + | + | + Settings

@@ -174,11 +177,13 @@
Clue Name:
Clue Information:
-- cgit v1.2.3