// 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 // Create a rectangle for the tooltip with a 9:16 aspect ratio const bg = document.createElementNS("http://www.w3.org/2000/svg", "rect"); bg.setAttribute("x", tooltipX.toString()); bg.setAttribute("y", tooltipY.toString()); // Calculate width and height based on ratio const height = tooltipHeight; const width = (9 / 16) * height; // Set the width and height for a 9:16 aspect ratio bg.setAttribute("width", width.toString()); bg.setAttribute("height", height.toString()); // Remove any background fill bg.setAttribute("fill", "none"); // Optional: Adjust corner rounding if needed bg.setAttribute("rx", "5"); // You can set this to 0 for sharp corners bg.setAttribute("ry", "5"); tooltip.appendChild(bg); // Create a foreignObject for centering content const container = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject'); container.setAttribute('width', width.toString()); container.setAttribute('height', height.toString()); container.setAttribute('x', tooltipX.toString()); container.setAttribute('y', tooltipY.toString()); // Create a div with flexbox for centering content const div = document.createElement('div'); div.style.display = 'flex'; div.style.justifyContent = 'center'; // Center horizontally div.style.alignItems = 'center'; // Center vertically div.style.width = '100%'; div.style.height = '100%'; // 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) 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 ratio 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 = "calc(100% - 4px)"; img.style.height = "calc(100% - 4px)"; img.style.objectFit = "cover"; // Ensure the image covers the space while maintaining aspect ratio // Make the image circular and add a white border img.style.borderRadius = "50%"; // Makes the image round img.style.border = "2px solid white"; // Adds a 1px white border around the image 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("class", "title"); 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(); } }