|
|
<!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>Tag Co-Occurrence Network</h1> |
|
|
<h2 class="caption">Thematic clustering of the most frequent promotional tags used across all shared assets on 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_15.html'">Next: Textual Training Data</button> |
|
|
|
|
|
|
|
|
<div class="theme-toggle"> |
|
|
<label><input type="checkbox" id="modeToggle"> Toggle Light Mode</label> |
|
|
|
|
|
|
|
|
<div class="controls"> |
|
|
<label>Top N Nodes: |
|
|
<input type="range" id="nodeCountInput" value="30" min="1" max="300" /> |
|
|
<output id="nodeCountOutput">20</output> |
|
|
</label><br/> |
|
|
|
|
|
<label>Link Distance Scale: |
|
|
<input type="range" id="linkScaleInput" value="1500" min="0" max="10000" /> |
|
|
<output id="linkScaleOutput">0</output> |
|
|
</label><br/> |
|
|
|
|
|
<label>Charge Strength: |
|
|
<input type="range" id="chargeInput" value="10" min="0" max="50" /> |
|
|
<output id="chargeOutput">5</output> |
|
|
</label><br/> |
|
|
|
|
|
<label>Collide Strength: |
|
|
<input type="range" id="collideInput" value="2" min="0" max="10" step="0.1" /> |
|
|
<output id="collideOutput">2</output> |
|
|
</label><br/> |
|
|
|
|
|
<label>Collide Base Radius: |
|
|
<input type="range" id="collideBaseInput" value="22" min="0" max="35" /> |
|
|
<output id="collideBaseOutput">17</output> |
|
|
</label><br/> |
|
|
|
|
|
<label>Node Radius Scale: |
|
|
<input type="range" id="radiusInput" value="65" min="5" max="80" /> |
|
|
<output id="radiusOutput">30</output> |
|
|
</label><br/> |
|
|
|
|
|
<label>Font Size Scale: |
|
|
<input type="range" id="fontInput" value="25" min="1" max="50" /> |
|
|
<output id="fontOutput">25</output> |
|
|
</label><br/> |
|
|
|
|
|
<label>Link Thickness Base: |
|
|
<input type="range" id="LinkStrokeIn" value="30" min="1" max="50" /> |
|
|
<output id="LinkStrokeOut">5</output> |
|
|
</label><br/> |
|
|
|
|
|
<label for="jsonSelector">Choose a JSON file:</label> |
|
|
<select id="jsonSelector"> |
|
|
<option value="json/nodes_all.json">nodes_all.json</option> |
|
|
<option value="json/nodes.json">nodes.json</option> |
|
|
<option value="json/promo_tags_poi_true.json">promo_tags_poi_true.json</option> |
|
|
<option value="json/promo_tags.json">promo_tags.json</option> |
|
|
<option value="json/sunburst_data.json">sunburst_data.json</option> |
|
|
<option value="json/tags_actor.json">tags_actor.json</option> |
|
|
<option value="json/tags_actress.json">tags_actress.json</option> |
|
|
<option value="json/tags_all_poi.json">tags_all_poi.json</option> |
|
|
<option value="json/tags_american_poi.json">tags_american_poi.json</option> |
|
|
<option value="json/tags_american.json">tags_american.json</option> |
|
|
<option value="json/tags_asian_poi.json">tags_asian_poi.json</option> |
|
|
<option value="json/tags_asian.json">tags_asian.json</option> |
|
|
<option value="json/tags_canada.json">tags_canada.json</option> |
|
|
<option value="json/tags_celebrity.json">tags_celebrity.json</option> |
|
|
<option value="json/tags_character.json">tags_character.json</option> |
|
|
<option value="json/tags_china.json">tags_china.json</option> |
|
|
<option value="json/tags_chinese_poi.json">tags_chinese_poi.json</option> |
|
|
<option value="json/tags_chinese.json">tags_chinese.json</option> |
|
|
<option value="json/tags_german_poi.json">tags_german_poi.json</option> |
|
|
<option value="json/tags_german.json">tags_german.json</option> |
|
|
<option value="json/tags_germany.json">tags_germany.json</option> |
|
|
<option value="json/tags_india.json">tags_india.json</option> |
|
|
<option value="json/tags_indian_poi.json">tags_indian_poi.json</option> |
|
|
<option value="json/tags_indian.json">tags_indian.json</option> |
|
|
<option value="json/tags_instagram.json">tags_instagram.json</option> |
|
|
<option value="json/tags_japan.json">tags_japan.json</option> |
|
|
<option value="json/tags_japanese_poi.json">tags_japanese_poi.json</option> |
|
|
<option value="json/tags_japanese.json">tags_japanese.json</option> |
|
|
<option value="json/tags_korea.json">tags_korea.json</option> |
|
|
<option value="json/tags_korean_poi.json">tags_korean_poi.json</option> |
|
|
<option value="json/tags_korean.json">tags_korean.json</option> |
|
|
<option value="json/tags_kpop.json">tags_kpop.json</option> |
|
|
<option value="json/tags_man_poi.json">tags_man_poi.json</option> |
|
|
<option value="json/tags_man.json">tags_man.json</option> |
|
|
<option value="json/tags_russia.json">tags_russia.json</option> |
|
|
<option value="json/tags_russian_poi.json">tags_russian_poi.json</option> |
|
|
<option value="json/tags_russian.json">tags_russian.json</option> |
|
|
<option value="json/tags_style_poi.json">tags_style_poi.json</option> |
|
|
<option value="json/tags_style.json">tags_style.json</option> |
|
|
<option value="json/tags_tiktok.json">tags_tiktok.json</option> |
|
|
<option value="json/tags_uk.json">tags_uk.json</option> |
|
|
<option value="json/tags_ukrainian.json">tags_ukrainian.json</option> |
|
|
<option value="json/tags_woman_poi.json">tags_woman_poi.json</option> |
|
|
<option value="json/tags_woman.json">tags_woman.json</option> |
|
|
<option value="json/tags_youtuber.json">tags_youtuber.json</option> |
|
|
<option value="json/tree_data.json">tree_data.json</option> |
|
|
</select> |
|
|
|
|
|
<button id="resetBtn">Reset</button> |
|
|
<button id="downloadBtn">Download SVG</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<svg viewBox="0 0 1600 1200" preserveAspectRatio="xMidYMid meet"></svg> |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<script> |
|
|
const svgElement = document.querySelector("svg"); |
|
|
const width = 1600; |
|
|
const height = 2000; |
|
|
const NODE_BASE_RADIUS = 1; |
|
|
const FONT_SIZE_BASE = 14; |
|
|
|
|
|
let LINK_DISTANCE_SCALE = -200; |
|
|
let CHARGE_STRENGTH = 80; |
|
|
let COLLIDE_STRENGTH = 2; |
|
|
let COLLIDE_BASE_RADIUS = 17; |
|
|
let NODE_RADIUS_SCALE = 30; |
|
|
let FONT_SIZE_SCALE = 25; |
|
|
let LINK_THICKNESS_BASE = 5; |
|
|
|
|
|
const svg = d3.select("svg"); |
|
|
const zoomLayer = svg.append("g").attr("class", "zoom-layer"); |
|
|
|
|
|
|
|
|
svg.call(d3.zoom() |
|
|
.scaleExtent([0.1, 8]) |
|
|
.on("zoom", (event) => { |
|
|
zoomLayer.attr("transform", event.transform); |
|
|
}) |
|
|
); |
|
|
|
|
|
|
|
|
zoomLayer.append("rect") |
|
|
.attr("width", width) |
|
|
.attr("height", height) |
|
|
.attr("fill", getComputedStyle(document.documentElement).getPropertyValue('--svg-fill').trim()); |
|
|
|
|
|
let fullGraph = null; |
|
|
|
|
|
function renderGraph(graph, nodeCount = 1) { |
|
|
zoomLayer.selectAll("*").remove(); |
|
|
|
|
|
zoomLayer.append("rect") |
|
|
.attr("width", width) |
|
|
.attr("height", height) |
|
|
.attr("fill", getComputedStyle(document.documentElement).getPropertyValue('--svg-fill').trim()); |
|
|
|
|
|
const nodeMap = new Map(graph.nodes.map(n => [n.id, n])); |
|
|
|
|
|
graph.nodes.sort((a, b) => b.size - a.size); |
|
|
const filteredNodes = graph.nodes.slice(0, nodeCount); |
|
|
const topIds = new Set(filteredNodes.map(n => n.id)); |
|
|
const filteredLinks = graph.links |
|
|
.filter(link => topIds.has(link.source) && topIds.has(link.target) && link.value >= 1) |
|
|
.map(link => ({ |
|
|
...link, |
|
|
source: nodeMap.get(link.source), |
|
|
target: nodeMap.get(link.target) |
|
|
})); |
|
|
|
|
|
const [minNodeSize, maxNodeSize] = d3.extent(filteredNodes, n => n.size); |
|
|
filteredNodes.forEach(n => { |
|
|
n.normSize = (n.size - minNodeSize) / (maxNodeSize - minNodeSize || 1); |
|
|
}); |
|
|
|
|
|
const [minLinkVal, maxLinkVal] = d3.extent(filteredLinks, d => d.value); |
|
|
filteredLinks.forEach(d => { |
|
|
d.normValue = (d.value - minLinkVal) / (maxLinkVal - minLinkVal || 1); |
|
|
d.distance = 100 + d.normValue * LINK_DISTANCE_SCALE; |
|
|
}); |
|
|
|
|
|
const edgeColor = d3.scaleLinear() |
|
|
.domain([0, 0.3, 1]) |
|
|
.interpolate(d3.interpolateRgb) |
|
|
.range(["#BC8F8F", "#FF7F50", "#800000"]); |
|
|
|
|
|
const link = zoomLayer.append("g").selectAll("line") |
|
|
.data(filteredLinks) |
|
|
.enter().append("line") |
|
|
.attr("stroke", d => edgeColor(d.normValue)) |
|
|
.attr("stroke-width", d => Math.max(0.1, d.value / maxLinkVal * LINK_THICKNESS_BASE)) |
|
|
.attr("stroke-opacity", d => d.normValue + 80) |
|
|
|
|
|
const node = zoomLayer.append("g").selectAll("circle") |
|
|
.data(filteredNodes) |
|
|
.enter().append("circle") |
|
|
.attr("r", d => d.size / maxNodeSize * NODE_RADIUS_SCALE) |
|
|
.attr("fill", "#FF7F50") |
|
|
.attr("stroke", "#800000") |
|
|
.attr("stroke-width", 5) |
|
|
.call(d3.drag().on("start", dragstarted).on("drag", dragged).on("end", dragended)); |
|
|
|
|
|
const labels = zoomLayer.append("g") |
|
|
.attr("class", "label-group") |
|
|
.selectAll("g") |
|
|
.data(filteredNodes) |
|
|
.enter().append("g") |
|
|
.attr("class", "label"); |
|
|
|
|
|
labels.append("text") |
|
|
.attr("text-anchor", "start") |
|
|
.style("font-weight", "bold") |
|
|
.style("font-size", d => `${FONT_SIZE_BASE + d.normSize * FONT_SIZE_SCALE}px`) |
|
|
.attr("x", d => NODE_BASE_RADIUS + d.normSize * NODE_RADIUS_SCALE + 6) |
|
|
.text(d => d.id); |
|
|
|
|
|
const top10 = filteredNodes.slice(0, 3); |
|
|
const insideLabels = zoomLayer.append("g") |
|
|
.selectAll("text") |
|
|
.data(top10) |
|
|
.enter().append("text") |
|
|
.attr("text-anchor", "middle") |
|
|
.attr("dy", "0.35em") |
|
|
.style("font-weight", "bold") |
|
|
.style("fill", "#800000") |
|
|
.style("pointer-events", "none") |
|
|
.style("font-size", d => `${FONT_SIZE_BASE + d.normSize * FONT_SIZE_SCALE * 0.2}px`) |
|
|
.text(d => d.size); |
|
|
|
|
|
labels.each(function(d) { |
|
|
const group = d3.select(this); |
|
|
const text = group.select("text"); |
|
|
|
|
|
const fontSize = FONT_SIZE_BASE + d.normSize * FONT_SIZE_SCALE; |
|
|
const paddingX = 4; |
|
|
const paddingY = 2; |
|
|
|
|
|
const labelX = NODE_BASE_RADIUS + d.normSize * NODE_RADIUS_SCALE + 6; |
|
|
text |
|
|
.attr("x", labelX) |
|
|
.attr("y", 0) |
|
|
.attr("dy", "0.35em") |
|
|
.style("font-size", `${fontSize}px`); |
|
|
|
|
|
const actualWidth = text.node().getComputedTextLength(); |
|
|
const actualHeight = fontSize; |
|
|
|
|
|
group.insert("rect", "text") |
|
|
.attr("x", labelX - paddingX) |
|
|
.attr("y", -actualHeight / 2 - paddingY) |
|
|
.attr("width", actualWidth + paddingX * 0.2) |
|
|
.attr("height", actualHeight + paddingY * 2) |
|
|
.attr("rx", 4) |
|
|
.attr("ry", 4) |
|
|
.attr("fill", "white") |
|
|
.attr("fill-opacity", 0.7) |
|
|
.attr("stroke", "#800000") |
|
|
.attr("stroke-width", 4) |
|
|
.attr("stroke-opacity", 1); |
|
|
}); |
|
|
const simulation = d3.forceSimulation(filteredNodes) |
|
|
.alphaDecay(0.05) |
|
|
.alphaMin(0.001) |
|
|
.force("link", d3.forceLink(filteredLinks).id(d => d.id).distance(d => d.distance)) |
|
|
.force("charge", d3.forceManyBody().strength(CHARGE_STRENGTH)) |
|
|
.force("center", d3.forceCenter(width / 2 -300, height / 2 - 310)) |
|
|
.force("collide", d3.forceCollide().radius(d => |
|
|
COLLIDE_BASE_RADIUS + d.normSize * NODE_RADIUS_SCALE + (FONT_SIZE_BASE + d.normSize * FONT_SIZE_SCALE) * 0.1 |
|
|
).strength(COLLIDE_STRENGTH)) |
|
|
.on("tick", ticked); |
|
|
function ticked() { |
|
|
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); |
|
|
labels.attr("transform", d => `translate(${d.x}, ${d.y})`); |
|
|
insideLabels.attr("x", d => d.x).attr("y", d => d.y); |
|
|
} |
|
|
|
|
|
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); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const sliders = { |
|
|
nodeCount: document.getElementById("nodeCountInput"), |
|
|
linkScale: document.getElementById("linkScaleInput"), |
|
|
charge: document.getElementById("chargeInput"), |
|
|
collide: document.getElementById("collideInput"), |
|
|
collideBase: document.getElementById("collideBaseInput"), |
|
|
radius: document.getElementById("radiusInput"), |
|
|
font: document.getElementById("fontInput"), |
|
|
linkThickness: document.getElementById("LinkStrokeIn"), |
|
|
}; |
|
|
|
|
|
const outputs = { |
|
|
nodeCount: document.getElementById("nodeCountOutput"), |
|
|
linkScale: document.getElementById("linkScaleOutput"), |
|
|
charge: document.getElementById("chargeOutput"), |
|
|
collide: document.getElementById("collideOutput"), |
|
|
collideBase: document.getElementById("collideBaseOutput"), |
|
|
radius: document.getElementById("radiusOutput"), |
|
|
font: document.getElementById("fontOutput"), |
|
|
linkThickness: document.getElementById("LinkStrokeOut"), |
|
|
}; |
|
|
|
|
|
function updateAndRender() { |
|
|
LINK_DISTANCE_SCALE = +sliders.linkScale.value; |
|
|
CHARGE_STRENGTH = +sliders.charge.value; |
|
|
COLLIDE_STRENGTH = +sliders.collide.value; |
|
|
COLLIDE_BASE_RADIUS = +sliders.collideBase.value; |
|
|
NODE_RADIUS_SCALE = +sliders.radius.value; |
|
|
FONT_SIZE_SCALE = +sliders.font.value; |
|
|
LINK_THICKNESS_BASE = +sliders.linkThickness.value; |
|
|
|
|
|
outputs.linkScale.textContent = LINK_DISTANCE_SCALE; |
|
|
outputs.charge.textContent = CHARGE_STRENGTH; |
|
|
outputs.collide.textContent = COLLIDE_STRENGTH; |
|
|
outputs.collideBase.textContent = COLLIDE_BASE_RADIUS; |
|
|
outputs.radius.textContent = NODE_RADIUS_SCALE; |
|
|
outputs.font.textContent = FONT_SIZE_SCALE; |
|
|
outputs.linkThickness.textContent = LINK_THICKNESS_BASE; |
|
|
outputs.nodeCount.textContent = sliders.nodeCount.value; |
|
|
|
|
|
if (fullGraph) renderGraph(fullGraph, +sliders.nodeCount.value); |
|
|
} |
|
|
|
|
|
for (const key in sliders) { |
|
|
sliders[key].addEventListener("input", updateAndRender); |
|
|
} |
|
|
|
|
|
document.getElementById("resetBtn").addEventListener("click", () => { |
|
|
sliders.nodeCount.value = 50; |
|
|
sliders.linkScale.value = -200; |
|
|
sliders.charge.value = 80; |
|
|
sliders.collide.value = 2; |
|
|
sliders.collideBase.value = 17; |
|
|
sliders.radius.value = 30; |
|
|
sliders.font.value = 25; |
|
|
sliders.linkThickness.value = 5; |
|
|
updateAndRender(); |
|
|
}); |
|
|
|
|
|
document.getElementById("jsonSelector").addEventListener("change", function() { |
|
|
loadJSON(this.value); |
|
|
}); |
|
|
|
|
|
function updateSvgThemeStyles() { |
|
|
const fill = getComputedStyle(document.documentElement).getPropertyValue('--svg-fill').trim(); |
|
|
const textColor = getComputedStyle(document.documentElement).getPropertyValue('--text-color').trim(); |
|
|
const labelBg = getComputedStyle(document.documentElement).getPropertyValue('--label-bg').trim(); |
|
|
|
|
|
zoomLayer.select("rect").attr("fill", fill); |
|
|
zoomLayer.selectAll("text").attr("fill", textColor); |
|
|
zoomLayer.selectAll("g.label rect").attr("fill", labelBg); |
|
|
} |
|
|
|
|
|
const toggle = document.getElementById('modeToggle'); |
|
|
const root = document.documentElement; |
|
|
|
|
|
root.classList.remove('light-mode'); |
|
|
|
|
|
toggle.addEventListener('change', () => { |
|
|
root.classList.toggle('light-mode'); |
|
|
setTimeout(updateSvgThemeStyles, 50); |
|
|
}); |
|
|
|
|
|
function loadJSON(filePath) { |
|
|
d3.json(filePath).then(graph => { |
|
|
fullGraph = graph; |
|
|
updateAndRender(); |
|
|
}); |
|
|
} |
|
|
|
|
|
const selector = document.getElementById("jsonSelector"); |
|
|
loadJSON(selector.value); |
|
|
|
|
|
document.getElementById("downloadBtn").addEventListener("click", () => { |
|
|
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 link = document.createElement("a"); |
|
|
link.href = url; |
|
|
link.download = "graph-export.svg"; |
|
|
document.body.appendChild(link); |
|
|
link.click(); |
|
|
document.body.removeChild(link); |
|
|
URL.revokeObjectURL(url); |
|
|
}); |
|
|
|
|
|
|
|
|
updateSvgThemeStyles(); |
|
|
updateAndRender(); |
|
|
</script> |
|
|
</body> |
|
|
</html> |