From 490e2e2f72403a0aefcc55341e2d266a6d47e8c2 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Mon, 2 Dec 2024 22:36:21 +0100 Subject: [PATCH 01/17] Add Vulcan container --- compose.yaml | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/compose.yaml b/compose.yaml index 474cd7e..78d25e3 100644 --- a/compose.yaml +++ b/compose.yaml @@ -128,7 +128,7 @@ services: restart: unless-stopped environment: - MG_PARSER_PORT=32770 - - FLASK_DEBUG=0 + - MG_PARSER_DEBUG=0 command: > gunicorn -w 1 -b 0.0.0.0:32770 'app:create_app()' --capture-output @@ -145,11 +145,42 @@ services: restart: no profiles: ["dev"] environment: - - FLASK_DEBUG=1 + - MG_PARSER_DEBUG=1 command: flask run --host 0.0.0.0 --port 32770 ports: - "32770:32770" + vulcan-prod: &vulcan-prod + image: pp-vulcan-prod + container_name: pp-vulcan + build: + context: ../vulcan-parseport + profiles: ["prod"] + restart: unless-stopped + environment: + - VULCAN_DEBUG=0 + - VULCAN_PORT=32771 + command: > + gunicorn -w 1 -b 0.0.0.0:32771 'app:create_app()' + --capture-output + --access-logfile /logs/access_log + --error-logfile /logs/error_log + expose: + - "32771" + volumes: + - ../../log/vulcan:/logs + + vulcan-dev: + <<: *vulcan-prod + image: pp-vulcan-dev + restart: no + profiles: ["dev"] + environment: + - VULCAN_DEBUG=1 + command: flask run --host 0.0.0.0 --port 32771 + ports: + - "32771:32771" + networks: parseport: From da45248f235a8493beab133b4c340a97994d6506 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 4 Dec 2024 10:57:16 +0100 Subject: [PATCH 02/17] Add Vulcan template + static files --- backend/parseport/settings.py | 1 + backend/parseport/urls.py | 1 + backend/vulcan/__init__.py | 0 backend/vulcan/admin.py | 3 + backend/vulcan/apps.py | 6 + backend/vulcan/migrations/__init__.py | 0 backend/vulcan/models.py | 3 + backend/vulcan/templates/vulcan/index.html | 46 ++ backend/vulcan/tests.py | 3 + backend/vulcan/urls.py | 7 + backend/vulcan/views.py | 12 + compose.yaml | 3 +- nginx.conf | 22 +- vulcan-static/baseScript.js | 604 ++++++++++++++++ vulcan-static/definitions.js | 56 ++ vulcan-static/graph.js | 564 +++++++++++++++ vulcan-static/label_alternatives.js | 68 ++ vulcan-static/mouseover_texts.js | 48 ++ vulcan-static/node.js | 387 ++++++++++ vulcan-static/search.js | 796 +++++++++++++++++++++ vulcan-static/style.css | 14 + vulcan-static/table.js | 593 +++++++++++++++ 22 files changed, 3235 insertions(+), 2 deletions(-) create mode 100644 backend/vulcan/__init__.py create mode 100644 backend/vulcan/admin.py create mode 100644 backend/vulcan/apps.py create mode 100644 backend/vulcan/migrations/__init__.py create mode 100644 backend/vulcan/models.py create mode 100644 backend/vulcan/templates/vulcan/index.html create mode 100644 backend/vulcan/tests.py create mode 100644 backend/vulcan/urls.py create mode 100644 backend/vulcan/views.py create mode 100644 vulcan-static/baseScript.js create mode 100644 vulcan-static/definitions.js create mode 100644 vulcan-static/graph.js create mode 100644 vulcan-static/label_alternatives.js create mode 100644 vulcan-static/mouseover_texts.js create mode 100644 vulcan-static/node.js create mode 100644 vulcan-static/search.js create mode 100644 vulcan-static/style.css create mode 100644 vulcan-static/table.js diff --git a/backend/parseport/settings.py b/backend/parseport/settings.py index c8919dc..5ae494b 100644 --- a/backend/parseport/settings.py +++ b/backend/parseport/settings.py @@ -38,6 +38,7 @@ "corsheaders", "aethel_db", "minimalist_parser", + "vulcan", ] MIDDLEWARE = [ diff --git a/backend/parseport/urls.py b/backend/parseport/urls.py index 66d3d62..b822716 100644 --- a/backend/parseport/urls.py +++ b/backend/parseport/urls.py @@ -44,4 +44,5 @@ namespace="rest_framework", ), ), + path("vulcan/", include("vulcan.urls")), ] diff --git a/backend/vulcan/__init__.py b/backend/vulcan/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/vulcan/admin.py b/backend/vulcan/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/backend/vulcan/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/vulcan/apps.py b/backend/vulcan/apps.py new file mode 100644 index 0000000..a7acd62 --- /dev/null +++ b/backend/vulcan/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class VulcanConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'vulcan' diff --git a/backend/vulcan/migrations/__init__.py b/backend/vulcan/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/vulcan/models.py b/backend/vulcan/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/backend/vulcan/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/backend/vulcan/templates/vulcan/index.html b/backend/vulcan/templates/vulcan/index.html new file mode 100644 index 0000000..749fb97 --- /dev/null +++ b/backend/vulcan/templates/vulcan/index.html @@ -0,0 +1,46 @@ + + + + + Graph visualization + + + + + + + + + +

VULCAN visualization

