Wikipedia History and History Visualizer

Visualize Wikipedia Articles that you've visited in an interactive graph

// ==UserScript==
// @name          Wikipedia History and History Visualizer
// @namespace     https://en.wikipedia.org/wiki/*
// @description   Visualize Wikipedia Articles that you've visited in an interactive graph
// @author        Sidem
// @version       0.1
// @match         https://en.wikipedia.org/wiki/*
// @grant         none
// @license       GPL-3.0-only
// ==/UserScript==

const dbName = "WikipediaHistoryDB";
const objectStoreName = "wikipediaHistory";

function openDatabase() {
    return new Promise((resolve, reject) => {
        const request = indexedDB.open(dbName, 1);
        request.onupgradeneeded = (event) => {
            const db = event.target.result;
            db.createObjectStore(objectStoreName, { keyPath: "id", autoIncrement: true });
        };
        request.onsuccess = (event) => resolve(event.target.result);
        request.onerror = (event) => reject(event.target.error);
    });
}

async function getWikipediaHistoryData() {
    const db = await openDatabase();
    const transaction = db.transaction(objectStoreName, "readonly");
    const objectStore = transaction.objectStore(objectStoreName);
    const request = objectStore.getAll();
    return new Promise((resolve, reject) => {
        request.onsuccess = (event) => resolve(event.target.result);
        request.onerror = (event) => reject(event.target.error);
    });
}

async function setWikipediaHistoryData(data) {
    const db = await openDatabase();
    const transaction = db.transaction(objectStoreName, "readwrite");
    const objectStore = transaction.objectStore(objectStoreName);
    const request = objectStore.clear();
    request.onsuccess = () => {
        if (data && data.length) { // Add this condition
            for (const item of data) {
                objectStore.add(item);
            }
        }
    };
    return new Promise((resolve, reject) => {
        transaction.oncomplete = () => resolve();
        transaction.onerror = (event) => reject(event.target.error);
    });
}


const linkToTitle = (link) => { return link.split('#')[0].split('/wiki/')[1] };

const createHistoryButton = () => {
    const button = document.createElement('button');
    button.innerText = 'View History Graph';
    button.style.position = 'fixed';
    button.style.top = '10px';
    button.style.right = '10px';
    button.style.zIndex = 1000;
    button.onclick = () => {
        window.location = '/wiki/History_visualization_script';
    };
    document.body.appendChild(button);
};
let tooltipTimer;


const addStyles = () => {
    const style = document.createElement("style");
    style.innerHTML = `
        svg {
            font-family: sans-serif;
            overflow: visible;
        }

        circle {
            stroke: #fff;
            stroke-width: 1.5px;
        }

        line {
            stroke: #999;
            stroke-opacity: 0.6;
            stroke-width: 2;
        }

        text {
            /*make unselectable*/
            -webkit-user-select: none;
            -moz-user-select: none;
            -ms-user-select: none;
            user-select: none;
            pointer-events: none;
        }
        body {
          overflow-y: hidden !important;
          overflow-x: hidden !important;
        }
    `;
    document.head.appendChild(style);
};

const forceCluster = () => {
    const strength = 0.3;
    const clusters = new Map();
    let nodes;

    function force(alpha) {
        for (const node of nodes) {
            const cluster = clusters.get(node.clusterId);
            if (!cluster) continue;
            let { x: cx, y: cy } = cluster;
            node.vx -= (node.x - cx) * strength * alpha;
            node.vy -= (node.y - cy) * strength * alpha;
        }
    }

    force.initialize = (_) => (nodes = _);

    force.clusters = (_) => {
        clusters.clear();
        for (const node of _) {
            clusters.set(node.clusterId, node);
        }
        return force;
    };

    return force;
};


function drag(simulation) {
    function dragstarted(event, d) {
        if (!event.active) simulation.alphaTarget(0.3).restart();
        d.fx = d.x;
        d.fy = d.y;
    }

    function dragged(event, d) {
        d.fx = event.x;
        d.fy = event.y;
    }

    function dragended(event, d) {
        if (!event.active) simulation.alphaTarget(0);
        d.fx = null;
        d.fy = null;
    }

    return d3
        .drag()
        .on("start", dragstarted)
        .on("drag", dragged)
        .on("end", dragended);
}

const clearGraph = () => {
    d3.select("#graphContainer").remove();
};
let isMouseOverNode = false;

