| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"> |
| <script src="https://d3js.org/d3.v7.min.js"></script> |
| <link rel="stylesheet" href="styles.css"> |
|
|
| </head> |
| <body> |
| |
| <div class="header-overlay"> |
| <div class="visualization-container"> |
| <h1>Semantic Network of Textual Training Data</h1> |
| <h2 class="caption">Co-occurence of textual captions (tags) extracted from model adapters downloaded from CivitAI</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_5.html'">Next: Promotional Tags</button> |
|
|
| |
| <div class="theme-toggle"> |
| <label><input type="checkbox" id="modeToggle"> Toggle Light Mode</label> |
|
|
|
|
| <div class="controls"> |
| <label>Number of tags shown: |
| <input type="range" id="tagSlider" min="0" max="2000" value="100" step="10" /> |
| <output id="tagCount">500</output> |
| </label><br/> |
| |
| <button id="resetBtn">Reset</button> |
| <button id="downloadBtn">Download SVG</button> |
| </div> |
| </div> |
|
|
| |
| |
| </div> |
|
|
| </div> |
| |
| </div> |
|
|
| |
| <svg viewBox="0 0 1600 1600" preserveAspectRatio="xMidYMid meet"></svg> |
|
|
| <script> |
| const width = 1600, height = 1600; |
| let fullGraph = null; |
| let currentTransform = d3.zoomIdentity; |
| |
| const categoryColors = { |
| visual_characteristics: "#708090", |
| attire_and_body_accessories: "#DC143C", |
| body: "#FF7F50", |
| more: "#BC8F8F", |
| subject: "black", |
| sex: "#8A2BE2", |
| default: "#C0C0C0" |
| }; |
| |
| const categoryLabels = { |
| visual_characteristics: "Image Characteristics", |
| attire_and_body_accessories: "Attire & Accessories", |
| body: "Body & Face", |
| more: "Actions & Probs", |
| subject: "Subjects", |
| sex: "Adult Content", |
| default: "Other" |
| }; |
| |
| const categoryPositions = {}; |
| const categories = Object.keys(categoryColors); |
| categories.forEach((cat, i) => { |
| const angle = (i / categories.length) * 2 * Math.PI; |
| categoryPositions[cat] = { |
| x: width / 2 * Math.cos(angle), |
| y: height / 2 * Math.sin(angle) |
| }; |
| }); |
| |
| const svg = d3.select("svg"); |
| const zoomLayer = svg.append("g") |
| .attr("class", "zoom-layer") |
| .attr("transform", "translate(300, 300) scale(0.45)"); |
| |
| |
| |
| const legendWrapper = svg.append("g") |
| .attr("class", "legend-wrapper") |
| .attr("transform", "translate(10, 400)"); |
| const legendItemHeight = 24; |
| |
| |
| const zoom = d3.zoom() |
| .scaleExtent([0.01, 8]) |
| .on("zoom", (event) => { |
| currentTransform = event.transform; |
| zoomLayer.attr("transform", currentTransform); |
| }); |
| |
| svg.call(zoom); |
| |
| const simulation = d3.forceSimulation() |
| .force("link", d3.forceLink().id(d => d.id).distance(500)) |
| .force("charge", d3.forceManyBody().strength(-185)) |
| .force("center", d3.forceCenter(width / 2, (height / 1.5) - 200)) |
| .force("collide", d3.forceCollide().radius(d => Math.sqrt(d.size) * 0.111)) |
| .force("category", forceCluster(0.1)) |
| .alphaDecay(0.15); |
| |
| function forceCluster(strength = 89.05) { |
| function force(alpha) { |
| for (const d of simulation.nodes()) { |
| const centroid = categoryPositions[d.category] || categoryPositions.default; |
| d.vx += (centroid.x - d.x) * strength * alpha; |
| d.vy += (centroid.y - d.y) * strength * alpha; |
| } |
| } |
| force.initialize = () => {}; |
| return force; |
| } |
| |
| d3.json("json/co_occurrence_network.json").then(graph => { |
| fullGraph = graph; |
| render(+document.getElementById("tagSlider").value); |
| }); |
| |
| function initializeZoom() { |
| const bounds = zoomLayer.node().getBBox(); |
| const contentWidth = bounds.width; |
| const contentHeight = bounds.height; |
| |
| |
| const scale = 0.4 / Math.max( |
| contentWidth / width, |
| contentHeight / height |
| ); |
| |
| |
| const translateX = (width - contentWidth * scale) / 3 - bounds.x * scale; |
| const translateY = (height - contentHeight * scale) / 2 - bounds.y * scale; |
| |
| |
| svg.transition() |
| .duration(1000) |
| .call( |
| zoom.transform, |
| d3.zoomIdentity |
| .translate(translateX, translateY) |
| .scale(scale) |
| ); |
| } |
| |
| function render(nodeCount) { |
| zoomLayer.selectAll("*").remove(); |
| |
| zoomLayer.append("rect") |
| .attr("width", width) |
| .attr("height", height) |
| .attr("fill", getComputedStyle(document.documentElement).getPropertyValue('--svg-fill') || "#fff"); |
| |
| const topNodes = fullGraph.nodes.sort((a, b) => b.size - a.size).slice(0, nodeCount); |
| const nodeIds = new Set(topNodes.map(d => d.id)); |
| const nodesWithPos = topNodes.map(d => { |
| const centroid = categoryPositions[d.category] || categoryPositions.default; |
| return { ...d, x: centroid.x + Math.random() * 100 - 50, y: centroid.y + Math.random() * 100 - 50 }; |
| }); |
| |
| const nodeLookup = new Map(nodesWithPos.map(n => [n.id, n])); |
| |
| const filteredLinks = fullGraph.links |
| .filter(l => nodeIds.has(l.source) && nodeIds.has(l.target)) |
| .map(l => { |
| const source = nodeLookup.get(l.source); |
| const target = nodeLookup.get(l.target); |
| return { |
| ...l, |
| source, |
| target, |
| dominant: source.size >= target.size ? source : target |
| }; |
| }); |
| |
| const linkValueExtent = d3.extent(filteredLinks, d => d.value); |
| const linkStrokeScale = d3.scaleLinear().domain(linkValueExtent).range([0.5, 6]); |
| |
| const link = zoomLayer.append("g").selectAll("line") |
| .data(filteredLinks) |
| .enter().append("line") |
| .attr("stroke", d => categoryColors[d.dominant.category] || categoryColors.default) |
| .attr("stroke-width", d => linkStrokeScale(d.value)) |
| .attr("stroke-opacity", d => Math.min(0.4, d.value / 100)); |
| |
| const node = zoomLayer.append("g").selectAll("circle.main") |
| .data(nodesWithPos) |
| .enter().append("circle") |
| .attr("class", "node main") |
| .attr("r", d => Math.max(1, Math.sqrt(d.size) * 0.04)) |
| .attr("fill", d => categoryColors[d.category] || categoryColors.default) |
| .attr("fill-opacity", d => Math.max(0.2, Math.min(1, d.size / 200))) |
| .call(d3.drag().on("start", dragstarted).on("drag", dragged).on("end", dragended)); |
| |
| const labelGroup = zoomLayer.append("g").selectAll("g.label") |
| .data(nodesWithPos) |
| .enter().append("g") |
| .attr("class", "label"); |
| |
| labelGroup.append("text") |
| .attr("text-anchor", "start") |
| .attr("dy", "0.35em") |
| .attr("fill", "black") |
| .attr("fill-opacity", 0.7) |
| .style("font-size", d => `${Math.max(11, Math.sqrt(d.size) * 0.04)}px`) |
| .text(d => d.id); |
| |
| labelGroup.insert("rect", "text") |
| .attr("rx", 4) |
| .attr("ry", 4) |
| .attr("fill", "white") |
| .attr("fill-opacity", 0.7); |
| |
| |
| legendWrapper.selectAll("*").remove(); |
| |
| const legend = legendWrapper.selectAll(".legend") |
| .data(Object.keys(categoryColors)) |
| .enter().append("g") |
| .attr("class", "legend") |
| .attr("transform", (d, i) => `translate(10,${i * legendItemHeight + 10})`); |
| |
| legend.append("rect") |
| .attr("width", 12) |
| .attr("height", 12) |
| .attr("fill", d => categoryColors[d]); |
| |
| legend.append("text") |
| .attr("x", 18) |
| .attr("y", 6) |
| .attr("dy", "0.35em") |
| .style("font-size", "14px") |
| .attr("fill", "black") |
| .text(d => categoryLabels[d] || d); |
| |
| simulation.nodes(nodesWithPos).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); |
| |
| labelGroup.each(function(d) { |
| const text = d3.select(this).select("text"); |
| const rect = d3.select(this).select("rect"); |
| const bbox = text.node().getBBox(); |
| const padding = 2; |
| const offset = Math.max(2, Math.sqrt(d.size) * 0.045 + padding); |
| |
| d3.select(this).attr("transform", `translate(${d.x + offset},${d.y})`); |
| rect |
| .attr("x", bbox.x - padding) |
| .attr("y", bbox.y - padding) |
| .attr("width", bbox.width + padding * 2) |
| .attr("height", bbox.height + padding * 2); |
| }); |
| }); |
| |
| simulation.force("link").links(filteredLinks); |
| simulation.alpha(1).restart(); |
| |
| |
| |
| } |
| |
| 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; |
| } |
| |
| function saveSvg() { |
| const svgNode = document.querySelector("svg"); |
| const serializer = new XMLSerializer(); |
| const source = serializer.serializeToString(svgNode); |
| const blob = new Blob([source], { type: "image/svg+xml;charset=utf-8" }); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement("a"); |
| a.href = url; |
| a.download = "graph.svg"; |
| document.body.appendChild(a); |
| a.click(); |
| document.body.removeChild(a); |
| URL.revokeObjectURL(url); |
| } |
| |
| document.getElementById("tagSlider").addEventListener("input", function () { |
| const value = +this.value; |
| document.getElementById("tagCount").textContent = value; |
| render(value); |
| }); |
| |
| document.getElementById("modeToggle").addEventListener("change", function () { |
| document.documentElement.classList.toggle("light-mode"); |
| render(+document.getElementById("tagSlider").value); |
| }); |
| </script> |
| </body> |
| </html> |