import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { Deck } from '@deck.gl/core';
import { ScatterplotLayer } from '@deck.gl/layers';
import { getEmbeddingsForProjects } from '../api';
import { when } from 'lit/directives/when.js';
const TSNE_ITERATIONS = 500;
const COLOR_PALETTE: [number, number, number][] = [
[255, 140, 0], // DarkOrange
[0, 191, 255], // DeepSkyBlue
[50, 205, 50], // LimeGreen
[255, 69, 0], // OrangeRed
[153, 50, 204], // DarkOrchid
[255, 215, 0], // Gold
[70, 130, 180], // SteelBlue
[218, 112, 214], // Orchid
];
interface ScatterplotData {
position: [number, number];
issueKey: string;
title: string;
createdAt: string;
projectId: string;
}
@customElement('embedding-viewer')
export class EmbeddingViewer extends LitElement {
@property({ type: String, attribute: 'project-ids' }) projectIds: string = '';
@state() private scatterplotData: ScatterplotData[] = [];
@state() private isLoading = false;
@state() private projectColorMap = new Map<
string,
[number, number, number]
>();
private deck: Deck | null = null;
private resizeObserver: ResizeObserver | null = null;
private tsneWorker: Worker | null = null;
updated(changedProperties: Map<string, any>) {
if (changedProperties.has('projectIds')) {
this.fetchAndVisualize();
}
if (changedProperties.has('scatterplotData')) {
this.renderLayers();
}
}
static styles = css`
:host {
display: block;
position: relative;
width: 100%;
height: 160px;
background-color: #2c3e50;
border-radius: var(--sl-border-radius-medium);
}
.tooltip {
position: absolute;
background: white;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 12px;
pointer-events: none;
z-index: 9999;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(44, 62, 80, 0.8);
color: white;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: var(--sl-spacing-medium);
z-index: 10000;
}
`;
firstUpdated() {
const canvas = this.shadowRoot?.querySelector(
'#deck-canvas'
) as HTMLCanvasElement;
this.deck = new Deck({
canvas,
initialViewState: {
longitude: 0,
latitude: 0,
zoom: 1,
},
controller: true,
onHover: this.handleHover.bind(this),
});
this.resizeObserver = new ResizeObserver(() => this.handleResize());
this.resizeObserver.observe(this);
}
disconnectedCallback() {
super.disconnectedCallback();
this.resizeObserver?.disconnect();
this.deck?.finalize();
if (this.tsneWorker) {
this.tsneWorker.terminate();
this.tsneWorker = null;
}
}
connectedCallback() {
super.connectedCallback();
// Initial fetch is handled by the 'updated' lifecycle method
// when projectIds is first set.
}
handleResize() {
if (this.deck) {
this.deck.setProps({
width: this.clientWidth,
height: this.clientHeight,
});
}
}
private async fetchAndVisualize() {
if (this.projectIds === undefined) {
this.scatterplotData = [];
return;
}
if (this.tsneWorker) {
this.tsneWorker.terminate();
}
console.log('[EmbeddingViewer] Fetching with projectIds:', this.projectIds);
const ids = this.projectIds
.split(',')
.map((id) => id.trim())
.filter((id) => id);
console.log('[EmbeddingViewer] Parsed project IDs to fetch:', ids);
this.isLoading = true;
try {
const result = await getEmbeddingsForProjects(ids);
console.log('[EmbeddingViewer] Received result from API:', result);
const allEmbeddings = result?.data || [];
const validData = allEmbeddings.filter(
(item) =>
item &&
item.embedding &&
Array.isArray(item.embedding) &&
item.embedding.length > 0
);
if (validData.length === 0) {
this.scatterplotData = [];
this.isLoading = false;
return;
}
// Generate color map for projects
const uniqueProjectIds = [
...new Set(validData.map((item) => item.project_id)),
];
const newProjectColorMap = new Map<string, [number, number, number]>();
uniqueProjectIds.forEach((id, index) => {
newProjectColorMap.set(id, COLOR_PALETTE[index % COLOR_PALETTE.length]);
});
this.projectColorMap = newProjectColorMap;
if (validData.length === 1) {
this.scatterplotData = [
{
position: [0, 0],
issueKey: validData[0].issue_id,
title: validData[0].issue_title,
createdAt: validData[0].issue_created_at,
projectId: validData[0].project_id,
},
];
console.log(
'[EmbeddingViewer] Final scatterplot data:',
this.scatterplotData
);
this.isLoading = false;
} else {
const vectors = validData.map((item) => item.embedding);
const perplexity = Math.max(
5,
Math.min(30, Math.floor(validData.length / 3))
);
const options = {
epsilon: 10,
perplexity: perplexity,
dim: 2,
};
this.tsneWorker = new Worker(
new URL('../workers/tsne.worker.ts', import.meta.url),
{ type: 'module' }
);
this.tsneWorker.onmessage = (e) => {
const output = e.data;
this.scatterplotData = validData.map((item, i) => ({
position: [output[i][0] * 5, output[i][1]],
issueKey: item.issue_id,
title: item.issue_title,
createdAt: item.issue_created_at,
projectId: item.project_id,
}));
console.log(
'[EmbeddingViewer] Final scatterplot data:',
this.scatterplotData
);
this.isLoading = false;
if (this.tsneWorker) {
this.tsneWorker.terminate();
this.tsneWorker = null;
}
};
this.tsneWorker.onerror = (e) => {
console.error('Error in t-SNE worker:', e);
this.scatterplotData = [];
this.isLoading = false;
if (this.tsneWorker) {
this.tsneWorker.terminate();
this.tsneWorker = null;
}
};
this.tsneWorker.postMessage({
vectors,
options,
iterations: TSNE_ITERATIONS,
});
}
} catch (error) {
console.error('Error fetching or visualizing embeddings:', error);
this.scatterplotData = [];
this.isLoading = false;
}
}
private renderLayers() {
if (!this.deck) return;
const layer = new ScatterplotLayer({
id: 'scatterplot-layer',
data: this.scatterplotData,
pickable: true,
opacity: 0.8,
stroked: true,
filled: true,
radiusScale: 6,
radiusMinPixels: 3,
radiusMaxPixels: 10,
lineWidthMinPixels: 1,
getPosition: (d: ScatterplotData) => d.position,
getFillColor: (d: ScatterplotData) =>
this.projectColorMap.get(d.projectId) || [128, 128, 128],
getLineColor: [255, 255, 255], // White outline
});
this.deck.setProps({ layers: [layer] });
}
private handleHover({ object, x, y }: { object: any; x: number; y: number }) {
const tooltip = this.shadowRoot?.querySelector('.tooltip') as HTMLElement;
if (!tooltip) return;
if (object) {
tooltip.style.display = 'block';
tooltip.style.left = `${x}px`;
tooltip.style.top = `${y}px`;
tooltip.innerHTML = `
<strong>${object.issueKey}</strong><br>
${object.title}<br>
<small>Created: ${new Date(object.createdAt).toLocaleDateString()}</small>
`;
} else {
tooltip.style.display = 'none';
}
}
render() {
return html`
<canvas id="deck-canvas" style="width: 100%; height: 100%;"></canvas>
<div class="tooltip"></div>
${when(
this.isLoading,
() => html`
<div class="loading-overlay">
<sl-spinner></sl-spinner>
<span>Loading embeddings...</span>
</div>
`
)}
`;
}
}