const createGraph = (wikipediaHistoryData) => {

    const deleteEntry = (id) => {
        const updatedHistoryData = wikipediaHistoryData.filter((_, index) => index !== id);
        localStorage.setItem('wikipediaHistory', JSON.stringify(updatedHistoryData));
        clearGraph();
        createGraph(updatedHistoryData);
    };

    const links = wikipediaHistoryData.flatMap((entry, index) =>
        entry.links.map((link) => {
            const targetIndex = wikipediaHistoryData.findIndex((e) => e.link === link.link);
            return targetIndex !== -1 ? { source: index, target: targetIndex } : null;
        })
    ).filter((link) => link !== null);
    const nodes = wikipediaHistoryData.map((entry, index) => {
        const connectedNodes = links.filter(link => link.source === index || link.target === index);
        const clusterId = connectedNodes.length > 0 ? index : null;
        return { id: index, label: entry.title, clusterId };
    });

    const svg = d3.select("#graph");
    const width = +svg.attr("width");
    const height = +svg.attr("height");

    const g = svg.append("g").attr("id", "graphContainer");

    svg.append("defs")
        .append("marker")
        .attr("id", "arrowhead")
        .attr("viewBox", "-0 -5 10 10")
        .attr("refX", 13)
        .attr("refY", 0)
        .attr("orient", "auto")
        .attr("markerWidth", 10)
        .attr("markerHeight", 10)
        .attr("xoverflow", "visible")
        .append("svg:path")
        .attr("d", "M 0,-5 L 10 ,0 L 0,5")
        .attr("fill", "#999")
        .style("stroke", "none");

    const simulation = d3
        .forceSimulation(nodes)
        .force("link", d3.forceLink(links).id((d) => d.id).distance(50))
        .force("charge", d3.forceManyBody().strength(-200))
        .force("center", d3.forceCenter(width / 2, height / 2))
        .force("collide", d3.forceCollide(60))
        .force("cluster", forceCluster().clusters(nodes)); // Add cluster force



    const link = g
        .selectAll("line")
        .data(links)
        .join("line")
        .attr("stroke", "#999")
        .attr("stroke-opacity", 0.6)
        .attr("stroke-width", 2)
        .attr("marker-end", (d) => {
            const sourceLinks = wikipediaHistoryData[d.source.index].links;
            const targetLinks = wikipediaHistoryData[d.target.index].links;
            const sourceToTarget = sourceLinks.some((link) => link.link === wikipediaHistoryData[d.target.index].link);
            const targetToSource = targetLinks.some((link) => link.link === wikipediaHistoryData[d.source.index].link);
            return sourceToTarget && !targetToSource ? "url(#arrowhead)" : "";
        });


    const node = g
        .selectAll("circle")
        .data(nodes)
        .join("circle")
        .attr("r", 5)
        .attr("fill", "#69b3a2");

    node.on("dblclick", (event, d) => {
        window.open(`https://en.wikipedia.org/wiki/${wikipediaHistoryData[d.id].link}`, "_blank");
    });

    const tooltip = d3.select("body").append("div")
        .attr("class", "tooltip")
        .style("opacity", 0)
        .style("background-color", "white")
        .style("border", "solid")
        .style("border-width", "1px")
        .style("border-radius", "5px")
        .style("padding", "10px")
        .style("position", "absolute")
        .style("pointer-events", "auto");

    node.on("mouseover", (event, d) => {
        isMouseOverNode = true;
        tooltip.transition()
            .duration(200)
            .style("opacity", 1);
        tooltip.html(`Title: ${wikipediaHistoryData[d.id].title}<br/>URL: https://en.wikipedia.org/wiki/${wikipediaHistoryData[d.id].link}<br/>Dates Accessed: ${wikipediaHistoryData[d.id].dates_accessed.join(', ')}<br/><button id="deleteButton">Delete</button>`)
            .style("left", `${event.pageX + 10}px`)
            .style("top", `${event.pageY + 10}px`);

        tooltip.select("#deleteButton").on("click", () => deleteEntry(d.id));


    })
        // ...
        .on("mousemove", (event) => {
            tooltip.style("left", `${event.pageX + 10}px`).style("top", `${event.pageY + 10}px`);
        });

    tooltip.on("mouseover", () => {
        clearTimeout(tooltipTimer);
        if (isMouseOverNode) {
            tooltip.style("opacity", 1);
            // move tooltip back to mouse position
            tooltip.style("left", `${d3.event.pageX + 10}px`).style("top", `${d3.event.pageY + 10}px`);
        }
    })
        .on("mouseout", () => {
            clearTimeout(tooltipTimer);
            tooltipTimer = setTimeout(() => {
                tooltip.transition()
                    .duration(200)
                    .style("opacity", 0);
                //also move invisible tooltip out of screen so it doesn't block mouse events
                tooltip.style("left", "-1000px").style("top", "-1000px");
            }, 150);
        });

    node.on("mouseout", () => {
        isMouseOverNode = false;
        clearTimeout(tooltipTimer);
        tooltipTimer = setTimeout(() => {
            tooltip.transition()
                .duration(200)
                .style("opacity", 0);
            //also move invisible tooltip out of screen so it doesn't block mouse events
            tooltip.style("left", "-1000px").style("top", "-1000px");
        }, 150);
    });




    const label = g
        .selectAll("text")
        .data(nodes)
        .join("text")
        .text((d) => d.label)
        .attr("font-size", "10px")
        .attr("dx", 8)
        .attr("dy", "0.35em");

    const zoomBehavior = d3.zoom()
        .scaleExtent([0.1, 5])
        .on('zoom', (event) => {
            g.attr('transform', event.transform);
        });
    svg.call(zoomBehavior);

    simulation.on("tick", () => {
        link
            .attr("x1", (d) => d.source.x)
            .attr("y1", (d) => d.source.y)
            .attr("x2", (d) => d.target.x)
            .attr("y2", (d) => d.target.y);

        node
            .attr("cx", (d) => d.x)
            .attr("cy", (d) => d.y);

        label
            .attr("x", (d) => d.x)
            .attr("y", (d) => d.y);
    });
};

