This VisConnect example is adapted from this web game. The only changes are adding the PeerJS and VisConnect script tags, and replacing d3.drag() with vc.drag(). Click the visconnect logo on the bottom right and send the coppied URL to a friend. Once they open the link all your interactions will be synchronized!
Last active
June 25, 2023 20:42
-
-
Save dsaffo/02e285a64149618cfe8429a323f9c91a to your computer and use it in GitHub Desktop.
Planarity Party
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html> | |
<head> | |
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"> | |
<title>Planarity Party</title> | |
<link href="planarity.css" rel="stylesheet"> | |
<script src="https://d3js.org/d3.v5.min.js"></script> | |
<script src="https://unpkg.com/peerjs@1.0.4/dist/peerjs.min.js"></script> | |
<script src="https://unpkg.com/visconnect@latest/visconnect-bundle.js"></script> | |
</head> | |
<body collaboration> | |
<p>Can you untangle the graph? See if you can position the vertices so that no two lines cross. | |
<p><b>Level <span id="levelindicator"></span></b>. Number of line crossings detected: <span id="count">0</span> | |
<p style="font-style: italic"> | |
<span id="move-count">0 moves</span>. | |
<button onclick="generate()" id="nextlevel" disabled="disabled">Next Level</button> | |
</p> | |
<div id="vis"></div> | |
<div id="force"></div> | |
<!--<input type="hidden" value="8" id="nodes" min="4">--> | |
<!--<p><label for="nodes">Number of vertices:</label> | |
<input type="number" value="8" id="nodes" min="4"> | |
<button id="generate" class="first last">Generate new, random graph</button>--> | |
<!--<p><label for="intersections"><input type="checkbox" id="intersections"> Highlight non-intersecting lines</label>. | |
<p>Don't worry, the game only generates solvable graphs! These are known as <a href="http://en.wikipedia.org/wiki/Planar_graph">planar graphs</a>. | |
<h2>Further Reading</h2> | |
<ul> | |
<li><a href="http://www.planarity.net/">John Tantalo's original version</a> of the puzzle. | |
<li>Information about the <a href="http://en.wikipedia.org/wiki/Planarity">Planarity</a> game on Wikipedia. | |
<li><a href="http://en.wikipedia.org/wiki/Planar_graph">Planar graph</a> on Wikipedia. | |
</ul> | |
<p><a href="http://mbostock.github.com/d3/">Built with D3</a>.--> | |
</body> | |
</html> | |
<p class="copyright">Adapted from <a href="https://www.jasondavies.com/planarity/">Jason Davies</a>. | |
<script src="planarity.js"></script> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
body { | |
font-size: 1em; | |
font-weight: 400; | |
word-spacing: normal; | |
letter-spacing: normal; | |
text-transform: none; | |
font-family: Verdana, Myriad Web, Syntax, sans-serif; | |
font-size-adjust: .58; | |
color: #000; | |
background: #FFF; | |
line-height: 1.58em; | |
border-top: 0; | |
border-left: 0; | |
border-bottom: 0; | |
border-right: 0; | |
width: auto; | |
margin: 20px 50px; | |
padding: 0 | |
} | |
circle { | |
fill: #fc0; | |
cursor: move; | |
stroke: #000; | |
stroke-width: 1.5px; | |
} | |
circle.intersection { | |
fill: #0cf; | |
} | |
line { | |
stroke: #fc0; | |
stroke-width: 1.5px; | |
} | |
line.intersection { | |
stroke: #000; | |
} | |
#vis svg { | |
border: 2px solid #ccc; | |
border-radius: 7px; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var w = 760, | |
h = 300, | |
p = 15, | |
x = d3.scaleLinear().range([0, w]), | |
y = d3.scaleLinear().range([0, h]), | |
start, | |
format = d3.format(",.1f"), | |
moves = 0, | |
highlightIntersections = false, | |
count = 0, // intersections | |
graph; | |
let level = 0; | |
graph = scramble(planarGraph(8)); | |
d3.select("#vis").selectAll("*").remove(); | |
var vis = d3.select("#vis").append("svg") | |
.attr("width", w + p * 2) | |
.attr("height", h + p * 2) | |
.append("g") | |
.attr("transform", "translate(" + [p, p] + ")"); | |
const lines = vis.append("g"); | |
const nodes = vis.append("g"); | |
const counter = d3.select("#count"); | |
const moveCounter = d3.select("#move-count"); | |
const timer = d3.select("#timer"); | |
const force = d3.select("#force").append("svg") | |
.attr("width", w + p * 2) | |
.attr("height", h + p * 2) | |
.append("g") | |
.attr("transform", "translate(" + [p, p] + ")"); | |
const forceLines = force.append("g"); | |
const forceNodes = force.append("g"); | |
const simulation = d3.forceSimulation() | |
.force("link", d3.forceLink().id(d => d.id)) | |
.force("charge", d3.forceManyBody()) | |
.force("center", d3.forceCenter( (w + p * 2)/2, (h + p * 2)/2)); | |
d3.select("#generate").on("click", generate); | |
d3.select("#intersections").on("change", function() { | |
highlightIntersections = this.checked; | |
update(); | |
}); | |
generate(); | |
d3.timer(function() { | |
if (count) timer.text(format((+new Date - start) / 1000)); | |
}); | |
function generate() { | |
if(intersections(graph.links) !== 0 && level !== 0) { | |
return; | |
} | |
level++; | |
seed = level; | |
moveCounter.text("0 moves"); | |
document.getElementById('levelindicator').innerText = level; | |
moves = 0; | |
start = +new Date; | |
lastCount = null; | |
forceLines.selectAll("line").remove(); | |
forceNodes.selectAll("circle").remove(); | |
graph = scramble(planarGraph(level + 4)); | |
simulation.restart(); | |
simulation.alpha(1); | |
update(); | |
} | |
function update() { | |
count = intersections(graph.links); | |
counter.text(count ? count + "." : "0! Well done!"); | |
const nextLevelButton = document.getElementById('nextlevel'); | |
const forceDiv = document.getElementById('force'); | |
if(count === 0) { | |
nextLevelButton.removeAttribute('disabled'); | |
forceDiv.style.display = 'inline'; | |
} else { | |
nextLevelButton.setAttribute('disabled', ''); | |
forceDiv.style.display = 'none'; | |
} | |
const line = lines.selectAll("line") | |
.data(graph.links); | |
const lineEnter = line.enter().append("line"); | |
line.exit().remove(); | |
lineEnter.merge(line).attr("x1", function(d) { return x(d[0][0]); }) | |
.attr("y1", function(d) { return y(d[0][1]); }) | |
.attr("x2", function(d) { return x(d[1][0]); }) | |
.attr("y2", function(d) { return y(d[1][1]); }) | |
.classed("intersection", highlightIntersections ? function(d) { return d.intersection; } : true); | |
const node = nodes.selectAll("circle") | |
.data(graph.nodes); | |
const nodeEnter = node.enter().append("circle"); | |
nodeEnter | |
.attr("r", p - 1) | |
.call(vc.drag() | |
.on("drag", function(d) { | |
// Jitter to prevent coincident nodes. | |
const pos = vc.mouse(this); | |
d[0] = x.invert(pos[0]); | |
d[1] = y.invert(pos[1]); | |
update(); | |
}) | |
.on("end", function() { | |
moveCounter.text(++moves + " move" + (moves !== 1 ? "s" : "")); | |
})); | |
node.exit().remove(); | |
nodeEnter.merge(node).attr("cx", function(d) { return x(d[0]); }) | |
.attr("cy", function(d) { return y(d[1]); }) | |
.classed("intersection", highlightIntersections ? | |
function(d) { return d.intersection; } : count); | |
const forceLine = forceLines.selectAll("line") | |
.data(graph.links) | |
.enter().append("line"); | |
const forceNode = forceNodes.attr("class", "nodes") | |
.selectAll("circle") | |
.data(graph.nodes) | |
.enter() | |
.append("circle") | |
.attr("r", 2); | |
simulation | |
.nodes(graph.nodes) | |
.on("tick", ticked); | |
simulation.force("link") | |
.links(graph.links); | |
function ticked() { | |
forceLine | |
.attr("x1", function(d) { return d[0].x; }) | |
.attr("y1", function(d) { return d[0].y; }) | |
.attr("x2", function(d) { return d[1].x; }) | |
.attr("y2", function(d) { return d[1].y; }); | |
forceNode | |
.attr("r", 16) | |
.style("fill", "#efefef") | |
.style("stroke", "#424242") | |
.style("stroke-width", "1px") | |
.attr("cx", function (d) { return d.x+5; }) | |
.attr("cy", function(d) { return d.y-3; }); | |
} | |
} | |
// Scramble the node positions. | |
function scramble(graph) { | |
if (graph.nodes.length < 4) return graph; | |
do { | |
graph.nodes.forEach(function(node) { | |
node[0] = vc.random(); | |
node[1] = vc.random(); | |
}); | |
} while (!intersections(graph.links)); | |
return graph; | |
} | |
// Generates a random planar graph with *n* nodes. | |
function planarGraph(n) { | |
var points = [], | |
links = [], | |
i = -1, | |
j; | |
while (++i < n) points[i] = [vc.random(), vc.random()]; | |
i = -1; while (++i < n) { | |
addPlanarLink([points[i], points[~~(vc.random() * n)]], links); | |
} | |
i = -1; while (++i < n) { | |
j = i; while (++j < n) addPlanarLink([points[i], points[j]], links); | |
} | |
//console.log(links) | |
return {nodes: points, links: links}; | |
} | |
// Adds a link if it doesn't intersect with anything. | |
function addPlanarLink(link, links) { | |
if (!links.some(function(to) { return intersect(link, to); })) { | |
links.push(link); | |
} | |
} | |
// Counts the number of intersections for a given array of links. | |
function intersections(links) { | |
var n = links.length, | |
i = -1, | |
j, | |
x, | |
count = 0; | |
// Reset flags. | |
while (++i < n) { | |
(x = links[i]).intersection = false; | |
x[0].intersection = false; | |
x[1].intersection = false; | |
} | |
i = -1; while (++i < n) { | |
x = links[i]; | |
j = i; while (++j < n) { | |
if (intersect(x, links[j])) { | |
x.intersection = | |
x[0].intersection = | |
x[1].intersection = | |
links[j].intersection = | |
links[j][0].intersection = | |
links[j][1].intersection = true; | |
count++; | |
} | |
} | |
} | |
return count; | |
} | |
// Returns true if two line segments intersect. | |
// Based on http://stackoverflow.com/a/565282/64009 | |
function intersect(a, b) { | |
// Check if the segments are exactly the same (or just reversed). | |
if (a[0] === b[0] && a[1] === b[1] || a[0] === b[1] && a[1] === b[0]) return true; | |
// Represent the segments as p + tr and q + us, where t and u are scalar | |
// parameters. | |
var p = a[0], | |
r = [a[1][0] - p[0], a[1][1] - p[1]], | |
q = b[0], | |
s = [b[1][0] - q[0], b[1][1] - q[1]]; | |
// Solve p + tr = q + us to find an intersection point. | |
// First, cross both sides with s: | |
// (p + tr) × s = (q + us) × s | |
// We know that s × s = 0, so this can be rewritten as: | |
// t(r × s) = (q − p) × s | |
// Then solve for t to get: | |
// t = (q − p) × s / (r × s) | |
// Similarly, for u we get: | |
// u = (q − p) × r / (r × s) | |
var rxs = cross(r, s), | |
q_p = [q[0] - p[0], q[1] - p[1]], | |
t = cross(q_p, s) / rxs, | |
u = cross(q_p, r) / rxs, | |
epsilon = 1e-6; | |
return t > epsilon && t < 1 - epsilon && u > epsilon && u < 1 - epsilon; | |
} | |
function cross(a, b) { | |
return a[0] * b[1] - a[1] * b[0]; | |
} |
Author
dsaffo
commented
Jul 22, 2020
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment