Compare commits

...
Sign in to create a new pull request.

5 commits

Author SHA1 Message Date
965e2ac6ad On main: Backup 2026-03-06 13:43:19 +01:00
5e7133cbfe index on main: 0cb1db6 25-02-2025 2026-03-06 13:43:19 +01:00
0cb1db6aca 25-02-2025 2026-02-25 17:05:52 +01:00
5cd1d1a870 20-02-2026 2026-02-20 17:56:18 +01:00
8491d3e454 docker setup 2025-10-20 17:55:52 +02:00
639 changed files with 156635 additions and 2162 deletions

207
.devcontainer/README.md Normal file
View file

@ -0,0 +1,207 @@
# DevContainer Setup für Thats-Me
## Was ist ein DevContainer?
Ein DevContainer ist eine vollständige Entwicklungsumgebung in Docker. Cursor/VSCode läuft direkt im Container und Sie entwickeln mit allen Tools, die bereits vorinstalliert sind.
## Voraussetzungen
1. **Docker Desktop** muss laufen
2. **Traefik Proxy** als externes Netzwerk:
```bash
docker network create proxy
```
## DevContainer starten
1. **In Cursor/VSCode:**
- Drücken Sie `Cmd+Shift+P` (macOS) oder `Ctrl+Shift+P` (Windows/Linux)
- Wählen Sie: **"Dev Containers: Reopen in Container"**
- Warten Sie, bis alle Container gebaut und gestartet sind
2. **Beim ersten Start:**
- Der Laravel Container wird gebaut (kann einige Minuten dauern)
- Alle Services werden gestartet (MySQL, Redis, Mailpit, Quasar)
- Sie werden automatisch im Laravel Container eingeloggt
## Was passiert beim Start?
Der DevContainer startet folgende Services:
- **laravel.test** - Laravel Backend (Sie arbeiten in diesem Container)
- **mysql** - Datenbank
- **redis** - Cache/Queue
- **mailpit** - E-Mail Testing
- **quasar.app** - Frontend App (läuft automatisch)
## Arbeiten im Container
### Terminal
Das Terminal in Cursor/VSCode ist bereits im Container. Sie können direkt arbeiten:
```bash
# Sie sind im /var/www/html Verzeichnis (= ./backend vom Host)
php artisan migrate
php artisan serve
composer install
npm install
npm run dev
```
### Verfügbare Services
Im DevContainer können Sie direkt auf die anderen Services zugreifen:
```bash
# MySQL Verbindung
mysql -h mysql -u sail -p
# Passwort: password
# Redis
redis-cli -h redis
# Composer
composer install
composer update
# Artisan
php artisan migrate
php artisan tinker
```
## Zugriff von außerhalb
### Von Ihrem Browser (Host)
Die Ports werden automatisch weitergeleitet:
- **Laravel App:** http://localhost (Port 80)
- **Vite Dev Server:** http://localhost:5173
- **Mailpit Dashboard:** http://localhost:8025
- **Quasar App:** http://localhost:9000
### Mit Traefik
Wenn Traefik läuft, können Sie auch die Domains verwenden:
- https://thats-me.test
- https://portal.thats-me.test
- https://api.thats-me.test
- https://app.thats-me.test
- https://assets.thats-me.test
## Backend .env Konfiguration
Stellen Sie sicher, dass `backend/.env` folgende Werte hat:
```env
DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=thats-me
DB_USERNAME=sail
DB_PASSWORD=password
MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
REDIS_HOST=redis
REDIS_PORT=6379
```
## Vite Dev Server starten
```bash
# Im DevContainer Terminal
npm install
npm run dev
```
Vite läuft dann auf Port 5173 und ist verfügbar unter:
- http://localhost:5173 (vom Host)
- https://assets.thats-me.test (mit Traefik)
## Quasar App
Die Quasar App läuft automatisch in einem separaten Container und ist verfügbar unter:
- http://localhost:9000 (vom Host)
- https://app.thats-me.test (mit Traefik)
## Troubleshooting
### Container startet nicht
```bash
# Schließen Sie den DevContainer
# Öffnen Sie ein normales Terminal auf dem Host
docker-compose down -v
docker-compose build --no-cache
# Dann neu starten: "Dev Containers: Reopen in Container"
```
### "Dockerfile not found" Fehler
Stellen Sie sicher, dass Laravel Sail installiert ist:
```bash
cd backend
composer require laravel/sail --dev
```
### Permission-Probleme
Die User-IDs in `devcontainer.json` anpassen:
```json
"containerEnv": {
"WWWUSER": "1000", // Ihre User-ID
"WWWGROUP": "1000" // Ihre Group-ID
}
```
Finden Sie Ihre IDs mit:
```bash
id -u # User ID
id -g # Group ID
```
### Logs ansehen
```bash
# Im DevContainer Terminal
docker-compose logs -f
# Nur MySQL
docker-compose logs -f mysql
# Nur Quasar
docker-compose logs -f quasar.app
```
## DevContainer verlassen
1. Drücken Sie `Cmd+Shift+P` / `Ctrl+Shift+P`
2. Wählen Sie: **"Dev Containers: Reopen Folder Locally"**
## Container stoppen
Nach dem Verlassen des DevContainers:
```bash
docker-compose down
```
## Vorteile des DevContainers
✅ Alle arbeiten mit der gleichen Umgebung
✅ Keine lokale PHP/MySQL Installation nötig
✅ Automatische Service-Verwaltung
✅ Isolierte Entwicklungsumgebung
✅ Einfaches Onboarding für neue Entwickler

View file

@ -0,0 +1,99 @@
{
"name": "Thats-Me (Dev Container)",
// 1. DIES IST DER WICHTIGSTE TEIL:
// Wir verwenden Docker Compose für alle Services
"dockerComposeFile": [
"../docker-compose.yml"
],
"service": "laravel.test",
// 3. WIR DEFINIEREN DEN ARBEITSBEREICH:
// Wir mounten das gesamte Projekt, damit Sie Backend UND Frontend sehen
"workspaceFolder": "/workspace",
// 4. WIR LEGEN DEN BENUTZER FEST:
// Laravel Sail führt Befehle standardmäßig als 'sail'-Benutzer aus, um Berechtigungsprobleme zu vermeiden.
"remoteUser": "sail",
// 5. ZUSÄTZLICHE ENTWICKLER-TOOLS (FEATURES):
// Features werden über postCreateCommand installiert um Kompatibilitätsprobleme zu vermeiden
"features": {},
// 6. BEFEHLE NACH DEM ERSTELLEN:
// Installiert nur die Tools die ohne Root-Rechte funktionieren
//"postCreateCommand": "composer install --no-interaction --prefer-dist --optimize-autoloader",
// 7. EDITOR-ANPASSUNGEN (Optional, aber sehr empfohlen):
"customizations": {
"vscode": {
"extensions": [
"bmewburn.vscode-intelephense-client",
"onecentlin.laravel-blade",
"shufo.vscode-blade-formatter",
"bradlc.vscode-tailwindcss",
"Anthropic.claude-code",
"adrianwilczynski.alpine-js-intellisense",
"onecentlin.laravel-extension-pack",
"cierra.livewire-vscode",
"Vue.volar"
]
}
},
// 8. ZU STARTENDE DIENSTE:
// Legt fest, welche Dienste aus der docker-compose.yml gestartet werden sollen.
"runServices": [
"laravel.test",
"quasar.app",
"mysql",
"redis",
"mailpit"
],
// 9. ZUSÄTZLICHE KONFIGURATION:
// Umgebungsvariablen für den DevContainer
"containerEnv": {
"WWWUSER": "501",
"WWWGROUP": "20",
"LARAVEL_SAIL": "1"
},
// 9b. MOUNTS:
// Mountet das gesamte Projekt (Root) nach /workspace, damit Sie Backend UND Frontend sehen
"mounts": [
"source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached"
],
// 10. FORWARD PORTS:
// Ports die automatisch weitergeleitet werden sollen (Container-Ports)
"forwardPorts": [
80,
5173,
3306,
6379,
1025,
8025,
9000
],
"portsAttributes": {
"80": {
"label": "Laravel App (HTTP)",
"onAutoForward": "notify"
},
"5173": {
"label": "Vite Dev Server",
"onAutoForward": "notify"
},
"3306": {
"label": "MySQL",
"onAutoForward": "silent"
},
"6379": {
"label": "Redis",
"onAutoForward": "silent"
},
"8025": {
"label": "Mailpit Dashboard",
"onAutoForward": "notify"
},
"1025": {
"label": "Mailpit SMTP",
"onAutoForward": "silent"
},
"9000": {
"label": "Quasar App",
"onAutoForward": "notify"
}
}
}

76
.gitignore vendored
View file

