287 lines
12 KiB
HTML
287 lines
12 KiB
HTML
{% extends 'base.html' %}
|
|
{% load static %}
|
|
{% block title %}Cluster Overview - Run #{{ run.id }}{% endblock %}
|
|
{% block content %}
|
|
<style>
|
|
.feature-chart { width: 100%; display: block; }
|
|
#featureChartsContainer canvas { max-height: 400px; }
|
|
</style>
|
|
<div class="card">
|
|
<h2>Cluster Overview — Run #{{ run.id }}</h2>
|
|
<p>Entity column: <code>{{ run.entity_column }}</code> | {{ run.entity_count }} entities, {{ run.cluster_count }} clusters</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>PCA-2D Embedding</h2>
|
|
<canvas id="scatterChart" class="scatter-canvas"></canvas>
|
|
</div>
|
|
|
|
{% if geo_count > 0 %}
|
|
<div class="card">
|
|
<h2>地理位置分布 <span class="badge badge-info">{{ geo_count }} entities</span></h2>
|
|
{% if geo_skipped > 0 %}
|
|
<p style="color:#888;font-size:0.85rem;">{{ geo_skipped }} entities excluded (missing lat/lon)</p>
|
|
{% endif %}
|
|
<canvas id="geoChart" class="scatter-canvas"></canvas>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="card" id="clusterSizesCard">
|
|
<h2>Cluster Size Distribution</h2>
|
|
<canvas id="clusterSizesChart" class="scatter-canvas"></canvas>
|
|
</div>
|
|
|
|
<div class="card" id="silhouetteCard">
|
|
<h2>Silhouette Score Comparison</h2>
|
|
<canvas id="silhouetteChart" class="scatter-canvas"></canvas>
|
|
</div>
|
|
|
|
<div class="card" id="topFeaturesCard">
|
|
<h2>Top Distinguishing Features per Cluster</h2>
|
|
<div id="featureChartsContainer"></div>
|
|
</div>
|
|
|
|
<div class="grid-2">
|
|
{% for c in clusters %}
|
|
<div class="card">
|
|
<h3>Cluster #{{ c.cluster_label }} <span class="badge badge-info">{{ c.size }} entities</span></h3>
|
|
<p>Proportion: {{ c.proportion|floatformat:3 }}</p>
|
|
<p>Silhouette: {{ c.silhouette_score|floatformat:4|default:"-" }}</p>
|
|
<h4 style="margin-top:0.75rem;font-size:0.9rem;">Top Distinguishing Features</h4>
|
|
<table>
|
|
<thead>
|
|
<tr><th>Feature</th><th>Score</th><th>Mean</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for f in c.features.all|slice:":5" %}
|
|
<tr>
|
|
<td>{{ f.feature_name }}</td>
|
|
<td>{{ f.distinguishing_score|floatformat:3|default:"-" }}</td>
|
|
<td>{{ f.mean|floatformat:3|default:"-" }}</td>
|
|
</tr>
|
|
{% empty %}
|
|
<tr><td colspan="3">No features</td></tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
<a href="{% url 'analysis:cluster_detail' run.id c.cluster_label %}" class="btn btn-primary" style="margin-top:0.75rem;">Detail</a>
|
|
</div>
|
|
{% empty %}
|
|
<div class="card">
|
|
<div class="empty-state"><p>No clusters found.</p></div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
{% if noise %}
|
|
<div class="card">
|
|
<h3>Noise (Cluster -1)</h3>
|
|
<p>{{ noise.size }} entities classified as noise</p>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<script>
|
|
// Pure Canvas scatter plot — no Chart.js needed (offline compatible)
|
|
const scatterData = {{ scatter_data_json|safe }};
|
|
const colors = ['#4361ee','#f72585','#7209b7','#4cc9f0','#f8961e','#43aa8b','#f94144','#577590','#e9c46a','#9b5de5'];
|
|
|
|
function drawScatter(canvasId, data, xLabel, yLabel) {
|
|
const canvas = document.getElementById(canvasId);
|
|
if (!canvas || data.length === 0) return;
|
|
const ctx = canvas.getContext('2d');
|
|
const W = canvas.parentElement.clientWidth || 600;
|
|
const H = 400;
|
|
canvas.width = W; canvas.height = H;
|
|
const pad = { top: 30, bottom: 50, left: 60, right: 30 };
|
|
const plotW = W - pad.left - pad.right;
|
|
const plotH = H - pad.top - pad.bottom;
|
|
|
|
// Compute bounds
|
|
const xs = data.map(d => d.x); const ys = data.map(d => d.y);
|
|
const xMin = Math.min(...xs), xMax = Math.max(...xs);
|
|
const yMin = Math.min(...ys), yMax = Math.max(...ys);
|
|
const xRange = xMax - xMin || 1; const yRange = yMax - yMin || 1;
|
|
|
|
const toX = v => pad.left + (v - xMin) / xRange * plotW;
|
|
const toY = v => pad.top + plotH - (v - yMin) / yRange * plotH;
|
|
|
|
// Clear
|
|
ctx.clearRect(0, 0, W, H);
|
|
|
|
// Grid lines
|
|
ctx.strokeStyle = '#eee'; ctx.lineWidth = 1;
|
|
for (let i = 0; i <= 5; i++) {
|
|
const y = pad.top + i * plotH / 5;
|
|
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(W - pad.right, y); ctx.stroke();
|
|
}
|
|
|
|
// Axes
|
|
ctx.strokeStyle = '#333'; ctx.lineWidth = 2;
|
|
ctx.beginPath(); ctx.moveTo(pad.left, pad.top); ctx.lineTo(pad.left, H - pad.bottom); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(pad.left, H - pad.bottom); ctx.lineTo(W - pad.right, H - pad.bottom); ctx.stroke();
|
|
|
|
// Labels
|
|
ctx.fillStyle = '#666'; ctx.font = '13px sans-serif'; ctx.textAlign = 'center';
|
|
ctx.fillText(xLabel, W / 2, H - 5);
|
|
ctx.save(); ctx.translate(15, H / 2); ctx.rotate(-Math.PI / 2); ctx.fillText(yLabel, 0, 0); ctx.restore();
|
|
|
|
// Points
|
|
data.forEach(d => {
|
|
const cx = toX(d.x), cy = toY(d.y);
|
|
const color = d.label === -1 ? 'rgba(150,150,150,0.5)' : colors[Math.abs(d.label) % colors.length];
|
|
const r = d.label === -1 ? 2 : 4;
|
|
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
|
ctx.fillStyle = color; ctx.fill();
|
|
});
|
|
|
|
// Tooltip on hover
|
|
canvas.onmousemove = function(e) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
|
const hit = data.find(d => Math.abs(toX(d.x) - mx) < 6 && Math.abs(toY(d.y) - my) < 6);
|
|
if (hit) {
|
|
canvas.title = `${hit.entity} (${hit.label === -1 ? 'Noise' : 'Cluster ' + hit.label})`;
|
|
}
|
|
};
|
|
|
|
// Legend
|
|
const uniqueLabels = [...new Set(data.map(d => d.label))];
|
|
let lx = W - pad.right - 120, ly = pad.top + 5;
|
|
ctx.font = '11px sans-serif'; ctx.textAlign = 'left';
|
|
uniqueLabels.forEach(label => {
|
|
const color = label === -1 ? 'rgba(150,150,150,0.8)' : colors[Math.abs(label) % colors.length];
|
|
ctx.fillStyle = color; ctx.fillRect(lx, ly, 10, 10);
|
|
ctx.fillStyle = '#333'; ctx.fillText(label === -1 ? 'Noise' : `Cluster ${label}`, lx + 15, ly + 10);
|
|
ly += 18;
|
|
});
|
|
}
|
|
|
|
drawScatter('scatterChart', scatterData, 'PCA-1', 'PCA-2');
|
|
|
|
{% if geo_count > 0 %}
|
|
const geoData = {{ geo_data_json|safe }};
|
|
drawScatter('geoChart', geoData, 'Longitude', 'Latitude');
|
|
{% endif %}
|
|
|
|
// ── Cluster size horizontal bar chart ──
|
|
const clusterSizes = {{ cluster_sizes_json|safe }};
|
|
const topFeaturesAll = {{ top_features_json|safe }};
|
|
const chartColors = ['#4361ee','#f72585','#7209b7','#4cc9f0','#f8961e','#43aa8b','#f94144','#577590','#e9c46a','#9b5de5'];
|
|
|
|
if (clusterSizes.length > 0) {
|
|
// Cluster size bar chart
|
|
(function() {
|
|
const canvas = document.getElementById('clusterSizesChart');
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext('2d');
|
|
const W = canvas.parentElement.clientWidth || 600;
|
|
const H = Math.max(200, clusterSizes.length * 40 + 60);
|
|
canvas.width = W; canvas.height = H;
|
|
const pad = { top: 20, bottom: 30, left: 80, right: 30 };
|
|
const plotW = W - pad.left - pad.right;
|
|
const plotH = H - pad.top - pad.bottom;
|
|
const maxSize = Math.max(...clusterSizes.map(d => d.size));
|
|
ctx.clearRect(0, 0, W, H);
|
|
clusterSizes.forEach((d, i) => {
|
|
const barH = Math.min(30, plotH / clusterSizes.length - 4);
|
|
const y = pad.top + i * (barH + 4);
|
|
const bw = (d.size / maxSize) * plotW;
|
|
ctx.fillStyle = chartColors[i % chartColors.length];
|
|
ctx.fillRect(pad.left, y, bw, barH);
|
|
ctx.fillStyle = '#333';
|
|
ctx.font = '12px sans-serif';
|
|
ctx.textAlign = 'right';
|
|
ctx.fillText('#' + d.label, pad.left - 5, y + barH / 2 + 4);
|
|
ctx.textAlign = 'left';
|
|
ctx.fillStyle = '#666';
|
|
ctx.fillText(d.size + ' (' + (d.size / clusterSizes.reduce((a,b) => a+b.size, 0) * 100).toFixed(1) + '%)', pad.left + bw + 5, y + barH / 2 + 4);
|
|
});
|
|
})();
|
|
|
|
// Silhouette score bar chart
|
|
(function() {
|
|
const canvas = document.getElementById('silhouetteChart');
|
|
if (!canvas) return;
|
|
const silData = clusterSizes.filter(d => d.silhouette !== null && d.silhouette !== undefined);
|
|
if (silData.length === 0) {
|
|
canvas.parentElement.style.display = 'none';
|
|
return;
|
|
}
|
|
const ctx = canvas.getContext('2d');
|
|
const W = canvas.parentElement.clientWidth || 600;
|
|
const H = Math.max(200, silData.length * 40 + 60);
|
|
canvas.width = W; canvas.height = H;
|
|
const pad = { top: 20, bottom: 30, left: 80, right: 40 };
|
|
const plotW = W - pad.left - pad.right;
|
|
const plotH = H - pad.top - pad.bottom;
|
|
const maxScore = Math.max(1, ...silData.map(d => Math.abs(d.silhouette)));
|
|
ctx.clearRect(0, 0, W, H);
|
|
silData.forEach((d, i) => {
|
|
const barH = Math.min(30, plotH / silData.length - 4);
|
|
const y = pad.top + i * (barH + 4);
|
|
const bw = (d.silhouette / maxScore) * plotW * 0.5;
|
|
const x = pad.left + plotW / 2;
|
|
ctx.fillStyle = d.silhouette >= 0 ? '#43aa8b' : '#f94144';
|
|
ctx.fillRect(x, y, d.silhouette >= 0 ? bw : -bw, barH);
|
|
ctx.fillStyle = '#333';
|
|
ctx.font = '12px sans-serif';
|
|
ctx.textAlign = 'right';
|
|
ctx.fillText('#' + d.label, pad.left - 5, y + barH / 2 + 4);
|
|
ctx.textAlign = 'left';
|
|
ctx.fillStyle = '#666';
|
|
ctx.fillText(d.silhouette.toFixed(4), d.silhouette >= 0 ? x + bw + 5 : x - bw - 40, y + barH / 2 + 4);
|
|
});
|
|
// Zero line
|
|
ctx.strokeStyle = '#999'; ctx.lineWidth = 1;
|
|
ctx.beginPath(); ctx.moveTo(pad.left + plotW / 2, pad.top); ctx.lineTo(pad.left + plotW / 2, H - pad.bottom); ctx.stroke();
|
|
ctx.fillStyle = '#999'; ctx.font = '11px sans-serif'; ctx.textAlign = 'center';
|
|
ctx.fillText('0', pad.left + plotW / 2, H - 5);
|
|
ctx.fillText('+1', W - pad.right, H - 5);
|
|
ctx.fillText('-1', pad.left, H - 5);
|
|
})();
|
|
|
|
// Top features per cluster horizontal bar charts
|
|
(function() {
|
|
const container = document.getElementById('featureChartsContainer');
|
|
if (!container) return;
|
|
clusterSizes.forEach(d => {
|
|
const label = d.label;
|
|
const features = topFeaturesAll[String(label)] || [];
|
|
if (features.length === 0) return;
|
|
const card = document.createElement('div');
|
|
card.style.marginBottom = '1.5rem';
|
|
card.innerHTML = '<h4 style="margin-bottom:0.5rem;">Cluster #' + label + ' - Top Features</h4><canvas class="feature-chart" data-label="' + label + '" style="width:100%;height:' + Math.max(120, features.length * 28 + 30) + 'px;"></canvas>';
|
|
container.appendChild(card);
|
|
const canvas = card.querySelector('canvas');
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext('2d');
|
|
const W2 = canvas.parentElement.clientWidth || 600;
|
|
const H2 = Math.max(120, features.length * 28 + 30);
|
|
canvas.width = W2; canvas.height = H2;
|
|
const pad2 = { top: 10, bottom: 20, left: 100, right: 60 };
|
|
const plotW2 = W2 - pad2.left - pad2.right;
|
|
const plotH2 = H2 - pad2.top - pad2.bottom;
|
|
const maxScore = Math.max(0.01, ...features.map(f => Math.abs(f.score || 0)));
|
|
ctx.clearRect(0, 0, W2, H2);
|
|
features.forEach((f, i) => {
|
|
const barH = Math.min(22, plotH2 / features.length - 3);
|
|
const y = pad2.top + i * (barH + 3);
|
|
const score = f.score || 0;
|
|
const bw = (score / maxScore) * plotW2;
|
|
ctx.fillStyle = chartColors[label % chartColors.length];
|
|
ctx.fillRect(pad2.left, y, bw, barH);
|
|
ctx.fillStyle = '#333';
|
|
ctx.font = '11px sans-serif';
|
|
ctx.textAlign = 'right';
|
|
const name = f.feature_name.length > 18 ? f.feature_name.slice(0, 16) + '..' : f.feature_name;
|
|
ctx.fillText(name, pad2.left - 5, y + barH / 2 + 4);
|
|
ctx.textAlign = 'left';
|
|
ctx.fillStyle = '#666';
|
|
ctx.fillText(score.toFixed(3), pad2.left + bw + 5, y + barH / 2 + 4);
|
|
});
|
|
});
|
|
})();
|
|
}
|
|
</script>
|
|
{% endblock %}
|