// ==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);