@ -1,24 +1,58 @@
# Laravel
/.phpunit.cache
/node_modules
/public/build
/public/hot
/public/storage
/public/vendor
/storage/*.key
/storage/app
/storage/framework
/storage/language
/storage/logs
/storage/pail
/vendor
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
Homestead.json
Homestead.yaml
auth.json
npm-debug.log
yarn-error.log
# IDEs & Editors
/.fleet
/.idea
/.nova
/.vscode
/.zed
.claude/
.cursor/
# macOS
.DS_Store
node_modules
/dist
.AppleDouble
.LSOverride
._*
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
Icon
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
/.history
# Project specific
_static/
_work/
_storage/

27
.mcp.json Normal file
View file

@ -0,0 +1,27 @@
{
"mcpServers": {
"laravel-boost": {
"command": "sh",
"args": [
"-c",
"cd /workspace/backend && php artisan boost:mcp"
]
},
"context7": {
"command": "npx",
"args": [
"-y",
"@upstash/context7-mcp",
"--api-key",
"ctx7sk-119cd4ab-8983-4229-8702-e84c59c34fc9"
]
},
"sequential-thinking": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-sequential-thinking"
]
}
}
}

90
CLAUDE.md Normal file
View file

@ -0,0 +1,90 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
"That's Me" is a full-stack offline-first web application with a Quasar/Vue.js 3 frontend and a Laravel 12 backend, orchestrated via Docker Compose with Traefik reverse proxy.
## Architecture
The project is a monorepo with two independent applications:
- **`frontend/`** — Quasar v2 SPA (Vue.js 3, Pinia, Vite). Serves the user-facing app at `app.thats-me.test`. Designed as offline-first with IndexedDB (via Dexie.js) for local storage and sync queue.
- **`backend/`** — Laravel 12 (PHP 8.4). Serves multiple roles via separate domains:
- `thats-me.test` — Public landing page (Blade + Tailwind + Flux UI)
- `portal.thats-me.test` — Admin panel (Livewire/Volt + Tailwind + Flux UI)
- `api.thats-me.test` — REST API for the Quasar frontend (OAuth2 via Laravel Passport)
- `assets.thats-me.test` — Vite dev server (HMR)
Data flow: User interaction → Vue component → Pinia store → IndexedDB → Sync queue → Laravel REST API → MySQL / Synology C2 Object Storage (for multimedia).
## Development Environment (Docker)
Requires Docker Desktop and a Traefik proxy network (`docker network create proxy`). Add domains to `/etc/hosts` pointing to `127.0.0.1`.
```bash
# Start all services
docker-compose up -d
# Access containers
docker-compose exec laravel.test bash # Laravel (PHP)
docker-compose exec quasar.app sh # Quasar (Node)
docker-compose exec mysql mysql -u sail -p # MySQL (password: password)
```
### Services & Ports
| Service | Port | Purpose |
|----------------|-------|----------------------------|
| quasar.app | 9000 | Quasar frontend dev server |
| mysql | 33070 | MySQL database |
| mailpit | 8028 | Email testing dashboard |
| redis | 6383 | Cache & queue |
| laravel.test | 5180 | Vite dev server (HMR) |
## Common Commands
### Backend (run inside `laravel.test` container or prefix with `docker-compose exec laravel.test`)
```bash
composer dev # Full dev environment (server + queue + pail + npm dev)
php artisan serve # Laravel server only
php artisan test --compact # Run all tests (Pest v3)
php artisan test --compact --filter=testName # Run specific test
vendor/bin/pint --dirty --format agent # Format code (Laravel Pint)
php artisan migrate # Run database migrations
php artisan db:seed # Seed database
npm run build # Build backend assets with Vite
```
### Frontend (run inside `quasar.app` container or from `frontend/` directory)
```bash
npm run dev # Quasar dev server with HMR
npm run build # Production build
npm run lint # ESLint check
npm run format # Prettier formatting
```
## Backend Conventions
The `backend/CLAUDE.md` file contains comprehensive Laravel Boost guidelines that must be followed. Key points:
- **PHP 8.4** with constructor property promotion, explicit return types, curly braces for all control structures
- **Testing**: Every change must be tested with Pest v3. Run `php artisan test --compact` with filter for targeted runs. Create tests with `php artisan make:test --pest {name}`.
- **Code formatting**: Always run `vendor/bin/pint --dirty --format agent` before finalizing changes.
- **Artisan generators**: Use `php artisan make:*` commands with `--no-interaction` to create files (controllers, models, migrations, etc.)
- **Database**: Prefer Eloquent over raw queries. Use `Model::query()` instead of `DB::`. Use eager loading to prevent N+1.
- **Config**: Never use `env()` outside config files; use `config()` instead.
- **Admin Panel UI**: Livewire + Volt (single-file components) styled with Tailwind CSS v4 + Flux UI (`<flux:*>` components)
- **Laravel Boost MCP**: Use `search-docs`, `tinker`, `database-query`, `database-schema`, and `list-artisan-commands` tools when available.
## Frontend Conventions
- **State management**: Pinia stores in `frontend/src/stores/`
- **Routing**: Vue Router with hash mode, config in `frontend/src/router/`
- **UI framework**: Quasar components (preferred over custom HTML). Tailwind CSS only for specific customizations.
- **Animation**: Anime.js for the "LifeWave" SVG visualization
- **Build target**: ES2022, Firefox 115+, Chrome 115+, Safari 14+
- **Dev server host**: `app.thats-me.test` on port 9000

119
DEV-NOTES.md Normal file
View file

@ -0,0 +1,119 @@
# Dev Notes – Stand 25.02.2026
## node_modules-Konflikt: Mac ↔ Docker ↔ Cursor (gelöst)
### Ausgangslage
Die Quasar App läuft in drei Kontexten parallel:
| Kontext | Pfad | Plattform |
|---|---|---|
| **`quasar.app` Docker-Service** | Docker Volume (isoliert) | Linux ARM64 |
| **Cursor IDE Terminal** | `/workspace/frontend/node_modules` | Linux ARM64 |
| **Mac Terminal** | `~/Sites/thats-me.local/frontend/node_modules` | macOS ARM64 |
Der `frontend/`-Ordner ist überall **derselbe** (gemountetes Host-Filesystem). Rollup und andere native Pakete benötigen plattformspezifische Binaries – ein `npm install` auf einer Seite überschrieb bisher die Binaries der anderen.
---
### Fix 1 – Docker Volume für `quasar.app` (docker-compose.yml)
Der `quasar.app`-Service nutzt ein eigenes benanntes Docker-Volume für `node_modules`. Dadurch bleibt sein Linux-`node_modules` vollständig vom Host isoliert:
```yaml
# quasar.app Service:
volumes:
- './frontend:/app'
- 'quasar-node-modules:/app/node_modules' # überschattet Host-Ordner
# volumes-Block:
volumes:
quasar-node-modules:
driver: local
```
Der `quasar.app`-Container führt beim Start automatisch `npm install && npm run dev` aus (in sein isoliertes Volume). Er ist damit **völlig unabhängig** vom Host-`node_modules`.
---
### Fix 2 – Rollup-Binaries als `optionalDependencies` (package.json)
Das Cursor-Terminal und der Mac teilen denselben `node_modules`-Ordner. Um den gegenseitigen Konflikt abzumildern, wurden alle relevanten Rollup-Plattform-Binaries explizit als `optionalDependencies` eingetragen:
```json
"optionalDependencies": {
"@rollup/rollup-darwin-arm64": "^4.0.0",
"@rollup/rollup-linux-arm64-gnu": "^4.0.0",
"@rollup/rollup-linux-x64-gnu": "^4.0.0"
}
```
npm installiert davon nur die zur aktuellen Plattform passenden, schlägt aber nie fehl wenn eine fehlt.
---
### Fix 3 – `npm run dev` repariert sich selbst (package.json)
Das `dev`-Script führt vor dem Start automatisch `npm install` aus:
```json
"dev": "npm install && quasar dev"
```
Damit werden nach einem Mac-`npm install` die Linux-Binaries im Cursor-Terminal beim nächsten `npm run dev` automatisch nachgezogen.
---
### Wichtig: Cursor Terminal ≠ `quasar.app`-Container
Das Cursor IDE Terminal hat **keinen** Zugriff auf das isolierte Docker-Volume des `quasar.app`-Services. Für den Web-Dev-Server gilt:
- **Empfohlen:** `docker-compose up -d quasar.app` → App läuft unter `app.thats-me.test` (isoliertes Volume, kein Konflikt)
- **Alternativ:** `npm run dev` direkt im Cursor-Terminal (teilt node_modules mit dem Mac, aber Fix 3 gleicht das aus)
---
### Workflow: iOS-Build auf dem Mac
Nach einem `npm install` im Linux-Kontext (Cursor oder Docker) müssen auf dem Mac die node_modules neu installiert werden:
```bash
cd ~/Sites/thats-me.local/frontend
rm -rf node_modules && npm install
npx quasar build -m capacitor -T ios --ide
```
---
## Allgemeiner Projekt-Stand
- **Backend:** Laravel 12, API-Routes unter `api.thats-me.test`, OAuth2 via Passport
- **Frontend:** Quasar v2 SPA, offline-first mit Dexie.js (IndexedDB)
- **Mobile:** Capacitor für iOS/Android (in `frontend/src-capacitor/`)
- **Neue Komponenten:**
- `AppSettingsModal.vue`, `ModalCard.vue`, `UserMenu.vue`, `ZoomControl.vue`
- `frontend/src/composables/`, `frontend/src/db/`, `frontend/src/services/`
- Backend API-Controller + Requests + Resources in `backend/app/Http/`
- Event-Model + Migrations + Factory
---
## Wichtige Domains (lokale Entwicklung)
| Domain | Zweck |
|---|---|
| `app.thats-me.test` | Quasar Frontend (Quasar Dev-Server) |
| `api.thats-me.test` | Laravel REST API |
| `portal.thats-me.test` | Admin Panel |
| `thats-me.test` | Landingpage |
| `assets.thats-me.test` | Vite HMR (Laravel Backend Assets) |
## Services & Ports
| Service | Port | Purpose |
|---|---|---|
| `quasar.app` | 9000 | Quasar Frontend Dev-Server |
| `mysql` | 33070 | MySQL Datenbank |
| `mailpit` | 8028 | E-Mail Testing Dashboard |
| `redis` | 6383 | Cache & Queue |
| `laravel.test` | 5180 | Vite Dev-Server (HMR) |

296
DOCKER-SETUP.md Normal file
View file

@ -0,0 +1,296 @@
# Docker Setup für Thats-Me Projekt
## Übersicht
Dieses Projekt verwendet Docker mit Laravel Sail für das Backend und einen Node-Container für die Quasar Frontend App.
### Services
- **laravel.test** - Laravel Backend (PHP 8.4)
- **quasar.app** - Quasar Frontend App (Node 20)
- **mysql** - MySQL 8.0 Database
- **mailpit** - E-Mail Testing Tool
- **redis** - Cache & Queue Service
### Domains
Das Setup konfiguriert 4 Domains über Traefik:
1. **thats-me.test** - Laravel Webseite/Landingpage
2. **portal.thats-me.test** - Laravel Admin Panel
3. **api.thats-me.test** - Laravel API für Quasar App
4. **app.thats-me.test** - Quasar Frontend App
Zusätzlich:
- **assets.thats-me.test** - Vite Dev Server für Laravel Assets
## Voraussetzungen
1. **Docker Desktop** installiert
2. **Traefik Proxy** muss als externes Netzwerk verfügbar sein:
```bash
docker network create proxy
```
3. **Laravel Sail** muss im Backend installiert sein:
```bash
cd backend
composer require laravel/sail --dev
```
## Installation
### 1. Environment Dateien einrichten
**Root .env Datei:**
```bash
cp .env.docker .env
```
**Backend .env Datei:**
```bash
cd backend
cp .env.example .env
php artisan key:generate
```
Bearbeite `backend/.env` und stelle sicher, dass diese Einstellungen gesetzt sind:
```env
DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=thats-me
DB_USERNAME=sail
DB_PASSWORD=password
MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
REDIS_HOST=redis
REDIS_PORT=6379
```
### 2. Hosts-Datei konfigurieren
Füge folgende Einträge zu deiner `/etc/hosts` Datei hinzu:
```
127.0.0.1 thats-me.test
127.0.0.1 portal.thats-me.test
127.0.0.1 api.thats-me.test
127.0.0.1 app.thats-me.test
127.0.0.1 assets.thats-me.test
```
### 3. Docker Container starten
```bash
docker-compose up -d
```
### 4. Laravel Installation abschließen
Beim ersten Start:
```bash
# In den Laravel Container einsteigen
docker-compose exec laravel.test bash
# Composer Dependencies installieren
composer install
# Datenbank migrieren
php artisan migrate
# Optional: Seeder ausführen
php artisan db:seed
```
### 5. Frontend Dependencies installieren
Der Quasar Container installiert automatisch die Dependencies beim Start.
Falls manuell nötig:
```bash
docker-compose exec quasar.app npm install
```
## Verwendung
### Container starten
```bash
docker-compose up -d
```
### Container stoppen
```bash
docker-compose down
```
### Logs ansehen
```bash
# Alle Services
docker-compose logs -f
# Nur Laravel
docker-compose logs -f laravel.test
# Nur Quasar
docker-compose logs -f quasar.app
```
### In Container einsteigen
**Laravel:**
```bash
docker-compose exec laravel.test bash
```
**Quasar:**
```bash
docker-compose exec quasar.app sh
```
**MySQL:**
```bash
docker-compose exec mysql mysql -u sail -p
# Passwort: password
```
### Artisan Commands
```bash
docker-compose exec laravel.test php artisan migrate
docker-compose exec laravel.test php artisan cache:clear
docker-compose exec laravel.test php artisan queue:work
```
### NPM Commands (Frontend)
```bash
docker-compose exec quasar.app npm run dev
docker-compose exec quasar.app npm run build
```
## Zugriff auf die Anwendung
### Mit Traefik (empfohlen)
- **Hauptwebseite:** https://thats-me.test
- **Admin Portal:** https://portal.thats-me.test
- **API:** https://api.thats-me.test
- **Frontend App:** https://app.thats-me.test
- **Vite Assets:** https://assets.thats-me.test
### Direkt über Ports
- **Laravel App:** http://localhost (Port 80 über Traefik)
- **Vite Dev Server:** http://localhost:5179 (Host) → 5173 (Container)
- **Quasar App:** http://localhost:9000
- **Mailpit Dashboard:** http://localhost:8028
- **MySQL:** localhost:33070
- **Redis:** localhost:6383
## Vite Development Server
Um den Vite Dev Server für Laravel zu starten:
```bash
docker-compose exec laravel.test npm install
docker-compose exec laravel.test npm run dev
```
Dann ist HMR (Hot Module Replacement) unter https://assets.thats-me.test verfügbar.
## Troubleshooting
### Proxy-Netzwerk existiert nicht
```bash
docker network create proxy
```
### Port-Konflikte
Ändere die Ports in der `.env` Datei:
```env
FORWARD_DB_PORT=33071
FORWARD_MAILPIT_DASHBOARD_PORT=8029
QUASAR_PORT=9001
VITE_PORT=5180
```
### Permission-Probleme
```bash
# User/Group IDs in .env anpassen
WWWUSER=1000
WWWGROUP=1000
```
### Container neu bauen
```bash
docker-compose down -v
docker-compose build --no-cache
docker-compose up -d
```
### Quasar startet nicht
```bash
# Manuell im Container starten
docker-compose exec quasar.app sh
cd /app
npm install
npm run dev
```
## Entwicklung ohne Traefik
Falls Sie kein Traefik haben, können Sie die Container auch direkt über Ports erreichen:
1. Entfernen Sie die `labels` Sektion aus `docker-compose.yml`
2. Aktivieren Sie den Port-Mapping für Laravel:
```yaml
ports:
- "${APP_PORT:-80}:80"
- "${VITE_PORT:-5173}:5173"
```
3. Zugriff dann über:
- Laravel: http://localhost
- Vite: http://localhost:5173
- Quasar: http://localhost:9000
## Nützliche Befehle
```bash
# Container Status
docker-compose ps
# Container neu starten
docker-compose restart
# Bestimmten Service neu starten
docker-compose restart laravel.test
# Container und Volumes löschen
docker-compose down -v
# Logs von heute
docker-compose logs --since 24h
# Ressourcen-Nutzung
docker stats
```

View file

@ -2,6 +2,13 @@
Eine kurze Beschreibung, was dieses Projekt macht (ein oder zwei Sätze).
## Domains auf dem Testserver
app.thats-me.test = frontend Quasar APP
portal.thats-me.test = backend Laravel Admin Panel mit Tailwind CSS + FluxUI
thats-me.test = backend Laravel Webseite / Landinpage mit Tailwind CSS + FluxUI
api.thats-me.test = backend Laravel API für Quasar APP
## Inhaltsverzeichnis
- [Start des Projekts](#start)
@ -93,4 +100,4 @@ Erkläre, wie andere zum Projekt beitragen können. Gibt es Richtlinien für Pul
Gib an, unter welcher Lizenz das Projekt veröffentlicht wird. Zum Beispiel:
Dieses Projekt ist unter der MIT-Lizenz lizenziert - siehe die [LICENSE.md](LICENSE.md)-Datei für Details (falls vorhanden).
Dieses Projekt ist unter der MIT-Lizenz lizenziert - siehe die [LICENSE.md](LICENSE.md)-Datei für Details (falls vorhanden).

11
backend/.mcp.json Normal file
View file

@ -0,0 +1,11 @@
{
"mcpServers": {
"laravel-boost": {
"command": "php",
"args": [
"artisan",
"boost:mcp"
]
}
}
}

269
backend/AGENTS.md Normal file
View file

@ -0,0 +1,269 @@
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.13
- laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0
- livewire/flux (FLUXUI_FREE) - v2
- livewire/livewire (LIVEWIRE) - v4
- livewire/volt (VOLT) - v1
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- pestphp/pest (PEST) - v3
- phpunit/phpunit (PHPUNIT) - v11
- tailwindcss (TAILWINDCSS) - v4
## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `fluxui-development` — Develops UIs with Flux UI Free components. Activates when creating buttons, forms, modals, inputs, dropdowns, checkboxes, or UI components; replacing HTML form elements with Flux; working with flux: components; or when the user mentions Flux, component library, UI components, form fields, or asks about available Flux components.
- `volt-development` — Develops single-file Livewire components with Volt. Activates when creating Volt components, converting Livewire to Volt, working with @volt directive, functional or class-based Volt APIs; or when the user mentions Volt, single-file components, functional Livewire, or inline component logic in Blade files.
- `pest-testing` — Tests applications using the Pest 3 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, architecture testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works.
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules ===
# Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
=== php rules ===
# PHP
- Always use curly braces for control structures, even for single-line bodies.
## Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- `public function __construct(public GitHub $github) { }`
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
## Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<!-- Explicit Return Types and Method Params -->
```php
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
```
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
## PHPDoc Blocks
- Add useful array shape type definitions when appropriate.
=== tests rules ===
# Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
=== laravel/core rules ===
# Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `php artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
## Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
## Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
## Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
## Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
## Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
# Laravel 12
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
## Laravel 12 Structure
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
## Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== fluxui-free/core rules ===
# Flux UI Free
- Flux UI is the official Livewire component library. This project uses the free edition, which includes all free components and variants but not Pro components.
- Use `<flux:*>` components when available; they are the recommended way to build Livewire interfaces.
- IMPORTANT: Activate `fluxui-development` when working with Flux UI components.
=== volt/core rules ===
# Livewire Volt
- Single-file Livewire components: PHP logic and Blade templates in one file.
- Always check existing Volt components to determine functional vs class-based style.
- IMPORTANT: Always use `search-docs` tool for version-specific Volt documentation and updated code examples.
- IMPORTANT: Activate `volt-development` every time you're working with a Volt or single-file component-related task.
=== pint/core rules ===
# Laravel Pint Code Formatter
- You must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
=== pest/core rules ===
## Pest
- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`.
- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`.
- Do NOT delete tests without approval.
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
=== tailwindcss/core rules ===
# Tailwind CSS
- Always use existing Tailwind conventions; check project patterns before adding new ones.
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
</laravel-boost-guidelines>

269
backend/CLAUDE.md Normal file
View file

@ -0,0 +1,269 @@
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.13
- laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0
- livewire/flux (FLUXUI_FREE) - v2
- livewire/livewire (LIVEWIRE) - v4
- livewire/volt (VOLT) - v1
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- pestphp/pest (PEST) - v3
- phpunit/phpunit (PHPUNIT) - v11
- tailwindcss (TAILWINDCSS) - v4
## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `fluxui-development` — Develops UIs with Flux UI Free components. Activates when creating buttons, forms, modals, inputs, dropdowns, checkboxes, or UI components; replacing HTML form elements with Flux; working with flux: components; or when the user mentions Flux, component library, UI components, form fields, or asks about available Flux components.
- `volt-development` — Develops single-file Livewire components with Volt. Activates when creating Volt components, converting Livewire to Volt, working with @volt directive, functional or class-based Volt APIs; or when the user mentions Volt, single-file components, functional Livewire, or inline component logic in Blade files.
- `pest-testing` — Tests applications using the Pest 3 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, architecture testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works.
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules ===
# Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
=== php rules ===
# PHP
- Always use curly braces for control structures, even for single-line bodies.
## Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- `public function __construct(public GitHub $github) { }`
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
## Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<!-- Explicit Return Types and Method Params -->
```php
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
```
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
## PHPDoc Blocks
- Add useful array shape type definitions when appropriate.
=== tests rules ===
# Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
=== laravel/core rules ===
# Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `php artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
## Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
## Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
## Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
## Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
## Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
# Laravel 12
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
## Laravel 12 Structure
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
## Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== fluxui-free/core rules ===
# Flux UI Free
- Flux UI is the official Livewire component library. This project uses the free edition, which includes all free components and variants but not Pro components.
- Use `<flux:*>` components when available; they are the recommended way to build Livewire interfaces.
- IMPORTANT: Activate `fluxui-development` when working with Flux UI components.
=== volt/core rules ===
# Livewire Volt
- Single-file Livewire components: PHP logic and Blade templates in one file.
- Always check existing Volt components to determine functional vs class-based style.
- IMPORTANT: Always use `search-docs` tool for version-specific Volt documentation and updated code examples.
- IMPORTANT: Activate `volt-development` every time you're working with a Volt or single-file component-related task.
=== pint/core rules ===
# Laravel Pint Code Formatter
- You must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
=== pest/core rules ===
## Pest
- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`.
- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`.
- Do NOT delete tests without approval.
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
=== tailwindcss/core rules ===
# Tailwind CSS
- Always use existing Tailwind conventions; check project patterns before adding new ones.
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
</laravel-boost-guidelines>

View file

@ -0,0 +1,199 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreEventRequest;
use App\Http\Requests\UpdateEventRequest;
use App\Http\Resources\EventResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class EventController extends Controller
{
/**
* GET /api/events
* Cursor-based pagination by date. Supports ?since=ISO for delta sync.
*/
public function index(Request $request): AnonymousResourceCollection
{
$query = $request->user()->events()->orderBy('date');
// Delta sync: only events updated since a given timestamp
if ($request->has('since')) {
$since = $request->date('since');
$query->where('updated_at', '>', $since);
}
// Cursor-based pagination (default 50, max 200)
$limit = min((int) $request->input('limit', 50), 200);
return EventResource::collection(
$query->cursorPaginate($limit)
);
}
/**
* POST /api/events
*/
public function store(StoreEventRequest $request): JsonResponse
{
$event = $request->user()->events()->create([
'client_id' => $request->validated('id'),
'title' => $request->validated('title'),
'date' => $request->validated('date'),
'emotion' => $request->validated('emotion'),
'custom_color' => $request->validated('customColor'),
'gradient_preset' => $request->validated('gradientPreset'),
'image' => $request->validated('image'),
'note' => $request->validated('note'),
]);
return (new EventResource($event))
->response()
->setStatusCode(201);
}
/**
* GET /api/events/{clientId}
*/
public function show(Request $request, string $clientId): EventResource
{
$event = $request->user()->events()
->where('client_id', $clientId)
->firstOrFail();
return new EventResource($event);
}
/**
* PUT /api/events/{clientId}
*/
public function update(UpdateEventRequest $request, string $clientId): EventResource
{
$event = $request->user()->events()
->where('client_id', $clientId)
->firstOrFail();
$data = [];
$validated = $request->validated();
if (isset($validated['title'])) {
$data['title'] = $validated['title'];
}
if (isset($validated['date'])) {
$data['date'] = $validated['date'];
}
if (isset($validated['emotion'])) {
$data['emotion'] = $validated['emotion'];
}
if (array_key_exists('customColor', $validated)) {
$data['custom_color'] = $validated['customColor'];
}
if (array_key_exists('gradientPreset', $validated)) {
$data['gradient_preset'] = $validated['gradientPreset'];
}
if (array_key_exists('image', $validated)) {
$data['image'] = $validated['image'];
}
if (array_key_exists('note', $validated)) {
$data['note'] = $validated['note'];
}
$event->update($data);
return new EventResource($event->fresh());
}
/**
* DELETE /api/events/{clientId}
*/
public function destroy(Request $request, string $clientId): JsonResponse
{
$event = $request->user()->events()
->where('client_id', $clientId)
->firstOrFail();
$event->delete();
return response()->json(null, 204);
}
/**
* POST /api/events/sync
* Batch sync: process multiple mutations in one request.
*/
public function sync(Request $request): JsonResponse
{
$request->validate([
'mutations' => ['required', 'array', 'max:100'],
'mutations.*.action' => ['required', 'in:create,update,delete'],
'mutations.*.eventId' => ['required', 'uuid'],
'mutations.*.payload' => ['nullable', 'array'],
]);
$user = $request->user();
$results = [];
foreach ($request->input('mutations') as $mutation) {
$action = $mutation['action'];
$clientId = $mutation['eventId'];
$payload = $mutation['payload'] ?? [];
try {
if ($action === 'create') {
$event = $user->events()->where('client_id', $clientId)->first();
if (! $event) {
$event = $user->events()->create([
'client_id' => $clientId,
'title' => $payload['title'] ?? 'Untitled',
'date' => $payload['date'] ?? now()->format('Y-m-d'),
'emotion' => $payload['emotion'] ?? 0,
'custom_color' => $payload['customColor'] ?? null,
'gradient_preset' => $payload['gradientPreset'] ?? null,
'image' => $payload['image'] ?? null,
'note' => $payload['note'] ?? null,
]);
}
$results[] = ['eventId' => $clientId, 'status' => 'ok'];
} elseif ($action === 'update') {
$event = $user->events()->where('client_id', $clientId)->first();
if ($event) {
$data = [];
if (isset($payload['title'])) {
$data['title'] = $payload['title'];
}
if (isset($payload['date'])) {
$data['date'] = $payload['date'];
}
if (isset($payload['emotion'])) {
$data['emotion'] = $payload['emotion'];
}
if (array_key_exists('customColor', $payload)) {
$data['custom_color'] = $payload['customColor'];
}
if (array_key_exists('gradientPreset', $payload)) {
$data['gradient_preset'] = $payload['gradientPreset'];
}
if (array_key_exists('image', $payload)) {
$data['image'] = $payload['image'];
}
if (array_key_exists('note', $payload)) {
$data['note'] = $payload['note'];
}
$event->update($data);
}
$results[] = ['eventId' => $clientId, 'status' => 'ok'];
} elseif ($action === 'delete') {
$user->events()->where('client_id', $clientId)->delete();
$results[] = ['eventId' => $clientId, 'status' => 'ok'];
}
} catch (\Throwable $e) {
$results[] = ['eventId' => $clientId, 'status' => 'error', 'message' => $e->getMessage()];
}
}
return response()->json(['results' => $results]);
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreEventRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'id' => ['required', 'uuid', 'unique:events,client_id'],
'title' => ['required', 'string', 'max:255'],
'date' => ['required', 'date_format:Y-m-d'],
'emotion' => ['required', 'numeric', 'min:-1', 'max:1'],
'customColor' => ['nullable', 'string', 'max:20'],
'gradientPreset' => ['nullable', 'integer', 'min:0', 'max:9'],
'image' => ['nullable', 'string', 'max:500'],
'note' => ['nullable', 'string', 'max:5000'],
];
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateEventRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => ['sometimes', 'required', 'string', 'max:255'],
'date' => ['sometimes', 'required', 'date_format:Y-m-d'],
'emotion' => ['sometimes', 'required', 'numeric', 'min:-1', 'max:1'],
'customColor' => ['nullable', 'string', 'max:20'],
'gradientPreset' => ['nullable', 'integer', 'min:0', 'max:9'],
'image' => ['nullable', 'string', 'max:500'],
'note' => ['nullable', 'string', 'max:5000'],
];
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EventResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->client_id,
'title' => $this->title,
'date' => $this->date->format('Y-m-d'),
'emotion' => (float) $this->emotion,
'customColor' => $this->custom_color,
'gradientPreset' => $this->gradient_preset,
'image' => $this->image,
'note' => $this->note ?? '',
'syncStatus' => 'synced',
'createdAt' => $this->created_at->getTimestampMs(),
'updatedAt' => $this->updated_at->getTimestampMs(),
];
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Event extends Model
{
/** @use HasFactory<\Database\Factories\EventFactory> */
use HasFactory;
protected $fillable = [
'client_id',
'title',
'date',
'emotion',
'custom_color',
'gradient_preset',
'image',
'note',
];
protected function casts(): array
{
return [
'date' => 'date:Y-m-d',
'emotion' => 'decimal:3',
'gradient_preset' => 'integer',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View file

@ -4,14 +4,16 @@ namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
use Laravel\Passport\HasApiTokens;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable;
use HasApiTokens, HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
@ -50,6 +52,11 @@ class User extends Authenticatable
/**
* Get the user's initials
*/
public function events(): HasMany
{
return $this->hasMany(Event::class);
}
public function initials(): string
{
return Str::of($this->name)

17
backend/boost.json Normal file
View file

@ -0,0 +1,17 @@
{
"agents": [
"claude_code",
"cursor"
],
"guidelines": true,
"herd_mcp": false,
"mcp": true,
"nightwatch_mcp": false,
"sail": false,
"skills": [
"fluxui-development",
"volt-development",
"pest-testing",
"tailwindcss-development"
]
}

View file

@ -7,6 +7,7 @@ use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)

View file

@ -11,15 +11,17 @@
"require": {
"php": "^8.2",
"laravel/framework": "^12.0",
"laravel/passport": "^13.0",
"laravel/tinker": "^2.10.1",
"livewire/flux": "^2.0",
"livewire/volt": "^1.7.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/boost": "^2.1",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.18",
"laravel/sail": "^1.41",
"laravel/sail": "^1.46",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"pestphp/pest": "^3.7",
@ -74,4 +76,4 @@
},
"minimum-stability": "stable",
"prefer-stable": true
}
}

3446
backend/composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -40,6 +40,10 @@ return [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'passport',
'provider' => 'users',
],
],
/*

View file

@ -0,0 +1,48 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Passport Guard
|--------------------------------------------------------------------------
|
| Here you may specify which authentication guard Passport will use when
| authenticating users. This value should correspond with one of your
| guards that is already present in your "auth" configuration file.
|
*/
'guard' => 'web',
'middleware' => [],
/*
|--------------------------------------------------------------------------
| Encryption Keys
|--------------------------------------------------------------------------
|
| Passport uses encryption keys while generating secure access tokens for
| your application. By default, the keys are stored as local files but
| can be set via environment variables when that is more convenient.
|
*/
'private_key' => env('PASSPORT_PRIVATE_KEY'),
'public_key' => env('PASSPORT_PUBLIC_KEY'),
/*
|--------------------------------------------------------------------------
| Passport Database Connection
|--------------------------------------------------------------------------
|
| By default, Passport's models will utilize your application's default
| database connection. If you wish to use a different connection you
| may specify the configured name of the database connection here.
|
*/
'connection' => env('PASSPORT_CONNECTION'),
];

