Hellthread visualiser Enter the URL of an Mastodon thread below and this will attempt to visualise it! 🎉
viewof url = html`<input id="toot-entry" type="text" placeholder="${thread || defaultThread}" value="${thread || defaultThread}" />`
chart = { const treeData = stratify(data); treeData.each(function(d) { d.name = d.data.account.acct; d.content = d.data.content; d.created_at = d.data.created_at; d.reblogs_count = d.data.reblogs_count; d.favourites_count = d.data.favourites_count; d.fake = d.data.fake; d.url = d.data.url; }); const root = tree(treeData); const svg = d3.select(DOM.svg(width, width)) .style("width", "100%") .style("height", "auto") .style("padding", "10px") .style("box-sizing", "border-box") .style("font", "10px sans-serif"); const r = d3.scaleLinear().domain(d3.extent(data, d => d.reblogs_count)).range([2, 8]); const color = d3.scaleLinear().domain(d3.extent(data, d => d.favourites_count)).range(["#555", "red"]); // Define the div for the tooltip const div = d3.select("body").append("div") .attr("class", "tooltip") .style("opacity", 0); const g = svg.append("g"); const link = g.append("g") .attr("fill", "none") .attr("stroke", "#555") .attr("stroke-opacity", 0.4) .attr("stroke-width", 1.5) .selectAll("path") .data(root.links()) .enter().append("path") .attr("d", d3.linkRadial() .angle(d => d.x) .radius(d => d.y)); const node = g.append("g") .attr("stroke-linejoin", "round") .attr("stroke-width", 3) .selectAll("g") .data(root.descendants().reverse()) .enter().append("g") .attr("transform", d => ` rotate(${d.x * 180 / Math.PI - 90}) translate(${d.y},0) `); const openLink = (d) => window.open(d.data.url, '_blank'); node.append("circle") .attr("fill", d => d.data.fake ? 'white' : color(d.data.favourites_count)) .attr("stroke", "#555") .attr("stroke-width", d => d.data.fake ? 1 : 0) .attr("r", d => r(d.data.reblogs_count)) .on("mouseover", (d) => { const content = d.data.fake ? '<div>(deleted toot)</div>' : `<div><div class="author"> ${d.data.name} posted on ${d.data.created_at}</div>${d.data.content}</div>` div.transition() .duration(200) .style("opacity", .9); div.html(content) .style("left", () => { if (svg.node().clientWidth - d3.event.pageX > div.node().clientWidth) { return d3.event.pageX + "px"; } return d3.event.pageX + "px" - (div.node().clientWidth / 2); }) .style("top", (d3.event.pageY - 28) + "px"); }) .on("mouseout", (d) => { div.transition() .duration(500) .style("opacity", 0); }) .on("click", openLink) .on("tap", openLink); document.body.appendChild(svg.node()); const box = g.node().getBBox(); svg.remove() .attr("width", box.width) .attr("height", box.height) .attr("viewBox", `${box.x} ${box.y} ${box.width} ${box.height}`); // @TODO Legend // const legend = svg.append("g") // .attr("class", "legendSize"); // const legendSize = d3Legend.legendSize() // .scale(r) // .shape('circle') // .shapePadding(15) // .labelOffset(20) // .orient('horizontal'); // svg.select(".legendSize") // .call(legendSize); // const { width: legendWidth, height: legendHeight } = legend.node().getBBox(); // legend.attr('transform', `translate(${box.width - legendWidth}, ${box.height - legendHeight}`); return svg.node(); }
Changelog* v3: Adds support for permalinks — add ?thread=<url> to this notebook's URL to link to a specific thread! Clicking on a node will open its URL in a new tab. v2: Fixed issue with deleted toots breaking d3.stratify Nodes are now sized based on number of 🔁 Nodes are now coloured based on number of ❤️. Red means more ❤️. v1: Initial release Versioning is not semantic because I enjoy watching the world burn
tree = data => d3.tree() .size([2 * Math.PI, radius]) .separation((a, b) => (a.parent == b.parent ? 1 : 2) / a.depth) (d3.hierarchy(data))
width = 1200
radius = width / 2
d3 = require("d3@5")
d3Legend = require('d3-svg-legend')
stratify = d3.stratify() .id(d => d.id) .parentId(d => d.in_reply_to_id)
accessToken = "889de0ab0d975f8efa4ce90c0bc3fdbe179e482695e68da157e27cfc099cccee"
data = { const prefix = `https://botsin.space/api`; const searchEndpoint = `${prefix}/v2/search?q=${encodeURIComponent(url)}&resolve=true`; const {statuses: [leader]} = await d3.json(searchEndpoint, { headers: { Authorization: `Bearer ${accessToken}` } }); const contextEndpoint = `${prefix}/v1/statuses/${leader.id}/context`; const getContext = async (fakes = []) => { const context = await d3.json(contextEndpoint, { headers: { Authorization: `Bearer ${accessToken}` } }); try { // This will throw if status not found stratify([...context.ancestors, leader, ...context.descendants, ...fakes]); return [...context.ancestors, leader, ...context.descendants, ...fakes]; } catch (e) { console.log(e); const [, problemId] = e.message.match(/missing: (\d+)/); // Push a fake entry to prevent stratify from dying fakes.push({ id: problemId, account: { acct: '<unknown>' }, content: '<none>', created_at: '<unknown>', reblogs_count: 0, favourites_count: 0, fake: true, }); // Try again! return await getContext(fakes); } }; return await getContext(); }
styles = html`<style> body { position: relative; overflow: scroll; } .tooltip { position: fixed; display: flex; text-align: center; padding: 1em; max-width: 20em; font: 15px sans-serif; background: lightsteelblue; border: 0px; border-radius: 8px; pointer-events: none; } .tooltip .h-card, .tooltip a { font: 12px sans-serif; text-emphasis: italic; } .tooltip .h-card:last-child:after { display: block; } </style>`
thread = new URLSearchParams(html`<a href>`.search).get('thread')
defaultThread = "https://radical.town/@starwall/101060156324703860"