+
+ + + + + +
+ + +
+ +
+ + + + + + + + + + + + + + + diff --git a/backend/vulcan/tests.py b/backend/vulcan/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/vulcan/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/vulcan/urls.py b/backend/vulcan/urls.py new file mode 100644 index 0000000..854676d --- /dev/null +++ b/backend/vulcan/urls.py @@ -0,0 +1,7 @@ +from django.urls import path, re_path + +from vulcan.views import VulcanView + +urlpatterns = [ + re_path(r'^(?P\w+)?$', VulcanView.as_view(), name='vulcan'), +] diff --git a/backend/vulcan/views.py b/backend/vulcan/views.py new file mode 100644 index 0000000..b7d424c --- /dev/null +++ b/backend/vulcan/views.py @@ -0,0 +1,12 @@ +from django.shortcuts import render +from django.views import View + +# Create your views here. +class VulcanView(View): + def get(self, request, *args, **kwargs): + route_param = kwargs.get('id' , None) + + print("Route param:", route_param) + + context = {'hello': 'Hello, Vulcan!'} + return render(request, 'vulcan/index.html', context) diff --git a/compose.yaml b/compose.yaml index 78d25e3..efc6984 100644 --- a/compose.yaml +++ b/compose.yaml @@ -7,6 +7,7 @@ services: image: nginx:latest volumes: - ./nginx.conf:/etc/nginx/nginx.conf + - ./vulcan-static:/usr/share/nginx/html:ro ports: - "5000:80" @@ -177,7 +178,7 @@ services: profiles: ["dev"] environment: - VULCAN_DEBUG=1 - command: flask run --host 0.0.0.0 --port 32771 + command: gunicorn -k eventlet -w 1 -b 0.0.0.0:32771 'app:create_app()' ports: - "32771:32771" diff --git a/nginx.conf b/nginx.conf index 770efa0..f491496 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,6 +1,7 @@ events { worker_connections 1024; } http { + include /etc/nginx/mime.types; server { listen 80; @@ -10,6 +11,25 @@ http { proxy_connect_timeout 1s; } + location /vulcan { + proxy_pass http://pp-dj:8000/vulcan; + proxy_read_timeout 5s; + proxy_connect_timeout 1s; + } + + location /vulcan-static { + alias /usr/share/nginx/html; + } + + location /socket.io { + proxy_pass http://pp-vulcan:32771; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + location / { proxy_pass http://pp-ng:4200; proxy_http_version 1.1; @@ -17,4 +37,4 @@ http { proxy_set_header Connection "Upgrade"; } } -} \ No newline at end of file +} diff --git a/vulcan-static/baseScript.js b/vulcan-static/baseScript.js new file mode 100644 index 0000000..07fee5e --- /dev/null +++ b/vulcan-static/baseScript.js @@ -0,0 +1,604 @@ +const sio = io(); +// sio.eio.pingTimeout = 120000; // 2 minutes +// sio.eio.pingInterval = 20000; // 20 seconds + +// https://stackoverflow.com/questions/16265123/resize-svg-when-window-is-resized-in-d3-js +// let canvas = create_canvas(100, 10) +// let canvas2 = create_canvas(30, 10) +// let canvas3 = create_canvas(30, 10) + +const canvas_dict = {} +const canvas_name_to_node_name_to_node_dict = {} + +let current_corpus_position = 0 +let corpus_length = 0 +let saved_layout = null + +const window_width = window.innerWidth || document.documentElement.clientWidth || +document.body.clientWidth; +const window_height = window.innerHeight|| document.documentElement.clientHeight|| +document.body.clientHeight; + +var current_mouseover_node = null +var current_mouseover_canvas = null +var current_mouseover_label_alternatives = null +var currently_showing_label_alternatives = false +const linker_dropdowns = {} + +function create_canvas(width_percent, height_percent, name="", only_horizontal_zoom=false) { + // console.log("input width_percent ", width_percent) + let canvas_width = width_percent*window_width/100 + // console.log("original canvas_width ", canvas_width) + let canvas_height = window_height * height_percent/100 + + let container = d3.select("div#chartId") + .append("div") + .attr("class","layoutDiv") + .style("width", width_percent + "%") + .style("padding-bottom", height_percent + "%") + // .style("border", "3px dotted red") + .classed("svg-container", true) + .style("position", "relative") // Add position property to the container div + // .style("padding-bottom", "3px") // Add padding to the bottom to make room for the border + // .style("padding", "0px") + // .style("padding-right", "0px") + + // console.log("container width "+container.node().getBoundingClientRect().width) + + let canvas_gap = 16 // that's 3 + 3 for the borders. The canvas is 10px down due to the css file. 10px less width adds a horizontal margin + let canvas = container.append("svg") + .attr("preserveAspectRatio", "xMinYMin meet") + .attr("viewBox", "0 0 " + (canvas_width - canvas_gap) + " " + (canvas_height - canvas_gap)) // TODO this scales the image, with currently unintended consequences (strings are too small) + // .attr("viewPort", "0 0 " + (canvas_width-16) + " " + (canvas_height-16)) // TODO this scales the image, with currently unintended consequences (strings are too small) + .attr("width", "calc(100% - " + canvas_gap + "px)") + .attr("height", "calc(100% - " + canvas_gap + "px)") + .classed("svg-content-responsive", true) + .style("background-color", "#eeeeee") + .style("border", "3px solid black") + // .style("box-shadow", "0px 0px 0px 10px black inset") + .style("padding-left", "0px") + .style("padding-right", "0px") + .call(d3.zoom().on("zoom", function () + { + // Transform the 'g' element when zooming + // as per "update vor v4" in https://coderwall.com/p/psogia/simplest-way-to-add-zoom-pan-on-d3-js + // console.log(d3.event.transform) + // let actual_transform = { + // x: d3.event.transform.x, + // y: d3.event.transform.y, + // k: d3.event.transform.k + // } + if (only_horizontal_zoom) { + d3.event.transform.y = 0 + } + d3.select(this).select("g").attr("transform", d3.event.transform); + })) + + // console.log("canvas width after creation "+canvas.node().getBoundingClientRect().width) + + + if (name !== "") { + // we need to store the canvas position here, because adding the text_div can change the canvas position + // once we move the text_div to the right position, the canvas will be back in place. + // a similar issue happens when adding a canvas creates a scrollbar. All previously created canvases will + // move, but their slice name labels will not move with them. + let canvas_right = canvas.node().getBoundingClientRect().right + let canvas_top = canvas.node().getBoundingClientRect().top + let text_div = d3.select("div#chartId").append("div") + .attr("class", "removeOnReset") + .style("position", "absolute") // Add position property to the text div + // .style("top", "10px") // Set position relative to the container div + // .style("right", "10px") // Set position relative to the container div + .style("color", "black") + .style("font-size", "20px") + .style("background-color", "#FFFFFF") + .style("border", "3px solid black") + // add margin + .style("padding", "10px") + .text(name); + + // Position the text relative to the SVG element + // let svgRect = container.node().getBoundingClientRect(); // Get the bounding box of the SVG element + // console.log(width_percent) + // let canvas_width = canvas.attr("width")// window_width * width_percent/100.0 + // console.log("canvas width at end "+canvas_width) + // console.log("canvas right at end "+canvas.node().getBoundingClientRect().right) + // use the -100 for a placeholder of the name for now. + // get width of the text_div + let text_div_width = text_div.node().getBoundingClientRect().width + let dx = canvas_right - text_div_width; // Calculate the distance from the right edge of the SVG element + let dy = canvas_top; // Calculate the distance from the top edge of the SVG element + text_div.style("top", dy + "px") // Set the position of the text element + .style("left", dx + "px") // Set the position of the text element + // .style("right", (canvas_width)+"px") + + + // // Append a new 'rect' element to the SVG element + // let rect = text_div.append("rect") + // .attr("rx", 10) + // .attr("ry", 10) + // .attr("x", 0) + // .attr("y", 0) + // .attr("width", 100) + // .attr("height", 20) + // .attr("fill", "white") + // .attr("stroke", "black") + // .attr("stroke-width", 2) + } + + addFilterDefinitions(canvas); + + let g = canvas.append("g") + return g +} + + +d3.select("#previousButton") + .on("click", function() { + if (set_corpus_position(current_corpus_position - 1)) { + sio.emit("instance_requested", current_corpus_position); + } + }); + +d3.select("#nextButton") + .on("click", function() { + if (set_corpus_position(current_corpus_position + 1)) { + sio.emit("instance_requested", current_corpus_position); + } + }); + +d3.select("#searchButton") + .on("click", function() { + onSearchIconClick() + }); + +d3.select("#clearSearchButton") + .on("click", function() { + clearSearch() + }); + +sio.on('connect', () => { + console.log('connected'); + initializeSearchFilters() +}); + +sio.on('disconnect', () => { + console.log('disconnected'); +}); + +sio.on('set_show_node_names', (data) => { + add_node_name_to_node_label = data["show_node_names"] +}) + +sio.on("set_graph", (data) => { + let canvas = canvas_dict[data["canvas_name"]] + remove_graphs_from_canvas(canvas) + let label_alternatives = null + if ("label_alternatives_by_node_name" in data) { + label_alternatives = data["label_alternatives_by_node_name"] + } + let highlights = null + if ("highlights" in data) { + highlights = data["highlights"] + } + let mouseover_texts = null + if ("mouseover_texts" in data) { + mouseover_texts = data["mouseover_texts"] + } + let graph = new Graph(20, 20, data["graph"], canvas, true, 0, + label_alternatives, highlights, mouseover_texts) + graph.registerNodesGlobally(data["canvas_name"]) +}) + +sio.on("set_table", (data) => { + let canvas = canvas_dict[data["canvas_name"]] + remove_strings_from_canvas(canvas) + let label_alternatives = null + if ("label_alternatives_by_node_name" in data) { + label_alternatives = data["label_alternatives_by_node_name"] + } + let highlights = null + if ("highlights" in data) { + highlights = data["highlights"] + // console.log(highlights) + } + let dependency_tree = null + if ("dependency_tree" in data) { + dependency_tree = data["dependency_tree"] + } + let table = new Table(20, 5, data["table"], canvas, label_alternatives, highlights, + dependency_tree) + table.registerNodesGlobally(data["canvas_name"]) +}) + +var alignment_color_scale = d3.scaleLinear().range(['white','#0742ac']); // just kinda experimenting + +sio.on("set_linker", (data) => { + let canvas_name1 = data["name1"] + let canvas_name2 = data["name2"] + // get arbitrary score from the linker + let nn1 = Object.keys(data["scores"])[0] + let nn2 = Object.keys(data["scores"][nn1])[0] + let score = data["scores"][nn1][nn2] + if (isNaN(score)) { + // then score must be a list or a table + let is_table = isNaN(score[0]) + let headcount + let layercount = score.length + if (is_table) { + headcount = score[0].length + } else { + headcount = 0 + } + create_linker_dropdown(canvas_name1, canvas_name2, headcount, layercount) + } + for (let node_name1 in data["scores"]) { + for (let node_name2 in data["scores"][node_name1]) { + let score = data["scores"][node_name1][node_name2] + let node1 = canvas_name_to_node_name_to_node_dict[canvas_name1][node_name1] + let node2 = canvas_name_to_node_name_to_node_dict[canvas_name2][node_name2] + let whitespaceRegex = /\s/g + // console.log(linker_dropdowns) + // console.log(linker_dropdowns[[canvas_name1, canvas_name2]]) + let dropdown = linker_dropdowns[[canvas_name1, canvas_name2]] + // console.log(dropdown_value) + register_mousover_alignment(node1, node2, score, + (canvas_name1+"_"+node_name1+"_"+canvas_name2+"_"+node_name2).replace(whitespaceRegex, "_"), + dropdown) + register_mousover_alignment(node2, node1, score, + (canvas_name2+"_"+node_name2+"_"+canvas_name1+"_"+node_name1).replace(whitespaceRegex, "_"), + dropdown) + } + } +}) + +function create_linker_dropdown(canvas_name1, canvas_name2, headcount, layercount) { + let label = d3.select("div#headerId").append("text") + .text("Linker " + canvas_name1 +"/" + canvas_name2) + .attr("class", "removeOnReset") + .style("margin-left", "10px") + let dropdown = d3.select("div#headerId") + .append("select") + .attr("name", canvas_name1+canvas_name2+"Selector") + .attr("class", "removeOnReset") + .style("margin-left", "5px") + + let option_values = [] + option_values.push(["all"]) + for (let i = 0; i < layercount; i++) { + option_values.push(["layer", i]) + for (let j = 0; j < headcount; j++) { // this actually makes the case distinction between table and list autmatically + option_values.push(["layer+head", i, j]) + } + } + + dropdown.selectAll("option") + .data(option_values) + .enter() + .append("option") + .text(function (d) { + if (d[0] == "all") { + return "All layers (and heads)" + } else if (d[0] == "layer") { + return "Layer " + (d[1] + 1) + } else if (d[0] == "layer+head") { + return "Layer " + (d[1] + 1) + ", Head " + (d[2] + 1) + } + }) + .attr("value", function (d) { return d; }) + + dropdown.property("value", ["all"]) + + linker_dropdowns[[canvas_name1, canvas_name2]] = dropdown +} + +function register_mousover_alignment(mouseover_node, aligned_node, score, linker_id, dropdown) { + mouseover_node.rectangle.on("mouseover.align_"+linker_id, function() { + // check if score is a number + if (isNaN(score)) { + // then score must be a list or table of numbers + let dropdown_value + if (dropdown != null) { + dropdown_value = dropdown.property("value").split(",") + } else { + console.log("Unexpectedly, no dropdown menu value was given for complex alignment. Using default value 'all'.") + dropdown_value = ["all"] + } + if (isNaN(score[0])) { + // then we have a table + if (dropdown_value[0] == "all") { + let table = [] + for (let i = 0; i < score.length; i++) { + let table_row = [] + for (let j = 0; j < score[i].length; j++) { + table_row.push(alignment_color_scale(Math.pow(score[i][j], 0.75))) + } + table.push(table_row) + } + aligned_node.setColor(table) + } else if (dropdown_value[0] == "layer") { + let list = [] + for (let i = 0; i < score[parseInt(dropdown_value[1])].length; i++) { + list.push(alignment_color_scale(Math.pow(score[parseInt(dropdown_value[1])][i], 0.75))) + } + aligned_node.setColor(list) + } else if (dropdown_value[0] == "layer+head") { + aligned_node.setColor(alignment_color_scale(Math.pow(score[parseInt(dropdown_value[1])][parseInt(dropdown_value[2])], 0.75))) + } + } else { + // then we have a list of numbers + if (dropdown_value[0] == "all") { + let list = [] + for (let i = 0; i < score.length; i++) { + // wrap in list to stack layers on top of each other, not left to right + list.push([alignment_color_scale(Math.pow(score[i], 0.75))]) + } + aligned_node.setColor(list) + } else if (dropdown_value[0] == "layer") { + aligned_node.setColor(alignment_color_scale(Math.pow(score[parseInt(dropdown_value[1])], 0.75))) + } + } + } else { + // then score is just a single number + aligned_node.setColor(alignment_color_scale(Math.pow(score, 0.75))) // just kinda experimenting + } + }) + .on("mouseout.align_"+linker_id, function() { + aligned_node.setColor(aligned_node.baseFillColors) + }) +} + +sio.on("set_layout", (layout) => { + saved_layout = layout + corpus_length = layout[0][0].length + set_layout(layout) +}) + +function set_layout(layout) { + let canvas_heights = [] + layout.forEach(row => { + let height_here = 0 + row.forEach(slice => { + let vis_type = slice["visualization_type"] + if (vis_type == "STRING") { + height_here = Math.max(height_here, 15) + } else { + height_here = Math.max(height_here, 100) + } + }) + height_here = height_here * (1 - 0.2 * (row.length - 1)) // make rows with many entries a bit smaller, + // for a more balanced feel. + canvas_heights.push(height_here) + }) + // normalize the heights + let total_height = canvas_heights.reduce((a, b) => a + b, 0) + for (let i = 0; i < canvas_heights.length; i++) { + let normalization_divisor = Math.max(total_height, 100) // to make a single string row not too big + canvas_heights[i] = 45 * canvas_heights[i] / normalization_divisor // TODO the 45 should probably depend on + // TODO the screen height or something + } + for (let i = 0; i < layout.length; i++) { + let row = layout[i] + let height = canvas_heights[i] + row.forEach(slice => { + canvas_dict[slice["name"]] = create_canvas(99/row.length, height, name=slice["name"], + only_horizontal_zoom = slice["visualization_type"] == "STRING") + + }) + } +} + +function reset() { + d3.selectAll(".layoutDiv").remove() + d3.selectAll(".removeOnReset").remove() +} + +sio.on("set_corpus_length", (data) => { + corpus_length = data; + set_corpus_position(current_corpus_position) +}) + +sio.on("search_completed", (data) => { + set_corpus_position(0) + sio.emit("instance_requested", current_corpus_position); +}) + +sio.on("refresh_to_position_zero", (data) => { + set_corpus_position(0) + sio.emit("instance_requested", current_corpus_position); +}) + +sio.on("set_search_filters", (data) => { + SEARCH_PATTERNS = data +}) + +sio.on("server_error", (data) => { + alert("Error on the server side. If you experience issues, please try reloading the page.") +}) + +function remove_graphs_from_canvas(canvas) { + canvas.selectAll("."+NODE_CLASSNAME+", ."+BACKGROUND_CLASSNAME+", ."+EDGE_CLASSNAME).remove() +} + +function remove_strings_from_canvas(canvas) { + canvas.selectAll("."+TOKEN_CLASSNAME).remove() +} + +function set_corpus_position(new_position) { + if (new_position >= 0 && new_position < corpus_length) { + current_corpus_position = new_position + document.getElementById("corpusPositionInput").value = current_corpus_position + 1 + d3.select("#corpusPositionText").text("/" + corpus_length) + reset() + set_layout(saved_layout) + return true + } else { + if (corpus_length == 0) { + // TODO maybe show some message that the search was empty, or that the corpus is empty. + reset() + document.getElementById("corpusPositionInput").value = 0 + d3.select("#corpusPositionText").text("/" + corpus_length) + } + return false + } +} + +d3.select("body").on("keydown", function () { + // keyCode of alt key is 18 + if (d3.event.keyCode == 17) { + if (current_mouseover_node != null && !currently_showing_label_alternatives) { + show_label_alternatives(current_mouseover_node, + current_mouseover_label_alternatives, + current_mouseover_canvas) + currently_showing_label_alternatives = true + } + } else if (d3.event.keyCode == 13) { + if (searchWindowVisible) { + performSearch(true) + } + } +}); + +d3.select("body").on("keyup", function () { + // keyCode of alt key is 18 + if (d3.event.keyCode == 17) { + if (current_mouseover_node != null && currently_showing_label_alternatives) { + hide_label_alternatives(current_mouseover_canvas) + currently_showing_label_alternatives = false + } + } +}); + +d3.select("#corpusPositionInput").on("keypress", function() { + if (d3.event.keyCode == 13) { + let new_position = parseInt(d3.select("#corpusPositionInput").property("value")) - 1 + if (set_corpus_position(new_position)) { + sio.emit("instance_requested", current_corpus_position) + } else { + d3.select("#corpusPositionText").text("/" + corpus_length + " Error: invalid position requested") + } + return true + } +}) + + + +function addFilterDefinitions(canvas) { + let defs = canvas.append("defs") + + // blur + defs.append("filter") + .attr("id", "blur") + .append("feGaussianBlur") + .attr("stdDeviation", 2); + + // cutting color in, inspired by https://css-tricks.com/adding-shadows-to-svg-icons-with-css-and-svg-filters/ + // and syntax inspired by https://stackoverflow.com/questions/12277776/how-to-add-drop-shadow-to-d3-js-pie-or-donut-chart + let filter = defs.append("filter") + .attr("id", "white-border-inset") + + filter.append("feOffset") + .attr("in", "SourceAlpha") + .attr("dx", 0) + .attr("dy", 0) + .attr("result", "offset") + filter.append("feGaussianBlur") + .attr("in", "SourceGraphic") + .attr("stdDeviation", 1.5) + .attr("result", "offsetBlur"); + filter.append("feComposite") + .attr("operator", "out") + .attr("in", "SourceGraphic") + .attr("in2", "offsetBlur") + .attr("result", "inverse"); + filter.append("feFlood") + .attr("flood-color", "white") + .attr("flood-opacity", "1.0") + .attr("result", "color"); + filter.append("feComposite") + .attr("operator", "in") + .attr("in", "color") + .attr("in2", "inverse") + .attr("result", "shadow"); + filter.append("feComposite") + .attr("operator", "over") + .attr("in", "shadow") + .attr("in2", "SourceGraphic") + .attr("result", "final"); + filter.append("feComposite") + .attr("in", "offsetColor") + .attr("in2", "offsetBlur") + .attr("operator", "in") + .attr("result", "offsetBlur"); + // + // let feMerge = filter.append("feMerge"); + // + // feMerge.append("feMergeNode") + // .attr("in", "offsetBlur") + // feMerge.append("feMergeNode") + // .attr("in", "SourceGraphic"); + + // dropshadow, see https://stackoverflow.com/questions/12277776/how-to-add-drop-shadow-to-d3-js-pie-or-donut-chart + // let filter = defs.append("filter") + // .attr("id", "dropshadow") + // + // filter.append("feGaussianBlur") + // .attr("in", "SourceAlpha") + // .attr("stdDeviation", 4) + // .attr("result", "blur"); + // filter.append("feOffset") + // .attr("in", "blur") + // .attr("dx", 2) + // .attr("dy", 2) + // .attr("result", "offsetBlur") + // filter.append("feFlood") + // .attr("in", "offsetBlur") + // .attr("flood-color", "#3d3d3d") + // .attr("flood-opacity", "0.5") + // .attr("result", "offsetColor"); + // filter.append("feComposite") + // .attr("in", "offsetColor") + // .attr("in2", "offsetBlur") + // .attr("operator", "in") + // .attr("result", "offsetBlur"); + // + // let feMerge = filter.append("feMerge"); + // + // feMerge.append("feMergeNode") + // .attr("in", "offsetBlur") + // feMerge.append("feMergeNode") + // .attr("in", "SourceGraphic"); + + + // + // + // + // + // + // + // + // + // + // + // + // + // +} diff --git a/vulcan-static/definitions.js b/vulcan-static/definitions.js new file mode 100644 index 0000000..1a74f8c --- /dev/null +++ b/vulcan-static/definitions.js @@ -0,0 +1,56 @@ +// define arrow shape +const arrowLength = 7 +const arrowWidth = 7 +const refX = -1 +const refY = 0 + +const centerThickness = 5 +const arrowPoints = [[0, 0], //tip + [-arrowLength, -arrowWidth/2], + [-centerThickness, 0], + [-arrowLength, arrowWidth/2], + ] + +// old convoluted shape (but nice try!) +// centerWidthFactor = 0.3 +// frontStrokeWidthFactor = 0.3 +// frontSideLiftFactor = 0.6 +// backDentFactor = 0.7 +// offCenterFrontBackoff = frontStrokeWidthFactor+(centerWidthFactor*2)*frontSideLiftFactor +// arrowPoints = [[0, 0],//tip +// [-arrowLength*frontSideLiftFactor, -arrowWidth/2], +// [-arrowLength*(frontSideLiftFactor+frontStrokeWidthFactor), -arrowWidth/2], +// [-arrowLength*offCenterFrontBackoff, -arrowWidth*centerWidthFactor], +// [-arrowLength, -arrowWidth*centerWidthFactor], +// [-arrowLength*backDentFactor, 0], +// [-arrowLength, arrowWidth*centerWidthFactor], +// [-arrowLength*offCenterFrontBackoff, arrowWidth*centerWidthFactor], +// [-arrowLength*(frontSideLiftFactor+frontStrokeWidthFactor), arrowWidth/2], +// [-arrowLength*frontSideLiftFactor, arrowWidth/2]] + +function marker(color, canvas) { + let id = "arrowhead"+color.replace("#", "") + canvas + .append('marker') + .attr("id", id) + .attr('viewBox', [-arrowLength, -arrowWidth/2, arrowLength, arrowWidth]) + .attr('markerWidth', arrowWidth) + .attr('markerHeight', arrowLength) + .attr('refX', refX) + .attr('refY', refY) + .attr('orient', 'auto-start-reverse') + .append('path') + .attr("shape-rendering", "geometricPrecision") + .attr("d", d3.line()(arrowPoints)) + .style("fill", color); + + return "url(#" + id + ")"; + } + +function make_html_safe(text) { + return text.replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} \ No newline at end of file diff --git a/vulcan-static/graph.js b/vulcan-static/graph.js new file mode 100644 index 0000000..9ca76db --- /dev/null +++ b/vulcan-static/graph.js @@ -0,0 +1,564 @@ +NODE_BUFFER_WIDTH = 50 +NODE_LEVEL_HEIGHT = 100 +HEIGHT_SWITCH_BUFFER = 6 +BACKGROUND_CLASSNAME = "backgroundbox" +EDGE_CLASSNAME = "edge" +EDGE_COLOR = "#000080" +REENTRANT_EDGE_COLOR = "#7777FF" + +let add_node_name_to_node_label = false + + +function child_is_above_parent(edge_position_data) { + return edge_position_data.parentNode.getY() > edge_position_data.childNode.getY() + edge_position_data.childNode.getHeight() + HEIGHT_SWITCH_BUFFER; +} + +function child_is_below_parent(edge_position_data) { + return edge_position_data.childNode.getY() > edge_position_data.parentNode.getY() + edge_position_data.parentNode.getHeight() + HEIGHT_SWITCH_BUFFER; +} + +function edge_is_sideways(edge_position_data) { + return !(child_is_below_parent(edge_position_data) || child_is_above_parent(edge_position_data)); +} + +class Graph { + + constructor(top_left_x, top_left_y, graph_as_dict, canvas, draw_boundary_box=true, margin=0, + node_label_alternatives_by_node_name=null, highlights=null, mouseover_texts=null) { + this.margin = margin + this.node_dict = {}; + this.box_position_dict = {}; + this.graph_as_dict = graph_as_dict + this.canvas = canvas + this.node_label_alternatives_by_node_name = node_label_alternatives_by_node_name + this.highlights = highlights + this.mouseover_texts = mouseover_texts + this.create_all_nodes() + this.compute_node_positions(top_left_x, top_left_y) + if (false) { //draw_boundary_box) { + this.draw_boundary_box(top_left_x, top_left_y) + } + this.draw_graph() + } + + compute_node_positions(top_left_x, top_left_y) { + this.set_total_widths_bottom_up() + this.set_positions_top_down(top_left_x, top_left_y) + } + + draw_graph() { + this.shift_nodes_to_their_positions() + this.drawEdges() + } + + static visit_graph_as_dict_bottom_up(subgraph_as_dict, visitor) { + let results = [] + subgraph_as_dict["child_nodes"].forEach(child => { + results.push(Graph.visit_graph_as_dict_bottom_up(child, visitor)) + }) + return visitor(subgraph_as_dict, results) + } + + recenter(new_width) { + // don't need to to anything here. + } + + static visit_graph_as_dict_top_down(subgraph_as_dict, visitor) { + visitor(subgraph_as_dict) + subgraph_as_dict["child_nodes"].forEach(child => { + Graph.visit_graph_as_dict_top_down(child, visitor) + }) + } + + set_total_widths_bottom_up() { + this.total_widths_dict = {}; + Graph.visit_graph_as_dict_bottom_up(this.graph_as_dict, (current_node, child_widths) => { + if (current_node.is_reentrancy) { + return -NODE_BUFFER_WIDTH; // to compensate for the NODE_BUFFER_WIDTH we add for each child, could be cleaner + } else { + let width_here = child_widths.reduce((a, b) => a + b, 0) + NODE_BUFFER_WIDTH * (child_widths.length - 1); + width_here = Math.max(width_here, this.node_dict[current_node.node_name].getWidth()) + this.total_widths_dict[current_node.node_name] = width_here + return width_here + } + }) + } + + set_positions_top_down(top_left_x, top_left_y) { + let root_node_name = this.graph_as_dict.node_name + this.box_position_dict[root_node_name] = {x: top_left_x + this.margin, y: top_left_y + this.margin} + // set values for all children + Graph.visit_graph_as_dict_top_down(this.graph_as_dict, (current_node) => { + if (!current_node.is_reentrancy) { + let current_node_x = this.box_position_dict[current_node.node_name].x + let current_node_y = this.box_position_dict[current_node.node_name].y + let running_child_width_total = 0 + current_node["child_nodes"].forEach(child => { + if (!child.is_reentrancy) { + let child_width = this.total_widths_dict[child.node_name] + this.box_position_dict[child.node_name] = { + x: current_node_x + running_child_width_total, // left border position + y: current_node_y + this.node_dict[current_node.node_name].getHeight() + NODE_BUFFER_WIDTH + } + running_child_width_total += child_width + NODE_BUFFER_WIDTH + } + }) + } + }) + } + + create_all_nodes() { + Graph.visit_graph_as_dict_top_down(this.graph_as_dict, (current_node) => { + if (!current_node.is_reentrancy) { + let label = current_node.node_label + if (add_node_name_to_node_label && current_node.label_type === "STRING") { + label = current_node.node_name + " / " + label + } + let is_bold = current_node.node_name === this.graph_as_dict.node_name + let node_object = createNode(0, 0, label, current_node.label_type, this.canvas, is_bold, + graphNodeDragged) + let do_highlight = this.highlights != null && current_node.node_name in this.highlights + if (do_highlight) { + node_object.setColor(this.highlights[current_node.node_name]) + } + this.node_dict[current_node.node_name] = node_object + this.registerNodeMouseoverNodeHighlighting(node_object) + if (this.node_label_alternatives_by_node_name != null) { + this.registerNodeAlternativeMouseover(node_object, + this.node_label_alternatives_by_node_name[current_node.node_name]) + } + if (this.mouseover_texts != null && this.mouseover_texts[current_node.node_name] != null) { + this.registerMouseoverTextEvent(node_object, + this.mouseover_texts[current_node.node_name]) + } + } + }) + } + + shift_nodes_to_their_positions() { + Graph.visit_graph_as_dict_top_down(this.graph_as_dict, (current_node) => { + if (!current_node.is_reentrancy) { + let y = this.box_position_dict[current_node.node_name].y + let x = this.get_centered_left_border_for_node(current_node) + this.node_dict[current_node.node_name].translate(x,y) + } + }) + } + + get_centered_left_border_for_node(node_as_dict) { + let left_box_border = this.box_position_dict[node_as_dict.node_name].x + let box_width= this.total_widths_dict[node_as_dict.node_name] + let node_width = this.node_dict[node_as_dict.node_name].getWidth() + return left_box_border + (box_width / 2) - (node_width / 2) + } + + getWidth() { + // console.log("graph width" + this.total_widths_dict[this.graph_as_dict.node_name] + 2 * this.margin) + return this.total_widths_dict[this.graph_as_dict.node_name] + 2 * this.margin + } + + getHeight() { + return this.getBottomY() - this.box_position_dict[this.graph_as_dict.node_name].y + 2 * this.margin + } + + getBottomY() { + let allBottomYs = [] + Object.keys(this.node_dict).forEach(node_name => { + allBottomYs.push(this.box_position_dict[node_name].y + this.node_dict[node_name].getHeight()) + }) + return Math.max(...allBottomYs) + } + + draw_boundary_box(top_left_x, top_left_y) { + const arr = Object.keys( this.box_position_dict ) + .map(key => this.box_position_dict[key].y + this.node_dict[key].getHeight()); + const height = Math.max.apply( null, arr ) - top_left_y; + // draw white box around graph with NODE_BUFFER_WIDTH as margin + this.canvas.append("rect") + .attr("x", top_left_x - NODE_BUFFER_WIDTH - 200) + .attr("y", top_left_y - NODE_BUFFER_WIDTH - 150) + .attr("width", this.total_widths_dict[this.graph_as_dict.node_name] + 2 * NODE_BUFFER_WIDTH + 400) + .attr("height", height + 2 * NODE_BUFFER_WIDTH + 300) + .attr("fill", "white") + .attr("class", BACKGROUND_CLASSNAME) + .lower() + // inner box showing actual boundaries (no margin) + // this.canvas.append("rect") + // .attr("x", top_left_x) + // .attr("y", top_left_y) + // .attr("width", this.total_widths_dict[this.graph_as_dict.node_name]) + // .attr("height", height) + // .attr("fill", '#eeeeee') + } + + drawEdges() { + Graph.visit_graph_as_dict_top_down(this.graph_as_dict, currentNode => { + currentNode.child_nodes.forEach(child => { + let edge_position_data = this.get_edge_position_data(currentNode, child) + let edge_object = this.draw_edge_from_data(edge_position_data); + let edge_label_object = this.draw_edge_label_from_data(edge_position_data); + this.registerEdgeMouseover(edge_object, edge_label_object) + this.registerNodeMouseoverEdgeHighlighting(this.node_dict[currentNode.node_name], edge_object, edge_label_object) + this.registerNodeMouseoverEdgeHighlighting(this.node_dict[child.node_name], edge_object, edge_label_object) + this.node_dict[currentNode.node_name].registerGraphEdge(edge_object, edge_label_object, edge_position_data, this) + this.node_dict[child.node_name].registerGraphEdge(edge_object, edge_label_object, edge_position_data, this) + }) + }) + } + + get_edge_position_data(current_node, child_node) { + // check if edge label ends with "-of" + if (child_node.incoming_edge.endsWith("-of")) { + return { + parentNode: this.node_dict[child_node.node_name], + childNode: this.node_dict[current_node.node_name], + edge_label: child_node.incoming_edge.slice(0, -3), + is_reentrancy: child_node.is_reentrancy, + parentBoxWidth: this.total_widths_dict[child_node.node_name], + childBoxWidth: this.total_widths_dict[current_node.node_name] + } + } else { + return { + parentNode: this.node_dict[current_node.node_name], + childNode: this.node_dict[child_node.node_name], + edge_label: child_node.incoming_edge, + is_reentrancy: child_node.is_reentrancy, + parentBoxWidth: this.total_widths_dict[current_node.node_name], + childBoxWidth: this.total_widths_dict[child_node.node_name] + } + } + } + + draw_edge_label_from_data(edge_position_data) { + let color = edge_position_data.is_reentrancy ? REENTRANT_EDGE_COLOR : EDGE_COLOR + return this.canvas.append("text").data([edge_position_data]) + .text(d => d.edge_label) + .attr("x", d => this.getEdgeLabelXFromData(d)) + .attr("y", d => this.getEdgeLabelYFromData(d)) + .style("font-size", "9px") + .style('fill', color) + // .style("pointer-events", "none") + .style("user-select", "none") + .attr("class", EDGE_CLASSNAME) + } + + EDGE_LABEL_CLOSE_RANGE = 100 + EDGE_LABEL_FAR_RANGE = 350 + + getEdgeLabelXFromData(edge_position_data) { + let p = edge_position_data.parentNode + let c = edge_position_data.childNode + let distance = c.getX() + c.getWidth()/2 - p.getX() - p.getWidth()/2 // negative if child is to the left + if (distance < -this.EDGE_LABEL_FAR_RANGE) { + // child is far to the left, we put label above edge close to child + if (edge_is_sideways(edge_position_data)) { + return c.getX() + c.getWidth() + 20 + } else { + return (c.getX() + c.getWidth()/2) + } + } else if (distance < -this.EDGE_LABEL_CLOSE_RANGE) { + //child is medium to the left, we put label below middle of edge + return (p.getX() + p.getWidth()/2 + c.getX() + c.getWidth()/2)/2-10 // -10 compensates to quasi-center text + } else if (distance < this.EDGE_LABEL_CLOSE_RANGE) { + // child is nearly centered, we put label left of middle of edge + return (p.getX() + p.getWidth()/2 + c.getX() + c.getWidth()/2)/2 + 10 + distance/3 // + 10 just to get a bit of distance + } else if (distance < this.EDGE_LABEL_FAR_RANGE) { + // child is medium to the right, we put label above middle of edge + return (p.getX() + p.getWidth()/2 + c.getX() + c.getWidth()/2)/2+5 + } else { + // child is far to the right, we put label above edge close to child + if (edge_is_sideways(edge_position_data)) { + return c.getX() - 10 - Node.get_hypothetical_node_width(edge_position_data.edge_label) + } else { + return (c.getX() + c.getWidth()/2)-20 + } + } + } + + getEdgeLabelYFromData(edge_position_data) { + let p = edge_position_data.parentNode + let c = edge_position_data.childNode + let yShift = 0 + if (edge_position_data.is_reentrancy && !childIsBelowParent(edge_position_data)) { + yShift = 50 + } + let baseY; + let distance = c.getX() + c.getWidth()/2 - p.getX() - p.getWidth()/2 // negative if child is to the left + if (distance < -this.EDGE_LABEL_FAR_RANGE) { + // child is far to the left, we put label above edge close to child + if (edge_is_sideways(edge_position_data)) { + baseY = c.getY() - 5 + } else { + baseY = c.getY() - 25 + } + } else if (distance < -this.EDGE_LABEL_CLOSE_RANGE) { + //child is medium to the left, we put label below middle of edge + baseY = (p.getY() + p.getHeight() + c.getY()) / 2 + 20 - 0.2*(c.getY()-p.getY()) // +20 to be below center + } else if (distance < this.EDGE_LABEL_CLOSE_RANGE) { + // child is nearly centered, we put label left of middle of edge + baseY = (p.getY() + p.getHeight() + c.getY()) / 2 + } else if (distance < this.EDGE_LABEL_FAR_RANGE) { + // child is medium to the right, we put label above middle of edge + baseY = (p.getY() + p.getHeight() + c.getY()) / 2 - 12 - 0.18*(c.getY()-p.getY()) // -20 to be above center + } else { + // child is far to the right, we put label above edge close to child + if (edge_is_sideways(edge_position_data)) { + baseY = c.getY() - 5 + } else { + baseY = c.getY() - 25 + } + } + return baseY + yShift + } + + draw_edge_from_data(edge_position_data) { + let color = edge_position_data.is_reentrancy ? REENTRANT_EDGE_COLOR : EDGE_COLOR + return this.canvas.append("path").data([edge_position_data]) + .attr("shape-rendering", "geometricPrecision") + .style("stroke", color) + .style("stroke-width", 1.5) + .style("fill", "none") + .attr("marker-end", marker(color, this.canvas)) + .attr("d", d => this.getEdgePathFromData(d)) + .attr("class", EDGE_CLASSNAME) + } + + registerEdgeMouseover(edge_object, edge_label_object) { + this.registerEdgeHighlightingOnObjectMouseover(edge_object, edge_object, edge_label_object) + this.registerEdgeHighlightingOnObjectMouseover(edge_label_object, edge_object, edge_label_object) + } + + registerEdgeHighlightingOnObjectMouseover(object, edge_object, edge_label_object, stroke_width=4) { + object.on("mouseover.edge"+create_alias(), function() { + edge_object.style("stroke-width", stroke_width) + edge_label_object.style("font-size", "15px") + edge_label_object.style("font-weight", "bold") + }) + .on("mouseout.edge"+create_alias(), function() { + edge_object.style("stroke-width", 1.5) + edge_label_object.style("font-size", "9px") + edge_label_object.style("font-weight", "normal") + }) + } + + registerNodeHighlightingOnObjectMouseover(object, node_object) { + let current_stroke_width = parseInt(node_object.rectangle.style("stroke-width")) + let bold_stroke_width = current_stroke_width + 2 + object.on("mouseover", function() { + node_object.rectangle.style("stroke-width", bold_stroke_width) + }) + .on("mouseout", function() { + node_object.rectangle.style("stroke-width", current_stroke_width) + }) + } + + registerNodeAlternativeMouseover(node_object, node_label_alternatives) { + let graph_object = this + node_object.rectangle.on("mouseover.node_alternative", function() { + current_mouseover_node = node_object + current_mouseover_canvas = graph_object.canvas + current_mouseover_label_alternatives = node_label_alternatives + // check if alt key is currently pressed + if (d3.event.ctrlKey) { + show_label_alternatives(node_object, node_label_alternatives, graph_object.canvas) + } + }) + .on("mouseout.node_alternative", function() { + current_mouseover_node = null + current_mouseover_canvas = null + current_mouseover_label_alternatives = null + if (d3.event.ctrlKey) { + hide_label_alternatives(graph_object.canvas) + } + }) + // this below does not seem to be working + // .on("keydown.node_alternative", function() { + // if (d3.event.keyCode == 18) { + // show_label_alternatives(node_object, null, graph_object.canvas) + // } + // }) + // .on("keyup.node_alternative", function() { + // if (d3.event.keyCode == 18) { + // hide_label_alternatives(graph_object.canvas) + // } + // }) + } + + registerMouseoverTextEvent(node_object, node_label_alternatives) { + let graph_object = this + node_object.rectangle.on("mouseover.mouseover_text", function() { + show_mouseover_text(node_object, node_label_alternatives, graph_object.canvas) + }) + .on("mouseout.mouseover_text", function() { + hide_mouseover_text(graph_object.canvas) + }) + } + + registerNodeMouseoverNodeHighlighting(node_object) { + this.registerNodeHighlightingOnObjectMouseover(node_object.rectangle, node_object) + } + + + registerNodeMouseoverEdgeHighlighting(node_object, edge_object, edge_label_object) { + this.registerEdgeHighlightingOnObjectMouseover(node_object.rectangle, edge_object, edge_label_object, 3) + } + + getEdgePathFromData(edge_position_data) { + if (edge_position_data.is_reentrancy) { + let startpoint = this.getReentrancyStartPoint(edge_position_data) + let endpoint = this.getReentrancyEndPoint(edge_position_data) + let edge = d3.path() + edge.moveTo(startpoint.x, startpoint.y) + if (childIsBelowParent(edge_position_data)) { + let verticalCenter = (startpoint.y+endpoint.y) / 2 + edge.bezierCurveTo(startpoint.x, verticalCenter, endpoint.x, startpoint.y, endpoint.x, endpoint.y) + } else { + edge.bezierCurveTo(startpoint.x, startpoint.y + NODE_LEVEL_HEIGHT / 2, + endpoint.x, endpoint.y + NODE_LEVEL_HEIGHT / 2, + endpoint.x, endpoint.y) + } + return edge + } else { + let startpoint = this.getEdgeStartPoint(edge_position_data) + let endpoint = this.getEdgeEndPoint(edge_position_data) + let edge = d3.path() + edge.moveTo(startpoint.x, startpoint.y) + if (edge_is_sideways(edge_position_data)) { + let horizontal_center = (startpoint.x + endpoint.x) / 2 + edge.bezierCurveTo(horizontal_center, startpoint.y, horizontal_center, endpoint.y, endpoint.x, endpoint.y) + } else { + let verticalCenter = (startpoint.y + endpoint.y) / 2 + edge.bezierCurveTo(startpoint.x, verticalCenter, endpoint.x, startpoint.y, endpoint.x, endpoint.y) + } + return edge + } + } + + + + + getEdgeStartPoint(edge_position_data) { + let xDistRatio = getXDistRatio(edge_position_data.parentNode, edge_position_data.childNode, edge_position_data.parentBoxWidth) + let x + let default_x = edge_position_data.parentNode.getX() + edge_position_data.parentNode.getWidth() * 0.8 * sigmoid(xDistRatio) + let y + if (child_is_above_parent(edge_position_data)) { + y = edge_position_data.parentNode.getY() + x = default_x + } else if (child_is_below_parent(edge_position_data)) { + y = edge_position_data.parentNode.getY() + edge_position_data.parentNode.getHeight() + x = default_x + } else { + y = edge_position_data.parentNode.getY() + 0.5 * edge_position_data.parentNode.getHeight() + if (edge_position_data.parentNode.getX() < edge_position_data.childNode.getX()) { + x = edge_position_data.parentNode.getX() + edge_position_data.parentNode.getWidth() + } else { + x = edge_position_data.parentNode.getX() + } + } + return { + x: x, + y: y + } + } + + getEdgeEndPoint(edge_position_data) { + let x + let default_x = edge_position_data.childNode.getX() + 0.5 * edge_position_data.childNode.getWidth() + let y + if (child_is_above_parent(edge_position_data)) { + y = edge_position_data.childNode.getY() + edge_position_data.childNode.getHeight() + x = default_x + } else if (child_is_below_parent(edge_position_data)) { + y = edge_position_data.childNode.getY() + x = default_x + } else { + y = edge_position_data.childNode.getY() + 0.5 * edge_position_data.childNode.getHeight() + if (edge_position_data.parentNode.getX() < edge_position_data.childNode.getX()) { + x = edge_position_data.childNode.getX() + } else { + x = edge_position_data.childNode.getX() + edge_position_data.childNode.getWidth() + } + } + return { + x: x, + y: y + } + } + + + getReentrancyStartPoint(edge_position_data) { + let xDistRatio = getXDistRatio(edge_position_data.parentNode, edge_position_data.childNode, this.getWidth()*2) + return { + x: edge_position_data.parentNode.getX() + edge_position_data.parentNode.getWidth() * 0.8 * sigmoid(xDistRatio), + y: edge_position_data.parentNode.getY() + edge_position_data.parentNode.getHeight() + } + } + + getReentrancyEndPoint(edge_position_data) { + let xDistRatio = getXDistRatio(edge_position_data.childNode, edge_position_data.parentNode, this.getWidth()*2) + let endPointX = edge_position_data.childNode.getX() + edge_position_data.childNode.getWidth() * 0.8 * sigmoid(xDistRatio) + if (childIsBelowParent(edge_position_data)) { + return { + x: endPointX, + y: edge_position_data.childNode.getY() + } + } else { + return { + x: endPointX, + y: edge_position_data.childNode.getY() + edge_position_data.childNode.getHeight() + } + } + } + + + + + registerNodesGlobally(canvas_name) { + let dict_here = {} + for (let node_name in this.node_dict) { + dict_here[node_name] = this.node_dict[node_name] + } + canvas_name_to_node_name_to_node_dict[canvas_name] = dict_here + } + + +} + +function getXDistRatio(mainNode, referenceNode, normalizingFactor) { + return (referenceNode.getX()+referenceNode.getWidth()/2 - mainNode.getX() - mainNode.getWidth()/2)/normalizingFactor +} + +function sigmoid(z) { + return 1 / (1 + Math.exp(-z)); +} + + +function childIsBelowParent(edge_position_data) { + return edge_position_data.childNode.getY() > + edge_position_data.parentNode.getY()+edge_position_data.parentNode.getHeight() +} + +function graphNodeDragged(d) { + // console.log(node_object.registeredEdges.length) + d.x += d3.event.dx; + d.y += d3.event.dy; + let registeredEdges = ALL_NODES[d3.select(this).data()[0].id].registeredEdges + for (let i = 0; i < registeredEdges.length; i++) { + let edge = registeredEdges[i][0] + let edge_label = registeredEdges[i][1] + let edge_position_data = registeredEdges[i][2] + let graph = registeredEdges[i][3] + edge.attr("d", d => graph.getEdgePathFromData(edge_position_data)) + edge_label.attr("x", d => graph.getEdgeLabelXFromData(edge_position_data)) + .attr("y", d => graph.getEdgeLabelYFromData(edge_position_data)) + } + // console.log(registeredEdges.length) + d3.select(this).attr("transform", "translate(" + d.x + "," + d.y + ")"); +} + +let alias = 0 +function create_alias() { + alias += 1 + return alias +} diff --git a/vulcan-static/label_alternatives.js b/vulcan-static/label_alternatives.js new file mode 100644 index 0000000..9002b98 --- /dev/null +++ b/vulcan-static/label_alternatives.js @@ -0,0 +1,68 @@ +NODE_ALTERNATIVE_CLASSNAME = "node_alternative" + +function show_label_alternatives(node_object, label_alternatives, canvas) { + // canvas.append("rect") + // .attr("x", node_object.getX() + 50) + // .attr("y", node_object.getY()) + // .attr("width", 50) + // .attr("height", 50) + // .attr("class", NODE_ALTERNATIVE_CLASSNAME) + if (label_alternatives != null) { + let first_x = node_object.getX() + node_object.getWidth() + 10 + let current_x = first_x + let y = node_object.getY() + let max_height = 0 + let all_new_nodes = [] + for (let i = 0; i < label_alternatives.length; i++) { + let label_alternative = label_alternatives[i] + let new_node = createNode(current_x, y, label_alternative['label'], label_alternative['format'], canvas, + false, null, NODE_ALTERNATIVE_CLASSNAME) + if (new_node.getWidth() < 60) { + new_node.setWidth(60) + } + all_new_nodes.push(new_node) + current_x += Math.max(60, new_node.getWidth()) + 10 + max_height = Math.max(max_height, new_node.getHeight()) + } + + canvas.append("rect") + .attr("x", first_x - 5) + .attr("y", y - 5) + .attr("width", current_x - first_x + 10) + .attr("height", max_height + 30) + .attr("fill", "#99FFBB") + .attr("stroke", "#005522") + .attr("stroke-width", 3) + .attr("class", NODE_ALTERNATIVE_CLASSNAME) + + all_new_nodes.forEach(function (new_node) { + d3.select(new_node.group.node()).raise() + }) + + for (let i = 0; i < label_alternatives.length; i++) { + canvas.append("text") + .attr("x", all_new_nodes[i].getX() + all_new_nodes[i].getWidth() / 2) + .attr("y", y + max_height + 15) + .attr("text-anchor", "middle") + .attr("class", NODE_ALTERNATIVE_CLASSNAME) + .text(make_score_human_readable(label_alternatives[i]['score'])) + } + } + +} + +function make_score_human_readable(score) { + if (score >= 100) { + return score.toFixed(1) + } + if (score >= 0.001) { + return score.toFixed(5) + } else { + // use scientific notation + return score.toExponential(3) + } +} + +function hide_label_alternatives(canvas) { + canvas.selectAll("."+NODE_ALTERNATIVE_CLASSNAME).remove() +} \ No newline at end of file diff --git a/vulcan-static/mouseover_texts.js b/vulcan-static/mouseover_texts.js new file mode 100644 index 0000000..bd55908 --- /dev/null +++ b/vulcan-static/mouseover_texts.js @@ -0,0 +1,48 @@ +MOUSEOVER_TEXT_CLASSNAME = "mouseover_text" + +function show_mouseover_text(node_object, mouseover_text, canvas) { + let x = node_object.getX() + node_object.getWidth() + 10 + let y = node_object.getY() + + let lines = mouseover_text.split("\n") + + let width = 500 + let height = 25 * lines.length + 20 + + + canvas.append("rect") + .attr("x", x) + .attr("y", y) + .attr("rx", 10) + .attr("ry", 10) + .attr("stroke", "black") + .attr("stroke-width", 1) + .attr("width", width) + .attr("height", height) + .attr("fill", "#d6eef5") + .attr("class", MOUSEOVER_TEXT_CLASSNAME) + + + // c.f. https://stackoverflow.com/questions/16701522/how-to-linebreak-an-svg-text-within-javascript/16701952#16701952 + + let textfield = canvas.append("text") + .attr("x", x) + .attr("y", y) + .attr("class", MOUSEOVER_TEXT_CLASSNAME) + lines.forEach(function(line, index) { + let bold = false + if (index === 0 && lines.length > 1) { + bold = true + } + textfield.append("tspan") + .attr("x", x + 10) + .attr("dy", 25) + .attr("font-weight", bold ? "bold" : "normal") + .text(line) + }) + +} + +function hide_mouseover_text(canvas) { + canvas.selectAll("."+MOUSEOVER_TEXT_CLASSNAME).remove() +} \ No newline at end of file diff --git a/vulcan-static/node.js b/vulcan-static/node.js new file mode 100644 index 0000000..d3d8820 --- /dev/null +++ b/vulcan-static/node.js @@ -0,0 +1,387 @@ +const NODE_CLASSNAME = "node" +const GRAPH_LABEL_MARGIN = 20 + +const SHADOW_OVERSIZE = 2 + +const ALL_NODES = {} + +let UNIQUE_NODE_COUNTER = 0 + +class Node { + constructor(node_position, group, rectangle, content, border_color, shadow) { + this.position = node_position[0] + this.group = group + this.rectangle = rectangle + this.content = content + this.border_color = border_color + this.rectangle.attr("stroke", border_color) + this.shadow = shadow + this.baseFillColors = "white" + this.currentFillColors = "white" + + // for debugging: draw a boundary rectangle + // this.group.append("rect") + // .attr("x", -2) + // .attr("y", -2) + // .attr("width", this.getWidth()+4) + // .attr("height", this.getHeight()+4) + // .attr("stroke", "red") + // .attr("stroke-width", 2) + // .attr("fill-opacity", 0.0) + + // console.log("mask_rect_" + this.position.id) + this.mask_rect = this.group.append("defs").append("clipPath") + .attr("id", "mask_rect_" + this.position.id) + .append("rect") + .attr("rx", this.rectangle.attr("rx")) + .attr("ry", this.rectangle.attr("ry")) + .attr("x", 0) + .attr("y", 0) + .attr("width", this.getWidth()) + .attr("height", this.getHeight()) + // .attr("x", -200) + // .attr("y", -200) + // .attr("width", 400) + // .attr("height", 400) + .attr("class", this.rectangle.attr("class")) + this.setColor(this.currentFillColors) + // get absolute position on page + // console.log("constructor " + this.position.id) + // console.log(this.group.select(".color_rect").node().getBoundingClientRect().x) + // console.log(this.mask_rect.node().getBoundingClientRect().x) + // console.log(this.rectangle.node().getBoundingClientRect().x) + this.registeredEdges = [] + } + + translate(x, y) { + // this.group.attr("transform", "translate(" + x + "," + y + ")") + this.position.x = x + this.position.y = y + this.group.attr("transform", "translate(" + this.position.x + "," + this.position.y + ")") + // this.mask_rect.attr("transform", "translate(" + this.position.x + "," + this.position.y + ")") + // console.log("translate " + this.position.id) + // console.log(this.group.select(".color_rect").node().getBoundingClientRect().x) + // console.log(this.mask_rect.node().getBoundingClientRect().x) + // console.log(this.rectangle.node().getBoundingClientRect().x) + } + + getWidth() { + return parseFloat(this.rectangle.attr("width")) + } + + setWidth(width) { + this.rectangle.attr("width", width) + this.content.recenter(width) + if (this.shadow != null) { + this.shadow.attr("width", width + 2*SHADOW_OVERSIZE) + } + this.mask_rect.attr("width", width) + this.setColor(this.currentFillColors) // need to update the size of the color rects + } + + static get_hypothetical_node_width(node_label) { + // if (isNaN(node_label.length*6.5 + 22)) { + // console.log("node_label length is NaN: " + node_label) + // } + let text_width = this.getTextWidth(node_label) + return Math.max(30, text_width + 20) + } + + static getTextWidth(text) { + + let text_object = document.createElement("span"); + document.body.appendChild(text_object); + + // text.style.font = "times new roman"; + // text.style.fontSize = 16 + "px"; + text_object.style.height = 'auto'; + text_object.style.width = 'auto'; + text_object.style.position = 'absolute'; + text_object.style.whiteSpace = 'no-wrap'; + text_object.innerHTML = make_html_safe(text); + + + let width = Math.ceil(text_object.clientWidth); + + document.body.removeChild(text_object); + + return width + } + + getHeight() { + return parseFloat(this.rectangle.attr("height")) + } + + setHeight(height) { + this.rectangle.attr("height", height) + // this.content.recenter(height) + if (this.shadow != null) { + this.shadow.attr("height", height + 2*SHADOW_OVERSIZE) + } + this.mask_rect.attr("height", height) + this.setColor(this.currentFillColors) // need to update the size of the color rects + } + + getX() { + return this.position.x + } + + getY() { + return this.position.y + } + + registerGraphEdge(edge, edge_label, edge_position_data, graph) { + this.registeredEdges.push([edge, edge_label, edge_position_data, graph]) + } + + registerDependencyEdge(edge, is_outgoing, label, table) { + this.registeredEdges.push([edge, is_outgoing, label, table]) + } + + setColor(colors) { + this.currentFillColors = colors + this.group.selectAll(".color_rect").remove() + // check if colors is an Array + if (Array.isArray(colors)) { + if (colors.length === 0) { + this.setColor("white") + } else { + if (Array.isArray(colors[0])) { + // then we have a table of colors + for (let r = 0; r < colors.length; r++) { + if (colors[r].length === 0) { + this.createColorRect("white", r, 0, colors.length, 1) + } else { + for (let c = 0; c < colors[r].length; c++) { + this.createColorRect(colors[r][c], r, c, colors.length, colors[r].length) + } + } + } + } else { + // then we have a list of colors, and we use them left to right + for (let c = 0; c < colors.length; c++) { + this.createColorRect(colors[c], 0, c, 1, colors.length) + } + } + } + } else { + this.createColorRect(colors, 0, 0, 1, 1) + } + + if (this.shadow != null) { + this.shadow.lower() + } + // console.log("setColor " + this.position.id) + // console.log(this.group.select(".color_rect").node().getBoundingClientRect().x) + // console.log(this.mask_rect.node().getBoundingClientRect().x) + // console.log(this.rectangle.node().getBoundingClientRect().x) + } + + setBaseColors(colors) { + this.baseFillColors = colors + } + + getBaseColors() { + return this.baseFillColors + } + + createColorRect(color, r, c, num_rows, num_cols) { + let widths = this.getWidth() / num_cols + let heights = this.getHeight() / num_rows + this.group.append("rect") + .attr("x", c * widths) + .attr("y", r * heights) + .attr("width", widths) + .attr("height", heights) + .attr("fill", color) + .attr("clip-path", "url(#mask_rect_" + this.position.id + ")") + // set class + .attr("class", this.rectangle.attr("class") + " color_rect") + .lower() + } + +} + +function create_and_register_node_object(node_position, node_group, rect, content_object, border_color, + shadow=null) { + let node_object = new Node(node_position, node_group, rect, content_object, border_color, shadow) + + ALL_NODES[node_position[0].id] = node_object + + return node_object; +} + +function getNodePosition(x, y) { + pos = [{x: x, y: y, id: UNIQUE_NODE_COUNTER}]; + UNIQUE_NODE_COUNTER += 1 + return pos +} + +function makeNodeGroup(canvas, node_position, classname) { + return canvas.data(node_position).append("g") + .attr("transform", function (d) { + // console.log("makeNodeGroup translate(" + d.x + "," + d.y + ")") + return "translate(" + d.x + "," + d.y + ")"; + }) + .attr("class", classname); +} + +function makeNodeRectangle(is_bold, node_group, content_object, classname) { + let stroke_width = is_bold ? "4" : "2" + + let fill = "white" + + return node_group.append("rect") + .attr("rx", 10) + .attr("ry", 10) + .attr("x", 0) + .attr("y", 0) + .attr("width", content_object.getWidth()) + .attr("height", content_object.getHeight()) + .attr("fill", fill) + .attr("fill-opacity", 0.0) // fill comes from Node#setColor() + .attr("stroke-width", stroke_width) + .attr("class", classname) + // .lower(); +} + +function makeCellRectangle(is_bold, node_group, content_object, classname) { + if (is_bold) { + console.log("Warning: Cell was marked as bold, but this is currently not possible.") + } + + let stroke_width = "0" + + let fill = "white" + + return node_group.append("rect") + .attr("x", 0) + .attr("y", 0) + .attr("width", content_object.getWidth()) + .attr("height", content_object.getHeight()) + .attr("fill", fill) + .attr("stroke-width", stroke_width) + .attr("class", classname) + .attr("fill-opacity", 0.0) + .lower(); +} + +function makeCellShadow(node_group, content_object, classname) { + + return node_group.append("rect") + .attr("x", -SHADOW_OVERSIZE) + .attr("y", -SHADOW_OVERSIZE) + .attr("width", content_object.getWidth() + 2 * SHADOW_OVERSIZE) + .attr("height", content_object.getHeight() + 2 * SHADOW_OVERSIZE) + .attr("fill", "#cccccc") + // .attr("fill", "green") + .attr("stroke-width", "0") + .attr("class", classname) + // blur + .attr("filter", "url(#white-border-inset)") + .lower(); +} + + +function createNode(x, y, content_data, content_type, canvas, is_bold, + drag_function=null, classname=NODE_CLASSNAME) { + + return createNodeWithBorderColor(x, y, content_data, content_type, canvas, is_bold, "black", + drag_function, classname); + +} + +function createNodeWithBorderColor(x, y, content_data, content_type, canvas, is_bold, border_color, + drag_function, classname=NODE_CLASSNAME) { + + let node_position = getNodePosition(x, y); + + let node_group = makeNodeGroup(canvas, node_position, classname) + + let content_object = createNodeContent(content_data, content_type, node_group, classname) + + let rect = makeNodeRectangle(is_bold, node_group, content_object, classname); + + if (drag_function != null) { + let nodeDragHandler = d3.drag().on('drag', drag_function); + nodeDragHandler(node_group); + } + + let node = create_and_register_node_object(node_position, node_group, rect, content_object, border_color); + + node.setColor("white") + + return node + +} + +function createCell(x, y, content_data, content_type, canvas, is_bold, classname=NODE_CLASSNAME) { + + let node_position = getNodePosition(x, y); + + + let node_group = makeNodeGroup(canvas, node_position, classname) + + let content_object = createNodeContent(content_data, content_type, node_group, classname) + + let rect = makeCellRectangle(is_bold, node_group, content_object, classname) + + let shadow = makeCellShadow(node_group, content_object, classname) + + let cell = create_and_register_node_object(node_position, node_group, rect, content_object, "black", + shadow) + + return cell + +} + + + + +function createNodeContent(content_data, content_type, append_to_this_object, classname) { + + if (content_type === "STRING") { + if (content_data == null) { + content_data = "" + } else { + content_data = content_data.toString() + } + let rect_width = Node.get_hypothetical_node_width(content_data); + let rect_height = 30; + let text_object = append_to_this_object.append("text") + .attr("text-anchor", "middle") + .attr("x", rect_width/2) + .attr("y", rect_height/2) + .attr("dy", ".3em") + .style("pointer-events", "none") + .attr("class", classname) + .text(content_data) + // console.log("content_data: " + content_data) + return new NodeStringContent(text_object, rect_width, rect_height) + } else if (content_type === "GRAPH" || content_type === "TREE") { + return new Graph(0, 0, content_data, append_to_this_object, false, + GRAPH_LABEL_MARGIN) + } + +} + +class NodeStringContent { + constructor(text_object, width, height) { + this.text_object = text_object + this.width = width + this.height = height + } + + recenter(new_width) { + this.width = new_width + this.text_object.attr("x", new_width/2) + } + + getWidth() { + return this.width + } + + getHeight() { + return this.height + } +} diff --git a/vulcan-static/search.js b/vulcan-static/search.js new file mode 100644 index 0000000..0bbf9be --- /dev/null +++ b/vulcan-static/search.js @@ -0,0 +1,796 @@ + +// pattern is: slice name maps to dict 1 +// in dict 1, outer layer id maps to outer layer in dict form +// outer layer dict has entries "label", "description", and "innerLayers" +// The first two are strings +// innerLayers is a dictionary again, mapping inner layer id to inner layer in dict form +// inner layer dict has entries "label" and "description" (string and list of strings respectively) +let SEARCH_PATTERNS + // = { +// "Sentence": { +// "OuterTableCellsLayer": { +// "innerLayers": { +// "CellContentEquals": { +// "label": ["Cell content equals", ""], +// "description": "This checks if the cell content equals the given string (modulo casing and outer whitespace)." +// }, +// "CellContentMatches": { +// "label": ["Cell content matches", "(regular expression)"], +// "description": "This checks if the cell content matches the given regular expression." +// } +// }, +// "label": "Any cell in the table matches:", +// "description": "This layer checks if any cell in a table matches the given criteria, and highlights the cells that do." +// }, +// "OuterTableAsAWholeLayer": { +// "innerLayers": { +// "ColumnCountAtLeast": { +// "label": ["The sentence has at least length", " (or the table has at least this many columns)"], +// "description": "Checks minimum sentence length / table width." +// } +// }, +// "label": "The sentence/table as a whole matches:", +// "description": "This layer checks if the sentence (or table) itself matches a given criterion." +// } +// }, +// "Tree": { +// "OuterGraphNodeLayer": { +// "innerLayers": { +// "NodeContentEquals": { +// "label": ["Node has label", ""], +// "description": "This checks if a node is labeled with the given string (modulo casing and outer whitespace)." +// } +// }, +// "label": "Any node in the graph matches:", +// "description": "This layer checks if any node in a graph matches the given criteria, and highlights the nodes that do." +// } +// } +// } + +let searchWindowVisible = false +let searchWindowContainer = null +let searchWindowCanvas = null + +// pastel green, pastel red, pastel yellow, pastel blue, pastel orange, pastel purple +const FILTER_COLORS = ["#b3ffb3", "#ff9999", "#ffffb3", "#b3ffff", "#ffb366", "#e6ccff"] +const BORDER_COLOR = "#444444" +const SEARCH_WINDOW_WIDTH = 1000 +const SEARCH_WINDOW_HEIGHT = 300 + +const FILTER_SELECTOR_SIZE = 40 +const FILTER_SELECTOR_BUFFER = 15 + +const SELECTOR_MASK_MARGIN = 10 +const SELECTOR_MASK_ROUNDING = 10 +const SELECTOR_MASK_CLASSNAME = "searchSelectorMask" +const SELECTOR_TEXT_CLASSNAME = "searchSelectorText" +const OUTER_SEARCH_LAYER_CLASSNAME = "outerSearchLayer" +const INNER_SEARCH_LAYER_CLASSNAME = "innerSearchLayer" +const EMPTY_SELECTION_TEXT = "-- Select --" + +let searchFilters +let searchFilterRects + +function initializeSearchFilters() { + searchFilters = [] + searchFilterRects = [] + addEmptySearchFilter() + // addDebuggingSearchFilters() +} + +function addDebuggingSearchFilters() { + let uniqueid3 = makeUniqueInnerLayerID("NodeContentEquals") + let argsGraph = {} + argsGraph[uniqueid3] = ["and"] + addSearchFilter(new FilterInfo("Gold graph", "OuterGraphNodeLayer", [uniqueid3], + argsGraph)) +} + +function addEmptySearchFilter() { + addSearchFilter(new FilterInfo(null, null, [])) +} + +function addSearchFilter(filterInfo) { + searchFilters.push(filterInfo) +} + +function createSearchWindowContainer() { + // get the position of the search icon + let searchButtonPosition = d3.select("#searchButton").node().getBoundingClientRect() + let searchButtonHeight = searchButtonPosition.height + let searchButtonX = searchButtonPosition.x + let searchButtonY = searchButtonPosition.y + + + + searchWindowVisible = true + searchWindowContainer = d3.select("div#chartId") + .append("div") + .style("position", "absolute") + .style("top", (searchButtonY + searchButtonHeight + 5).toString() + "px") + .style("left", searchButtonX.toString() + "px") + // .style("border", "3px solid black") +} + +function createSearchWindowCanvas() { + searchWindowCanvas = searchWindowContainer.append("svg") + .attr("viewBox", "0 0 " + SEARCH_WINDOW_WIDTH + " " + SEARCH_WINDOW_HEIGHT) + .attr("width", SEARCH_WINDOW_WIDTH) // that's 3 + 3 for the borders, I think, but I don't know where the 10 come from + .attr("height", SEARCH_WINDOW_HEIGHT) + .style("background-color", "white") + .style("border", "2px solid " + BORDER_COLOR) + .style("position", "relative") +} + +function drawFilterSelectorMask(activeFilterSelectorRect) { + let indentedLeftBoundary = parseFloat(activeFilterSelectorRect.attr("x")) + + FILTER_SELECTOR_SIZE + SELECTOR_MASK_MARGIN + let selectorRectBottomWithMargin = parseFloat(activeFilterSelectorRect.attr("y")) + FILTER_SELECTOR_SIZE + 5 + let selectorRectTopWithMargin = parseFloat(activeFilterSelectorRect.attr("y")) - 5 + let bottom = SEARCH_WINDOW_HEIGHT - SELECTOR_MASK_MARGIN - 45 // the 45 makes space for the "search now" button + let right = SEARCH_WINDOW_WIDTH - SELECTOR_MASK_MARGIN + let top = SELECTOR_MASK_MARGIN + let left = SELECTOR_MASK_MARGIN + + + // should be able to generalize the below, by setting it up for a middle one, and not drawing a line if its length is negative. + drawXLine(indentedLeftBoundary, right, top) + drawCornerTopRight(right, top) + drawYLine(right, top, bottom) + drawCornerBottomRight(right, bottom) + drawXLine(indentedLeftBoundary, right, bottom) + if (selectorRectBottomWithMargin < bottom - 2*SELECTOR_MASK_ROUNDING) { + drawCornerBottomLeft(indentedLeftBoundary, bottom) + drawYLine(indentedLeftBoundary, selectorRectBottomWithMargin, bottom) + drawCornerTopRight(indentedLeftBoundary, selectorRectBottomWithMargin) + } else { + drawLine(indentedLeftBoundary, bottom, indentedLeftBoundary, selectorRectBottomWithMargin, SELECTOR_MASK_ROUNDING, 0) + } + drawXLine(left, indentedLeftBoundary, selectorRectBottomWithMargin) + drawCornerBottomLeft(left, selectorRectBottomWithMargin) + drawYLine(left, selectorRectTopWithMargin, selectorRectBottomWithMargin) + drawCornerTopLeft(left, selectorRectTopWithMargin) + drawXLine(left, indentedLeftBoundary, selectorRectTopWithMargin) + if (selectorRectTopWithMargin > top + 2*SELECTOR_MASK_ROUNDING) { + drawCornerBottomRight(indentedLeftBoundary, selectorRectTopWithMargin) + drawYLine(indentedLeftBoundary, top, selectorRectTopWithMargin) + drawCornerTopLeft(indentedLeftBoundary, top) + } else { + drawLine(indentedLeftBoundary, selectorRectTopWithMargin, indentedLeftBoundary, top, SELECTOR_MASK_ROUNDING, 0) + } + +} + +function createFilterSelectorRect(filterSelectorIndex) { + let y = 15 + filterSelectorIndex * (FILTER_SELECTOR_SIZE + FILTER_SELECTOR_BUFFER) + let selectorRectGroup = searchWindowCanvas.append("g") + .attr("transform", "translate(0, 0)") + return selectorRectGroup.append("rect") + .attr("rx", 5) + .attr("ry", 5) + .attr("x", 15) + .attr("y", y) + .attr("width", FILTER_SELECTOR_SIZE) + .attr("height", FILTER_SELECTOR_SIZE) + .attr("fill", FILTER_COLORS[filterSelectorIndex % FILTER_COLORS.length]) + .attr("stroke", BORDER_COLOR) + .attr("stroke-width", 2) + .style("opacity", "0.5") // is by default unselected + .on("click", function () { + selectSelectorRect(d3.select(this)) + }) +} + +function createAddFilterButton() { + let y = 15 + searchFilters.length * (FILTER_SELECTOR_SIZE + FILTER_SELECTOR_BUFFER) + let buttonGroup = searchWindowCanvas.append("g") + .attr("id", "addFilterButton") + .attr("transform", "translate(0, 0)") + // plus sign + buttonGroup.append("line") + .attr("x1", 15 + FILTER_SELECTOR_SIZE / 2) + .attr("y1", y + FILTER_SELECTOR_SIZE / 4) + .attr("x2", 15 + FILTER_SELECTOR_SIZE / 2) + .attr("y2", y + 3 * FILTER_SELECTOR_SIZE / 4) + .attr("stroke", "#aaaaaa") + .attr("stroke-width", 2) + buttonGroup.append("line") + .attr("x1", 15 + FILTER_SELECTOR_SIZE / 4) + .attr("y1", y + FILTER_SELECTOR_SIZE / 2) + .attr("x2", 15 + 3 * FILTER_SELECTOR_SIZE / 4) + .attr("y2", y + FILTER_SELECTOR_SIZE / 2) + .attr("stroke", "#aaaaaa") + .attr("stroke-width", 2) + // border (and clickable thing, on top) + buttonGroup.append("rect") + .attr("rx", 5) + .attr("ry", 5) + .attr("x", 15) + .attr("y", y) + .attr("width", FILTER_SELECTOR_SIZE) + .attr("height", FILTER_SELECTOR_SIZE) + // transparent fill + .attr("fill", "rgba(0,0,0,0)") + .attr("stroke", "#aaaaaa") + .attr("stroke-width", 2) + // dashed border + .attr("stroke-dasharray", "5,5") + .on("click", function () { + addFilter() + }) +} + +function addFilter() { + addEmptySearchFilter() + searchFilterRects.push(createFilterSelectorRect(searchFilters.length - 1)) + d3.select("#addFilterButton").remove() + createAddFilterButton() + selectSelectorRect(searchFilterRects[searchFilterRects.length - 1]) +} + +function removeFilter(selectedFilterIndex) { + // remove all search filters after (and including) the selected one + for (let i = selectedFilterIndex; i < searchFilters.length; i++) { + searchFilterRects[i].remove() + } + d3.select("#addFilterButton").remove() + + // remove the search filter + searchFilters.splice(selectedFilterIndex, 1) + // remove all search filter rects after (and including) the selected one + searchFilterRects.splice(selectedFilterIndex) + if (searchFilters.length <= selectedFilterIndex) { + selectedFilterIndex = searchFilters.length - 1 + } else { + // shift colors + removedColor = FILTER_COLORS[selectedFilterIndex] + FILTER_COLORS.splice(selectedFilterIndex, 1) + FILTER_COLORS.push(removedColor) + } + + // re-add the search filter rects below the selected one + for (let i = selectedFilterIndex; i < searchFilters.length; i++) { + searchFilterRects[i] = createFilterSelectorRect(i) + } + createAddFilterButton() + + + selectSelectorRect(searchFilterRects[selectedFilterIndex]) +} + +function createRemoveFilterButton(selectedFilterIndex) { + let removeButton = d3.select("div#chartId").append("button") + .attr("id", "removeFilterButton") + .text("Remove this filter") + .style("position", "absolute") + .style("left", (searchWindowCanvas.node().getBoundingClientRect().right - 150) + "px") + .style("top", (searchWindowCanvas.node().getBoundingClientRect().top + SELECTOR_MASK_MARGIN + 10) + "px") + .on("click", function () { + + removeFilter(selectedFilterIndex); + + }) + .attr("class", SELECTOR_TEXT_CLASSNAME) +} + +function selectSelectorRect(selectorRect) { + selectorRect.style("opacity", "1") + let selectedFilterIndex; + for (let i = 0; i < searchFilterRects.length; i++) { + if (searchFilterRects[i].node() !== selectorRect.node()) { + searchFilterRects[i].style("opacity", "0.5") + } else { + selectedFilterIndex = i + } + } + // select all objects of class SELECTOR_MASK_CLASSNAME and remove them + d3.selectAll("." + SELECTOR_MASK_CLASSNAME).remove() + // remove selector text + d3.select("div#chartId").selectAll("." + SELECTOR_TEXT_CLASSNAME).remove() + drawFilterSelectorMask(selectorRect) + drawFilterSelectorText(selectedFilterIndex) +} + +function drawOuterLayerTexts(sliceSelectorDropdown, x0, y0, selectedFilter) { + let sliceName = sliceSelectorDropdown.property("value") + let outerLayerDropdown = d3.select("div#chartId") + .append("select") + .attr("name", "outerLayerSelector") + .style("position", "absolute") + .style("left", x0 + "px") + .style("top", (y0 + 30) + "px") + .attr("class", SELECTOR_TEXT_CLASSNAME + " " + OUTER_SEARCH_LAYER_CLASSNAME) + let outerLayerIDs = [EMPTY_SELECTION_TEXT] + for (let outerLayerID in SEARCH_PATTERNS[sliceSelectorDropdown.property("value")]) { + outerLayerIDs.push(outerLayerID) + } + let outerLayerOptions = outerLayerDropdown.selectAll("option") + .data(outerLayerIDs) + .enter() + .append("option") + .text(function (d) { + if (d === EMPTY_SELECTION_TEXT) { + return d; + } else { + return getOuterLayer(sliceName, d)["label"]; + } + }) + .attr("value", function (d) { + return d; + }) + + if (selectedFilter.outer_layer_id == null) { + outerLayerDropdown.property("value", EMPTY_SELECTION_TEXT) + } else { + outerLayerDropdown.property("value", selectedFilter.outer_layer_id) + outerLayerDropdown.attr("title", getOuterLayer(sliceName, selectedFilter.outer_layer_id)["description"]) + } + + outerLayerDropdown.on("change", function () { + d3.select("div#chartId").selectAll("." + INNER_SEARCH_LAYER_CLASSNAME).remove() + selectedFilter.clearInnerLayers() + let selectedOuterLayerID = outerLayerDropdown.property("value") + if (selectedOuterLayerID === EMPTY_SELECTION_TEXT) { + selectedFilter.clearOuterLayer() + outerLayerDropdown.attr("title", null) + } else { + selectedFilter.outer_layer_id = selectedOuterLayerID + drawInnerLayerTexts(selectedFilter) + drawInnerLayerDropdown(selectedFilter) + outerLayerDropdown.attr("title", getOuterLayer(sliceName, selectedOuterLayerID)["description"]) + } + }) + + return outerLayerDropdown; +} + +function drawFilterSelectorText(selectedFilterIndex) { + let selectedFilter = searchFilters[selectedFilterIndex] + let x0 = searchWindowCanvas.node().getBoundingClientRect().x + 2 * SELECTOR_MASK_MARGIN + FILTER_SELECTOR_SIZE + 15 + let y0 = searchWindowCanvas.node().getBoundingClientRect().y + SELECTOR_MASK_MARGIN + 10 + + if (searchFilters.length > 1) { + createRemoveFilterButton(selectedFilterIndex) + } + + // draw slice selector + let sliceSelectionLabel = d3.select("div#chartId").append("text") + .text("Object to apply the filter to:") + .style("position", "absolute") + .style("left", x0 + "px") + .style("top", y0 + "px") + .attr("class", SELECTOR_TEXT_CLASSNAME) + let sliceSelectorDropdown = d3.select("div#chartId") + .append("select") + .attr("name", "sliceSelector") + .style("position", "absolute") + .style("left", (x0 + sliceSelectionLabel.node().getBoundingClientRect().width + 5) + "px") + .style("top", y0 + "px") + .attr("class", SELECTOR_TEXT_CLASSNAME) + let slice_names = [EMPTY_SELECTION_TEXT] + // add all in Object.keys(canvas_name_to_node_name_to_node_dict) + for (let slice_name in canvas_name_to_node_name_to_node_dict) { + slice_names.push(slice_name) + } + let options = sliceSelectorDropdown.selectAll("option") + .data(slice_names) + .enter() + .append("option") + .text(function (d) { return d; }) + .attr("value", function (d) { return d; }) + if (selectedFilter.slice_name == null) { + sliceSelectorDropdown.property("value", EMPTY_SELECTION_TEXT) + } else { + sliceSelectorDropdown.property("value", selectedFilter.slice_name) + } + sliceSelectorDropdown.on("change", function () { + // remove inner and outer layer texts and dropdowns + d3.selectAll("." + INNER_SEARCH_LAYER_CLASSNAME).remove() + d3.selectAll("." + OUTER_SEARCH_LAYER_CLASSNAME).remove() + selectedFilter.outer_layer_id = null + selectedFilter.inner_layer_ids = [] + if (sliceSelectorDropdown.property("value") !== EMPTY_SELECTION_TEXT) { + selectedFilter.slice_name = sliceSelectorDropdown.property("value") + drawOuterLayerTexts(sliceSelectorDropdown, x0, y0, selectedFilter); + } else { + selectedFilter.clearSliceName() + } + }) + + // draw outer-layer selector + if (sliceSelectorDropdown.property("value") !== EMPTY_SELECTION_TEXT) { + let outerLayerDropdown = drawOuterLayerTexts(sliceSelectorDropdown, x0, y0, selectedFilter); + + // draw inner-layer selector(s) + if (outerLayerDropdown.property("value") !== EMPTY_SELECTION_TEXT) { + drawInnerLayerTexts(selectedFilter) + drawInnerLayerDropdown(selectedFilter) + } + } + +} + +function drawInnerLayerTexts(searchFilter) { + let x0 = searchWindowCanvas.node().getBoundingClientRect().x + 2 * SELECTOR_MASK_MARGIN + FILTER_SELECTOR_SIZE + 15 + + 25 // for indent + let y0 = searchWindowCanvas.node().getBoundingClientRect().y + SELECTOR_MASK_MARGIN + 10 + 30 + + for (let i in searchFilter.inner_layer_ids) { + let y = y0 + 60 + 30 * i + let uniqueInnerLayerID = searchFilter.inner_layer_ids[i] + let innerLayerID = getInnerLayerIDFromUniqueID(uniqueInnerLayerID) + let innerLayer = getInnerLayer(searchFilter.slice_name, searchFilter.outer_layer_id, innerLayerID) + let x; + if (i > 0) { + let andLabel = d3.select("div#chartId").append("text") + .text("and:") + .style("position", "absolute") + .style("left", x0 + "px") + .style("top", y + "px") + .attr("title", innerLayer["description"]) + .attr("class", SELECTOR_TEXT_CLASSNAME + " " + INNER_SEARCH_LAYER_CLASSNAME) + x = x0 + andLabel.node().getBoundingClientRect().width + 5 + } else { + x = x0 + } + for (let j in innerLayer["label"]) { + let innerLayerLabel = d3.select("div#chartId").append("text") + .text(innerLayer["label"][j]) + .style("position", "absolute") + .style("left", x + "px") + .style("top", y + "px") + .attr("title", innerLayer["description"]) + .attr("class", SELECTOR_TEXT_CLASSNAME + " " + INNER_SEARCH_LAYER_CLASSNAME) + x += innerLayerLabel.node().getBoundingClientRect().width + 5 + // add editable field + if (j < innerLayer["label"].length - 1) { + let editableField = d3.select("div#chartId").append("textarea") + .attr("id", uniqueInnerLayerID + "_" + j) + .style("position", "absolute") + .style("left", x + "px") + .style("top", y + "px") + .style("resize", "none") + .attr("rows", 1) + .on("focus", function () { + this.rows = 5 + d3.select(this).style("z-index", 1); // Raise the element using z-index + }) + .on("blur", function () { + this.rows = 1 + d3.select(this).style("z-index", "auto"); // Raise the element using z-index + }) + .text(searchFilter.inner_layer_inputs[uniqueInnerLayerID][j]) + .attr("title", innerLayer["description"]) + .on("input", function () { + searchFilter.inner_layer_inputs[uniqueInnerLayerID][j] = this.value + }) + .attr("class", SELECTOR_TEXT_CLASSNAME + " " + INNER_SEARCH_LAYER_CLASSNAME) + x += editableField.node().getBoundingClientRect().width + 5 + } + } + // add delete button + let deleteButton = d3.select("div#chartId").append("button") + .text("x") + .style("position", "absolute") + .style("left", (searchWindowCanvas.node().getBoundingClientRect().right - 50) + "px") + .style("top", y + "px") + .on("click", function () { + searchFilter.removeInnerLayer(uniqueInnerLayerID) + d3.selectAll("." + INNER_SEARCH_LAYER_CLASSNAME).remove() + drawInnerLayerTexts(searchFilter) + drawInnerLayerDropdown(searchFilter) + }) + .attr("class", SELECTOR_TEXT_CLASSNAME + " " + INNER_SEARCH_LAYER_CLASSNAME) + } + +} + +function drawInnerLayerDropdown(searchFilter) { + let sliceName = searchFilter.slice_name + let outerLayerID = searchFilter.outer_layer_id + let x0 = searchWindowCanvas.node().getBoundingClientRect().x + 2 * SELECTOR_MASK_MARGIN + FILTER_SELECTOR_SIZE + 15 + + 25 // for indent + let y0 = searchWindowCanvas.node().getBoundingClientRect().y + SELECTOR_MASK_MARGIN + 10 + 30 + let y = y0 + 60 + 30 * searchFilter.inner_layer_ids.length + + let x; + let gray = "#aaaaaa" + if (searchFilter.inner_layer_ids.length > 0) { + let andLabel = d3.select("div#chartId").append("text") + .text("and:") + .style("position", "absolute") + .style("left", x0 + "px") + .style("top", y + "px") + // make text gray + .style("color", gray) + .attr("class", SELECTOR_TEXT_CLASSNAME + " " + INNER_SEARCH_LAYER_CLASSNAME) + x = x0 + andLabel.node().getBoundingClientRect().width + 5 + } else { + x = x0 + } + let innerLayerDropdown = d3.select("div#chartId") + .append("select") + .attr("name", "innerLayerSelector") + .style("position", "absolute") + .style("left", x + "px") + .style("top", y + "px") + // make the whole thing gray + .style("color", gray) + .style("border-color", gray) + .attr("class", SELECTOR_TEXT_CLASSNAME + " " + INNER_SEARCH_LAYER_CLASSNAME) + let innerLayerIDs = [EMPTY_SELECTION_TEXT] + for (let innerLayerID in getOuterLayer(sliceName, outerLayerID)["innerLayers"]) { + innerLayerIDs.push(innerLayerID) + } + let innerLayerOptions = innerLayerDropdown.selectAll("option") + .data(innerLayerIDs) + .enter() + .append("option") + .text(function (d) { + if (d === EMPTY_SELECTION_TEXT) { + return d; + } else { + return getInnerLayer(sliceName, outerLayerID, d)["label"].join(" _ "); + } + }) + .attr("value", function (d) { return d; }) + // make text black during the selection process + innerLayerDropdown.on("focus", function () { + d3.select(this).style("color", "black") + }) + innerLayerDropdown.on("blur", function () { + d3.select(this).style("color", gray) + }) + innerLayerDropdown.on("change", function () { + searchFilter.addInnerLayer(this.value) + d3.select("div#chartId").selectAll("." + INNER_SEARCH_LAYER_CLASSNAME).remove() + drawInnerLayerTexts(searchFilter) + drawInnerLayerDropdown(searchFilter) + }) + innerLayerDropdown.property("value", EMPTY_SELECTION_TEXT) +} + +function getOuterLayer(sliceName, outerLayerID) { + return SEARCH_PATTERNS[sliceName][outerLayerID] +} + +function getInnerLayer(sliceName, outerLayerID, innerLayerID) { + return SEARCH_PATTERNS[sliceName][outerLayerID]["innerLayers"][innerLayerID] +} + +function onSearchIconClick() { + if (!searchWindowVisible) { + createSearchWindowContainer(); + + createSearchWindowCanvas(); + + for (let i = 0; i < searchFilters.length; i++) { + searchFilterRects[i] = createFilterSelectorRect(i) + } + + createAddFilterButton() + + selectSelectorRect(searchFilterRects[0]); + + drawSearchNowButton(); + + } else { + searchFilterRects = [] + searchWindowVisible = false + searchWindowContainer.remove() + d3.select("div#chartId").selectAll("." + SELECTOR_TEXT_CLASSNAME).remove() + d3.select("div#chartId").selectAll("." + "searchNowButton").remove() + } +} + +function clearSearch() { + if (searchWindowVisible) { + onSearchIconClick() // remove the search window + } + searchFilters = [] + addEmptySearchFilter() + sio.emit("clear_search") +} + +function drawSearchNowButton() { + let width = 120 + let height = 33 + let r = searchWindowCanvas.node().getBoundingClientRect() + let x = r.right - width - 15 + let y = r.bottom - height - 15 + let searchNowButton = d3.select("div#chartId").append("button") + .text("Search Now") + .style("position", "absolute") + .style("left", x + "px") + .style("top", y + "px") + .style("width", width + "px") + .style("height", height + "px") + // round the corners + .style("border-radius", "5px") + .on("click", function () { + performSearch(true) + }) + .attr("class", "searchNowButton") + // make the button stand out a bit more + searchNowButton.style("background-color", "white") + searchNowButton.style("border-color", "black") + searchNowButton.style("color", "black") + // make it bold and larger font + // searchNowButton.style("font-weight", "bold") + searchNowButton.style("font-size", "16px") + +} + +function performSearch(doCloseWindow) { + if (isSearchLegal()) { + let searchFiltersToTransmit = [] + for (let i = 0; i < searchFilters.length; i++) { + searchFiltersToTransmit.push(searchFilters[i].getTransmissibleDict(FILTER_COLORS[i % FILTER_COLORS.length])) + } + sio.emit("perform_search", searchFiltersToTransmit) + if (doCloseWindow) { + onSearchIconClick() // little hack to make the search window disappear + } + } else { + // notfiy user that search is not legal + alert("Search is not legal. Please make sure that each search filter has at least one search pattern fully selected.") + } +} + +function isSearchLegal() { + for (let i = 0; i < searchFilters.length; i++) { + if (searchFilters[i].slice_name == null || searchFilters[i].outer_layer_id == null + || searchFilters[i].inner_layer_ids.length == 0) { + return false + } + } + return true +} + +function drawXLine(x1, x2, y) { + if (x1 < x2) { + drawLine(x1, y, x2, y, SELECTOR_MASK_ROUNDING, 0) + } + // else the line has "negative length" and we don't draw it. +} + +function drawYLine(x, y1, y2) { + if (y1 < y2) { + drawLine(x, y1, x, y2, 0, SELECTOR_MASK_ROUNDING) + } + // else the line has "negative length" and we don't draw it. +} + + + +function drawLine(x1, y1, x2, y2, xShortening, yShortening) { + // assumes that x2 >= x1 and y2 >= y1 + x1 = x1 + xShortening + x2 = x2 - xShortening + y1 = y1 + yShortening + y2 = y2 - yShortening + searchWindowCanvas.append("line") + .attr("x1", x1) + .attr("y1", y1) + .attr("x2", x2) + .attr("y2", y2) + .attr("stroke", BORDER_COLOR) + .attr("stroke-width", 2) + .attr("class", SELECTOR_MASK_CLASSNAME) +} + + +function drawCornerTopRight(xCorner, yCorner) { + drawCorner(xCorner, yCorner, -SELECTOR_MASK_ROUNDING, 0, 0, SELECTOR_MASK_ROUNDING) +} + +function drawCornerBottomRight(xCorner, yCorner) { + drawCorner(xCorner, yCorner, 0, -SELECTOR_MASK_ROUNDING, -SELECTOR_MASK_ROUNDING, 0) +} + +function drawCornerBottomLeft(xCorner, yCorner) { + drawCorner(xCorner, yCorner, SELECTOR_MASK_ROUNDING, 0, 0, -SELECTOR_MASK_ROUNDING) +} + +function drawCornerTopLeft(xCorner, yCorner) { + drawCorner(xCorner, yCorner, 0, SELECTOR_MASK_ROUNDING, SELECTOR_MASK_ROUNDING, 0) +} + + +function drawCorner(xCorner, yCorner, xOffsetIn, yOffsetIn, xOffsetOut, yOffsetOut) { + let edge = d3.path() + let x1 = xCorner + xOffsetIn + let y1 = yCorner + yOffsetIn + let x2 = xCorner + xOffsetOut + let y2 = yCorner + yOffsetOut + edge.moveTo(x1, y1) + edge.bezierCurveTo(xCorner, yCorner, xCorner, yCorner, x2, y2) + searchWindowCanvas.append("path") + .attr("d", edge) + .attr("stroke", BORDER_COLOR) + .attr("stroke-width", 2) + .attr("fill", "none") + .attr("class", SELECTOR_MASK_CLASSNAME) +} + +function getInnerLayerIDFromUniqueID(uniqueID) { + return uniqueID.split("_bsdfe_uniquefier_fafswee_")[0] +} + +function makeUniqueInnerLayerID(innerLayerID) { + return innerLayerID + "_bsdfe_uniquefier_fafswee_" + create_alias() +} + +class FilterInfo { + constructor(slice_name, outer_layer_id, unique_inner_layer_ids, inner_layer_inputs=undefined) { + this.slice_name = slice_name + this.outer_layer_id = outer_layer_id + this.inner_layer_ids = unique_inner_layer_ids + if (inner_layer_inputs === undefined) { + this.inner_layer_inputs = {} // maps inner layer id to list of input values + for (let i in unique_inner_layer_ids) { + let inner_layer_ID = getInnerLayerIDFromUniqueID(unique_inner_layer_ids[i]) + let inner_label = getInnerLayer(slice_name, outer_layer_id, inner_layer_ID)["label"] + this.inner_layer_inputs[unique_inner_layer_ids[i]] = [] + for (let j = 0; j< inner_label.length -1; j++) { + this.inner_layer_inputs[unique_inner_layer_ids[i]].push("") + } + } + } else { + this.inner_layer_inputs = inner_layer_inputs + } + } + + addInnerLayer(inner_layer_id) { + let unique_id = makeUniqueInnerLayerID(inner_layer_id) + this.inner_layer_ids.push(unique_id) + this.inner_layer_inputs[unique_id] = [] + let inner_label = getInnerLayer(this.slice_name, this.outer_layer_id, inner_layer_id)["label"] + for (let j = 0; j< inner_label.length -1; j++) { + this.inner_layer_inputs[unique_id].push("") + } + return unique_id + } + + clearInnerLayers() { + this.inner_layer_ids = [] + this.inner_layer_inputs = {} + } + + removeInnerLayer(unique_inner_layer_id) { + let index = this.inner_layer_ids.indexOf(unique_inner_layer_id) + if (index > -1) { + this.inner_layer_ids.splice(index, 1) + } + delete this.inner_layer_inputs[unique_inner_layer_id] + } + + clearOuterLayer() { + this.outer_layer_id = null + this.clearInnerLayers() + } + + clearSliceName() { + this.slice_name = null + this.clearOuterLayer() + } + + getTransmissibleDict(color) { + let dict = {} + dict["slice_name"] = this.slice_name + dict["outer_layer_id"] = this.outer_layer_id + let inner_layer_ids = [] + for (let i in this.inner_layer_ids) { + inner_layer_ids.push(getInnerLayerIDFromUniqueID(this.inner_layer_ids[i])) + } + dict["inner_layer_ids"] = inner_layer_ids + let inner_layer_inputs = [] + for (let i in this.inner_layer_ids) { + let inner_layer_input = this.inner_layer_inputs[this.inner_layer_ids[i]] + inner_layer_inputs.push(inner_layer_input) + } + dict["inner_layer_inputs"] = inner_layer_inputs + dict["color"] = color + return dict + } +} \ No newline at end of file diff --git a/vulcan-static/style.css b/vulcan-static/style.css new file mode 100644 index 0000000..b139c27 --- /dev/null +++ b/vulcan-static/style.css @@ -0,0 +1,14 @@ +.svg-container { + display: inline-block; + position: relative; + /*width: 100%;*/ + /*padding-bottom: 100%;*/ + vertical-align: top; + /*overflow: visible;*/ +} +.svg-content-responsive { + display: inline-block; + position: absolute; + top: 10px; + left: 0; +} \ No newline at end of file diff --git a/vulcan-static/table.js b/vulcan-static/table.js new file mode 100644 index 0000000..4926351 --- /dev/null +++ b/vulcan-static/table.js @@ -0,0 +1,593 @@ +const TOKEN_CLASSNAME = "token_obj" +const TOKEN_DISTANCE = 5 +const MAX_DEPTREE_HEIGHT = 10 +const MIN_DEPLABEL_CELL_DIST = 20 +const MIN_DEPLABEL_DEPLABEL_DIST = 15 +const MAX_DEPEDGES_OVERLAPPING = 1 +const DEP_LEVEL_DISTANCE = 40 +const DEP_TREE_BASE_Y_OFFSET = 40 +const NODE_ID_TO_XY_BOX = {} + +function makeRandomDependencyEdgeColor() { + let random_addition = Math.random() + let blue = 0.2 + 0.8 * random_addition + let red = 0.4 * random_addition + let green = 0.1 + 0.5 * random_addition + return "#"+turnToRBG(red)+turnToRBG(green)+turnToRBG(blue) +} + +function turnToRBG(value) { + let ret = Math.floor(value*255).toString(16) + while (ret.length < 2) { + ret = "0"+ret + } + return ret +} + +function getReproducibleRandomNumberFromLabel(label) { + return mulberry32(1000*label[0] + label[1])() +} + +function mulberry32(a) { + // see https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript + return function() { + var t = a += 0x6D2B79F5; + t = Math.imul(t ^ t >>> 15, t | 1); + t ^= t + Math.imul(t ^ t >>> 7, t | 61); + return ((t ^ t >>> 14) >>> 0) / 4294967296; + } +} + +class Table { + constructor(top_left_x, top_left_y, content, canvas, label_alternatives, highlights, dependency_tree) { + this.top_left_x = top_left_x + this.top_left_y = top_left_y + this.cells = [] + this.canvas = canvas + this.label_alternatives = label_alternatives + this.highlights = highlights + this.dependency_tree = dependency_tree + this.create_cells(content) + this.create_dependency_tree() + } + + create_cells(content) { + let current_x = this.top_left_x + let current_y = this.top_left_y + // We do the rows such that the first row is at the bottom, because this is more intuitive for the tagging + // scenario + for (let c = 0; c < content.length; c++) { + let cells_in_column = [] + let column = content[c] + let max_width = 0 + let cells_here = [] + for (let r = column.length-1; r >= 0 ; r--) { + let cell_here = this.create_cell_node(column[r], current_x, current_y, this.getCellName(c, r)) + cells_here.push(cell_here) + current_y = current_y + parseFloat(cell_here.getHeight()) + TOKEN_DISTANCE + let width_here = parseFloat(cell_here.getWidth()) + max_width = Math.max(max_width, width_here) + cells_in_column.push(cell_here) + } + this.cells.push(cells_in_column) + // set all widths to max_width + for (let i = 0; i < cells_here.length; i++) { + cells_here[i].setWidth(max_width) + } + current_y = this.top_left_y + current_x = current_x + max_width + TOKEN_DISTANCE + } + + // realign vertically + let cumulative_max_heights = 0 + for (let r = 0; r < this.cells[0].length; r++) { + let max_height = 0 + for (let c = 0; c < this.cells.length; c++) { + let cell_here = this.cells[c][r] + let height_here = parseFloat(cell_here.getHeight()) + max_height = Math.max(max_height, height_here) + } + for (let c = 0; c < this.cells.length; c++) { + let cell_here = this.cells[c][r] + cell_here.setHeight(max_height) + cell_here.translate(cell_here.getX(), this.top_left_y + cumulative_max_heights) + } + cumulative_max_heights += max_height + TOKEN_DISTANCE + } + } + + getCellName(column, row) { + return "("+row+", "+column+")" // mimic python's tuple notation, i.e. what you get for str((row, column)) + } + + create_cell_node(token, pos_x, pos_y, node_name) { + // let node = createNode(pos_x, pos_y, token, "STRING", this.canvas, false, null, TOKEN_CLASSNAME) + // check if token is a string + let node; + if (typeof token === 'string' || token instanceof String) { + node = createCell(pos_x, pos_y, token, "STRING", this.canvas, false, TOKEN_CLASSNAME) + } else { + node = createCell(pos_x, pos_y, token[1], token[0], this.canvas, false, TOKEN_CLASSNAME) + } + let do_highlight = this.highlights != null && node_name in this.highlights + if (do_highlight) { + node.setColor(this.highlights[node_name]) + } else { + node.setColor("white") + } + this.register_mouseover_highlighting(node) + if (this.label_alternatives != null && node_name in this.label_alternatives) { + this.registerNodeAlternativeMouseover(node, this.label_alternatives[node_name]) + } + return node + } + + create_dependency_tree() { + // console.log(this.label_alternatives) + if (this.dependency_tree != null) { + // sort edges in dependency tree by absolute distance between head and tail, shortest distance first + this.dependency_tree.sort(function(a, b) { + let distance_a = Math.abs(a[0] - a[1]) + let distance_b = Math.abs(b[0] - b[1]) + return distance_a - distance_b + }) + let edge_count_at_position = [] + let label_at_position = [] + // position i, j is (i+1)-st level above the table, and between token j and j+1. + for (let i = 0; i <= MAX_DEPTREE_HEIGHT; i++) { + let edge_counts_in_this_level = [] + for (let j = 0; j < this.dependency_tree.length - 1; j++) { + edge_counts_in_this_level.push(0) + } + edge_count_at_position.push(edge_counts_in_this_level) + let labels_in_this_level = [] + for (let j = 0; j < this.dependency_tree.length - 1; j++) { + labels_in_this_level.push(null) + } + label_at_position.push(labels_in_this_level) + } + let max_level_here = 0 + let total_min_y = 0 + for (let i = 0; i < this.dependency_tree.length; i++) { + let edge = this.dependency_tree[i] + let head = edge[0] + let tail = edge[1] + let label = edge[2] + + if (head >= 0) { + let min_bound = Math.min(head, tail) + let max_bound = Math.max(head, tail) + + let found_position = false + let current_level = 0 + while (!found_position) { + let max_edge_count = 0; + for (let k = min_bound; k < max_bound; k++) { + max_edge_count = Math.max(max_edge_count, edge_count_at_position[current_level][k]) + } + if (max_edge_count >= MAX_DEPEDGES_OVERLAPPING && !(current_level === MAX_DEPTREE_HEIGHT - 1)) { + current_level++ + continue + } + let available_label_slots = [] + for (let k = min_bound; k < max_bound; k++) { + if (label_at_position[current_level][k] == null) { + available_label_slots.push(k) + } + } + if (available_label_slots.length === 0) { + if (!(current_level === MAX_DEPTREE_HEIGHT - 1)) { + current_level++ + continue + } else { + available_label_slots = [min_bound] + } + } + found_position = true + // choose the label slot that is closest to the center of the edge + let best_label_slot = this.find_slot_closest_to_center(available_label_slots, + min_bound, max_bound); + max_level_here = Math.max(max_level_here, current_level) + for (let k = min_bound; k < max_bound; k++) { + edge_count_at_position[current_level][k]++ + } + let y = -DEP_TREE_BASE_Y_OFFSET-current_level*DEP_LEVEL_DISTANCE + let color = makeRandomDependencyEdgeColor() + let dependency_label_node = createNodeWithBorderColor( + 40 + best_label_slot*60, + y, label, "STRING", this.canvas, + false, color, dependencyTreeNodeDragged) + let depedge_name = "depedge_"+head+"_"+tail + if (this.label_alternatives != null && depedge_name in this.label_alternatives) { + this.registerNodeAlternativeMouseover(dependency_label_node, this.label_alternatives[depedge_name]) + } + label_at_position[current_level][best_label_slot] = [head, tail, dependency_label_node] + total_min_y = Math.min(total_min_y, y) + } + + } + + + } + for (let i = 0; i < this.dependency_tree.length; i++) { + let edge = this.dependency_tree[i] + let head = edge[0] + let tail = edge[1] + let label = edge[2] + if (head === -1) { + let y = -DEP_TREE_BASE_Y_OFFSET-(max_level_here + 1)*DEP_LEVEL_DISTANCE + let color = makeRandomDependencyEdgeColor() + let root_label_node = createNodeWithBorderColor( + 40 + (tail - 0.5) *60, + y, label, "STRING", this.canvas, + false, color, dependencyTreeNodeDragged) + let depedge_name = "depedge_"+head+"_"+tail + if (this.label_alternatives != null && depedge_name in this.label_alternatives) { + this.registerNodeAlternativeMouseover(root_label_node, this.label_alternatives[depedge_name]) + } + label_at_position[max_level_here + 1][tail] = [head, tail, root_label_node] + total_min_y = Math.min(total_min_y, y) + } + } + + total_min_y = total_min_y - 20 // just to have a bit of a gap at the top + + // fix column and node positions + for (let i = 0; i < this.cells.length; i++) { + // fix column position first + + // get the rightmost edge of any attached edge that is to the left of this + let max_edge_right = -MIN_DEPLABEL_CELL_DIST // so if we add that distance later, we get 0 + for (let gap_index = 0; gap_index < i; gap_index++) { + for (let level_index = 0; level_index < label_at_position.length; level_index++) { + let label = label_at_position[level_index][gap_index] + if (label != null) { + // check if the edge is actually an edge that is attached here (and the edge is to the left) + let right_attached_column = Math.max(label[0], label[1]) + if (right_attached_column === i) { + let label_node = label[2] + max_edge_right = Math.max(max_edge_right, label_node.getX() + label_node.getWidth()) + } + } + } + } + let min_x_by_dep_node = max_edge_right - 0.5 * this.cells[i][0].getWidth() + MIN_DEPLABEL_CELL_DIST + + let min_x_by_cell = 0 + if (i > 0) { + min_x_by_cell = this.cells[i-1][0].getX() + this.cells[i-1][0].getWidth() + TOKEN_DISTANCE + } + let new_cell_x = Math.max(min_x_by_cell, min_x_by_dep_node) + + + for (let j = 0; j= 0) { + let right_attached_column = Math.max(label[0], label[1]) + max_x = Math.max(this.cells[right_attached_column][0].getX() + + 0.5 * this.cells[right_attached_column][0].getWidth(), min_x) + - label_node.getWidth() - MIN_DEPLABEL_CELL_DIST + } else { + max_x = label_node.getX() + } + let min_y = -1000 + let max_y = -total_min_y - DEP_TREE_BASE_Y_OFFSET + NODE_ID_TO_XY_BOX[id] = [min_x, max_x, min_y, max_y] + + if (label[0] >= 0) { + let left_attached_column = Math.min(label[0], label[1]) + let right_attached_column = Math.max(label[0], label[1]) + let new_node_x = 0 + new_node_x = (this.cells[left_attached_column][0].getX() + + 0.5 * this.cells[left_attached_column][0].getWidth() + + this.cells[right_attached_column][0].getX() + + 0.5 * this.cells[right_attached_column][0].getWidth()) / 2 + - 0.5 * label_node.getWidth() + label_node.translate(new_node_x, label_node.getY()) + } + } + } + } + + + + // draw edges + + + for (let level_index = 0; level_index < label_at_position.length; level_index++) { + for (let gap_index = 0; gap_index < label_at_position[level_index].length; gap_index++) { + if (label_at_position[level_index][gap_index] != null) { + let label = label_at_position[level_index][gap_index] + let color = label[2].border_color + + // arrow in + let entering_edge = null + if (label[0] >= 0) { + entering_edge = this.canvas.append("path").data([label]) + .attr("shape-rendering", "geometricPrecision") + .style("stroke", color) + .style("stroke-width", 1.5) + .style("fill", "none") + // .attr("marker-end", marker(color, this.canvas)) + .attr("d", d => this.getEnteringEdgePathFromLabel(d)) + .attr("class", EDGE_CLASSNAME) + .lower() + label[2].registerDependencyEdge(entering_edge, false, label, this) + } + let outgoing_edge = this.canvas.append("path").data([label]) + .attr("shape-rendering", "geometricPrecision") + .style("stroke", color) + .style("stroke-width", 1.5) + .style("fill", "none") + .attr("marker-end", marker(color, this.canvas)) + .attr("d", d => this.getOutgoingEdgePathFromLabel(d)) + .attr("class", EDGE_CLASSNAME) + .lower() + label[2].registerDependencyEdge(outgoing_edge, true, label, this) + if (entering_edge != null) { + this.registerFullDependencyEdgeHighlightingOnObjectMouseover(entering_edge, entering_edge, + outgoing_edge, label[2]) + } + this.registerFullDependencyEdgeHighlightingOnObjectMouseover(outgoing_edge, entering_edge, + outgoing_edge, label[2]) + this.registerFullDependencyEdgeHighlightingOnObjectMouseover(label[2].rectangle, entering_edge, + outgoing_edge, label[2]) + for (let i = 0; i < this.cells[label[1]].length; i++) { + this.registerFullDependencyEdgeHighlightingOnObjectMouseover(this.cells[label[1]][i].rectangle, entering_edge, + outgoing_edge, label[2]) + } + if (label[0] >= 0) { + for (let i = 0; i < this.cells[label[0]].length; i++) { + this.registerFullDependencyEdgeHighlightingOnObjectMouseover(this.cells[label[0]][i].rectangle, entering_edge, + outgoing_edge, label[2]) + } + } + } + } + } + + } + } + + getEnteringEdgePathFromLabel(label) { + let cell_x_width_factor = this.get_dep_edge_x_attachment_factor(label[0], label[1]); + let cell_x = this.cells[label[0]][0].getX() + cell_x_width_factor * this.cells[label[0]][0].getWidth() + let cell_y = this.cells[label[0]][0].getY() + let edge_goes_left_to_right = label[0] < label[1] + let label_x = null + if (edge_goes_left_to_right) { + label_x = label[2].getX() + } else { + label_x = label[2].getX() + label[2].getWidth() + } + let label_y = label[2].getY() + 0.8 * getReproducibleRandomNumberFromLabel(label) * label[2].getHeight() + let startpoint = {x: cell_x, y: cell_y} + let endpoint = {x: label_x, y: label_y} + let edge = d3.path() + edge.moveTo(startpoint.x, startpoint.y) + edge.bezierCurveTo(startpoint.x, endpoint.y, + startpoint.x, endpoint.y, + endpoint.x, endpoint.y) + return edge + } + + getOutgoingEdgePathFromLabel(label) { + let cell_x_width_factor = this.get_dep_edge_x_attachment_factor(label[1], label[0]); + let cell_x = this.cells[label[1]][0].getX() + cell_x_width_factor * this.cells[label[1]][0].getWidth() + let cell_y = this.cells[label[1]][0].getY() + let label_x + let label_y + if (label[0] === -1) { + label_x = label[2].getX() + 0.5 * label[2].getWidth() + label_y = label[2].getY() + label[2].getHeight() + } else { + let edge_goes_left_to_right = label[0] < label[1] + if (edge_goes_left_to_right) { + label_x = label[2].getX() + label[2].getWidth() + } else { + label_x = label[2].getX() + } + label_y = label[2].getY() + 0.8 * getReproducibleRandomNumberFromLabel(label) * label[2].getHeight() + } + let startpoint = {x: label_x, y: label_y} + let endpoint = {x: cell_x, y: cell_y} + let edge = d3.path() + edge.moveTo(startpoint.x, startpoint.y) + edge.bezierCurveTo(endpoint.x, startpoint.y, + endpoint.x, startpoint.y, + endpoint.x, endpoint.y) + return edge + } + + + get_dep_edge_x_attachment_factor(cell_index_here, other_cell_index) { + let cell_x_width_factor = 0.5 + if (other_cell_index >= 0) { + if (cell_index_here < other_cell_index) { + cell_x_width_factor = 0.9 - 0.8 * (sigmoid((other_cell_index - cell_index_here) / 2)-0.5) + } else { + cell_x_width_factor = 0.1 + 0.8 * (sigmoid((cell_index_here - other_cell_index) / 2)-0.5) + } + } + return cell_x_width_factor; + } + + registerEdgeHighlightingOnObjectMouseover(object, edge_object, stroke_width=3) { + object.on("mouseover.edge"+create_alias(), function() { + edge_object.style("stroke-width", stroke_width) + }) + .on("mouseout.edge"+create_alias(), function() { + edge_object.style("stroke-width", 1.5) + }) + } + + registerNodeHighlightingOnObjectMouseover(object, node_object) { + let current_stroke_width = parseInt(node_object.rectangle.style("stroke-width")) + let bold_stroke_width = current_stroke_width + 2 + object.on("mouseover.node"+create_alias(), function() { + node_object.rectangle.style("stroke-width", bold_stroke_width) + }) + .on("mouseout.node"+create_alias(), function() { + node_object.rectangle.style("stroke-width", current_stroke_width) + }) + } + + registerFullDependencyEdgeHighlightingOnObjectMouseover(object, entering_edge, outgoing_edge, node_object) { + this.registerNodeHighlightingOnObjectMouseover(object, node_object) + if (entering_edge != null) { + this.registerEdgeHighlightingOnObjectMouseover(object, entering_edge) + } + this.registerEdgeHighlightingOnObjectMouseover(object, outgoing_edge) + } + + find_slot_closest_to_center(available_label_slots, min_bound, max_bound) { + let center = (min_bound + max_bound - 1) / 2 + let best_label_slot = available_label_slots[0] + let best_distance = Math.abs(best_label_slot - center) + for (let k = 1; k < available_label_slots.length; k++) { + let distance_here = Math.abs(available_label_slots[k] - center) + if (distance_here < best_distance) { + best_distance = distance_here + best_label_slot = available_label_slots[k] + } + } + return best_label_slot; + } + + register_mouseover_highlighting(node_object) { + let current_stroke_width = parseInt(node_object.rectangle.style("stroke-width")) + let bold_stroke_width = current_stroke_width + 2 + node_object.rectangle.on("mouseover", function() { + node_object.rectangle.style("stroke-width", bold_stroke_width) + }) + .on("mouseout", function() { + node_object.rectangle.style("stroke-width", current_stroke_width) + }) + } + + registerNodesGlobally(canvas_name) { + let dict_here = {} + for (let i = 0; i < this.cells.length; i++) { + for (let j = 0; j < this.cells[i].length; j++) { + // note that node names have the row index first, and then the column index + // even though in this.cells, the column index comes first + // this is to match the general convention of having the row index first in the node name + // the fact that this.cells is the other way around has technical reasons, and shouldn't spread further. + dict_here["("+j+", "+i+")"] = this.cells[i][j] + } + } + canvas_name_to_node_name_to_node_dict[canvas_name] = dict_here + } + + registerNodeAlternativeMouseover(node_object, node_label_alternatives) { + let strings_object = this + + node_object.rectangle.on("mouseover.node_alternative", function() { + // console.log("mouseover") + current_mouseover_node = node_object + current_mouseover_canvas = strings_object.canvas + current_mouseover_label_alternatives = node_label_alternatives + // check if alt key is currently pressed + if (d3.event.ctrlKey) { + show_label_alternatives(node_object, node_label_alternatives, strings_object.canvas) + } + }) + .on("mouseout.node_alternative", function() { + current_mouseover_node = null + current_mouseover_canvas = null + current_mouseover_label_alternatives = null + if (d3.event.ctrlKey) { + hide_label_alternatives(strings_object.canvas) + } + }) + // this below does not seem to be working + // .on("keydown.node_alternative", function() { + // if (d3.event.keyCode == 18) { + // show_label_alternatives(node_object, null, graph_object.canvas) + // } + // }) + // .on("keyup.node_alternative", function() { + // if (d3.event.keyCode == 18) { + // hide_label_alternatives(graph_object.canvas) + // } + // }) + } +} + +function dependencyTreeNodeDragged(d) { + // console.log(node_object.registeredEdges.length) + // console.log(d.id) + // console.log(NODE_ID_TO_XY_BOX) + let min_x = NODE_ID_TO_XY_BOX[d.id][0] + let max_x = NODE_ID_TO_XY_BOX[d.id][1] + let min_y = NODE_ID_TO_XY_BOX[d.id][2] + let max_y = NODE_ID_TO_XY_BOX[d.id][3] + d.x = Math.max(Math.min(d.x + d3.event.dx, max_x), min_x); + d.y = Math.max(Math.min(d.y + d3.event.dy, max_y), min_y); + let registeredEdges = ALL_NODES[d.id].registeredEdges + for (let i = 0; i < registeredEdges.length; i++) { + let edge = registeredEdges[i][0] + let is_outgoing = registeredEdges[i][1] + let label = registeredEdges[i][2] + let table = registeredEdges[i][3] + if (is_outgoing) { + edge.attr("d", d => table.getOutgoingEdgePathFromLabel(label)) + } else { + edge.attr("d", d => table.getEnteringEdgePathFromLabel(label)) + } + } + // console.log(registeredEdges.length) + ALL_NODES[d.id].group.attr("transform", "translate(" + d.x + "," + d.y + ")"); +} From 73e4018a3d7b57885893e358a1b266b0ba389cf7 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Thu, 5 Dec 2024 16:31:27 +0100 Subject: [PATCH 03/17] Persistent Vulcan database in dev --- compose.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/compose.yaml b/compose.yaml index efc6984..51dcafa 100644 --- a/compose.yaml +++ b/compose.yaml @@ -178,9 +178,13 @@ services: profiles: ["dev"] environment: - VULCAN_DEBUG=1 - command: gunicorn -k eventlet -w 1 -b 0.0.0.0:32771 'app:create_app()' + command: > + gunicorn -k eventlet -w 1 -b 0.0.0.0:32771 'app:create_app()' + --reload ports: - "32771:32771" + volumes: + - ../vulcan-parseport/app:/app networks: From 96490b59a391906aa94f4a5796f320b91dbe70d1 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Thu, 5 Dec 2024 16:34:11 +0100 Subject: [PATCH 04/17] More sensible naming --- backend/minimalist_parser/urls.py | 4 ++-- backend/minimalist_parser/views/visualise.py | 0 backend/src/aethel | 1 - ...ervice.spec.ts => mg-parser-api.service.spec.ts} | 8 ++++---- .../{mp-api.service.ts => mg-parser-api.service.ts} | 13 +++++++------ 5 files changed, 13 insertions(+), 13 deletions(-) delete mode 100644 backend/minimalist_parser/views/visualise.py delete mode 160000 backend/src/aethel rename frontend/src/app/shared/services/{mp-api.service.spec.ts => mg-parser-api.service.spec.ts} (64%) rename frontend/src/app/shared/services/{mp-api.service.ts => mg-parser-api.service.ts} (80%) diff --git a/backend/minimalist_parser/urls.py b/backend/minimalist_parser/urls.py index 6d0a063..04d21a6 100644 --- a/backend/minimalist_parser/urls.py +++ b/backend/minimalist_parser/urls.py @@ -1,5 +1,5 @@ from django.urls import path -from minimalist_parser.views.parse import MPParseView +from minimalist_parser.views.parse import MGParserView -urlpatterns = [path("parse", MPParseView.as_view(), name="mp-parse")] +urlpatterns = [path("parse", MGParserView.as_view(), name="mp-parse")] diff --git a/backend/minimalist_parser/views/visualise.py b/backend/minimalist_parser/views/visualise.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/aethel b/backend/src/aethel deleted file mode 160000 index 41eab8f..0000000 --- a/backend/src/aethel +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 41eab8fb178a197cdf8de738b68e386f07e6e4f5 diff --git a/frontend/src/app/shared/services/mp-api.service.spec.ts b/frontend/src/app/shared/services/mg-parser-api.service.spec.ts similarity index 64% rename from frontend/src/app/shared/services/mp-api.service.spec.ts rename to frontend/src/app/shared/services/mg-parser-api.service.spec.ts index 708d43b..d601b1a 100644 --- a/frontend/src/app/shared/services/mp-api.service.spec.ts +++ b/frontend/src/app/shared/services/mg-parser-api.service.spec.ts @@ -1,16 +1,16 @@ import { TestBed } from "@angular/core/testing"; -import { MpApiService } from "./mp-api.service"; +import { MGParserAPIService } from "./mg-parser-api.service"; import { HttpClientTestingModule } from "@angular/common/http/testing"; -describe("MpApiService", () => { - let service: MpApiService; +describe("MGParserAPIService", () => { + let service: MGParserAPIService; beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], }); - service = TestBed.inject(MpApiService); + service = TestBed.inject(MGParserAPIService); }); it("should be created", () => { diff --git a/frontend/src/app/shared/services/mp-api.service.ts b/frontend/src/app/shared/services/mg-parser-api.service.ts similarity index 80% rename from frontend/src/app/shared/services/mp-api.service.ts rename to frontend/src/app/shared/services/mg-parser-api.service.ts index e7d1610..33b782a 100644 --- a/frontend/src/app/shared/services/mp-api.service.ts +++ b/frontend/src/app/shared/services/mg-parser-api.service.ts @@ -14,16 +14,15 @@ import { import { HttpClient, HttpHeaders } from "@angular/common/http"; import { environment } from "src/environments/environment"; import { ErrorHandlerService } from "./error-handler.service"; +import { MGParserInput, MGParserLoading, MGParserOutput } from "../types"; -type MPInput = string; -type MPOutput = string; -type MPLoading = boolean; @Injectable({ providedIn: "root", }) -export class MpApiService - implements ParsePortDataService +export class MGParserAPIService + implements + ParsePortDataService { public input$ = new Subject(); @@ -35,7 +34,7 @@ export class MpApiService public output$ = this.throttledInput$.pipe( switchMap((input) => this.http - .post( + .post( `${environment.apiUrl}mp/parse`, { input }, { @@ -50,6 +49,8 @@ export class MpApiService error, $localize`An error occurred while handling your input.`, ); + // Returning null instead of EMPTY (which completes) + // because the outer observable should be notified return of(null); }), ), From c1e54b221573d7629388c7a0b40e1cc1a3c4981d Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Thu, 5 Dec 2024 16:35:07 +0100 Subject: [PATCH 05/17] MGParser error handling and types in frontend --- .../shared/services/error-handler.service.ts | 22 +++++++++++++++---- frontend/src/app/shared/types.ts | 18 +++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/shared/services/error-handler.service.ts b/frontend/src/app/shared/services/error-handler.service.ts index 666934c..dcd824d 100644 --- a/frontend/src/app/shared/services/error-handler.service.ts +++ b/frontend/src/app/shared/services/error-handler.service.ts @@ -2,7 +2,7 @@ import { ErrorHandler, Injectable } from "@angular/core"; import { AlertService } from "./alert.service"; import { AlertType } from "../components/alert/alert.component"; import { HttpErrorResponse } from "@angular/common/http"; -import { SpindleErrorSource } from "../types"; +import { MGParserErrorSource, SpindleErrorSource } from "../types"; const spindleErrorMapping: Record = { [SpindleErrorSource.LATEX]: $localize`An error occurred while compiling your PDF.`, @@ -11,13 +11,20 @@ const spindleErrorMapping: Record = { [SpindleErrorSource.INPUT]: $localize`Your input is invalid.`, }; +const mgParserErrorMapping: Record = { + [MGParserErrorSource.INPUT]: $localize`Your input is invalid.`, + [MGParserErrorSource.MG_PARSER]: $localize`The parser encountered an error.`, + [MGParserErrorSource.GENERAL]: $localize`An error occurred while processing your input.`, + [MGParserErrorSource.VULCAN]: $localize`The results of the parse could not be displayed.`, +}; + @Injectable({ providedIn: "root", }) export class ErrorHandlerService implements ErrorHandler { constructor(private alertService: AlertService) {} - handleHttpError(error: HttpErrorResponse, message?: string): void { + public handleHttpError(error: HttpErrorResponse, message?: string): void { if (error.status === 0) { // Client-side or network error console.error("An error occurred:", error.error); @@ -38,17 +45,24 @@ export class ErrorHandlerService implements ErrorHandler { }); } - handleError(errorMessage: string): void { + public handleError(errorMessage: string): void { this.alertService.alert$.next({ type: AlertType.DANGER, message: errorMessage, }); } - handleSpindleError(error: SpindleErrorSource): void { + public handleSpindleError(error: SpindleErrorSource): void { this.alertService.alert$.next({ type: AlertType.DANGER, message: spindleErrorMapping[error], }); } + + public handleMGParserError(error: MGParserErrorSource): void { + this.alertService.alert$.next({ + type: AlertType.DANGER, + message: mgParserErrorMapping[error], + }); + } } diff --git a/frontend/src/app/shared/types.ts b/frontend/src/app/shared/types.ts index 52f5104..69aa72b 100644 --- a/frontend/src/app/shared/types.ts +++ b/frontend/src/app/shared/types.ts @@ -103,3 +103,21 @@ export interface AethelSampleDataReturn { results: AethelSampleDataResult[]; error: string | null; } + +// This should be the same as the one in the backend. +export enum MGParserErrorSource { + INPUT = "input", + MG_PARSER = "mg_parser", + GENERAL = "general", + VULCAN = "vulcan", +} + + +export type MGParserInput = string; + +export interface MGParserOutput { + error: MGParserErrorSource | null; + id: string | null; +} + +export type MGParserLoading = boolean; From 1cc343957d79eba59b752ef5d81de5162d52be39 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Thu, 5 Dec 2024 16:36:26 +0100 Subject: [PATCH 06/17] MGParser frontend styling --- .../minimalist-parser-input.component.html | 78 ++++++++++--------- 1 file changed, 40 insertions(+), 38 deletions(-) diff --git a/frontend/src/app/minimalist-parser/minimalist-parser-input/minimalist-parser-input.component.html b/frontend/src/app/minimalist-parser/minimalist-parser-input/minimalist-parser-input.component.html index 8f5683a..3eec664 100644 --- a/frontend/src/app/minimalist-parser/minimalist-parser-input/minimalist-parser-input.component.html +++ b/frontend/src/app/minimalist-parser/minimalist-parser-input/minimalist-parser-input.component.html @@ -1,44 +1,46 @@ -