View file

@ -0,0 +1,28 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Event>
*/
class EventFactory extends Factory
{
public function definition(): array
{
return [
'client_id' => Str::uuid()->toString(),
'user_id' => User::factory(),
'title' => fake()->sentence(3),
'date' => fake()->date(),
'emotion' => fake()->randomFloat(3, -1, 1),
'custom_color' => null,
'gradient_preset' => fake()->optional(0.3)->numberBetween(0, 9),
'image' => null,
'note' => fake()->optional(0.5)->sentence(),
];
}
}

View file

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_auth_codes', function (Blueprint $table) {
$table->char('id', 80)->primary();
$table->foreignId('user_id')->index();
$table->foreignUuid('client_id');
$table->text('scopes')->nullable();
$table->boolean('revoked');
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_auth_codes');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View file

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_access_tokens', function (Blueprint $table) {
$table->char('id', 80)->primary();
$table->foreignId('user_id')->nullable()->index();
$table->foreignUuid('client_id');
$table->string('name')->nullable();
$table->text('scopes')->nullable();
$table->boolean('revoked');
$table->timestamps();
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_access_tokens');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View file

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_refresh_tokens', function (Blueprint $table) {
$table->char('id', 80)->primary();
$table->char('access_token_id', 80)->index();
$table->boolean('revoked');
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_refresh_tokens');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View file

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_clients', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->nullableMorphs('owner');
$table->string('name');
$table->string('secret')->nullable();
$table->string('provider')->nullable();
$table->text('redirect_uris');
$table->text('grant_types');
$table->boolean('revoked');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_clients');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View file

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_device_codes', function (Blueprint $table) {
$table->char('id', 80)->primary();
$table->foreignId('user_id')->nullable()->index();
$table->foreignUuid('client_id')->index();
$table->char('user_code', 8)->unique();
$table->text('scopes');
$table->boolean('revoked');
$table->dateTime('user_approved_at')->nullable();
$table->dateTime('last_polled_at')->nullable();
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_device_codes');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('events', function (Blueprint $table) {
$table->id();
$table->uuid('client_id')->unique();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->date('date');
$table->decimal('emotion', 4, 3)->default(0);
$table->string('custom_color')->nullable();
$table->unsignedTinyInteger('gradient_preset')->nullable();
$table->string('image')->nullable();
$table->text('note')->nullable();
$table->timestamps();
$table->index(['user_id', 'date']);
$table->index(['user_id', 'updated_at']);
});
}
public function down(): void
{
Schema::dropIfExists('events');
}
};

