[+] Bubble graph

This commit is contained in:
2026-03-13 21:13:23 -04:00
parent d6b99c1a86
commit cea457ff94
4 changed files with 256 additions and 1 deletions
+1 -1
View File
@@ -161,5 +161,5 @@ cython_debug/
*.iml
config.toml
public/index.html
admin
admin.html
user_states.json
+205
View File
@@ -0,0 +1,205 @@
<!DOCTYPE html>
<html lang="en">
<head>
<style> body { margin: 0; font-family: sans-serif; } </style>
<script src="//d3js.org/d3.v7.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/force-graph"></script>
<style>
#controls {
position: absolute;
top: 10px;
left: 10px;
z-index: 10;
background: rgba(255, 255, 255, 0.8);
padding: 10px;
border-radius: 5px;
}
</style>
</head>
<body>
<div id="controls">
<label for="sizeMetric"><strong>尺寸</strong></label>
<select id="sizeMetric">
<option value="subscribers">订阅数</option>
<option value="waters">浇水数</option>
</select>
</div>
<div id="graph"></div>
<script>
fetch('/api/graph_data').then(res => res.json()).then(data => {
const elem = document.getElementById('graph');
const selectEl = document.getElementById('sizeMetric');
let metric = selectEl.value;
// Calculate a robust maximum by using the 95th percentile
// so extreme outliers don't crush the scale for everyone else.
const calcMax = () => {
let values = data.nodes.map(n => n[metric] || 0).sort((a, b) => a - b);
if (values.length === 0) return 1;
// Take the 95th percentile value as our "max" scale reference
let idx = Math.floor(values.length * 0.95);
if (idx >= values.length) idx = values.length - 1;
return Math.max(1, values[idx]);
};
let maxVal = calcMax();
const calcRadius = (node) => {
const val = node[metric] || 0;
const minSize = 5;
const maxSize = 50; // Maximum added size
let factor = 0;
if (maxVal > 0) {
const clampedVal = Math.min(val, maxVal);
if (metric === 'subscribers') {
factor = Math.sqrt(clampedVal / maxVal);
} else {
factor = clampedVal / maxVal; // Linear scaling for water count
}
}
return minSize + factor * maxSize;
};
const maxLayer = Math.max(1, ...data.nodes.map(n => n.height || 0));
const colorScale = d3.scaleLinear()
.domain([0, 1, 2, 3])
.range(['#FFCB3E', '#FB836F', '#C1549C', '#7E549F']);
const Graph = new ForceGraph(elem)
.backgroundColor('#101020')
.nodeColor(node => colorScale(node.height || 0))
// Hover tooltip
.nodeLabel(node => `${node.name} (@${node.id})<br>Subs: ${node.subscribers}<br>Waters: ${node.waters}`)
.linkColor(() => 'rgba(255,255,255,0.2)')
.linkDirectionalParticles(1)
.onNodeClick(node => window.open(`https://t.me/${node.id}`, '_blank'))
.nodeCanvasObject((node, ctx, globalScale) => {
// Calculate radius
const radius = calcRadius(node);
// Draw Circle
ctx.beginPath();
ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI, false);
ctx.fillStyle = node.color || colorScale(node.height || 0);
ctx.fill();
// Stroke
ctx.lineWidth = 1 / globalScale;
ctx.strokeStyle = '#222';
ctx.stroke();
// Decode HTML entities and Cache
let label = node._decodedName;
if (!label) {
const txt = document.createElement("textarea");
txt.innerHTML = node.name;
label = txt.value;
node._decodedName = label;
}
// Wrapping support + Caching based purely on aspect-ratio proportions
if (!node._lines || node._lastRadius !== radius) {
let lines = [];
// Target characters per line for a properly proportioned text block
let targetCols = Math.max(1, Math.ceil(Math.sqrt(label.length * 1.5)));
let words = label.split(' ');
if (words.length === 1 && label.length > 5) {
// No spaces (like CJK), just chunk text by block length
for (let i = 0; i < label.length; i += targetCols) {
lines.push(label.substring(i, i + targetCols));
}
} else {
// Words/Spaces present, pack intelligently
let currentLine = '';
words.forEach((word) => {
if (currentLine && (currentLine + word).length > targetCols) {
lines.push(currentLine.trim());
currentLine = word + ' ';
} else {
currentLine += word + ' ';
}
});
if (currentLine) lines.push(currentLine.trim());
}
// Calculate initial purely height-based font size
let numLines = lines.length;
let availableWidth = radius * 1.5;
let availableHeight = radius * 1.3;
let fSize = availableHeight / (numLines * 1.15);
if (fSize > radius * 0.45) fSize = radius * 0.45;
// Setup canvas to measure ACTUAL pixel width, handling wide CJK characters accurately
ctx.font = `bold ${fSize}px Sans-Serif`;
let maxLineWidth = 0;
lines.forEach(line => {
let w = ctx.measureText(line).width;
if (w > maxLineWidth) maxLineWidth = w;
});
// If the physical render width is still too large, crush the font size down proportionally
if (maxLineWidth > availableWidth) {
fSize *= availableWidth / maxLineWidth;
}
node._lines = lines;
node._fontSize = fSize;
node._lastRadius = radius;
}
// Anti-Jitter / Anti-Blurry Emoji Optimization:
// The browser natively rasterizes emoji images based on the nominal font size string (e.g. 10px).
// If we allow the canvas scale() property to blow up a 10px emoji, it becomes extremely blurry
// and its sub-pixels jitter across frames. We fix this by multiplying the nominal font size
// up to 1:1 monitor pixels, and then mathematically undoing the canvas transform.
const fSizeGlobal = node._fontSize * globalScale;
ctx.font = `bold ${fSizeGlobal}px Sans-Serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#fff';
ctx.save();
// Move 0,0 to circle center natively
ctx.translate(node.x, node.y);
// Revert Canvas zoom multiplier so 1 drawing unit roughly equals 1 screen pixel
ctx.scale(1 / globalScale, 1 / globalScale);
const lineHeight = fSizeGlobal * 1.15;
const startY = -((node._lines.length - 1) * lineHeight) / 2;
node._lines.forEach((line, i) => {
// Snap to whole pixels to prevent sub-pixel antialiasing flicker
ctx.fillText(line, 0, Math.round(startY + i * lineHeight));
});
ctx.restore();
})
// Add collision force to prevent overlap
.d3Force('collide', d3.forceCollide(node => calcRadius(node) + 1))
// Increase repulsion so they spread out like a radial network
.d3Force('charge', d3.forceManyBody().strength(-150))
.graphData(data);
// Re-render when dropdown changes
selectEl.addEventListener('change', (e) => {
metric = e.target.value;
maxVal = calcMax();
// Update collision force radiuses
Graph.d3Force('collide', d3.forceCollide(node => calcRadius(node) + 1));
// Re-heat simulation to spread out
Graph.numDimensions(2); // Triggers re-render
Graph.d3ReheatSimulation();
});
});
</script>
</body>
</html>
+36
View File
@@ -586,6 +586,42 @@ def api_tree():
return tree_to_dict("azaneko")
@app.get("/api/graph_data")
def api_graph_data():
nodes = []
links = []
r_chan = re.compile(r"([\d ]+) subscribers")
r_grp = re.compile(r"([\d ]+) members")
for entity in db.Channel.select().where(db.Channel.hidden == False):
html_t = channel_html(entity.username)
subs = 0
if m1 := r_chan.search(html_t):
subs = int(m1.group(1).replace(" ", ""))
elif m2 := r_grp.search(html_t):
subs = int(m2.group(1).replace(" ", ""))
waters = db.get_votes(entity.username)
nodes.append({
"id": entity.username,
"name": entity.name,
"subscribers": subs,
"waters": waters,
"height": entity.height
})
if entity.parent_id:
links.append({
"source": entity.parent_id,
"target": entity.username
})
return {"nodes": nodes, "links": links}
@app.get("/api/admin/channels")
def api_admin_channels(x_admin_password: str = Header(None)):
if x_admin_password not in CONFIG.get("admin-passwords", [CONFIG.get("init-password")]):
Generated
+14
View File
@@ -58,6 +58,7 @@ dependencies = [
{ name = "python-telegram-bot" },
{ name = "requests" },
{ name = "starlette" },
{ name = "tqdm" },
{ name = "uvicorn" },
]
@@ -71,6 +72,7 @@ requires-dist = [
{ name = "python-telegram-bot", specifier = ">=22.6" },
{ name = "requests", specifier = ">=2.32.5" },
{ name = "starlette", specifier = ">=0.52.1" },
{ name = "tqdm", specifier = ">=4.67.3" },
{ name = "uvicorn", specifier = ">=0.41.0" },
]
@@ -343,6 +345,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
]
[[package]]
name = "tqdm"
version = "4.67.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"