fix(cluster_overview): responsive charts with adaptive bandwidth and overflow-safe legend
This commit is contained in:
@@ -5,6 +5,22 @@
|
||||
<style>
|
||||
.feature-chart { width: 100%; display: block; }
|
||||
#featureChartsContainer canvas { max-height: 400px; }
|
||||
.chart-wrapper { overflow-x: auto; }
|
||||
.chart-container { position: relative; }
|
||||
.canvas-legend {
|
||||
max-height: 110px; overflow-y: auto;
|
||||
display: flex; flex-wrap: wrap; gap: 4px 14px;
|
||||
padding: 6px 10px; font-size: 11px;
|
||||
border-top: 1px solid #eee; margin-top: 4px;
|
||||
}
|
||||
.canvas-legend .legend-item {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.canvas-legend .legend-swatch {
|
||||
width: 10px; height: 10px; display: inline-block;
|
||||
border-radius: 2px; flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
<div class="card">
|
||||
<h2>Cluster Overview — Run #{{ run.id }}</h2>
|
||||
@@ -13,7 +29,12 @@
|
||||
|
||||
<div class="card">
|
||||
<h2>PCA-2D Embedding</h2>
|
||||
<canvas id="scatterChart" class="scatter-canvas"></canvas>
|
||||
<div class="chart-wrapper">
|
||||
<div class="chart-container">
|
||||
<canvas id="scatterChart" class="scatter-canvas"></canvas>
|
||||
<div id="scatterLegend" class="canvas-legend"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if geo_count > 0 %}
|
||||
@@ -22,18 +43,27 @@
|
||||
{% 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 class="chart-wrapper">
|
||||
<div class="chart-container">
|
||||
<canvas id="geoChart" class="scatter-canvas"></canvas>
|
||||
<div id="geoLegend" class="canvas-legend"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card" id="clusterSizesCard">
|
||||
<h2>Cluster Size Distribution</h2>
|
||||
<canvas id="clusterSizesChart" class="scatter-canvas"></canvas>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="clusterSizesChart" class="scatter-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="silhouetteCard">
|
||||
<h2>Silhouette Score Comparison</h2>
|
||||
<canvas id="silhouetteChart" class="scatter-canvas"></canvas>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="silhouetteChart" class="scatter-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="topFeaturesCard">
|
||||
@@ -144,16 +174,19 @@ function drawScatter(canvasId, data, xLabel, yLabel) {
|
||||
}
|
||||
};
|
||||
|
||||
// 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;
|
||||
});
|
||||
// Legend — HTML overlay (supports overflow-y: auto via .canvas-legend)
|
||||
const legendEl = document.getElementById(canvasId.replace('Chart', 'Legend'));
|
||||
if (legendEl) {
|
||||
legendEl.innerHTML = '';
|
||||
const uniqueLabels = [...new Set(data.map(d => d.label))];
|
||||
uniqueLabels.forEach(label => {
|
||||
const color = label === -1 ? 'rgba(150,150,150,0.8)' : colors[Math.abs(label) % colors.length];
|
||||
const item = document.createElement('span');
|
||||
item.className = 'legend-item';
|
||||
item.innerHTML = '<span class="legend-swatch" style="background:' + color + '"></span>' + (label === -1 ? 'Noise' : 'Cluster ' + label);
|
||||
legendEl.appendChild(item);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
drawScatter('scatterChart', scatterData, 'PCA-1', 'PCA-2');
|
||||
@@ -183,7 +216,7 @@ if (clusterSizes.length > 0) {
|
||||
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 barH = Math.min(40, plotH / clusterSizes.length * 0.6);
|
||||
const y = pad.top + i * (barH + 4);
|
||||
const bw = (d.size / maxSize) * plotW;
|
||||
ctx.fillStyle = chartColors[i % chartColors.length];
|
||||
@@ -204,7 +237,7 @@ if (clusterSizes.length > 0) {
|
||||
if (!canvas) return;
|
||||
const silData = clusterSizes.filter(d => d.silhouette !== null && d.silhouette !== undefined);
|
||||
if (silData.length === 0) {
|
||||
canvas.parentElement.style.display = 'none';
|
||||
document.getElementById('silhouetteCard').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const ctx = canvas.getContext('2d');
|
||||
@@ -217,7 +250,7 @@ if (clusterSizes.length > 0) {
|
||||
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 barH = Math.min(40, plotH / silData.length * 0.6);
|
||||
const y = pad.top + i * (barH + 4);
|
||||
const bw = (d.silhouette / maxScore) * plotW * 0.5;
|
||||
const x = pad.left + plotW / 2;
|
||||
@@ -227,9 +260,14 @@ if (clusterSizes.length > 0) {
|
||||
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);
|
||||
if (d.silhouette >= 0) {
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(d.silhouette.toFixed(4), x + bw + 5, y - 2);
|
||||
} else {
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(d.silhouette.toFixed(4), x + bw - 5, y + barH + 12);
|
||||
}
|
||||
});
|
||||
// Zero line
|
||||
ctx.strokeStyle = '#999'; ctx.lineWidth = 1;
|
||||
@@ -264,7 +302,7 @@ if (clusterSizes.length > 0) {
|
||||
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 barH = Math.min(30, plotH2 / features.length * 0.6);
|
||||
const y = pad2.top + i * (barH + 3);
|
||||
const score = f.score || 0;
|
||||
const bw = (score / maxScore) * plotW2;
|
||||
|
||||
Reference in New Issue
Block a user