View file

@ -1,9 +1,10 @@
{
"name": "backend",
"name": "html",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "html",
"dependencies": {
"@tailwindcss/vite": "^4.0.7",
"autoprefixer": "^10.4.20",
@ -420,9 +421,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.8.tgz",
"integrity": "sha512-q217OSE8DTp8AFHuNHXo0Y86e1wtlfVrXiAlwkIvGRQv9zbc6mE3sjIVfwI8sYUyNxwOg0j/Vm1RKM04JcWLJw==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz",
"integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==",
"cpu": [
"arm"
],
@ -433,9 +434,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.8.tgz",
"integrity": "sha512-Gigjz7mNWaOL9wCggvoK3jEIUUbGul656opstjaUSGC3eT0BM7PofdAJaBfPFWWkXNVAXbaQtC99OCg4sJv70Q==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz",
"integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==",
"cpu": [
"arm64"
],
@ -446,9 +447,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.8.tgz",
"integrity": "sha512-02rVdZ5tgdUNRxIUrFdcMBZQoaPMrxtwSb+/hOfBdqkatYHR3lZ2A2EGyHq2sGOd0Owk80oV3snlDASC24He3Q==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz",
"integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==",
"cpu": [
"arm64"
],
@ -459,9 +460,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.8.tgz",
"integrity": "sha512-qIP/elwR/tq/dYRx3lgwK31jkZvMiD6qUtOycLhTzCvrjbZ3LjQnEM9rNhSGpbLXVJYQ3rq39A6Re0h9tU2ynw==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz",
"integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==",
"cpu": [
"x64"
],
@ -472,9 +473,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.8.tgz",
"integrity": "sha512-IQNVXL9iY6NniYbTaOKdrlVP3XIqazBgJOVkddzJlqnCpRi/yAeSOa8PLcECFSQochzqApIOE1GHNu3pCz+BDA==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz",
"integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==",
"cpu": [
"arm64"
],
@ -485,9 +486,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.8.tgz",
"integrity": "sha512-TYXcHghgnCqYFiE3FT5QwXtOZqDj5GmaFNTNt3jNC+vh22dc/ukG2cG+pi75QO4kACohZzidsq7yKTKwq/Jq7Q==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz",
"integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==",
"cpu": [
"x64"
],
@ -498,9 +499,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.8.tgz",
"integrity": "sha512-A4iphFGNkWRd+5m3VIGuqHnG3MVnqKe7Al57u9mwgbyZ2/xF9Jio72MaY7xxh+Y87VAHmGQr73qoKL9HPbXj1g==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz",
"integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==",
"cpu": [
"arm"
],
@ -511,9 +512,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.8.tgz",
"integrity": "sha512-S0lqKLfTm5u+QTxlFiAnb2J/2dgQqRy/XvziPtDd1rKZFXHTyYLoVL58M/XFwDI01AQCDIevGLbQrMAtdyanpA==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz",
"integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==",
"cpu": [
"arm"
],
@ -524,9 +525,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.8.tgz",
"integrity": "sha512-jpz9YOuPiSkL4G4pqKrus0pn9aYwpImGkosRKwNi+sJSkz+WU3anZe6hi73StLOQdfXYXC7hUfsQlTnjMd3s1A==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz",
"integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==",
"cpu": [
"arm64"
],
@ -537,9 +538,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.8.tgz",
"integrity": "sha512-KdSfaROOUJXgTVxJNAZ3KwkRc5nggDk+06P6lgi1HLv1hskgvxHUKZ4xtwHkVYJ1Rep4GNo+uEfycCRRxht7+Q==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz",
"integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==",
"cpu": [
"arm64"
],
@ -549,10 +550,10 @@
"linux"
]
},
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.8.tgz",
"integrity": "sha512-NyF4gcxwkMFRjgXBM6g2lkT58OWztZvw5KkV2K0qqSnUEqCVcqdh2jN4gQrTn/YUpAcNKyFHfoOZEer9nwo6uQ==",
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz",
"integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==",
"cpu": [
"loong64"
],
@ -562,10 +563,10 @@
"linux"
]
},
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.8.tgz",
"integrity": "sha512-LMJc999GkhGvktHU85zNTDImZVUCJ1z/MbAJTnviiWmmjyckP5aQsHtcujMjpNdMZPT2rQEDBlJfubhs3jsMfw==",
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz",
"integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==",
"cpu": [
"ppc64"
],
@ -576,9 +577,22 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.8.tgz",
"integrity": "sha512-xAQCAHPj8nJq1PI3z8CIZzXuXCstquz7cIOL73HHdXiRcKk8Ywwqtx2wrIy23EcTn4aZ2fLJNBB8d0tQENPCmw==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz",
"integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz",
"integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==",
"cpu": [
"riscv64"
],
@ -589,9 +603,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.8.tgz",
"integrity": "sha512-DdePVk1NDEuc3fOe3dPPTb+rjMtuFw89gw6gVWxQFAuEqqSdDKnrwzZHrUYdac7A7dXl9Q2Vflxpme15gUWQFA==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz",
"integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==",
"cpu": [
"s390x"
],
@ -615,9 +629,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.8.tgz",
"integrity": "sha512-SCXcP0ZpGFIe7Ge+McxY5zKxiEI5ra+GT3QRxL0pMMtxPfpyLAKleZODi1zdRHkz5/BhueUrYtYVgubqe9JBNQ==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz",
"integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==",
"cpu": [
"x64"
],
@ -627,10 +641,23 @@
"linux"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz",
"integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.8.tgz",
"integrity": "sha512-YHYsgzZgFJzTRbth4h7Or0m5O74Yda+hLin0irAIobkLQFRQd1qWmnoVfwmKm9TXIZVAD0nZ+GEb2ICicLyCnQ==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz",
"integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==",
"cpu": [
"arm64"
],
@ -641,9 +668,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.8.tgz",
"integrity": "sha512-r3NRQrXkHr4uWy5TOjTpTYojR9XmF0j/RYgKCef+Ag46FWUTltm5ziticv8LdNsDMehjJ543x/+TJAek/xBA2w==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz",
"integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==",
"cpu": [
"ia32"
],
@ -653,10 +680,23 @@
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz",
"integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.8.tgz",
"integrity": "sha512-U0FaE5O1BCpZSeE6gBl3c5ObhePQSfk9vDRToMmTkbhCOgW4jqvtS5LGyQ76L1fH8sM0keRp4uDTsbjiUyjk0g==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz",
"integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==",
"cpu": [
"x64"
],
@ -891,9 +931,9 @@
}
},
"node_modules/@types/estree": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT"
},
"node_modules/ansi-regex": {
@ -964,13 +1004,13 @@
}
},
"node_modules/axios": {
"version": "1.7.9",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
@ -993,6 +1033,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001688",
"electron-to-chromium": "^1.5.73",
@ -1311,14 +1352,15 @@
}
},
"node_modules/form-data": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
@ -1844,6 +1886,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.8",
"picocolors": "^1.1.1",
@ -1875,12 +1918,12 @@
}
},
"node_modules/rollup": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.8.tgz",
"integrity": "sha512-489gTVMzAYdiZHFVA/ig/iYFllCcWFHMvUHI1rpFmkoUtRlQxqh6/yiNqnYibjMZ2b/+FUQwldG+aLsEt6bglQ==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz",
"integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==",
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.6"
"@types/estree": "1.0.8"
},
"bin": {
"rollup": "dist/bin/rollup"
@ -1890,32 +1933,35 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.34.8",
"@rollup/rollup-android-arm64": "4.34.8",
"@rollup/rollup-darwin-arm64": "4.34.8",
"@rollup/rollup-darwin-x64": "4.34.8",
"@rollup/rollup-freebsd-arm64": "4.34.8",
"@rollup/rollup-freebsd-x64": "4.34.8",
"@rollup/rollup-linux-arm-gnueabihf": "4.34.8",
"@rollup/rollup-linux-arm-musleabihf": "4.34.8",
"@rollup/rollup-linux-arm64-gnu": "4.34.8",
"@rollup/rollup-linux-arm64-musl": "4.34.8",
"@rollup/rollup-linux-loongarch64-gnu": "4.34.8",
"@rollup/rollup-linux-powerpc64le-gnu": "4.34.8",
"@rollup/rollup-linux-riscv64-gnu": "4.34.8",
"@rollup/rollup-linux-s390x-gnu": "4.34.8",
"@rollup/rollup-linux-x64-gnu": "4.34.8",
"@rollup/rollup-linux-x64-musl": "4.34.8",
"@rollup/rollup-win32-arm64-msvc": "4.34.8",
"@rollup/rollup-win32-ia32-msvc": "4.34.8",
"@rollup/rollup-win32-x64-msvc": "4.34.8",
"@rollup/rollup-android-arm-eabi": "4.52.4",
"@rollup/rollup-android-arm64": "4.52.4",
"@rollup/rollup-darwin-arm64": "4.52.4",
"@rollup/rollup-darwin-x64": "4.52.4",
"@rollup/rollup-freebsd-arm64": "4.52.4",
"@rollup/rollup-freebsd-x64": "4.52.4",
"@rollup/rollup-linux-arm-gnueabihf": "4.52.4",
"@rollup/rollup-linux-arm-musleabihf": "4.52.4",
"@rollup/rollup-linux-arm64-gnu": "4.52.4",
"@rollup/rollup-linux-arm64-musl": "4.52.4",
"@rollup/rollup-linux-loong64-gnu": "4.52.4",
"@rollup/rollup-linux-ppc64-gnu": "4.52.4",
"@rollup/rollup-linux-riscv64-gnu": "4.52.4",
"@rollup/rollup-linux-riscv64-musl": "4.52.4",
"@rollup/rollup-linux-s390x-gnu": "4.52.4",
"@rollup/rollup-linux-x64-gnu": "4.52.4",
"@rollup/rollup-linux-x64-musl": "4.52.4",
"@rollup/rollup-openharmony-arm64": "4.52.4",
"@rollup/rollup-win32-arm64-msvc": "4.52.4",
"@rollup/rollup-win32-ia32-msvc": "4.52.4",
"@rollup/rollup-win32-x64-gnu": "4.52.4",
"@rollup/rollup-win32-x64-msvc": "4.52.4",
"fsevents": "~2.3.2"
}
},
"node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.8.tgz",
"integrity": "sha512-8y7ED8gjxITUltTUEJLQdgpbPh1sUQ0kMTmufRF/Ns5tI9TNMNlhWtmPKKHCU0SilX+3MJkZ0zERYYGIVBYHIA==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz",
"integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==",
"cpu": [
"x64"
],
@ -2011,6 +2057,52 @@
"node": ">=6"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyglobby/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
@ -2057,14 +2149,18 @@
}
},
"node_modules/vite": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz",
"integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==",
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.0.tgz",
"integrity": "sha512-oLnWs9Hak/LOlKjeSpOwD6JMks8BeICEdYMJBf6P4Lac/pO9tKiv/XhXnAM7nNfSkZahjlCZu9sS50zL8fSnsw==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
"picomatch": "^4.0.2",
"postcss": "^8.5.3",
"rollup": "^4.30.1"
"rollup": "^4.34.9",
"tinyglobby": "^0.2.13"
},
"bin": {
"vite": "bin/vite.js"
@ -2137,6 +2233,36 @@
"picomatch": "^2.3.1"
}
},
"node_modules/vite/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

