Add graph-maker site
This commit is contained in:
parent
dcf5cc3c45
commit
5a0cd8d1ca
|
@ -0,0 +1,33 @@
|
||||||
|
svg {
|
||||||
|
cursor: crosshair;
|
||||||
|
display: block;
|
||||||
|
margin: auto;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge {
|
||||||
|
stroke: #888;
|
||||||
|
stroke-width: 2px;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge:hover,
|
||||||
|
.dragLine {
|
||||||
|
stroke: #333;
|
||||||
|
stroke-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertex {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertex:hover {
|
||||||
|
stroke: #333;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragLine.hidden {
|
||||||
|
stroke-width: 0;
|
||||||
|
}
|
|
@ -0,0 +1,324 @@
|
||||||
|
"use strict";
|
||||||
|
//node ids are in order in which nodes come in existence
|
||||||
|
|
||||||
|
var univSvgHeight;
|
||||||
|
var univSvgWidth;
|
||||||
|
|
||||||
|
const nodes = [{ id: 1 }, { id: 2 }, { id: 3 }];
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ source: 0, target: 1 },
|
||||||
|
{ source: 0, target: 2 },
|
||||||
|
{ source: 1, target: 2 }
|
||||||
|
];
|
||||||
|
|
||||||
|
//universal width and height let index.htm control svg dimensions when needed
|
||||||
|
let lastNodeId = nodes.length;
|
||||||
|
var w = univSvgWidth ? univSvgWidth : 616,
|
||||||
|
h = univSvgHeight ? univSvgHeight : 400,
|
||||||
|
rad = 10,
|
||||||
|
wid = 10;
|
||||||
|
|
||||||
|
const svg = d3
|
||||||
|
.select("#svg-wrap")
|
||||||
|
.append("svg")
|
||||||
|
.attr("width", w)
|
||||||
|
.attr("height", h);
|
||||||
|
|
||||||
|
const dragLine = svg
|
||||||
|
.append("path")
|
||||||
|
.attr("class", "dragLine hidden")
|
||||||
|
.attr("d", "M0,0L0,0");
|
||||||
|
|
||||||
|
let edges = svg.append("g").selectAll(".edge");
|
||||||
|
|
||||||
|
let vertices = svg.append("g").selectAll(".vertex");
|
||||||
|
|
||||||
|
const force = d3
|
||||||
|
.forceSimulation()
|
||||||
|
.force(
|
||||||
|
"charge",
|
||||||
|
d3
|
||||||
|
.forceManyBody()
|
||||||
|
.strength(-300)
|
||||||
|
.distanceMax(w / 2)
|
||||||
|
)
|
||||||
|
.force("link", d3.forceLink().distance(60))
|
||||||
|
.force("x", d3.forceX(w / 2))
|
||||||
|
.force("y", d3.forceY(h / 2))
|
||||||
|
.on("tick", tick);
|
||||||
|
|
||||||
|
force.nodes(nodes);
|
||||||
|
force.force("link").links(links);
|
||||||
|
|
||||||
|
const fgColor = '#ffffff'
|
||||||
|
const colors = d3.schemeCategory10;
|
||||||
|
|
||||||
|
let mousedownNode = null;
|
||||||
|
|
||||||
|
const clrBtn = d3.select("#clear-graph");
|
||||||
|
clrBtn.on("click", clearGraph);
|
||||||
|
|
||||||
|
const nodename = (id) =>
|
||||||
|
id <= 26 ?
|
||||||
|
String.fromCharCode(id + 96) :
|
||||||
|
String.fromCharCode(parseInt(id / 26) + 96) + String.fromCharCode((id % 26) + 96);
|
||||||
|
|
||||||
|
//empties the graph
|
||||||
|
function clearGraph() {
|
||||||
|
nodes.splice(0);
|
||||||
|
links.splice(0);
|
||||||
|
lastNodeId = 0;
|
||||||
|
restart();
|
||||||
|
showGraphLatex();
|
||||||
|
}
|
||||||
|
|
||||||
|
//update the simulation
|
||||||
|
function tick() {
|
||||||
|
edges
|
||||||
|
.attr("x1", (d) => d.source.x)
|
||||||
|
.attr("y1", (d) => d.source.y)
|
||||||
|
.attr("x2", (d) => d.target.x)
|
||||||
|
.attr("y2", (d) => d.target.y);
|
||||||
|
|
||||||
|
vertices
|
||||||
|
.attr("cx", (d) => d.x)
|
||||||
|
.attr("cy", (d) => d.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNode() {
|
||||||
|
const e = d3.event;
|
||||||
|
if (e.button == 0) {
|
||||||
|
var coords = d3.mouse(e.currentTarget);
|
||||||
|
var newNode = { x: coords[0], y: coords[1], id: ++lastNodeId };
|
||||||
|
nodes.push(newNode);
|
||||||
|
restart();
|
||||||
|
showGraphLatex();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeNode(d, i) {
|
||||||
|
//to make ctrl-drag works for mac/osx users
|
||||||
|
if (d3.event.ctrlKey) return;
|
||||||
|
|
||||||
|
nodes.splice(nodes.indexOf(d), 1);
|
||||||
|
links
|
||||||
|
.filter((l) => l.source === d || l.target === d)
|
||||||
|
.foreach((l) => links.splice(links.indexOf(l), 1));
|
||||||
|
|
||||||
|
d3.event.preventDefault();
|
||||||
|
restart();
|
||||||
|
showGraphLatex();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEdge(d, i) {
|
||||||
|
links.splice(links.indexOf(d), 1);
|
||||||
|
d3.event.preventDefault();
|
||||||
|
restart();
|
||||||
|
showGraphLatex();
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginDragLine(d) {
|
||||||
|
//to prevent call of addNode through svg
|
||||||
|
d3.event.stopPropagation();
|
||||||
|
//to prevent dragging of svg in firefox
|
||||||
|
d3.event.preventDefault();
|
||||||
|
if (d3.event.ctrlKey || d3.event.button != 0) return;
|
||||||
|
|
||||||
|
mousedownNode = d;
|
||||||
|
dragLine
|
||||||
|
.classed("hidden", false)
|
||||||
|
.attr("d", `M${mousedownNode.x},${mousedownNode.y}L${mousedownNode.x},${mousedownNode.y}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDragLine() {
|
||||||
|
var coords = d3.mouse(d3.event.currentTarget);
|
||||||
|
if (!mousedownNode) return;
|
||||||
|
dragLine.attr("d", `M${mousedownNode.x},${mousedownNode.y}L${coords[0]},${coords[1]}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideDragLine() {
|
||||||
|
dragLine.classed("hidden", true);
|
||||||
|
mousedownNode = null;
|
||||||
|
restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
//no need to call hideDragLine() and restart() in endDragLine
|
||||||
|
//mouseup on vertices propagates to svg which calls hideDragLine
|
||||||
|
function endDragLine(d) {
|
||||||
|
if (!mousedownNode || mousedownNode === d) return;
|
||||||
|
//return if link already exists
|
||||||
|
if (links.some(l =>
|
||||||
|
(l.source === mousedownNode && l.target === d) ||
|
||||||
|
(l.source === d && l.target === mousedownNode)
|
||||||
|
)) return;
|
||||||
|
|
||||||
|
const newLink = { source: mousedownNode, target: d };
|
||||||
|
links.push(newLink);
|
||||||
|
showGraphLatex();
|
||||||
|
}
|
||||||
|
|
||||||
|
//one response per ctrl keydown
|
||||||
|
var lastKeyDown = -1;
|
||||||
|
|
||||||
|
function keydown() {
|
||||||
|
d3.event.preventDefault();
|
||||||
|
if (lastKeyDown !== -1) return;
|
||||||
|
lastKeyDown = d3.event.key;
|
||||||
|
|
||||||
|
if (lastKeyDown === "Control") {
|
||||||
|
vertices.call(
|
||||||
|
d3
|
||||||
|
.drag()
|
||||||
|
.on("start", (d) => {
|
||||||
|
if (!d3.event.active) force.alphaTarget(1).restart();
|
||||||
|
d.fx = d.x;
|
||||||
|
d.fy = d.y;
|
||||||
|
})
|
||||||
|
.on("drag", (d) => {
|
||||||
|
d.fx = d3.event.x;
|
||||||
|
d.fy = d3.event.y;
|
||||||
|
})
|
||||||
|
.on("end", (d) => {
|
||||||
|
if (!d3.event.active) force.alphaTarget(0);
|
||||||
|
d.fx = null;
|
||||||
|
d.fy = null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyup() {
|
||||||
|
lastKeyDown = -1;
|
||||||
|
if (d3.event.key === "Control")
|
||||||
|
vertices.on("mousedown.drag", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
//updates the graph by updating links, nodes and binding them with DOM
|
||||||
|
//interface is defined through several events
|
||||||
|
function restart() {
|
||||||
|
edges = edges.data(links, (d) => "v" + d.source.id + "-v" + d.target.id);
|
||||||
|
edges.exit().remove();
|
||||||
|
|
||||||
|
var ed = edges
|
||||||
|
.enter()
|
||||||
|
.append("line")
|
||||||
|
.attr("class", "edge")
|
||||||
|
// .style("stroke", (d, _) => '#888')
|
||||||
|
.on("mousedown", () => d3.event.stopPropagation())
|
||||||
|
.on("contextmenu", removeEdge);
|
||||||
|
|
||||||
|
ed.append("title").text(d => "v" + d.source.id + "-v" + d.target.id);
|
||||||
|
|
||||||
|
edges = ed.merge(edges);
|
||||||
|
|
||||||
|
//vertices are known by id
|
||||||
|
vertices = vertices.data(nodes, (d) => d.id);
|
||||||
|
vertices.exit().remove();
|
||||||
|
|
||||||
|
var ve = vertices
|
||||||
|
.enter()
|
||||||
|
.append("circle")
|
||||||
|
.attr("r", rad)
|
||||||
|
.attr("class", "vertex")
|
||||||
|
.style("fill", (d, _) => colors[d.id % 10])
|
||||||
|
.on("mousedown", beginDragLine)
|
||||||
|
.on("mouseup", endDragLine)
|
||||||
|
.on("contextmenu", removeNode);
|
||||||
|
|
||||||
|
ve.append("title").text(d => "v" + d.id);
|
||||||
|
|
||||||
|
vertices = ve.merge(vertices);
|
||||||
|
|
||||||
|
|
||||||
|
// edges.sort((a, b) => a.source !== b.source ? a.source > b.source : a.target > b.target)
|
||||||
|
links.sort((a, b) => a.source !== b.source ? a.source > b.source : a.target > b.target)
|
||||||
|
|
||||||
|
force.nodes(nodes);
|
||||||
|
force.force("link").links(links);
|
||||||
|
force.alpha(0.8).restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
//further interface
|
||||||
|
svg
|
||||||
|
.on("mousedown", addNode)
|
||||||
|
.on("mousemove", updateDragLine)
|
||||||
|
.on("mouseup", hideDragLine)
|
||||||
|
.on("contextmenu", () => d3.event.preventDefault())
|
||||||
|
.on("mouseleave", hideDragLine);
|
||||||
|
|
||||||
|
d3.select(window)
|
||||||
|
.on("keydown", keydown)
|
||||||
|
.on("keyup", keyup);
|
||||||
|
|
||||||
|
restart();
|
||||||
|
showGraphLatex();
|
||||||
|
|
||||||
|
//handling output area
|
||||||
|
function showGraphLatex() {
|
||||||
|
const v = "\\[ V = \\{ " +
|
||||||
|
nodes
|
||||||
|
.map(({ id }, i) => {
|
||||||
|
return (i == 0) ?
|
||||||
|
`${nodename(id)}` :
|
||||||
|
`, ${nodename(id)}`;
|
||||||
|
//add line break
|
||||||
|
// if ((i + 1) % 15 == 0) v += "\\\\";
|
||||||
|
})
|
||||||
|
.join('') +
|
||||||
|
" \\} \\]\n\n";
|
||||||
|
|
||||||
|
const e = "\\[ E = \\{" +
|
||||||
|
links.map(({ source, target }, i) => {
|
||||||
|
if (i == links.length - 1)
|
||||||
|
return `${nodename(source.id)}${nodename(target.id)}`;
|
||||||
|
else
|
||||||
|
return `${nodename(source.id)}${nodename(target.id)}, `;
|
||||||
|
// //add line break
|
||||||
|
// if ((i + 1) % 10 == 0) e += "\\\\";
|
||||||
|
}).join('') +
|
||||||
|
"\\} \\]";
|
||||||
|
|
||||||
|
document.getElementById("output").textContent = v + e;
|
||||||
|
//recall mathjax
|
||||||
|
document.getElementById("latex-output").textContent = v + e;
|
||||||
|
MathJax.Hub.Queue(["Typeset", MathJax.Hub]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSVG() {
|
||||||
|
const svgClone =
|
||||||
|
document
|
||||||
|
.getElementsByTagName("svg")[0]
|
||||||
|
.cloneNode({ deep: true });
|
||||||
|
|
||||||
|
svgClone
|
||||||
|
.firstChild
|
||||||
|
.childNodes
|
||||||
|
.foreach(e => e.style.stroke = "#000000");
|
||||||
|
|
||||||
|
var serializer = new XMLSerializer();
|
||||||
|
var source = serializer.serializeToString(svgClone);
|
||||||
|
|
||||||
|
if (!source.match(/^<svg[^>]+xmlns="http\:\/\/www\.w3\.org\/2000\/svg"/)) {
|
||||||
|
source = source.replace(/^<svg/, '<svg xmlns="http://www.w3.org/2000/svg"');
|
||||||
|
}
|
||||||
|
if (!source.match(/^<svg[^>]+"http\:\/\/www\.w3\.org\/1999\/xlink"/)) {
|
||||||
|
source = source.replace(/^<svg/, '<svg xmlns:xlink="http://www.w3.org/1999/xlink"');
|
||||||
|
}
|
||||||
|
|
||||||
|
source = '<?xml version="1.0" standalone="no"?>\r\n' + source;
|
||||||
|
return "data:image/svg+xml;charset=utf-8," + encodeURIComponent(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadSVG() {
|
||||||
|
var downloadLink = document.createElement("a");
|
||||||
|
downloadLink.href = generateSVG();
|
||||||
|
downloadLink.download = 'graph.svg';
|
||||||
|
document.body.appendChild(downloadLink);
|
||||||
|
downloadLink.click();
|
||||||
|
document.body.removeChild(downloadLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
d3
|
||||||
|
.select('#export-svg')
|
||||||
|
.on('click', downloadSVG)
|
|
@ -0,0 +1,48 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Graph maker</title>
|
||||||
|
|
||||||
|
<!-- <script src="https://d3js.org/d3.v6.min.js" defer></script> -->
|
||||||
|
<script src="https://d3js.org/d3.v5.min.js"></script>
|
||||||
|
<script src="https://code.jquery.com/jquery-3.6.0.slim.min.js" defer></script>
|
||||||
|
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/MathJax.js?config=TeX-AMS_HTML-full"></script>
|
||||||
|
<script src="./graph.js" defer></script>
|
||||||
|
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-eOJMYsd53ii+scO/bJGFsiCZc+5NDVN2yr8+0RDqr0Ql0h+rP48ckxlpbzKgwra6" crossorigin="anonymous" />
|
||||||
|
<link rel="stylesheet" href="graph.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="app-area" class="d-flex-column justify-content-center">
|
||||||
|
<div id="svg-wrap" class="bg-dark flex-column"></div>
|
||||||
|
<div id="svg-buttons" class="text-center my-5 flex-column">
|
||||||
|
<button class="btn btn-primary" id="clear-graph">Clear All</button>
|
||||||
|
<button class="btn btn-primary" id="export-svg">Export SVG</button>
|
||||||
|
<div class="flex-column">
|
||||||
|
<input type="radio" class="btn-check" id="btn-check-outlined" autocomplete="off" checked>
|
||||||
|
<label class="btn btn-outline-secondary" for="btn-check-outlined">Undirected</label><br>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" id="btn-check-outlined" autocomplete="off" disabled>
|
||||||
|
<label class="btn btn-outline-secondary" for="btn-check-outlined">Directed</label><br>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" id="btn-check-outlined" autocomplete="off" disabled>
|
||||||
|
<label class="btn btn-outline-secondary" for="btn-check-outlined">Tree</label><br>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row my-5 mx-5">
|
||||||
|
<textarea name="output" id="output" cols="30" rows="10" class="col-sm"></textarea>
|
||||||
|
<div id="latex-output" class="col-sm"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
Loading…
Reference in New Issue