// Define interfaces export interface DotConfig { id: number; value: number; x: number; link?: string; // URL to navigate to when dot is clicked imageUrl?: string; // Image to display in tooltip title?: string; // Optional title for the tooltip description?: string; // Optional description for the tooltip } export interface Config { totalWidth: number; height: number; dotRadius: number; xUnitSize: number; tension: number; showGrid: boolean; tooltipWidth: number; tooltipHeight: number; } interface ControlPoints { x1: number; y1: number; x2: number; y2: number; } interface TooltipEdges { leftmost: number; rightmost: number; } export class ConnectedDotsVisualization { private config: Config; private dots: DotConfig[]; private preloadedImages: Map = new Map(); // DOM Elements private scrollContainer: HTMLElement; private svg: SVGElement; private gridGroup: SVGGElement; private curvePath: SVGPathElement; private dotsGroup: SVGGElement; private tooltipGroup: SVGGElement; // Active tooltip private activeTooltip: SVGElement | null = null; constructor(containerId: string, dots: DotConfig[], config?: Partial) { // Use the provided dots or empty array this.dots = dots || []; // Calculate the total width based on dots data const xUnitSize = config?.xUnitSize || 120; let calculatedWidth = 0; if (this.dots.length > 0) { // Find the minimum and maximum x values const minX = Math.min(...this.dots.map(dot => dot.x)); const maxX = Math.max(...this.dots.map(dot => dot.x)); // Calculate width based on the range of x values // Add padding on both sides (3 units on each side) calculatedWidth = (maxX - minX + 6) * xUnitSize; } else { calculatedWidth = 6 * xUnitSize; // Default width if no dots } // Default configuration this.config = { totalWidth: calculatedWidth, height: window.innerHeight, dotRadius: 6, xUnitSize: xUnitSize, tension: 0.5, showGrid: false, tooltipWidth: 128, tooltipHeight: 128, ...config }; // Initialize DOM elements this.scrollContainer = document.getElementById(containerId) as HTMLElement; // Create SVG elements this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); this.gridGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); this.curvePath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); this.dotsGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); this.tooltipGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); // Initialize the visualization this.addStyles(); this.initializeSVG(); this.setupEventListeners(); this.preloadImages(); this.render(); } private preloadImages(): void { // Extract all unique image URLs from dots const imageUrls: string[] = this.dots .filter(dot => dot.imageUrl) // biome-ignore lint/style/noNonNullAssertion: .map(dot => dot.imageUrl!) .filter((url, index, self) => self.indexOf(url) === index); // Remove duplicates // Create a loading indicator (optional) const loadingCount = {current: 0, total: imageUrls.length}; if (imageUrls.length > 0) { console.log(`Preloading ${imageUrls.length} images...`); } // Preload each image for (const url of imageUrls) { const img = new Image(); // Optional loading events img.onload = () => { loadingCount.current++; if (loadingCount.current === loadingCount.total) { console.log('All images preloaded successfully'); } }; img.onerror = () => { loadingCount.current++; console.error(`Failed to preload image: ${url}`); }; // Set src to start loading img.src = url; // Store in map for potential later use this.preloadedImages.set(url, img); }; } private addStyles(): void { // Add necessary styles for tooltips and interactions const styleId = 'connected-dots-styles'; if (!document.getElementById(styleId)) { const style = document.createElement('style'); style.id = styleId; style.textContent = ` .dot { transition: r 0.2s ease, fill 0.2s ease; cursor: pointer; } .dot:hover { fill: rgba(255, 255, 255, 0.9); filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.8)); } .dot-tooltip { pointer-events: none; opacity: 1; /* Always visible */ } .tooltip-img { width: 100%; height: 100%; object-fit: cover; border-radius: 4px; } `; document.head.appendChild(style); } } private initializeSVG(): void { // Configure SVG this.svg.setAttribute('width', `${this.config.totalWidth}`); this.svg.setAttribute('height', `${this.config.height}`); this.svg.style.overflow = 'visible'; this.scrollContainer.appendChild(this.svg); // Configure grid group this.gridGroup.classList.add('grid'); this.svg.appendChild(this.gridGroup); // Configure curve path this.curvePath.setAttribute('fill', 'none'); this.curvePath.setAttribute('stroke', 'white'); this.curvePath.setAttribute('stroke-width', '2'); this.curvePath.setAttribute('stroke-linecap', 'round'); this.curvePath.classList.add('curve-path'); this.svg.appendChild(this.curvePath); // Configure dots group this.svg.appendChild(this.dotsGroup); // Configure tooltip group (always on top) this.tooltipGroup.classList.add('tooltips'); this.svg.appendChild(this.tooltipGroup); } private setupEventListeners(): void { // Event listeners removed as the controls were removed } private getDotX(x: number): number { return (x + 3) * this.config.xUnitSize; } private getDotY(value: number): number { const centerY = this.config.height / 2; // Calculate raw Y position const rawY = centerY - (value / 3) * (this.config.height / 2 * 0.8); // Calculate minimum Y position to ensure tooltip fits const minY = this.config.tooltipHeight + 30; // tooltip height + some padding // Ensure Y is never less than minimum (never too high on screen) return Math.max(rawY, minY); } private calculateBezierControlPoints(dots: DotConfig[], index: number): ControlPoints { const tension = this.config.tension * 260; // Scale tension for Bezier curve // Get current point and its neighbors const curr = dots[index]; const next = dots[index + 1]; // Calculate control points for a smooth bezier curve const x1 = this.getDotX(curr.x) + tension; const y1 = this.getDotY(curr.value); const x2 = this.getDotX(next.x) - tension; const y2 = this.getDotY(next.value); return { x1, y1, x2, y2 }; } private generateBezierPath(): string { if (this.dots.length < 2) return ''; let path = `M ${this.getDotX(this.dots[0].x)} ${this.getDotY(this.dots[0].value)}`; for (let i = 0; i < this.dots.length - 1; i++) { const { x1, y1, x2, y2 } = this.calculateBezierControlPoints(this.dots, i); const nextX = this.getDotX(this.dots[i + 1].x); const nextY = this.getDotY(this.dots[i + 1].value); path += ` C ${x1} ${y1}, ${x2} ${y2}, ${nextX} ${nextY}`; } return path; } private drawGrid(): void { // Clear previous grid while (this.gridGroup.firstChild) { this.gridGroup.removeChild(this.gridGroup.firstChild); } if (!this.config.showGrid) return; // Horizontal grid lines for (const value of [-3, -2, -1, 0, 1, 2, 3]) { const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); line.setAttribute('x1', '0'); line.setAttribute('y1', this.getDotY(value).toString()); line.setAttribute('x2', this.config.totalWidth.toString()); line.setAttribute('y2', this.getDotY(value).toString()); line.setAttribute('stroke', 'rgba(219, 39, 119, 0.4)'); line.setAttribute('stroke-width', '1'); this.gridGroup.appendChild(line); const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); text.setAttribute('x', '10'); text.setAttribute('y', (this.getDotY(value) + 4).toString()); text.setAttribute('fill', 'rgba(219, 39, 119, 0.8)'); text.setAttribute('font-size', '12'); text.textContent = value.toString(); this.gridGroup.appendChild(text); } // Vertical grid lines const numVertLines = Math.ceil(this.config.totalWidth / this.config.xUnitSize); for (let i = 0; i < numVertLines; i++) { const x = i * this.config.xUnitSize; const xValue = i - 3; // Starting from -3 const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); line.setAttribute('x1', x.toString()); line.setAttribute('y1', '0'); line.setAttribute('x2', x.toString()); line.setAttribute('y2', this.config.height.toString()); line.setAttribute('stroke', 'rgba(219, 39, 119, 0.4)'); line.setAttribute('stroke-width', '1'); this.gridGroup.appendChild(line); if (xValue !== 0) { const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); text.setAttribute('x', x.toString()); text.setAttribute('y', (this.config.height / 2 + 20).toString()); text.setAttribute('fill', 'rgba(219, 39, 119, 0.8)'); text.setAttribute('font-size', '12'); text.setAttribute('text-anchor', 'middle'); text.textContent = xValue.toString(); this.gridGroup.appendChild(text); } } } private createTooltip(dot: DotConfig, x: number, y: number): SVGElement { const tooltip = document.createElementNS('http://www.w3.org/2000/svg', 'g'); tooltip.classList.add('dot-tooltip'); tooltip.setAttribute('data-dot-id', dot.id.toString()); // Calculate tooltip position const tooltipWidth = this.config.tooltipWidth; const tooltipHeight = this.config.tooltipHeight; const tooltipX = x - tooltipWidth / 2; // Calculate tooltip Y position, ensuring it stays within the container let tooltipY = y - tooltipHeight - 20; // Position above the dot with some spacing // Ensure tooltip doesn't go above the container tooltipY = Math.max(tooltipY, 10); // Keep at least 10px from the top // Background rectangle const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); bg.setAttribute('x', tooltipX.toString()); bg.setAttribute('y', tooltipY.toString()); bg.setAttribute('width', tooltipWidth.toString()); bg.setAttribute('height', tooltipHeight.toString()); bg.setAttribute('rx', '5'); bg.setAttribute('ry', '5'); bg.setAttribute('fill', 'rgba(0, 0, 0, 0.8)'); tooltip.appendChild(bg); // Tooltip arrow (pointing to the dot) const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'path'); arrow.setAttribute('d', `M ${x} ${tooltipY + tooltipHeight} L ${x - 10} ${tooltipY + tooltipHeight - 10} L ${x + 10} ${tooltipY + tooltipHeight - 10} Z`); arrow.setAttribute('fill', 'rgba(0, 0, 0, 0.8)'); tooltip.appendChild(arrow); // Image (if provided) // Image (if provided) if (dot.imageUrl) { const imgContainer = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject'); imgContainer.setAttribute('x', (tooltipX + 10).toString()); imgContainer.setAttribute('y', (tooltipY + 10).toString()); // Set width and height to the same value for a square aspect const imageSize = Math.min((tooltipWidth - 20), (tooltipHeight / 2)); imgContainer.setAttribute('width', imageSize.toString()); imgContainer.setAttribute('height', imageSize.toString()); const img = document.createElement('img'); img.src = dot.imageUrl; img.className = 'tooltip-img'; img.style.width = '100%'; img.style.height = '100%'; img.style.objectFit = 'cover'; // Ensure the image covers the space while maintaining aspect ratio imgContainer.appendChild(img); tooltip.appendChild(imgContainer); } // Title (if provided) if (dot.title) { const title = document.createElementNS('http://www.w3.org/2000/svg', 'text'); title.setAttribute('x', (tooltipX + 10).toString()); title.setAttribute('y', dot.imageUrl ? (tooltipY + tooltipHeight / 2 + 26).toString() : (tooltipY + 25).toString()); title.setAttribute('fill', 'white'); title.setAttribute('font-size', '14'); title.setAttribute('font-weight', 'bold'); title.textContent = dot.title; tooltip.appendChild(title); } // Description (if provided) if (dot.description) { const descriptionFO = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject'); descriptionFO.setAttribute('x', (tooltipX + 10).toString()); descriptionFO.setAttribute('y', dot.imageUrl ? (tooltipY + tooltipHeight / 2 + 32).toString() : dot.title ? (tooltipY + 35).toString() : (tooltipY + 15).toString()); descriptionFO.setAttribute('width', (tooltipWidth - 20).toString()); descriptionFO.setAttribute('height', (tooltipHeight / 2 - 10).toString()); const descriptionDiv = document.createElement('div'); descriptionDiv.style.color = 'white'; descriptionDiv.style.fontSize = '12px'; descriptionDiv.style.overflow = 'hidden'; descriptionDiv.textContent = dot.description; descriptionFO.appendChild(descriptionDiv); tooltip.appendChild(descriptionFO); } return tooltip; } private showTooltip(dot: DotConfig, x: number, y: number): void { // Create tooltip const tooltip = this.createTooltip(dot, x, y); this.tooltipGroup.appendChild(tooltip); this.activeTooltip = tooltip; } private hideTooltip(): void { // This method is kept for compatibility but doesn't hide tooltips anymore } private drawCurve(): void { const pathData = this.generateBezierPath(); this.curvePath.setAttribute('d', pathData); } private calculateTooltipEdges(): TooltipEdges { let leftmost = 0; let rightmost = 0; let firstTooltipFound = false; // If no dots with tooltips, return default values if (this.dots.length === 0) { return { leftmost: 0, rightmost: this.config.totalWidth }; } // Calculate the leftmost and rightmost edges of all tooltips for (const dot of this.dots) { // Skip dots without tooltip content if (!dot.imageUrl && !dot.title && !dot.description) { continue; } const x = this.getDotX(dot.x); const tooltipWidth = this.config.tooltipWidth; const tooltipX = x - tooltipWidth / 2; if (!firstTooltipFound) { leftmost = tooltipX; rightmost = tooltipX + tooltipWidth; firstTooltipFound = true; } else { // Update leftmost and rightmost values leftmost = Math.min(leftmost, tooltipX); rightmost = Math.max(rightmost, tooltipX + tooltipWidth); } } // If no dots with tooltips were found, use default values if (!firstTooltipFound) { return { leftmost: 0, rightmost: this.config.totalWidth }; } return { leftmost, rightmost }; } private drawDots(): void { // Clear previous dots while (this.dotsGroup.firstChild) { this.dotsGroup.removeChild(this.dotsGroup.firstChild); } // Clear previous tooltips while (this.tooltipGroup.firstChild) { this.tooltipGroup.removeChild(this.tooltipGroup.firstChild); } for (const dot of this.dots) { const x = this.getDotX(dot.x); const y = this.getDotY(dot.value); const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); circle.setAttribute('cx', x.toString()); circle.setAttribute('cy', y.toString()); circle.setAttribute('r', this.config.dotRadius.toString()); circle.setAttribute('fill', 'white'); circle.setAttribute('data-dot-id', dot.id.toString()); circle.classList.add('dot'); // Always show tooltip if it has content if (dot.imageUrl || dot.title || dot.description) { this.showTooltip(dot, x, y); } // Click event for navigation if (dot.link) { circle.addEventListener('click', () => { if (dot.link) { window.location.href = dot.link; } else { console.error('Dot has no link'); throw new Error('Dot has no link'); } }); } this.dotsGroup.appendChild(circle); }; } public render(): void { this.drawGrid(); this.drawCurve(); this.drawDots(); // Calculate tooltip edges and set SVG width const { leftmost, rightmost } = this.calculateTooltipEdges(); // Set the SVG width based on the rightmost tooltip edge if (rightmost > 0) { // Add some padding const padding = 40; this.config.totalWidth = rightmost + padding; this.svg.setAttribute('width', `${this.config.totalWidth}`); // Update grid width this.drawGrid(); } } // Public API methods for external use public updateDots(newDots: DotConfig[]): void { this.dots = newDots; // Initial width calculation based on dot positions (for grid) if (this.dots.length > 0) { // Find the minimum and maximum x values const minX = Math.min(...this.dots.map(dot => dot.x)); const maxX = Math.max(...this.dots.map(dot => dot.x)); // Calculate width based on the range of x values // Add padding on both sides (3 units on each side) this.config.totalWidth = (maxX - minX + 6) * this.config.xUnitSize; } // Render will calculate the tooltip edges and update the SVG width this.render(); } public updateConfig(newConfig: Partial): void { this.config = { ...this.config, ...newConfig }; this.render(); } public resize(): void { this.config.height = window.innerHeight; this.svg.setAttribute('height', `${this.config.height}`); this.render(); } }