View file

@ -22,8 +22,10 @@
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_CONNECTION" value="sqlite" force="true"/>
<env name="DB_DATABASE" value=":memory:" force="true"/>
<server name="DB_CONNECTION" value="sqlite" force="true"/>
<server name="DB_DATABASE" value=":memory:" force="true"/>
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>

14
backend/routes/api.php Normal file
View file

@ -0,0 +1,14 @@
<?php
use App\Http\Controllers\Api\EventController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:api')->group(function () {
Route::get('/user', fn (Request $request) => $request->user());
Route::apiResource('events', EventController::class)->parameters([
'events' => 'clientId',
]);
Route::post('/events/sync', [EventController::class, 'sync']);
});

View file

@ -0,0 +1,186 @@
<?php
use App\Models\Event;
use App\Models\User;
use Illuminate\Support\Str;
use Laravel\Passport\Passport;
beforeEach(function () {
$this->user = User::factory()->create();
Passport::actingAs($this->user);
});
test('can list events', function () {
Event::factory()->count(3)->create(['user_id' => $this->user->id]);
$response = $this->getJson('/api/events');
$response->assertOk()
->assertJsonCount(3, 'data');
});
test('list only returns own events', function () {
Event::factory()->count(2)->create(['user_id' => $this->user->id]);
Event::factory()->count(3)->create(); // other user
$response = $this->getJson('/api/events');
$response->assertOk()
->assertJsonCount(2, 'data');
});
test('can filter events by since parameter', function () {
Event::factory()->create([
'user_id' => $this->user->id,
'updated_at' => now()->subDays(5),
]);
Event::factory()->create([
'user_id' => $this->user->id,
'updated_at' => now()->subHour(),
]);
$response = $this->getJson('/api/events?since=' . now()->subDay()->toISOString());
$response->assertOk()
->assertJsonCount(1, 'data');
});
test('can create an event', function () {
$clientId = Str::uuid()->toString();
$response = $this->postJson('/api/events', [
'id' => $clientId,
'title' => 'Mein Event',
'date' => '2024-06-15',
'emotion' => 0.75,
'customColor' => null,
'gradientPreset' => 2,
'image' => null,
'note' => 'Eine Notiz',
]);
$response->assertCreated()
->assertJsonPath('data.id', $clientId)
->assertJsonPath('data.title', 'Mein Event')
->assertJsonPath('data.syncStatus', 'synced');
$this->assertDatabaseHas('events', [
'client_id' => $clientId,
'user_id' => $this->user->id,
]);
});
test('create validates required fields', function () {
$response = $this->postJson('/api/events', []);
$response->assertUnprocessable()
->assertJsonValidationErrors(['id', 'title', 'date', 'emotion']);
});
test('can show a single event', function () {
$event = Event::factory()->create(['user_id' => $this->user->id]);
$response = $this->getJson("/api/events/{$event->client_id}");
$response->assertOk()
->assertJsonPath('data.id', $event->client_id)
->assertJsonPath('data.title', $event->title);
});
test('cannot show another users event', function () {
$event = Event::factory()->create();
$response = $this->getJson("/api/events/{$event->client_id}");
$response->assertNotFound();
});
test('can update an event', function () {
$event = Event::factory()->create(['user_id' => $this->user->id]);
$response = $this->putJson("/api/events/{$event->client_id}", [
'title' => 'Updated Title',
'emotion' => -0.5,
]);
$response->assertOk()
->assertJsonPath('data.title', 'Updated Title');
});
test('can delete an event', function () {
$event = Event::factory()->create(['user_id' => $this->user->id]);
$response = $this->deleteJson("/api/events/{$event->client_id}");
$response->assertNoContent();
$this->assertDatabaseMissing('events', ['id' => $event->id]);
});
test('cannot delete another users event', function () {
$event = Event::factory()->create();
$response = $this->deleteJson("/api/events/{$event->client_id}");
$response->assertNotFound();
});
test('batch sync creates updates and deletes', function () {
$existingEvent = Event::factory()->create(['user_id' => $this->user->id]);
$newId = Str::uuid()->toString();
$deleteEvent = Event::factory()->create(['user_id' => $this->user->id]);
$response = $this->postJson('/api/events/sync', [
'mutations' => [
[
'action' => 'create',
'eventId' => $newId,
'payload' => [
'title' => 'New via sync',
'date' => '2025-01-01',
'emotion' => 0.3,
],
],
[
'action' => 'update',
'eventId' => $existingEvent->client_id,
'payload' => [
'title' => 'Updated via sync',
],
],
[
'action' => 'delete',
'eventId' => $deleteEvent->client_id,
'payload' => null,
],
],
]);
$response->assertOk()
->assertJsonCount(3, 'results');
$this->assertDatabaseHas('events', ['client_id' => $newId, 'title' => 'New via sync']);
$this->assertDatabaseHas('events', ['client_id' => $existingEvent->client_id, 'title' => 'Updated via sync']);
$this->assertDatabaseMissing('events', ['client_id' => $deleteEvent->client_id]);
});
test('sync is idempotent for creates', function () {
$clientId = Str::uuid()->toString();
$this->postJson('/api/events/sync', [
'mutations' => [[
'action' => 'create',
'eventId' => $clientId,
'payload' => ['title' => 'First', 'date' => '2025-01-01', 'emotion' => 0],
]],
])->assertOk();
$this->postJson('/api/events/sync', [
'mutations' => [[
'action' => 'create',
'eventId' => $clientId,
'payload' => ['title' => 'Duplicate', 'date' => '2025-01-01', 'emotion' => 0],
]],
])->assertOk();
expect(Event::where('client_id', $clientId)->count())->toBe(1);
});

