git @ Cat's Eye Technologies LiveMarkov / master src / live-markov.js
master

Tree @master (Download .tar.gz)

live-markov.js @masterraw · history · blame

// 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('');
}