thats-me/dot-line-system/.history/src/ConnectedDotsVisualization_20250515094215.ts
Kevin Adametz c62234e1ca docker setup
# Conflicts:
#	.gitignore
#	backend/vite.config.js
#	frontend/package-lock.json
2026-03-06 13:46:43 +01:00

545 lines
20 KiB
TypeScript

// 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<string, HTMLImageElement> = 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<Config>) {
// 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: <explanation>
.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 * 250; // 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)
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());
imgContainer.setAttribute('width', (tooltipWidth - 20).toString());
imgContainer.setAttribute('height', (tooltipHeight / 2).toString());
const img = document.createElement('img');
img.src = dot.imageUrl;
img.className = 'tooltip-img';
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<Config>): 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();
}
}