View file

@ -1,15 +1,24 @@
// npm run dev # dev server
// npm run build # build for production
// npm run preview # preview production build
/**
* Vite-Konfiguration für Backend (Thats-Me)
* - Domain: thats-me.test
* - Port: 5173
* - Verwendet FluxUI
* - Build-Verzeichnis: public/build/thats-me
*
* Starten mit: npm run dev
*/
import { defineConfig } from "vite";
import laravel from "laravel-vite-plugin";
import tailwindcss from "@tailwindcss/vite";
import fs from "fs";
// Pfade zu deinen MAMP-Zertifikaten
const certPath = "/Applications/MAMP/Library/OpenSSL/certs/thats-me.test.crt";
const keyPath = "/Applications/MAMP/Library/OpenSSL/certs/thats-me.test.key";
const httpsConfig =
process.env.NODE_ENV === "production"
? {
// In Produktion: echte Zertifikate verwenden
key: process.env.SSL_KEY_PATH,
cert: process.env.SSL_CERT_PATH,
}
: true; // Self-signed für Entwicklung
export default defineConfig({
plugins: [
@ -20,17 +29,34 @@ export default defineConfig({
tailwindcss(),
],
server: {
https: {
key: fs.readFileSync(keyPath),
cert: fs.readFileSync(certPath),
https: false, // Traefik übernimmt SSL
cors: {
origin: ["https://thats-me.test", "https://assets.thats-me.test"],
credentials: true,
},
cors: true, // Ergänze diese Zeile
host: "192.168.1.8",
host: "0.0.0.0",
port: 5173,
strictPort: true,
allowedHosts: [
"assets.thats-me.test",
"thats-me.test",
"localhost",
"0.0.0.0",
],
hmr: {
host: "192.168.1.8",
protocol: "https",
host: "assets.thats-me.test",
protocol: "wss",
},
origin: "https://assets.thats-me.test", // Ohne Port!
},
build: {
outDir: "public/build/thats-me",
assetsDir: "",
manifest: "manifest.json",
rollupOptions: {
output: {
manualChunks: undefined,
},
},
origin: "https://192.168.1.8:5173",
},
});

165
docker-compose.yml Normal file
View file

@ -0,0 +1,165 @@
services:
# Laravel Backend Service
laravel.test:
build:
context: './backend/vendor/laravel/sail/runtimes/8.4'
dockerfile: Dockerfile
args:
WWWGROUP: '${WWWGROUP:-20}'
WWWUSER: '${WWWUSER:-501}'
image: 'sail-8.4/app'
extra_hosts:
- 'host.docker.internal:host-gateway'
ports:
- '${VITE_PORT:-5180}:5173'
environment:
WWWUSER: '${WWWUSER:-501}'
WWWGROUP: '${WWWGROUP:-20}'
LARAVEL_SAIL: 1
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
IGNITION_LOCAL_SITES_PATH: '${PWD}'
DB_CONNECTION: mysql
DB_HOST: mysql
DB_PORT: 3306
DB_DATABASE: thats-me
DB_USERNAME: sail
DB_PASSWORD: password
MAIL_HOST: mailpit
MAIL_PORT: 1025
REDIS_HOST: redis
volumes:
- './backend:/var/www/html'
networks:
- sail
- proxy
depends_on:
- mysql
- mailpit
- redis
labels:
- "traefik.enable=true"
# Domain 1: Hauptdomain thats-me.test (Webseite/Landingpage)
- "traefik.http.routers.thatsme-main.rule=Host(`thats-me.test`)"
- "traefik.http.routers.thatsme-main.entrypoints=websecure"
- "traefik.http.routers.thatsme-main.tls=true"
- "traefik.http.routers.thatsme-main.service=thatsme-service"
# Domain 2: portal.thats-me.test (Admin Panel)
- "traefik.http.routers.thatsme-portal.rule=Host(`portal.thats-me.test`)"
- "traefik.http.routers.thatsme-portal.entrypoints=websecure"
- "traefik.http.routers.thatsme-portal.tls=true"
- "traefik.http.routers.thatsme-portal.service=thatsme-service"
# Domain 3: api.thats-me.test (API für Quasar App)
- "traefik.http.routers.thatsme-api.rule=Host(`api.thats-me.test`)"
- "traefik.http.routers.thatsme-api.entrypoints=websecure"
- "traefik.http.routers.thatsme-api.tls=true"
- "traefik.http.routers.thatsme-api.service=thatsme-service"
# Vite Asset Domain für Backend Development
- "traefik.http.routers.thatsme-assets.rule=Host(`assets.thats-me.test`)"
- "traefik.http.routers.thatsme-assets.entrypoints=websecure"
- "traefik.http.routers.thatsme-assets.tls=true"
- "traefik.http.routers.thatsme-assets.service=thatsme-assets-service"
# Service Definitions
- "traefik.http.services.thatsme-service.loadbalancer.server.port=80"
- "traefik.http.services.thatsme-assets-service.loadbalancer.server.port=5173"
- "traefik.http.services.thatsme-assets-service.loadbalancer.server.scheme=http"
- "traefik.docker.network=proxy"
# Quasar Frontend Service
quasar.app:
image: 'node:20-alpine'
working_dir: /app
command: sh -c "npm install && npm run dev"
ports:
- '${QUASAR_PORT:-9000}:9000'
environment:
NODE_ENV: development
volumes:
- './frontend:/app'
- 'quasar-node-modules:/app/node_modules'
networks:
- sail
- proxy
labels:
- "traefik.enable=true"
# Domain 4: app.thats-me.test (Quasar Frontend App)
- "traefik.http.routers.thatsme-app.rule=Host(`app.thats-me.test`)"
- "traefik.http.routers.thatsme-app.entrypoints=websecure"
- "traefik.http.routers.thatsme-app.tls=true"
- "traefik.http.routers.thatsme-app.service=thatsme-app-service"
# Service Definition
- "traefik.http.services.thatsme-app-service.loadbalancer.server.port=9000"
- "traefik.http.services.thatsme-app-service.loadbalancer.server.scheme=http"
- "traefik.docker.network=proxy"
# MySQL Database
mysql:
image: 'mysql/mysql-server:8.0'
ports:
- '${FORWARD_DB_PORT:-33070}:3306'
environment:
MYSQL_ROOT_PASSWORD: '${DB_PASSWORD:-password}'
MYSQL_ROOT_HOST: '%'
MYSQL_DATABASE: '${DB_DATABASE:-thats-me}'
MYSQL_USER: '${DB_USERNAME:-sail}'
MYSQL_PASSWORD: '${DB_PASSWORD:-password}'
MYSQL_ALLOW_EMPTY_PASSWORD: 1
volumes:
- 'sail-mysql:/var/lib/mysql'
- './backend/vendor/laravel/sail/database/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh'
networks:
- sail
healthcheck:
test:
- CMD
- mysqladmin
- ping
- '-p${DB_PASSWORD:-password}'
retries: 3
timeout: 5s
# Mailpit für E-Mail Testing
mailpit:
image: 'axllent/mailpit:latest'
ports:
- '${FORWARD_MAILPIT_PORT:-1028}:1025'
- '${FORWARD_MAILPIT_DASHBOARD_PORT:-8028}:8025'
networks:
- sail
# Redis Cache/Queue
redis:
image: 'redis:alpine'
ports:
- '${FORWARD_REDIS_PORT:-6383}:6379'
volumes:
- 'sail-redis:/data'
networks:
- sail
healthcheck:
test:
- CMD
- redis-cli
- ping
retries: 3
timeout: 5s
networks:
sail:
driver: bridge
proxy:
external: true
volumes:
sail-mysql:
driver: local
sail-redis:
driver: local
quasar-node-modules:
driver: local

24
dot-line-system/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View file

@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
min-height: 100vh;
margin: 0;
padding: 0;
box-sizing: border-box;
overflow-x: hidden;
}
.controls {
display: flex;
justify-content: space-between;
width: 100%;
max-width: 500px;
margin-bottom: 10px;
}
.button {
padding: 6px 12px;
background-color: #4f46e5;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.visualization-container {
position: absolute;
width: 100vw;
height: 100vh;
overflow-x: hidden;
overflow-y: hidden;
left: 0;
right: 0;
margin-left: calc(-50vw + 50%);
}
.gradient-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
min-width: 100%;
background: linear-gradient(to bottom, #4f46e5, #60a5fa, #4ade80);
z-index: -1;
}
.scroll-container {
position: relative;
height: 100%;
overflow-x: auto;
overflow-y: hidden;
}
.spacer {
height: 100vh;
}
</style>
</head>
<body>
<div class="spacer"></div>
<!-- Controls removed as requested -->
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
</div>
<div class="spacer"></div>
</body>
</html>

View file

@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
min-height: 100vh;
margin: 0;
padding: 0;
box-sizing: border-box;
overflow-x: hidden;
}
.controls {
display: flex;
justify-content: space-between;
width: 100%;
max-width: 500px;
margin-bottom: 10px;
}
.button {
padding: 6px 12px;
background-color: #4f46e5;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.visualization-container {
position: absolute;
width: 100vw;
height: 100vh;
overflow-x: hidden;
overflow-y: hidden;
left: 0;
right: 0;
margin-left: calc(-50vw + 50%);
}
.gradient-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
min-width: 100%;
background: linear-gradient(to bottom, #4f46e5, #60a5fa, #4ade80);
z-index: -1;
}
.scroll-container {
position: relative;
height: 100%;
overflow-x: auto;
overflow-y: hidden;
}
.spacer {
height: 100vh;
}
</style>
</head>
<body>
<!-- <div class="spacer"></div> -->
<!-- Controls removed as requested -->
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
</div>
<!-- <div class="spacer"></div> -->
</body>
</html>

View file

@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
min-height: 100vh;
margin: 0;
padding: 0;
box-sizing: border-box;
overflow-x: hidden;
}
.controls {
display: flex;
justify-content: space-between;
width: 100%;
max-width: 500px;
margin-bottom: 10px;
}
.button {
padding: 6px 12px;
background-color: #4f46e5;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.visualization-container {
position: absolute;
width: 100vw;
height: 100vh;
overflow-x: hidden;
overflow-y: hidden;
left: 0;
right: 0;
margin-left: calc(-50vw + 50%);
}
.gradient-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
min-width: 100%;
background: linear-gradient(to bottom, #4f46e5, #60a5fa, #4ade80);
z-index: -1;
}
.scroll-container {
position: relative;
height: 100%;
overflow-x: auto;
overflow-y: hidden;
}
.scroll-container:active {
cursor: grabbing; /* Change cursor when active */
}
.spacer {
height: 100vh;
}
</style>
</head>
<body>
<!-- <div class="spacer"></div> -->
<!-- Controls removed as requested -->
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
</div>
<!-- <div class="spacer"></div> -->
<script>
const scrollContainer = document.querySelector('.scroll-container');
let isDown = false;
let startX;
let scrollLeft;
scrollContainer.addEventListener('mousedown', (e) => {
isDown = true;
scrollContainer.classList.add('active');
startX = e.pageX - scrollContainer.offsetLeft;
scrollLeft = scrollContainer.scrollLeft;
});
scrollContainer.addEventListener('mouseleave', () => {
isDown = false;
scrollContainer.classList.remove('active');
});
scrollContainer.addEventListener('mouseup', () => {
isDown = false;
scrollContainer.classList.remove('active');
});
scrollContainer.addEventListener('mousemove', (e) => {
if(!isDown) return;
e.preventDefault();
const x = e.pageX - scrollContainer.offsetLeft;
const walk = (x - startX) * 3; // Multiply by a number to speed up scrolling
scrollContainer.scrollLeft = scrollLeft - walk;
});
</script>
</body>
</html>

