import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { Chart, registerables } from 'chart.js';
import {
getProjectDuplicateStats,
ProjectStats,
DuplicateStatsResponse,
} from '../api';
Chart.register(...registerables);
interface ProjectStats {
project_id: string;
project_name: string;
total: number;
duplicates: number;
}
interface DuplicateStatsResponse {
projects: { [key: string]: ProjectStats };
}
@customElement('duplicate-stats-chart')
export class DuplicateStatsChart extends LitElement {
@property({ type: Array }) projectIds: string[] = [];
@property({ type: String }) selectedStatus: 'opened' | 'closed' | 'all' =
'opened';
@property({ type: Number }) similarityThreshold = 0.8;
@property({ type: Boolean }) interactive = false;
@state()
private _loading = false;
@state()
private _error: string | null = null;
@state()
private _statsData: { [key: string]: ProjectStats } | null = null;
private charts: Chart[] = [];
static styles = css`
:host {
display: block;
}
#chart-container {
display: flex;
justify-content: space-around;
align-items: flex-start;
height: 100%;
width: 100%;
gap: var(--sl-spacing-medium);
}
.chart-wrapper {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
max-width: 120px; /* Limit width of each chart item */
}
canvas {
width: 100% !important;
height: auto !important;
aspect-ratio: 1 / 1;
}
.project-label {
margin-top: var(--sl-spacing-x-small);
font-size: var(--sl-font-size-small);
color: var(--sl-color-neutral-400);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
text-align: center;
}
`;
updated(changedProperties: Map<string, any>) {
if (
changedProperties.has('projectIds') ||
changedProperties.has('selectedStatus') ||
changedProperties.has('similarityThreshold')
) {
this.fetchData();
}
// Re-render the charts if the stats data is updated
if (this._statsData && changedProperties.has('_statsData')) {
this.renderCharts(this._statsData);
}
}
async fetchData() {
this._loading = true;
this._error = null;
this._statsData = null; // Clear previous stats
try {
const data = await getProjectDuplicateStats({
project_ids: this.projectIds,
status: this.selectedStatus,
similarity_threshold: this.similarityThreshold,
});
// Guard against a missing projects property to prevent crashes
if (data && data.projects) {
this._statsData = data.projects;
} else {
this._statsData = {}; // Set empty stats to trigger render
}
} catch (error) {
this._error =
error instanceof Error ? error.message : 'An unknown error occurred.';
console.error('Failed to fetch duplicate stats:', error);
} finally {
this._loading = false;
}
}
renderCharts(stats: { [key: string]: ProjectStats }) {
this.charts.forEach((chart) => chart.destroy());
this.charts = [];
const container = this.shadowRoot?.querySelector('#chart-container');
if (!container) return;
container.innerHTML = ''; // Clear previous charts
const sortedStats = Object.values(stats)
.filter((stat) => stat.duplicates > 0)
.sort((a, b) => b.duplicates - a.duplicates)
.slice(0, 5);
const alarmingColor = 'hsl(350, 70%, 60%)';
const alarmingBgColor = 'hsl(350, 70%, 65%)';
sortedStats.forEach((stat) => {
const wrapper = document.createElement('div');
wrapper.className = 'chart-wrapper';
const canvas = document.createElement('canvas');
const label = document.createElement('div');
label.className = 'project-label';
label.textContent = stat.project_name;
label.title = stat.project_name;
wrapper.appendChild(canvas);
wrapper.appendChild(label);
container.appendChild(wrapper);
const ctx = canvas.getContext('2d');
if (!ctx) return;
const duplicatePattern = this._createDiagonalPattern(
ctx,
alarmingColor,
alarmingBgColor
);
const chart = new Chart(ctx, {
type: 'pie',
data: {
labels: ['Similar', 'Unique'],
datasets: [
{
data: [stat.duplicates, stat.total - stat.duplicates],
backgroundColor: [duplicatePattern, 'hsl(210, 60%, 55%)'],
hoverBackgroundColor: [
'hsl(350, 70%, 50%)',
'hsl(210, 60%, 65%)',
],
borderWidth: 0,
hoverOffset: 8,
},
],
},
options: {
onClick: () => {
if (this.interactive) {
this.dispatchEvent(
new CustomEvent('project-selected', {
detail: { projectId: stat.project_id },
bubbles: true,
composed: true,
})
);
}
},
onHover: (event, chartElement) => {
const target = event.native?.target as HTMLCanvasElement;
if (target) {
target.style.cursor =
this.interactive && chartElement[0] ? 'pointer' : 'default';
}
},
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: 'var(--sl-color-neutral-1000)',
displayColors: false,
callbacks: {
title: () => stat.project_name,
label: (context) => {
const label = context.label || '';
const value = context.parsed || 0;
return `${label}: ${value}`;
},
},
},
},
},
});
this.charts.push(chart);
});
}
private _createDiagonalPattern(
chartCtx: CanvasRenderingContext2D,
color = 'black',
backgroundColor = 'transparent'
) {
const patternCanvas = document.createElement('canvas');
const patternCtx = patternCanvas.getContext('2d');
if (!patternCtx) return color;
const size = 10;
patternCanvas.width = size;
patternCanvas.height = size;
patternCtx.fillStyle = backgroundColor;
patternCtx.fillRect(0, 0, size, size);
patternCtx.strokeStyle = color;
patternCtx.lineWidth = 3;
patternCtx.beginPath();
patternCtx.moveTo(-2, size / 2 - 2);
patternCtx.lineTo(size / 2 + 2, size + 2);
patternCtx.stroke();
patternCtx.beginPath();
patternCtx.moveTo(size / 2 - 2, 0 - 2);
patternCtx.lineTo(size + 2, size / 2 + 2);
patternCtx.stroke();
return chartCtx.createPattern(patternCanvas, 'repeat') || color;
}
render() {
return html`
<div id="chart-container">
${this._loading
? html`<div class="spinner-container"><sl-spinner></sl-spinner></div>`
: this._error
? html`<div>Error: ${this._error}</div>`
: ''}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'duplicate-stats-chart': DuplicateStatsChart;
}
}