window.addEventListener('load', () => {
    let links = document.querySelector('#bodyContent').querySelectorAll('a[href*="/wiki/"]');
    let title = document.querySelector('#firstHeading').innerText;
    let link = linkToTitle(window.location.pathname);
    let linkEntries = [];
    for (let i = 0; i < links.length; i++) {
        if (links[i].href.includes('/wiki/')) {
            let linkEntry = {
                title: links[i].title,
                link: linkToTitle(links[i].href)
            };
            linkEntries.push(linkEntry);
        }
    }

    let entry = {
        title: title,
        link: link,
        links: linkEntries
    };
    let date = new Date();
    let dateString = `${date.getDate()}.${date.getMonth() + 1}.${date.getFullYear()} ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`;

    let database;
    getWikipediaHistoryData().then(data => {
        database = data;
        if (database) {
            let entryIndex = -1;
            for (let i = 0; i < database.length; i++) {
                if (database[i].title === entry.title) {
                    entryIndex = i;
                    break;
                }
            }
            if (entryIndex < 0) {
                entry.dates_accessed = [dateString];
                database.push(entry);
            } else {
                let lastDate = database[entryIndex].dates_accessed[database[entryIndex].dates_accessed.length - 1];
                if (lastDate.split(' ')[0] !== dateString.split(' ')[0]) {
                    database[entryIndex].dates_accessed.push(dateString);
                }
                database[entryIndex].links = entry.links;
            }
        } else {
            entry.dates_accessed = [dateString];
            database = [entry];
        }
        if (link === 'History_visualization_script') {
            addStyles();
            const bodyContent = document.querySelector('#bodyContent');
            bodyContent.innerHTML = '';
            document.body.innerHTML = '';
            document.body.appendChild(bodyContent);
            let script = document.createElement('script');
            script.src = 'https://d3js.org/d3.v7.min.js';
            document.querySelector('#bodyContent').appendChild(script);
            let script2 = document.createElement('script');
            script2.src = 'https://unpkg.com/d3-force-cluster@latest';
            document.querySelector('#bodyContent').appendChild(script2);

            script.onload = () => {
                let svgElement = document.createElementNS("http://www.w3.org/2000/svg", 'svg');
                svgElement.setAttribute('width', window.innerWidth);
                svgElement.setAttribute('height', window.innerHeight);
                svgElement.setAttribute('id', 'graph');
                document.querySelector('#bodyContent').appendChild(svgElement);
                getWikipediaHistoryData().then(wikipediaHistoryData => {
                    createGraph(wikipediaHistoryData);
                });
            }
        } else {
            setWikipediaHistoryData(database);
            createHistoryButton();
        }
    });
}, false);