View file

@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
min-height: 100vh;
margin: 0;
padding: 0;
box-sizing: border-box;
overflow-x: hidden;
}
.controls {
display: flex;
justify-content: space-between;
width: 100%;
max-width: 500px;
margin-bottom: 10px;
}
.button {
padding: 6px 12px;
background-color: #4f46e5;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.visualization-container {
position: absolute;
width: 100vw;
height: 100vh;
overflow-x: hidden;
overflow-y: hidden;
left: 0;
right: 0;
margin-left: calc(-50vw + 50%);
}
.gradient-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
min-width: 100%;
background: linear-gradient(to bottom, #4f46e5, #60a5fa, #4ade80);
z-index: -1;
}
.scroll-container {
position: relative;
height: 100%;
overflow-x: auto;
overflow-y: hidden;
}
.scroll-container:active {
cursor: grabbing; /* Change cursor when active */
}
.spacer {
height: 100vh;
}
</style>
</head>
<body>
<!-- <div class="spacer"></div> -->
<!-- Controls removed as requested -->
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
</div>
<!-- <div class="spacer"></div> -->
</body>
</html>

View file

@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
min-height: 100vh;
margin: 0;
padding: 0;
box-sizing: border-box;
overflow-x: hidden;
}
.controls {
display: flex;
justify-content: space-between;
width: 100%;
max-width: 500px;
margin-bottom: 10px;
}
.button {
padding: 6px 12px;
background-color: #4f46e5;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.visualization-container {
position: absolute;
width: 100vw;
height: 100vh;
overflow-x: hidden;
overflow-y: hidden;
left: 0;
right: 0;
margin-left: calc(-50vw + 50%);
}
.gradient-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
min-width: 100%;
background: linear-gradient(to bottom, #4f46e5, #60a5fa, #4ade80);
z-index: -1;
}
.scroll-container {
position: relative;
height: 100%;
overflow-x: auto;
overflow-y: hidden;
scroll-behavior: smooth;
}
.scroll-container:active {
cursor: grabbing; /* Change cursor when active */
}
.spacer {
height: 100vh;
}
</style>
</head>
<body>
<!-- <div class="spacer"></div> -->
<!-- Controls removed as requested -->
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
</div>
<!-- <div class="spacer"></div> -->
</body>
</html>

View file

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<link rel="stylesheet" href="style.css">
<style>
</style>
</head>
<body>
<!-- <div class="spacer"></div> -->
<!-- Controls removed as requested -->
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
</div>
<!-- <div class="spacer"></div> -->
</body>
</html>

