Compare commits
5 commits
main
...
wip/stash-
| Author | SHA1 | Date | |
|---|---|---|---|
| 965e2ac6ad | |||
| 5e7133cbfe | |||
| 0cb1db6aca | |||
| 5cd1d1a870 | |||
| 8491d3e454 |
639 changed files with 156635 additions and 2162 deletions
207
.devcontainer/README.md
Normal file
207
.devcontainer/README.md
Normal 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
|
||||
|
||||
99
.devcontainer/devcontainer.json
Normal file
99
.devcontainer/devcontainer.json
Normal 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
76
.gitignore
vendored
|
|
@ -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
27
.mcp.json
Normal 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
90
CLAUDE.md
Normal 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
119
DEV-NOTES.md
Normal 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
296
DOCKER-SETUP.md
Normal 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
|
||||
```
|
||||
|
|
@ -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
11
backend/.mcp.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"laravel-boost": {
|
||||
"command": "php",
|
||||
"args": [
|
||||
"artisan",
|
||||
"boost:mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
269
backend/AGENTS.md
Normal file
269
backend/AGENTS.md
Normal 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
269
backend/CLAUDE.md
Normal 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>
|
||||
199
backend/app/Http/Controllers/Api/EventController.php
Normal file
199
backend/app/Http/Controllers/Api/EventController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
27
backend/app/Http/Requests/StoreEventRequest.php
Normal file
27
backend/app/Http/Requests/StoreEventRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
26
backend/app/Http/Requests/UpdateEventRequest.php
Normal file
26
backend/app/Http/Requests/UpdateEventRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
26
backend/app/Http/Resources/EventResource.php
Normal file
26
backend/app/Http/Resources/EventResource.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
38
backend/app/Models/Event.php
Normal file
38
backend/app/Models/Event.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
17
backend/boost.json
Normal 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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
3446
backend/composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -40,6 +40,10 @@ return [
|
|||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
'api' => [
|
||||
'driver' => 'passport',
|
||||
'provider' => 'users',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|
|
|
|||
48
backend/config/passport.php
Normal file
48
backend/config/passport.php
Normal 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'),
|
||||
|
||||
];
|
||||
28
backend/database/factories/EventFactory.php
Normal file
28
backend/database/factories/EventFactory.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
320
backend/package-lock.json
generated
320
backend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
14
backend/routes/api.php
Normal 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']);
|
||||
});
|
||||
186
backend/tests/Feature/Api/EventTest.php
Normal file
186
backend/tests/Feature/Api/EventTest.php
Normal 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);
|
||||
});
|
||||
|
|
@ -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
165
docker-compose.yml
Normal 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
24
dot-line-system/.gitignore
vendored
Normal 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?
|
||||
86
dot-line-system/.history/index_20250515080205.html
Normal file
86
dot-line-system/.history/index_20250515080205.html
Normal 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>
|
||||
86
dot-line-system/.history/index_20250515093839.html
Normal file
86
dot-line-system/.history/index_20250515093839.html
Normal 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>
|
||||
125
dot-line-system/.history/index_20250522083017.html
Normal file
125
dot-line-system/.history/index_20250522083017.html
Normal 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>
|
||||
92
dot-line-system/.history/index_20250522083042.html
Normal file
92
dot-line-system/.history/index_20250522083042.html
Normal 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>
|
||||
93
dot-line-system/.history/index_20250522083155.html
Normal file
93
dot-line-system/.history/index_20250522083155.html
Normal 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>
|
||||
28
dot-line-system/.history/index_20250522085953.html
Normal file
28
dot-line-system/.history/index_20250522085953.html
Normal 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>
|
||||
28
dot-line-system/.history/index_20250522090004.html
Normal file
28
dot-line-system/.history/index_20250522090004.html
Normal 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>
|
||||
25
dot-line-system/.history/index_20250522090012.html
Normal file
25
dot-line-system/.history/index_20250522090012.html
Normal 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>
|
||||
26
dot-line-system/.history/index_20250522090611.html
Normal file
26
dot-line-system/.history/index_20250522090611.html
Normal 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>
|
||||
26
dot-line-system/.history/index_20250522090656.html
Normal file
26
dot-line-system/.history/index_20250522090656.html
Normal 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>
|
||||
27
dot-line-system/.history/index_20250522090717.html
Normal file
27
dot-line-system/.history/index_20250522090717.html
Normal 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>
|
||||
26
dot-line-system/.history/index_20250522090730.html
Normal file
26
dot-line-system/.history/index_20250522090730.html
Normal 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>
|
||||
29
dot-line-system/.history/index_20250522095109.html
Normal file
29
dot-line-system/.history/index_20250522095109.html
Normal 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>
|
||||
29
dot-line-system/.history/index_20250522114322.html
Normal file
29
dot-line-system/.history/index_20250522114322.html
Normal 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>
|
||||
30
dot-line-system/.history/index_20250522114359.html
Normal file
30
dot-line-system/.history/index_20250522114359.html
Normal 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>
|
||||
29
dot-line-system/.history/index_20250522114425.html
Normal file
29
dot-line-system/.history/index_20250522114425.html
Normal 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>
|
||||
29
dot-line-system/.history/index_20250522114819.html
Normal file
29
dot-line-system/.history/index_20250522114819.html
Normal 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>
|
||||
29
dot-line-system/.history/index_20250522130240.html
Normal file
29
dot-line-system/.history/index_20250522130240.html
Normal 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>
|
||||
29
dot-line-system/.history/index_20250522130258.html
Normal file
29
dot-line-system/.history/index_20250522130258.html
Normal 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>
|
||||
29
dot-line-system/.history/index_20250522130455.html
Normal file
29
dot-line-system/.history/index_20250522130455.html
Normal 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>
|
||||
25
dot-line-system/.history/index_20250522130505.html
Normal file
25
dot-line-system/.history/index_20250522130505.html
Normal 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>
|
||||
25
dot-line-system/.history/index_20250522130518.html
Normal file
25
dot-line-system/.history/index_20250522130518.html
Normal 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>
|
||||
25
dot-line-system/.history/index_20250522130539.html
Normal file
25
dot-line-system/.history/index_20250522130539.html
Normal 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>
|
||||
25
dot-line-system/.history/index_20250522130613.html
Normal file
25
dot-line-system/.history/index_20250522130613.html
Normal 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>
|
||||
25
dot-line-system/.history/index_20250522132851.html
Normal file
25
dot-line-system/.history/index_20250522132851.html
Normal 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>
|
||||
25
dot-line-system/.history/index_20250522133036.html
Normal file
25
dot-line-system/.history/index_20250522133036.html
Normal 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>
|
||||
25
dot-line-system/.history/index_20250522222202.html
Normal file
25
dot-line-system/.history/index_20250522222202.html
Normal 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>
|
||||
25
dot-line-system/.history/index_20250522222233.html
Normal file
25
dot-line-system/.history/index_20250522222233.html
Normal 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>
|
||||
15
dot-line-system/.history/package_20250515093313.json
Normal file
15
dot-line-system/.history/package_20250515093313.json
Normal 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"
|
||||
}
|
||||
}
|
||||
20
dot-line-system/.history/package_20250515093412.json
Normal file
20
dot-line-system/.history/package_20250515093412.json
Normal 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"
|
||||
}
|
||||
}
|
||||
20
dot-line-system/.history/package_20250515093415.json
Normal file
20
dot-line-system/.history/package_20250515093415.json
Normal 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"
|
||||
}
|
||||
}
|
||||
0
dot-line-system/.history/readme_20250521221324.md
Normal file
0
dot-line-system/.history/readme_20250521221324.md
Normal file
1
dot-line-system/.history/readme_20250521221329.md
Normal file
1
dot-line-system/.history/readme_20250521221329.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
npm run dev
|
||||
2
dot-line-system/.history/readme_20250521221343.md
Normal file
2
dot-line-system/.history/readme_20250521221343.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
*Start the project*
|
||||
npm run dev
|
||||
3
dot-line-system/.history/readme_20250521221558.md
Normal file
3
dot-line-system/.history/readme_20250521221558.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
**Start the project**
|
||||
|
||||
npm run dev
|
||||
2
dot-line-system/.history/readme_20250521221603.md
Normal file
2
dot-line-system/.history/readme_20250521221603.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
**Start the project**
|
||||
npm run dev
|
||||
4
dot-line-system/.history/readme_20250521221932.md
Normal file
4
dot-line-system/.history/readme_20250521221932.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
**Start the project**
|
||||
npm install
|
||||
|
||||
npm run
|
||||
7
dot-line-system/.history/readme_20250522081835.md
Normal file
7
dot-line-system/.history/readme_20250522081835.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
**Prepare the project**
|
||||
npm install
|
||||
|
||||
**Start the project**
|
||||
npm start
|
||||
|
||||
http://localhost:5173/
|
||||
8
dot-line-system/.history/readme_20250522081843.md
Normal file
8
dot-line-system/.history/readme_20250522081843.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
**Prepare the project**
|
||||
npm install
|
||||
|
||||
**Start the project**
|
||||
npm start
|
||||
|
||||
**Aufrufen**
|
||||
http://localhost:5173/
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue