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.r###lt;
- db.createObjectStore(objectStoreName, { keyPath: "id", autoIncrement: true });
- };
- request.onsuccess = (event) => resolve(event.target.r###lt);
- 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.r###lt);
- 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);