View file

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<link rel="stylesheet" href="../src/style.css">
<style>
</style>
</head>
<body>
<!-- <div class="spacer"></div> -->
<!-- Controls removed as requested -->
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
</div>
<!-- <div class="spacer"></div> -->
</body>
</html>

View file

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<link rel="stylesheet" href="../src/style.css">
</head>
<body>
<!-- <div class="spacer"></div> -->
<!-- Controls removed as requested -->
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
</div>
<!-- <div class="spacer"></div> -->
</body>
</html>

View file

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<link rel="stylesheet" href="../src/style.css">
</head>
<body>
<!-- <div class="spacer"></div> -->
<!-- Controls removed as requested -->
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
<div class="median"></div>
</div>
<!-- <div class="spacer"></div> -->
</body>
</html>

View file

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<link rel="stylesheet" href="../src/style.css">
</head>
<body>
<!-- <div class="spacer"></div> -->
<!-- Controls removed as requested -->
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
<div class="median"></div>
</div>
<!-- <div class="spacer"></div> -->
</body>
</html>

View file

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<link rel="stylesheet" href="../src/style.css">
</head>
<body>
<!-- <div class="spacer"></div> -->
<!-- Controls removed as requested -->
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container">
<div class="median"></div>
</div>
</div>
<!-- <div class="spacer"></div> -->
</body>
</html>

View file

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<link rel="stylesheet" href="../src/style.css">
</head>
<body>
<!-- <div class="spacer"></div> -->
<!-- Controls removed as requested -->
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
<div class="median"></div>
</div>
<!-- <div class="spacer"></div> -->
</body>
</html>

View file

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<link rel="stylesheet" href="../src/style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
</head>
<body>
<!-- <div class="spacer"></div> -->
<!-- Controls removed as requested -->
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
<div class="median"></div>
</div>
<!-- <div class="spacer"></div> -->
</body>
</html>

View file

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<link rel="stylesheet" href="../src/style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
</head>
<body>
<!-- <div class="spacer"></div> -->
<!-- Controls removed as requested -->
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
<div class="median"></div>
</div>
<!-- <div class="spacer"></div> -->
</body>
</html>

View file

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<link rel="stylesheet" href="../src/style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
</head>
<body>
<!-- <div class="spacer"></div> -->
<!-- Controls removed as requested -->
<div class="visualization-container">
<div class="dynamic-gradient-bg"></div>
<!-- <div class="gradient-bg"></div> -->
<div class="scroll-container" id="scroll-container"></div>
<div class="median"></div>
</div>
<!-- <div class="spacer"></div> -->
</body>
</html>

View file

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<link rel="stylesheet" href="../src/style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
</head>
<body>
<!-- <div class="spacer"></div> -->
<!-- Controls removed as requested -->
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
<div class="median"></div>
</div>
<!-- <div class="spacer"></div> -->
</body>
</html>

View file

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<link rel="stylesheet" href="../src/style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
</head>
<body>
<!-- <div class="spacer"></div> -->
<!-- Controls removed as requested -->
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
<div class="median"></div>
</div>
<!-- <div class="spacer"></div> -->
</body>
</html>

View file

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<link rel="stylesheet" href="../src/style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
</head>
<body>
<!-- <div class="spacer"></div> -->
<!-- Controls removed as requested -->
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
<div class="median"></div>
</div>
<!-- <div class="spacer"></div> -->
</body>
</html>

View file

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<link rel="stylesheet" href="../src/style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
</head>
<body>
<!-- <div class="spacer"></div> -->
<!-- Controls removed as requested -->
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
<div class="median"></div>
</div>
<!-- <div class="spacer"></div> -->
</body>
</html>

View file

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<link rel="stylesheet" href="../src/style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
</head>
<body>
<!-- <div class="spacer"></div> -->
<!-- Controls removed as requested -->
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
<div class="median"></div>
</div>
<!-- <div class="spacer"></div> -->
</body>
</html>

View file

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<link rel="stylesheet" href="../src/style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
</head>
<body>
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
<div class="median"></div>
</div>
</body>
</html>

View file

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../src/style.css">
</head>
<body>
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
<div class="median"></div>
</div>
</body>
</html>

View file

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../src/style.css">
</head>
<body>
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
<div class="median"></div>
</div>
</body>
</html>

View file

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../src/style.css">
</head>
<body>
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
<div class="median"></div>
</div>
</body>
</html>

View file

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../src/style.css">
</head>
<body>
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
<div class="median"></div>
</div>
</body>
</html>

View file

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../src/style.css">
</head>
<body>
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
<div class="median"></div>
</div>
</body>
</html>

View file

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Connected Dots Visualization</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../src/style.css">
</head>
<body>
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
<div class="median"></div>
</div>
</body>
</html>

View file

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" src="/src/main.ts"></script>
<title>Life Line</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../src/style.css">
</head>
<body>
<div class="visualization-container">
<div class="gradient-bg"></div>
<div class="scroll-container" id="scroll-container"></div>
<div class="median"></div>
</div>
</body>
</html>

View file

@ -0,0 +1,15 @@
{
"name": "dot-line-system",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "~5.7.2",
"vite": "^6.3.5"
}
}

View file

@ -0,0 +1,20 @@
{
"name": "dot-line-system",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "~5.7.2",
"vite": "^6.3.5"
},
"scripts": {
"build": "vite build",
"start": "vite",
"tsc": "tsc"
}
}

View file

@ -0,0 +1,20 @@
{
"name": "dot-line-system",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "~5.7.2",
"vite": "^6.3.5"
},
"scripts": {
"build": "vite build",
"start": "vite",
"tsc": "tsc"
}
}

View file

@ -0,0 +1 @@
npm run dev

View file

@ -0,0 +1,2 @@
*Start the project*
npm run dev

View file

@ -0,0 +1,3 @@
**Start the project**
npm run dev

View file

@ -0,0 +1,2 @@
**Start the project**
npm run dev

View file

@ -0,0 +1,4 @@
**Start the project**
npm install
npm run

View file

@ -0,0 +1,7 @@
**Prepare the project**
npm install
**Start the project**
npm start
http://localhost:5173/

View file

@ -0,0 +1,8 @@
**Prepare the project**
npm install
**Start the project**
npm start
**Aufrufen**
http://localhost:5173/

View file

@ -0,0 +1,545 @@
// 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: 200,
tooltipHeight: 150,
...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 * 150; // 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();
}
}

View file

@ -0,0 +1,545 @@
// 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 || 200;
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: 200,
tooltipHeight: 150,
...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 * 150; // 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();
}
}

View file

@ -0,0 +1,545 @@
// 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 || 160;
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: 200,
tooltipHeight: 150,
...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 * 150; // 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();
}
}

View file

@ -0,0 +1,545 @@
// 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 || 160;
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 + 10) * 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: 200,
tooltipHeight: 150,
...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 * 150; // 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();
}
}

View file

@ -0,0 +1,545 @@
// 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 || 160;
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: 200,
tooltipHeight: 150,
...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 * 150; // 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();
}
}

View file

@ -0,0 +1,545 @@
// 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: 200,
tooltipHeight: 150,
...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 * 150; // 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();
}
}

View file

@ -0,0 +1,545 @@
// 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 * 150; // 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();
}
}

View file

@ -0,0 +1,545 @@
// 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();
}
}

View file

@ -0,0 +1,545 @@
// 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 * 300; // 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();
}
}

View file

@ -0,0 +1,545 @@
// 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 * 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)
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();
}
}

View file

@ -0,0 +1,553 @@
// 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 * 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<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();
}
}

View file

@ -0,0 +1,553 @@
// 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 * 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<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();
}
}

View file

@ -0,0 +1,551 @@
// 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 * 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)
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<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();
}
}

View file

@ -0,0 +1,561 @@
// 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 * 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 square background for the tooltip
const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
bg.setAttribute('x', tooltipX.toString());
bg.setAttribute('y', tooltipY.toString());
// Make the width and height equal for a square shape
const squareSize = Math.min(tooltipWidth, tooltipHeight);
bg.setAttribute('width', squareSize.toString());
bg.setAttribute('height', squareSize.toString());
// Optional: Adjust corner rounding if needed
bg.setAttribute('rx', '5'); // You can change this value for rounder corners
bg.setAttribute('ry', '5');
// Set the fill color to white
bg.setAttribute('fill', 'white');
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());
// 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<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();
}
}

View file

@ -0,0 +1,561 @@
// 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 * 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 square background for the tooltip
const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
bg.setAttribute('x', tooltipX.toString());
bg.setAttribute('y', tooltipY.toString());
// Make the width and height equal for a square shape
const squareSize = Math.min(tooltipWidth, tooltipHeight);
bg.setAttribute('width', squareSize.toString());
bg.setAttribute('height', squareSize.toString());
// Optional: Adjust corner rounding if needed
bg.setAttribute('rx', '2'); // You can change this value for rounder corners
bg.setAttribute('ry', '2');
// Set the fill color to white
bg.setAttribute('fill', 'white');
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());
// 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<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();
}
}

View file

@ -0,0 +1,561 @@
// 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 * 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 square background for the tooltip
const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
bg.setAttribute('x', tooltipX.toString());
bg.setAttribute('y', tooltipY.toString());
// Make the width and height equal for a square shape
const squareSize = Math.min(tooltipWidth, tooltipHeight);
bg.setAttribute('width', squareSize.toString());
bg.setAttribute('height', squareSize.toString());
// Optional: Adjust corner rounding if needed
bg.setAttribute('rx', '10'); // You can change this value for rounder corners
bg.setAttribute('ry', '10');
// Set the fill color to white
bg.setAttribute('fill', 'white');
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());
// 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<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();
}
}

View file

@ -0,0 +1,565 @@
// 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 * 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);
// 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
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<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();
}
}

View file

@ -0,0 +1,570 @@
// 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 * 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);
// 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 = '100%';
img.style.height = '100%';
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 = '1px 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('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();
}
}

View file

@ -0,0 +1,570 @@
// 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 * 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);
// 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 = '100%';
img.style.height = '100%';
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('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();
}
}

View file

@ -0,0 +1,570 @@
// 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 * 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);
// 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 = '100%';
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('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();
}
}

View file

@ -0,0 +1,570 @@
// 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 * 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);
// 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('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();
}
}

View file

@ -0,0 +1,631 @@
// 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 * 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);
// 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("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();
}
}

View file

@ -0,0 +1,632 @@
// 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 * 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);
// 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<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();
}
}

View file

@ -0,0 +1,641 @@
// 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 * 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 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<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();
}
}

Some files were not shown because too many files have changed in this diff Show more