Minimalist Parser

+
+
+
+

Minimalist Parser

-

Please enter a sentence below to begin.

+

Please enter a sentence below to begin.

-@if (statusOk$ | async) { -
-
-
- -
-
- + @if (statusOk$ | async) { + + +
+
+ +
+
- -
- @if (form.touched && form.invalid) { -

- Please enter a sentence first. + @if (form.touched && form.invalid) { +

+ Please enter a sentence first. +

+ } + + } @else { +

+ The Minimalist Parser is temporarily unavailable.

} - +
-
-} @else { -

- The Minimalist Parser is temporarily unavailable. -

-} +
From 51cad1a69972893fa5811dde2bf1a945b2c920b2 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Thu, 5 Dec 2024 16:42:49 +0100 Subject: [PATCH 07/17] Update client JS files --- vulcan-static/baseScript.js | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/vulcan-static/baseScript.js b/vulcan-static/baseScript.js index 07fee5e..c6722cc 100644 --- a/vulcan-static/baseScript.js +++ b/vulcan-static/baseScript.js @@ -1,4 +1,16 @@ -const sio = io(); +// Apart from removing console.log statements, and the change below, the JS +// files are unchanged compared to those in the original Vulcan repo. + +// Original: +// const sio = io(); + +// Modified: +const sio = io({ + query: { + id: window.location.pathname.split("/").pop() + } +}); + // sio.eio.pingTimeout = 120000; // 2 minutes // sio.eio.pingInterval = 20000; // 20 seconds @@ -158,12 +170,12 @@ d3.select("#clearSearchButton") }); sio.on('connect', () => { - console.log('connected'); + // console.log('connected'); initializeSearchFilters() }); sio.on('disconnect', () => { - console.log('disconnected'); +// console.log('disconnected'); }); sio.on('set_show_node_names', (data) => { From 391a220d462f3458b331fff9a0237c9f28383a71 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Thu, 5 Dec 2024 16:50:42 +0100 Subject: [PATCH 08/17] Route to Vulcan after receiving response --- .../minimalist-parser-input.component.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/minimalist-parser/minimalist-parser-input/minimalist-parser-input.component.ts b/frontend/src/app/minimalist-parser/minimalist-parser-input/minimalist-parser-input.component.ts index 4bda0e9..a81e9b8 100644 --- a/frontend/src/app/minimalist-parser/minimalist-parser-input/minimalist-parser-input.component.ts +++ b/frontend/src/app/minimalist-parser/minimalist-parser-input/minimalist-parser-input.component.ts @@ -2,7 +2,8 @@ import { Component, DestroyRef, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl, FormGroup, Validators } from "@angular/forms"; import { map } from "rxjs"; -import { MpApiService } from "src/app/shared/services/mp-api.service"; +import { ErrorHandlerService } from "src/app/shared/services/error-handler.service"; +import { MGParserAPIService } from "src/app/shared/services/mg-parser-api.service"; import { StatusService } from "src/app/shared/services/status.service"; @Component({ @@ -25,8 +26,9 @@ export class MinimalistParserInputComponent implements OnInit { constructor( private destroyRef: DestroyRef, - private apiService: MpApiService, + private apiService: MGParserAPIService, private statusService: StatusService, + private errorHandler: ErrorHandlerService, ) {} ngOnInit(): void { @@ -36,7 +38,13 @@ export class MinimalistParserInputComponent implements OnInit { if (!response) { return; } - // Do something with the response. + if (response.error) { + this.errorHandler.handleMGParserError(response.error); + } + if (response.id) { + // TODO: Use dynamic, env-based URL instead. + window.location.href = `http://localhost:5000/vulcan/${response.id}`; + } }); } From f5845f0deb19ae7f5b0d2e57d9c44a87b8579f30 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Thu, 5 Dec 2024 16:52:35 +0100 Subject: [PATCH 09/17] Forward MGParse results to Vulcan --- backend/minimalist_parser/views/parse.py | 110 ++++++++++++++++++----- 1 file changed, 90 insertions(+), 20 deletions(-) diff --git a/backend/minimalist_parser/views/parse.py b/backend/minimalist_parser/views/parse.py index 11bcaa0..2e0f6b7 100644 --- a/backend/minimalist_parser/views/parse.py +++ b/backend/minimalist_parser/views/parse.py @@ -1,4 +1,4 @@ -import pickle +import base64 import json from typing import Optional from dataclasses import dataclass @@ -10,71 +10,141 @@ from parseport.http_client import http_client from parseport.logger import logger +from uuid import uuid4 class MGParserErrorSource(Enum): INPUT = "input" MG_PARSER = "mg_parser" GENERAL = "general" + VULCAN = "vulcan" @dataclass class MGParserResponse: - ok: Optional[bool] = None error: Optional[MGParserErrorSource] = None + id: Optional[str] = None def json_response(self) -> JsonResponse: return JsonResponse( { - "ok": self.ok or False, - "error": getattr(self, "error", None), + "error": self.error.value if self.error else None, + "id": getattr(self, "id", None), }, status=400 if self.error else 200, ) # Create your views here. -class MPParseView(View): +class MGParserView(View): def post(self, request: HttpRequest) -> HttpResponse: - data = self.read_request(request) + """ + Expects a POST request with a JSON body containing an English string to + be parsed, in the following format: + { + "input": str + } + + The input is sent to the parser, and an ID is generated if the parse is + successful. The parse results are forwarded to the Vulcan server, where + a visualisation is created ahead of time. The ID is returned to the + client, which can be used to redirect the client to the Vulcan page + where the visualisation is displayed. + + Returns a JSON response with the following format: + { + "id": str | None, + "error": str | None + } + """ + data = self.validate_input(request) if data is None: + logger.warning("Failed to validate user input.") return MGParserResponse(error=MGParserErrorSource.INPUT).json_response() - parsed = self.send_to_parser(data) + logger.info("User input validated. Sending to parser...") - if parsed is None: + parsed_binary = self.send_to_parser(data) + + if parsed_binary is None: + logger.warning("Failed to parse input: %s", data) return MGParserResponse(error=MGParserErrorSource.MG_PARSER).json_response() - # TODO: send parsed data to Vulcan. + logger.info("Parse successful. Sending to Vulcan...") + parse_id = self.generate_parse_id() + + vulcan_response = self.send_to_vulcan(parsed_binary, parse_id) + if vulcan_response is None: + return MGParserResponse(error=MGParserErrorSource.VULCAN).json_response() + + logger.info("Vulcan response received. Returning ID to client...") - return MGParserResponse(ok=True).json_response() + return MGParserResponse(id=parse_id).json_response() + + def generate_parse_id(self) -> str: + """Generate a unique, URL-safe ID for the current request.""" + return str(uuid4()).replace("-", "") + + def send_to_vulcan(self, parsed_data: bytes, id: str) -> Optional[dict]: + """ + Send request to downstream Vulcan server. + """ + try: + base64_encoded = base64.b64encode(parsed_data).decode("utf-8") + except Exception as e: + logger.warning("Failed to base64 encode parsed data: %s", e) + return None + + vulcan_response = http_client.request( + method="POST", + url=settings.VULCAN_URL, + body=json.dumps( + { + "parse_data": base64_encoded, + "id": id, + } + ), + headers={"Content-Type": "application/json"}, + ) - def send_to_parser(self, text: str) -> Optional[str]: + if vulcan_response.status != 200: + logger.warning( + "Received non-200 response from Vulcan server for input %s", parsed_data + ) + return None + + try: + json_response = vulcan_response.json() + except json.JSONDecodeError: + logger.warning("Received non-JSON response from Vulcan server") + return None + + return json_response + + def send_to_parser(self, input_string: str) -> Optional[bytes]: """Send request to downstream MG Parser""" mg_parser_response = http_client.request( method="POST", url=settings.MINIMALIST_PARSER_URL, - body=json.dumps({"input": text}), + body=json.dumps({"input": input_string}), headers={"Content-Type": "application/json"}, ) if mg_parser_response.status != 200: logger.warning( - "Received non-200 response from MG Parser server for input %s", text + "Received non-200 response from MG Parser server for input %s", + input_string, ) return None - # Parse as pickle - try: - response_body = mg_parser_response.data - parsed_data = pickle.loads(response_body) - except pickle.UnpicklingError: - logger.warning("Received non-pickle response from MG Parser server") + parsed_data = mg_parser_response.data + if type(parsed_data) != bytes: + logger.warning("Received non-bytes response from MG Parser server") return None return parsed_data - def read_request(self, request: HttpRequest) -> Optional[str]: + def validate_input(self, request: HttpRequest) -> Optional[str]: """Read and validate the HTTP request received from the frontend""" request_body = request.body.decode("utf-8") From cc0ae4b022b9c63ed36d1ec8208e713b8b11ef54 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Thu, 5 Dec 2024 16:52:50 +0100 Subject: [PATCH 10/17] Remove unused route params --- backend/vulcan/views.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/backend/vulcan/views.py b/backend/vulcan/views.py index b7d424c..dc51f77 100644 --- a/backend/vulcan/views.py +++ b/backend/vulcan/views.py @@ -4,9 +4,4 @@ # Create your views here. class VulcanView(View): def get(self, request, *args, **kwargs): - route_param = kwargs.get('id' , None) - - print("Route param:", route_param) - - context = {'hello': 'Hello, Vulcan!'} - return render(request, 'vulcan/index.html', context) + return render(request, 'vulcan/index.html') From 4224761ecb385386bde885cab3471feba3293b1c Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Thu, 5 Dec 2024 16:54:12 +0100 Subject: [PATCH 11/17] Fix modules --- frontend/src/app/shared/shared.module.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 05d1a8b..f5a5152 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -14,7 +14,7 @@ import { CommonModule } from "@angular/common"; import { ExportTextComponent } from "./components/spindle-export/export-text/export-text.component"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; import { SpindleExportComponent } from "./components/spindle-export/spindle-export.component"; -import { MpApiService } from "./services/mp-api.service"; +import { MGParserAPIService } from "./services/mg-parser-api.service"; import { ReactiveFormsModule } from "@angular/forms"; @NgModule({ @@ -35,7 +35,7 @@ import { ReactiveFormsModule } from "@angular/forms"; ErrorHandlerService, SpindleApiService, StatusService, - MpApiService, + MGParserAPIService, ], exports: [ AlertComponent, From 11177e973bc2168f61c692d392af64e9743efc84 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Thu, 5 Dec 2024 16:54:23 +0100 Subject: [PATCH 12/17] Increase Nginx proxy timeouts --- nginx.conf | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nginx.conf b/nginx.conf index f491496..ad447c3 100644 --- a/nginx.conf +++ b/nginx.conf @@ -7,14 +7,14 @@ http { location /api { proxy_pass http://pp-dj:8000/api; - proxy_read_timeout 5s; - proxy_connect_timeout 1s; + proxy_read_timeout 30s; + proxy_connect_timeout 30s; } location /vulcan { proxy_pass http://pp-dj:8000/vulcan; - proxy_read_timeout 5s; - proxy_connect_timeout 1s; + proxy_read_timeout 30s; + proxy_connect_timeout 30s; } location /vulcan-static { From 2ca22807a0fdd8527a87aa4eded2623461716d08 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Tue, 10 Dec 2024 11:47:49 +0100 Subject: [PATCH 13/17] Enable Vulcan status check --- backend/parseport/views.py | 4 +--- frontend/src/proxy.conf.json | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/backend/parseport/views.py b/backend/parseport/views.py index a094bc2..758fc42 100644 --- a/backend/parseport/views.py +++ b/backend/parseport/views.py @@ -34,8 +34,6 @@ def get(self, request): aethel=aethel_status(), spindle=status_check('spindle'), mp=status_check('minimalist_parser'), - vulcan=True, - # When Vulcan is up and running, uncomment the following line. - # vulcan=status_check('vulcan'), + vulcan=status_check('vulcan'), ) ) diff --git a/frontend/src/proxy.conf.json b/frontend/src/proxy.conf.json index 2011cda..f1e3fad 100644 --- a/frontend/src/proxy.conf.json +++ b/frontend/src/proxy.conf.json @@ -3,4 +3,4 @@ "target": "http://localhost:8000", "secure": false } -} \ No newline at end of file +} From 2fc839edbd4bba6881de9f5a06d6c29adeabaa37 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Tue, 10 Dec 2024 11:54:02 +0100 Subject: [PATCH 14/17] Minimalist Parser browser component --- .../minimalist-parser-browser.component.html | 23 +++++++++++++++++-- .../minimalist-parser-browser.component.ts | 16 ++++++++++++- .../minimalist-parser-input.component.ts | 9 ++++++-- frontend/src/environments/environment.prod.ts | 1 + frontend/src/environments/environment.ts | 1 + 5 files changed, 45 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/minimalist-parser/minimalist-parser-browser/minimalist-parser-browser.component.html b/frontend/src/app/minimalist-parser/minimalist-parser-browser/minimalist-parser-browser.component.html index 60e12f7..e89e572 100644 --- a/frontend/src/app/minimalist-parser/minimalist-parser-browser/minimalist-parser-browser.component.html +++ b/frontend/src/app/minimalist-parser/minimalist-parser-browser/minimalist-parser-browser.component.html @@ -1,3 +1,22 @@ -

Minimalist Parser

+
+
+
+

Minimalist Parser

-Coming soon: browse a pre-parsed corpus. +

+ Click the button below to browse through a pre-parsed corpus + consisting of 100 sentences taken from the Wall Street Journal. +

+ + @if (statusOk$ | async) { + + } @else { +

+ The Vulcan visualization tool is temporarily unavailable. +

+ } +
+
+
diff --git a/frontend/src/app/minimalist-parser/minimalist-parser-browser/minimalist-parser-browser.component.ts b/frontend/src/app/minimalist-parser/minimalist-parser-browser/minimalist-parser-browser.component.ts index 5163122..37e4ac3 100644 --- a/frontend/src/app/minimalist-parser/minimalist-parser-browser/minimalist-parser-browser.component.ts +++ b/frontend/src/app/minimalist-parser/minimalist-parser-browser/minimalist-parser-browser.component.ts @@ -1,8 +1,22 @@ import { Component } from "@angular/core"; +import { map } from "rxjs"; +import { StatusService } from "src/app/shared/services/status.service"; +import { environment } from "src/environments/environment"; @Component({ selector: "pp-minimalist-parser-browser", templateUrl: "./minimalist-parser-browser.component.html", styleUrl: "./minimalist-parser-browser.component.scss", }) -export class MinimalistParserBrowserComponent {} +export class MinimalistParserBrowserComponent { + public statusOk$ = this.statusService + .getStatus$() + .pipe(map((status) => status.vulcan)); + + constructor(private statusService: StatusService) {} + + public navigateToVulcan(): void { + const origin = window.location.origin; + window.location.href = `${origin}${environment.vulcanUrl}`; + } +} diff --git a/frontend/src/app/minimalist-parser/minimalist-parser-input/minimalist-parser-input.component.ts b/frontend/src/app/minimalist-parser/minimalist-parser-input/minimalist-parser-input.component.ts index a81e9b8..7cc56ea 100644 --- a/frontend/src/app/minimalist-parser/minimalist-parser-input/minimalist-parser-input.component.ts +++ b/frontend/src/app/minimalist-parser/minimalist-parser-input/minimalist-parser-input.component.ts @@ -5,6 +5,7 @@ import { map } from "rxjs"; import { ErrorHandlerService } from "src/app/shared/services/error-handler.service"; import { MGParserAPIService } from "src/app/shared/services/mg-parser-api.service"; import { StatusService } from "src/app/shared/services/status.service"; +import { environment } from "src/environments/environment"; @Component({ selector: "pp-minimalist-parser-input", @@ -42,12 +43,16 @@ export class MinimalistParserInputComponent implements OnInit { this.errorHandler.handleMGParserError(response.error); } if (response.id) { - // TODO: Use dynamic, env-based URL instead. - window.location.href = `http://localhost:5000/vulcan/${response.id}`; + this.navigateToVulcan(response.id); } }); } + private navigateToVulcan(id: string): void { + const origin = window.location.origin; + window.location.href = `${origin}${environment.vulcanUrl}${id}`; + } + public parse(): void { this.form.controls.mpInput.markAsTouched(); this.form.controls.mpInput.updateValueAndValidity(); diff --git a/frontend/src/environments/environment.prod.ts b/frontend/src/environments/environment.prod.ts index ce45330..9231cb4 100644 --- a/frontend/src/environments/environment.prod.ts +++ b/frontend/src/environments/environment.prod.ts @@ -4,6 +4,7 @@ export const environment = { production: true, assets: "/static/assets", apiUrl: "/api/", + vulcanUrl: "/vulcan/", buildTime, version, sourceUrl, diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts index 2a7b590..657a121 100644 --- a/frontend/src/environments/environment.ts +++ b/frontend/src/environments/environment.ts @@ -8,6 +8,7 @@ export const environment = { // URL path prefix for assets assets: "assets", apiUrl: "/api/", + vulcanUrl: "/vulcan/", buildTime, version, sourceUrl, From fd1862906995b1310260ff1ab8621794019a44c1 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Tue, 10 Dec 2024 17:01:51 +0100 Subject: [PATCH 15/17] Frontend styling changes --- .../minimalist-parser-about.component.html | 30 +++++++++++-------- ...inimalist-parser-references.component.html | 10 +++++-- .../minimalist-parser.component.html | 22 +++++++++----- 3 files changed, 41 insertions(+), 21 deletions(-) diff --git a/frontend/src/app/minimalist-parser/minimalist-parser-about/minimalist-parser-about.component.html b/frontend/src/app/minimalist-parser/minimalist-parser-about/minimalist-parser-about.component.html index bf7be69..6966bdc 100644 --- a/frontend/src/app/minimalist-parser/minimalist-parser-about/minimalist-parser-about.component.html +++ b/frontend/src/app/minimalist-parser/minimalist-parser-about/minimalist-parser-about.component.html @@ -1,15 +1,21 @@ -

About Minimalist Parser

+
+
+
+

About Minimalist Parser

-

- Minimalist Parser is a parser developed by Meaghan Fowlie at Utrecht - University. It produces syntactic parses of English sentences in accordance - with the framework of Chomskyan Minimalism. -

+

+ Minimalist Parser is a parser developed by Meaghan Fowlie at + Utrecht University. It produces syntactic parses of English + sentences in accordance with the framework of Chomskyan + Minimalism. +

-
-

Demonstrated parse

+

Demonstrated parse

- - Coming soon: an example of a parse produced by Minimalist Parser. - -
+ + Coming soon: an example of a parse produced by Minimalist + Parser. + +
+
+
diff --git a/frontend/src/app/minimalist-parser/minimalist-parser-references/minimalist-parser-references.component.html b/frontend/src/app/minimalist-parser/minimalist-parser-references/minimalist-parser-references.component.html index 56ae0f3..f4dfd92 100644 --- a/frontend/src/app/minimalist-parser/minimalist-parser-references/minimalist-parser-references.component.html +++ b/frontend/src/app/minimalist-parser/minimalist-parser-references/minimalist-parser-references.component.html @@ -1,3 +1,9 @@ -

References

+
+
+
+

References

-Coming soon: references to scientific literature. + Coming soon: references to scientific literature. +
+
+
diff --git a/frontend/src/app/minimalist-parser/minimalist-parser.component.html b/frontend/src/app/minimalist-parser/minimalist-parser.component.html index 5fa835f..4dbf784 100644 --- a/frontend/src/app/minimalist-parser/minimalist-parser.component.html +++ b/frontend/src/app/minimalist-parser/minimalist-parser.component.html @@ -1,8 +1,16 @@ -

Minimalist Parser

+
+
+
+

Minimalist Parser

-

- Welcome to the Minimalist Parser, developed by Meaghan Fowlie at Utrecht - University. This parser has been trained to analyze the syntax of English - sentences and provide minimalist-style tree diagrams using the Vulcan - visualisation tool. -

+

+ Welcome to the Minimalist Parser, developed by Meaghan Fowlie at + Utrecht University. This parser has been trained to analyze the + syntax of English sentences and provide minimalist-style tree + diagrams using the Vulcan visualisation tool. +

+ + More info coming soon! +
+
+
From 9b17b5de07eb0e29a9e5d9877bb2ac0d6af52ef2 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Tue, 10 Dec 2024 17:30:00 +0100 Subject: [PATCH 16/17] Update README --- README.md | 70 +++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index c0bdb54..c3d5a1f 100644 --- a/README.md +++ b/README.md @@ -2,52 +2,76 @@ [![Actions Status](https://github.com/UUDigitalHumanitieslab/parseport/workflows/Unit%20tests/badge.svg)](https://github.com/UUDigitalHumanitieslab/parseport/actions) -ParsePort is an interface for the [Spindle](https://github.com/konstantinosKokos/spindle) parser using the [Æthel](https://github.com/konstantinosKokos/aethel) library, both developed by dr. Konstantinos Kogkalidis as part of a research project conducted with prof. dr. Michaël Moortgat at Utrecht University. Other parsers may be added in the future. +ParsePort is a web interface for two NLP-related (natural language processing) parsers and two associated pre-parsed text corpora, both developed at Utrecht University. + +1. The [Spindle](https://github.com/konstantinosKokos/spindle) parser is used to produce type-logical parses of Dutch sentences. It features a pre-parsed corpus of around 65.000 sentences (based on [Lassy Small](https://taalmaterialen.ivdnt.org/download/lassy-klein-corpus6/)) called [Æthel](https://github.com/konstantinosKokos/aethel). These tools have been developed by dr. Konstantinos Kogkalidis as part of a research project conducted with prof. dr. Michaël Moortgat at Utrecht University. + +2. The Minimalist Parser produces syntactic tree models of English sentences based on user input, creating syntax trees in the style of [Chomskyan Minimalist Grammar](https://en.wikipedia.org/wiki/Minimalist_program). The parser has been developed by dr. Meaghan Fowlie at Utrecht University and comes with a pre-parsed corpus of 100 sentences taken from the Wall Street Journal. The tool used to visualize these syntax trees in an interactive way is Vulcan, developed by dr. Jonas Groschwitz, also at Utrecht University. ## Running this application in Docker -In order to run this application you need a working installation of Docker and an internet connection. You will also need the source code from two other repositories, `spindle-server` and `latex-service` to be present in the same directory as the `parseport` source code. +In order to run this application you need a working installation of Docker and an internet connection. You will also need the source code from four other repositories. These must be located in the same directory as the `parseport` source code. + +1. [`spindle-server`](https://github.com/CentreForDigitalHumanities/spindle-server) hosts the source code for a server with the Spindle parser; +2. [`latex-service`](https://github.com/CentreForDigitalHumanities/latex-service) contains a LaTeX compiler that is used to export the Spindle parse results in PDF format; +3. [`mg-parser-server`](https://github.com/CentreForDigitalHumanities/mg-parser-server) has the source code for the Minimalist Grammar parser; +4. [`vulcan-parseport`](https://github.com/CentreForDigitalHumanities/vulcan-parseport) is needed for the websocket-based webserver that hosts Vulcan, the visualization tool for MGParser parse results. + +See the instructions in the README files of these repositories for more information on these codebases. In addition, you need to add a configuration file named `.env` to the root directory of this project with at least the following setting. -``` +```conf DJANGO_SECRET_KEY=... ``` In overview, your file structure should be as follows. ``` +┌── parseport (this project) +| ├── compose.yaml +| ├── .env +| ├── frontend +| | └── Dockerfile +| └── backend +| ├── Dockerfile +| └── aethel_db +| └── data +| └── aethel.pickle +| ├── spindle-server -| └── Dockerfile +| ├── Dockerfile | └── model_weights.pt | ├── latex-service | └── Dockerfile | -└── parseport (this project) - ├── compose.yaml - ├── .env - ├── frontend - | └── Dockerfile - └── backend - ├── Dockerfile - └── aethel.pickle +├── mg-parser-server +| └── Dockerfile +| +└── vulcan-parseport + ├── Dockerfile + └── app + └── standard.pickle ``` -Note that you will need two data files in order to run this project. +Note that you will need three data files in order to run this project. - `model_weights.pt` should be put in the root directory of the `spindle-server` project. It can be downloaded from _Yoda-link here_. -- `aethel.pickle` should live at `parseport/backend/`. You can find it in the zip archive [here](https://github.com/konstantinosKokos/aethel/tree/stable/data). +- `aethel.pickle` contains the pre-parsed data for Æthel and should live at `parseport/backend/aethel_db/data`. You can find it in the zip archive [here](https://github.com/konstantinosKokos/aethel/tree/stable/data). +- `standard.pickle` contains the pre-parsed corpus for the Minimalist Parser. It should be placed in the `vulcan-parseport/app` directory. You can download it from _Yoda-link here_. -This application can be run in both `production` and `development` mode. Either mode will start a network of five containers. +This application can be run in both `production` and `development` mode. Either mode will start a network of seven containers. -| Name | Description | -|--------------|---------------------------------------------------| -| `nginx` | Entry point and reverse proxy, exposes port 5000. | -| `pp-ng` | The frontend server (Angular). | -| `pp-dj` | The backend/API server (Django). | -| `pp-spindle` | The server hosting the Spindle parser. | -| `pp-latex` | The server hosting a LaTeX compiler. | +| Name | Description | +|-------------------|---------------------------------------------------| +| `nginx` | Entry point and reverse proxy, exposes port 5000. | +| `pp-ng` | The frontend server (Angular). | +| `pp-dj` | The backend/API server (Django). | +| `pp-spindle` | The server hosting the Spindle parser. | +| `pp-latex` | The server hosting a LaTeX compiler. | +| `pp-mg-parser` | The server hosting the Minimalist Grammar parser. | +| `pp-vulcan` | The server hosting the Vulcan visualization tool. | Start the Docker network in **development mode** by running the following command in your terminal. @@ -61,7 +85,7 @@ For **production mode**, run the following instead. docker compose --profile prod up --build -d ``` -The Spindle server needs to download several files before the parser is ready to receive. You should wait a few minutes until the message *App is ready!* appears in the Spindle container logs. +The Spindle server needs to download several files before the parser is ready to receive input. You should wait a few minutes until the message *App is ready!* appears in the Spindle container logs. Open your browser and visit your project at http://localhost:5000 to view the application. From 0fbd4e417cf64aec12ccb8e9555da36147894c67 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Tue, 10 Dec 2024 17:39:44 +0100 Subject: [PATCH 17/17] Fix tests --- .../minimalist-parser-browser.component.spec.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/minimalist-parser/minimalist-parser-browser/minimalist-parser-browser.component.spec.ts b/frontend/src/app/minimalist-parser/minimalist-parser-browser/minimalist-parser-browser.component.spec.ts index 7776c6e..0de5c05 100644 --- a/frontend/src/app/minimalist-parser/minimalist-parser-browser/minimalist-parser-browser.component.spec.ts +++ b/frontend/src/app/minimalist-parser/minimalist-parser-browser/minimalist-parser-browser.component.spec.ts @@ -1,13 +1,18 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { MinimalistParserBrowserComponent } from "./minimalist-parser-browser.component"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { SharedModule } from "src/app/shared/shared.module"; describe("MinimalistParserBrowserComponent", () => { let component: MinimalistParserBrowserComponent; let fixture: ComponentFixture; beforeEach(async () => { - await TestBed.configureTestingModule({}).compileComponents(); + await TestBed.configureTestingModule({ + declarations: [MinimalistParserBrowserComponent], + imports: [HttpClientTestingModule, SharedModule], + }).compileComponents(); fixture = TestBed.createComponent(MinimalistParserBrowserComponent); component = fixture.componentInstance;