| <!DOCTYPE html> |
| <html lang="en" class="light-mode"> |
| <head> |
| <meta charset="UTF-8"> |
| <title>Danbooru Tag Taxonomy</title> |
| <script src="https://d3js.org/d3.v7.min.js"></script> |
| <link rel="stylesheet" href="styles.css"> |
| <style> |
| |
| html, body { overflow: auto; } |
| svg { position: static; margin-top: 0; z-index: auto; } |
| .tree-svg { |
| width: 100%; |
| overflow: visible; |
| display: block; |
| } |
| .link { |
| fill: none; |
| stroke: #ccc; |
| stroke-width: 1.5px; |
| } |
| .node circle { |
| stroke: #555; |
| stroke-width: 1.5px; |
| cursor: pointer; |
| } |
| .node.leaf circle { |
| cursor: default; |
| } |
| .node text { |
| font-size: 12px; |
| fill: #333; |
| pointer-events: none; |
| } |
| .node.leaf text { |
| font-size: 10px; |
| fill: #666; |
| } |
| .node:hover circle { |
| stroke: #000; |
| stroke-width: 2.5px; |
| } |
| .tree-controls { |
| margin: 0.5em 0 1em 0; |
| } |
| .tree-controls button { |
| margin-right: 6px; |
| padding: 6px 14px; |
| cursor: pointer; |
| background-color: rgba(138, 43, 226, 0.7); |
| color: white; |
| border: none; |
| border-radius: 4px; |
| transition: background-color 0.3s; |
| } |
| .tree-controls button:hover { |
| background-color: rgba(138, 43, 226, 0.9); |
| } |
| </style> |
| </head> |
| <body> |
| |
| <div class="header-overlay"> |
| <div class="visualization-container"> |
| <h1>Danbooru Tag Taxonomy</h1> |
| <h2 class="caption">Interactive hierarchical tree of Danbooru's tag classification system (~39,000 tags across 200 categories)</h2> |
|
|
| <div class="nav-buttons"></div> |
| <button class="nav-button" onclick="location.href='index.html'">Home</button> |
| <button class="nav-button" onclick="location.href='figure_15.html'">Next: Textual Training Data</button> |
|
|
| <div class="tree-controls"> |
| <button id="expandAllBtn">Expand All Categories</button> |
| <button id="collapseAllBtn">Collapse All</button> |
| <button id="downloadBtn">Download SVG</button> |
| </div> |
| </div> |
| </div> |
|
|
| <svg class="tree-svg"></svg> |
|
|
| <script> |
| |
| document.documentElement.classList.add('light-mode'); |
| |
| d3.json("json/danbooru_flat_full.json").then(function(data) { |
| |
| |
| const margin = { top: 20, right: 250, bottom: 20, left: 80 }; |
| const nodeWidth = 220; |
| const duration = 400; |
| |
| const svg = d3.select("svg.tree-svg"); |
| const g = svg.append("g") |
| .attr("transform", `translate(${margin.left},${margin.top})`); |
| |
| const linkGroup = g.append("g").attr("class", "links"); |
| const nodeGroup = g.append("g").attr("class", "nodes"); |
| |
| |
| const categoryColors = { |
| "attire_and_body_accessories": "#e6194b", |
| "body": "#f58231", |
| "characters": "#3cb44b", |
| "copyrights_artists_projects_and_media": "#4363d8", |
| "creatures": "#42d4f4", |
| "drawing_software": "#911eb4", |
| "games": "#f032e6", |
| "metatags": "#bfef45", |
| "more": "#9A6324", |
| "objects": "#e6beff", |
| "plant": "#006400", |
| "real_world": "#ffd8b1", |
| "sex": "#ff69b4", |
| "visual_characteristics": "#000075", |
| "subject": "#ffe119", |
| "uncategorized": "#a9a9a9", |
| "actions_and_expressions": "#800000", |
| "objects_and_backgrounds": "#469990", |
| }; |
| |
| function assignColors(node, inheritedColor) { |
| if (inheritedColor) node.color = inheritedColor; |
| if (node.children) node.children.forEach(child => assignColors(child, node.color)); |
| } |
| if (data.children) { |
| data.children.forEach(child => { |
| child.color = categoryColors[child.name] || "#888"; |
| assignColors(child, child.color); |
| }); |
| } |
| |
| |
| function preprocess(node) { |
| if (node.children && node.children.length > 0) { |
| node.children.forEach(preprocess); |
| node.children.sort((a, b) => (b.tag_count || 0) - (a.tag_count || 0)); |
| } |
| else if (node.tags && node.tags.length > 0) { |
| node.children = node.tags |
| .filter(t => t !== "...") |
| .map(tag => ({ |
| name: tag, |
| _isTag: true, |
| color: node.color, |
| tag_count: 0, |
| category_count: 0 |
| })); |
| node.children.sort((a, b) => a.name.localeCompare(b.name)); |
| } |
| return node; |
| } |
| preprocess(data); |
| |
| function getColor(d) { |
| return d.data.color || "#888"; |
| } |
| |
| |
| const root = d3.hierarchy(data, d => d.children); |
| root.x0 = 0; |
| root.y0 = 0; |
| |
| let idCounter = 0; |
| root.each(d => { d.id = ++idCounter; }); |
| |
| |
| const allTagCounts = root.descendants() |
| .filter(d => d.depth > 0 && !d.data._isTag) |
| .map(d => d.data.tag_count || 0); |
| const sizeScale = d3.scaleSqrt() |
| .domain([0, d3.max(allTagCounts)]) |
| .range([4, 18]); |
| |
| function nodeRadius(d) { |
| if (d.data._isTag) return 2.5; |
| if (d.depth === 0) return 8; |
| return sizeScale(d.data.tag_count || 0); |
| } |
| |
| function collapse(d) { |
| if (d.children) { |
| d._children = d.children; |
| d._children.forEach(collapse); |
| d.children = null; |
| } |
| } |
| |
| function expandCategories(d) { |
| if (d._children) { |
| if (!d._children[0].data._isTag) { |
| d.children = d._children; |
| d._children = null; |
| } |
| } |
| if (d.children) d.children.forEach(expandCategories); |
| } |
| |
| function expandAll(d) { |
| if (d._children) { |
| d.children = d._children; |
| d._children = null; |
| } |
| if (d.children) d.children.forEach(expandAll); |
| } |
| |
| |
| if (root.children) { |
| root.children.forEach(collapse); |
| } |
| |
| function computeTreeLayout() { |
| const leaves = root.descendants().filter(d => |
| !d.children || d.children.length === 0 |
| ); |
| const visibleCount = leaves.length; |
| const rowHeight = 18; |
| const treeHeight = Math.max(300, visibleCount * rowHeight); |
| |
| const treeLayout = d3.tree() |
| .size([treeHeight, 0]) |
| .separation((a, b) => { |
| if (a.data._isTag && b.data._isTag) return 0.6; |
| return a.parent === b.parent ? 1 : 1.3; |
| }); |
| |
| treeLayout(root); |
| |
| root.each(d => { d.y = d.depth * nodeWidth; }); |
| |
| const maxDepth = d3.max(root.descendants(), d => d.depth) || 0; |
| const totalWidth = margin.left + margin.right + (maxDepth + 1) * nodeWidth; |
| const totalHeight = margin.top + margin.bottom + treeHeight; |
| svg.attr("width", totalWidth) |
| .attr("height", totalHeight) |
| .attr("viewBox", `0 0 ${totalWidth} ${totalHeight}`); |
| } |
| |
| |
| function update(source) { |
| computeTreeLayout(); |
| |
| const nodes = root.descendants(); |
| const links = root.links(); |
| |
| |
| const link = linkGroup.selectAll("path.link") |
| .data(links, d => d.target.id); |
| |
| const linkEnter = link.enter() |
| .append("path") |
| .attr("class", "link") |
| .attr("d", () => { |
| const o = { x: source.x0, y: source.y0 }; |
| return diagonal({ source: o, target: o }); |
| }); |
| |
| linkEnter.merge(link).transition().duration(duration) |
| .attr("d", d => diagonal(d)) |
| .attr("stroke", d => d.target.data.color || "#ccc") |
| .attr("stroke-opacity", 0.5); |
| |
| link.exit().transition().duration(duration) |
| .attr("d", () => { |
| const o = { x: source.x, y: source.y }; |
| return diagonal({ source: o, target: o }); |
| }) |
| .remove(); |
| |
| |
| const node = nodeGroup.selectAll("g.node") |
| .data(nodes, d => d.id); |
| |
| const nodeEnter = node.enter() |
| .append("g") |
| .attr("class", d => d.data._isTag ? "node leaf" : "node") |
| .attr("transform", () => `translate(${source.y0},${source.x0})`) |
| .on("click", (event, d) => { |
| if (d.data._isTag) return; |
| if (d.children) { |
| d._children = d.children; |
| d.children = null; |
| } else if (d._children) { |
| d.children = d._children; |
| d._children = null; |
| } |
| update(d); |
| }); |
| |
| nodeEnter.append("circle").attr("r", 1e-6); |
| |
| nodeEnter.append("text") |
| .attr("dy", "0.32em") |
| .attr("x", d => nodeRadius(d) + 4) |
| .text(d => { |
| if (d.data._isTag) return d.data.name; |
| const count = d.data.tag_count || 0; |
| return count > 0 ? `${d.data.name} (${count})` : d.data.name; |
| }); |
| |
| nodeEnter.append("title"); |
| |
| const nodeUpdate = nodeEnter.merge(node); |
| |
| nodeUpdate.transition().duration(duration) |
| .attr("transform", d => `translate(${d.y},${d.x})`); |
| |
| nodeUpdate.select("circle") |
| .attr("r", d => nodeRadius(d)) |
| .style("fill", d => { |
| if (d.data._isTag) return getColor(d); |
| return (d._children && d._children.length) ? getColor(d) : "#fff"; |
| }) |
| .style("stroke", d => getColor(d)); |
| |
| nodeUpdate.select("text") |
| .style("fill-opacity", 1) |
| .style("font-weight", d => { |
| if (d.data._isTag) return "normal"; |
| return d.depth <= 1 ? "bold" : "normal"; |
| }) |
| .style("font-size", d => d.data._isTag ? "10px" : "12px") |
| .style("fill", d => d.data._isTag ? "#666" : "#333"); |
| |
| nodeUpdate.select("title") |
| .text(d => { |
| if (d.data._isTag) return d.data.name; |
| const parts = [d.data.name]; |
| if (d.data.tag_count) parts.push(`Tags: ${d.data.tag_count}`); |
| if (d.data.category_count) parts.push(`Sub-categories: ${d.data.category_count}`); |
| const hidden = d._children ? d._children.length : 0; |
| if (hidden) { |
| const type = d._children[0].data._isTag ? "tags" : "children"; |
| parts.push(`Click to expand (${hidden} ${type})`); |
| } else if (d.children && d.children.length) { |
| parts.push("Click to collapse"); |
| } |
| return parts.join("\n"); |
| }); |
| |
| const nodeExit = node.exit().transition().duration(duration) |
| .attr("transform", () => `translate(${source.y},${source.x})`) |
| .remove(); |
| |
| nodeExit.select("circle").attr("r", 1e-6); |
| nodeExit.select("text").style("fill-opacity", 1e-6); |
| |
| nodes.forEach(d => { d.x0 = d.x; d.y0 = d.y; }); |
| } |
| |
| function diagonal(d) { |
| return `M${d.source.y},${d.source.x} |
| C${(d.source.y + d.target.y) / 2},${d.source.x} |
| ${(d.source.y + d.target.y) / 2},${d.target.x} |
| ${d.target.y},${d.target.x}`; |
| } |
| |
| |
| update(root); |
| |
| |
| document.getElementById("expandAllBtn").addEventListener("click", () => { |
| expandCategories(root); |
| update(root); |
| }); |
| |
| |
| document.getElementById("collapseAllBtn").addEventListener("click", () => { |
| if (root.children) root.children.forEach(collapse); |
| update(root); |
| }); |
| |
| |
| document.getElementById("downloadBtn").addEventListener("click", () => { |
| const svgNode = document.querySelector("svg"); |
| const clonedSvg = svgNode.cloneNode(true); |
| clonedSvg.setAttribute("xmlns", "http://www.w3.org/2000/svg"); |
| |
| const styleEl = document.createElement("style"); |
| styleEl.textContent = ` |
| .link { fill: none; stroke: #ccc; stroke-width: 1.5px; } |
| .node circle { stroke-width: 1.5px; } |
| .node text { font-size: 12px; fill: #333; font-family: sans-serif; } |
| .node.leaf text { font-size: 10px; fill: #666; } |
| `; |
| clonedSvg.insertBefore(styleEl, clonedSvg.firstChild); |
| |
| const svgData = new XMLSerializer().serializeToString(clonedSvg); |
| const svgBlob = new Blob([svgData], { type: "image/svg+xml;charset=utf-8" }); |
| const url = URL.createObjectURL(svgBlob); |
| const a = document.createElement("a"); |
| a.href = url; |
| a.download = "danbooru_tree_interactive.svg"; |
| document.body.appendChild(a); |
| a.click(); |
| document.body.removeChild(a); |
| URL.revokeObjectURL(url); |
| }); |
| |
| }); |
| </script> |
| </body> |
| </html> |
|
|