// SPDX-FileCopyrightText: Chris Pressey, the creator of this work, has dedicated it to the public domain.
// For more information, please refer to <https://unlicense.org/>
// SPDX-License-Identifier: Unlicense
const svg = d3.select("svg");
const width = +svg.attr("width");
const height = +svg.attr("height");
let simulation = d3.forceSimulation()
.force("link", d3.forceLink().id(d => d.id).distance(50))
.force("charge", d3.forceManyBody().strength(-300))
.force("center", d3.forceCenter(width / 2, height / 2));
// Initialize containers for nodes, links, and labels
const linkGroup = svg.append("g");
const nodeGroup = svg.append("g");
const labelGroup = svg.append("g");
// Define arrow markers for graph links
const defs = svg.append("defs");
function updateGraph(text) {
const graph = createWeightedGraph(text);
// Calculate the maximum weight for scaling
const maxWeight = 10; // Math.max(...graph.links.map(d => d.value));
// Create a non-linear scale for arc thickness
const arcThicknessScale = d3.scalePow()
.exponent(0.5) // Square root scale for more pronounced differences
.domain([1, maxWeight])
.range([1, 10]); // Minimum thickness of 1, maximum of 10
const nodeRadius = 2;
// Update arrow markers
defs.selectAll("marker").remove();
graph.links.forEach((link, i) => {
const size = 6; // arrowhead size
defs.append("marker")
.attr("id", `arrowhead-${i}`)
.attr("viewBox", `0 -${size/2} ${size} ${size}`)
.attr("refX", size + nodeRadius) // Increase refX to move the arrowhead away from the center
.attr("refY", 0)
.attr("markerWidth", size)
.attr("markerHeight", size)
.attr("orient", "auto")
.append("path")
.attr("fill", "#999")
.attr("d", `M0,-${size/2}L${size},0L0,${size/2}`);
});
// Update links
const link = linkGroup.selectAll("path")
.data(graph.links, d => `${d.source.id || d.source}-${d.target.id || d.target}`);
link.exit().remove();
const linkEnter = link.enter().append("path")
.attr("fill", "none")
.attr("stroke", "#999")
.attr("stroke-opacity", 0.6);
link.merge(linkEnter)
.attr("stroke-width", d => arcThicknessScale(d.value))
.attr("marker-end", (d, i) => `url(#arrowhead-${i})`);
// Update nodes
const node = nodeGroup.selectAll("circle")
.data(graph.nodes, d => d.id);
node.exit().remove();
const nodeEnter = node.enter().append("circle")
.attr("r", 5)
.attr("fill", "#69b3a2");
node.merge(nodeEnter);
// Update labels
const label = labelGroup.selectAll("text")
.data(graph.nodes, d => d.id);
label.exit().remove();
const labelEnter = label.enter().append("text")
.attr("font-size", "12px")
.attr("dx", 8)
.attr("dy", 3);
label.merge(labelEnter)
.text(d => d.id);
// Update and restart the simulation
simulation.nodes(graph.nodes);
simulation.force("link").links(graph.links);
// Restart the simulation without resetting node positions
simulation.alpha(1).restart();
simulation.on("tick", ticked);
function ticked() {
linkGroup.selectAll("path").attr("d", linkArc);
nodeGroup.selectAll("circle")
.attr("cx", d => d.x)
.attr("cy", d => d.y);
labelGroup.selectAll("text")
.attr("x", d => d.x)
.attr("y", d => d.y);
}
function linkArc(d) {
const dx = d.target.x - d.source.x;
const dy = d.target.y - d.source.y;
const dr = Math.sqrt(dx * dx + dy * dy);
if (d.source.id === d.target.id) {
// Self-link
return `M${d.source.x},${d.source.y} A20,20 0 1,1 ${d.source.x+1},${d.source.y}`;
}
const endX = d.source.x + dx * (1 - nodeRadius / dr);
const endY = d.source.y + dy * (1 - nodeRadius / dr);
return `M${d.source.x},${d.source.y} A${dr},${dr} 0 0,1 ${endX},${endY}`;
}
// Generate and update output text
const generatedText = generateMarkovText(text.length, graph);
document.getElementById('output').value = generatedText;
}
function createWeightedGraph(text) {
const nodes = new Set();
const links = new Map();
for (let i = 0; i < text.length - 1; i++) {
const source = text[i];
const target = text[i + 1];
nodes.add(source);
nodes.add(target);
const key = `${source}-${target}`;
links.set(key, (links.get(key) || 0) + 1);
}
return {
nodes: Array.from(nodes).map(id => ({ id })),
links: Array.from(links, ([key, value]) => {
const [source, target] = key.split('-');
return { source, target, value };
})
};
}
function generateMarkovText(length, graph) {
if (graph.nodes.length === 0) return "";
let current = graph.nodes[Math.floor(Math.random() * graph.nodes.length)].id;
let result = [current];
for (let i = 1; i < length; i++) {
const possibleTransitions = graph.links.filter(link => link.source.id === current || link.source === current);
if (possibleTransitions.length === 0) {
current = graph.nodes[Math.floor(Math.random() * graph.nodes.length)].id;
} else {
const totalWeight = possibleTransitions.reduce((sum, link) => sum + link.value, 0);
let randomWeight = Math.random() * totalWeight;
for (const transition of possibleTransitions) {
randomWeight -= transition.value;
if (randomWeight <= 0) {
current = transition.target.id || transition.target;
break;
}
}
}
result.push(current);
}
return result.join('');
}