[+] Bubble graph
This commit is contained in:
+1
-1
@@ -161,5 +161,5 @@ cython_debug/
|
|||||||
*.iml
|
*.iml
|
||||||
config.toml
|
config.toml
|
||||||
public/index.html
|
public/index.html
|
||||||
admin
|
admin.html
|
||||||
user_states.json
|
user_states.json
|
||||||
@@ -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
@@ -586,6 +586,42 @@ def api_tree():
|
|||||||
return tree_to_dict("azaneko")
|
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")
|
@app.get("/api/admin/channels")
|
||||||
def api_admin_channels(x_admin_password: str = Header(None)):
|
def api_admin_channels(x_admin_password: str = Header(None)):
|
||||||
if x_admin_password not in CONFIG.get("admin-passwords", [CONFIG.get("init-password")]):
|
if x_admin_password not in CONFIG.get("admin-passwords", [CONFIG.get("init-password")]):
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ dependencies = [
|
|||||||
{ name = "python-telegram-bot" },
|
{ name = "python-telegram-bot" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "starlette" },
|
{ name = "starlette" },
|
||||||
|
{ name = "tqdm" },
|
||||||
{ name = "uvicorn" },
|
{ name = "uvicorn" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ requires-dist = [
|
|||||||
{ name = "python-telegram-bot", specifier = ">=22.6" },
|
{ name = "python-telegram-bot", specifier = ">=22.6" },
|
||||||
{ name = "requests", specifier = ">=2.32.5" },
|
{ name = "requests", specifier = ">=2.32.5" },
|
||||||
{ name = "starlette", specifier = ">=0.52.1" },
|
{ name = "starlette", specifier = ">=0.52.1" },
|
||||||
|
{ name = "tqdm", specifier = ">=4.67.3" },
|
||||||
{ name = "uvicorn", specifier = ">=0.41.0" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.15.0"
|
version = "4.15.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user