// Define interfaces export interface DotConfig { id: number; value: number; x: number; link?: string; // URL to navigate to when dot is clicked onClick?: () => void; // Function to call 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 || 100; 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; // Calculate the container height dynamically const containerHeight = this.scrollContainer.clientHeight || this.scrollContainer.offsetHeight || window.innerHeight; // Default configuration this.config = { totalWidth: calculatedWidth, height: containerHeight, // Use the calculated container height dotRadius: 6, xUnitSize: xUnitSize, tension: 0.5, showGrid: false, tooltipWidth: 128, tooltipHeight: 128, ...config, }; // 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 = ` // `; 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 / 1.95; // Calculate raw Y position // height of the amplitude const rawY = centerY - (value / 3) * ((this.config.height / 2) * 0.6); // Calculate minimum Y position to ensure tooltip fits const minY = this.config.tooltipHeight + 40; // 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 * 500; // Scale tension for Bezier curve Rundung Kurve // 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 dimensions and position const tooltipWidth = 128; // Base width for your tooltip const tooltipHeight = (4 / 3) * tooltipWidth; const tooltipX = x - tooltipWidth / 2; let tooltipY = y - tooltipHeight - 10; // Positioned above the dot tooltipY = Math.max(tooltipY, 10); // Ensure it doesn't go above the view // Create 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", "0"); // Rounded corners bg.classList.add("tooltip-background"); tooltip.appendChild(bg); // Create foreignObject for the content const contentContainer = document.createElementNS( "http://www.w3.org/2000/svg", "foreignObject" ); contentContainer.setAttribute("x", tooltipX.toString()); contentContainer.setAttribute("y", tooltipY.toString()); contentContainer.setAttribute("width", tooltipWidth.toString()); contentContainer.setAttribute("height", tooltipHeight.toString()); // Create a div to contain the content const div = document.createElement("div"); div.classList.add("tooltip-content"); // Add title if available if (dot.title) { const title = document.createElement("div"); title.textContent = dot.title; title.classList.add("tooltip-title"); div.appendChild(title); } // Add description if available if (dot.description) { const desc = document.createElement("div"); desc.textContent = dot.description; desc.classList.add("tooltip-description"); div.appendChild(desc); } // Add image if available // Create a container div const imageContainer = document.createElement("div"); imageContainer.classList.add("image_container"); // Add image_container class // Define a variable for handling case with or without link let imgWrapper: HTMLElement; // if (dot.imageUrl) { if (dot.link || dot.onClick) { const link = document.createElement("a"); if (dot.link) { link.href = dot.link; } else { link.href = "#"; // Prevent default href for onClick } link.target = "_self"; // Opens in the same window const imgElement = document.createElement("img"); imgElement.src = dot.imageUrl; imgElement.classList.add("tooltip-image"); // Append the image element to the link link.appendChild(imgElement); imgWrapper = link; // Use the link as the wrapper // Add the event listener to the link link.addEventListener("click", (e) => { if (dot.onClick) { e.preventDefault(); // Prevent default navigation dot.onClick(); } else if (dot.link) { window.location.href = dot.link; } else { console.error("Dot has no link or onClick handler"); throw new Error("Dot has no link or onClick handler"); } }); } else { const img = document.createElement("img"); img.src = dot.imageUrl; img.classList.add("tooltip-image"); imgWrapper = img; // Use the image directly as the wrapper } // } else { // console.error("Dot has no image URL"); // throw new Error("Dot has no image URL"); // } // Append imageWrapper to the container imageContainer.appendChild(imgWrapper); // Append the image container to the main div div.appendChild(imageContainer); const arrow = document.createElement("div"); arrow.classList.add("tooltip-arrow"); div.appendChild(arrow); // Append the arrow to the tooltip-content div contentContainer.appendChild(div); tooltip.appendChild(contentContainer); 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 or custom function if (dot.link || dot.onClick) { circle.addEventListener("click", () => { if (dot.onClick) { dot.onClick(); } else if (dot.link) { window.location.href = dot.link; } else { console.error("Dot has no link or onClick handler"); throw new Error("Dot has no link or onClick handler"); } }); } 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 { const containerHeight = this.scrollContainer.clientHeight || this.scrollContainer.offsetHeight || window.innerHeight; this.config.height = containerHeight; this.svg.setAttribute("height", `${this.config.height}`); this.render(); } }