add several pages and life wave
7
frontend/package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@quasar/extras": "^1.16.4",
|
"@quasar/extras": "^1.16.4",
|
||||||
|
"gsap": "^3.13.0",
|
||||||
"pinia": "^3.0.1",
|
"pinia": "^3.0.1",
|
||||||
"quasar": "^2.16.0",
|
"quasar": "^2.16.0",
|
||||||
"vue": "^3.4.18",
|
"vue": "^3.4.18",
|
||||||
|
|
@ -3819,6 +3820,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/gsap": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw==",
|
||||||
|
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
|
||||||
|
},
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -15,23 +15,24 @@
|
||||||
"postinstall": "quasar prepare"
|
"postinstall": "quasar prepare"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pinia": "^3.0.1",
|
|
||||||
"@quasar/extras": "^1.16.4",
|
"@quasar/extras": "^1.16.4",
|
||||||
|
"gsap": "^3.13.0",
|
||||||
|
"pinia": "^3.0.1",
|
||||||
"quasar": "^2.16.0",
|
"quasar": "^2.16.0",
|
||||||
"vue": "^3.4.18",
|
"vue": "^3.4.18",
|
||||||
"vue-router": "^4.0.0"
|
"vue-router": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.14.0",
|
"@eslint/js": "^9.14.0",
|
||||||
|
"@quasar/app-vite": "^2.1.0",
|
||||||
|
"@vue/eslint-config-prettier": "^10.1.0",
|
||||||
|
"autoprefixer": "^10.4.2",
|
||||||
"eslint": "^9.14.0",
|
"eslint": "^9.14.0",
|
||||||
"eslint-plugin-vue": "^9.30.0",
|
"eslint-plugin-vue": "^9.30.0",
|
||||||
"globals": "^15.12.0",
|
"globals": "^15.12.0",
|
||||||
"vite-plugin-checker": "^0.9.0",
|
"postcss": "^8.4.14",
|
||||||
"@vue/eslint-config-prettier": "^10.1.0",
|
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"@quasar/app-vite": "^2.1.0",
|
"vite-plugin-checker": "^0.9.0"
|
||||||
"autoprefixer": "^10.4.2",
|
|
||||||
"postcss": "^8.4.14"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^28 || ^26 || ^24 || ^22 || ^20 || ^18",
|
"node": "^28 || ^26 || ^24 || ^22 || ^20 || ^18",
|
||||||
|
|
|
||||||
BIN
frontend/public/images/0_2.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
frontend/public/images/0_3.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
frontend/public/images/disco.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
frontend/public/images/entspannung.png
Normal file
|
After Width: | Height: | Size: 278 KiB |
BIN
frontend/public/images/familie.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
frontend/public/images/familie2.png
Normal file
|
After Width: | Height: | Size: 340 KiB |
BIN
frontend/public/images/feier.png
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
frontend/public/images/gpt.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
frontend/public/images/grosseltern.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
frontend/public/images/hochzeit.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
frontend/public/images/kindergeburtstag.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
frontend/public/images/kinobesuch.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
frontend/public/images/klasse.png
Normal file
|
After Width: | Height: | Size: 344 KiB |
BIN
frontend/public/images/oma.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
frontend/public/images/pferd.png
Normal file
|
After Width: | Height: | Size: 274 KiB |
BIN
frontend/public/images/see.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
frontend/public/images/sonntag.png
Normal file
|
After Width: | Height: | Size: 630 KiB |
BIN
frontend/public/images/work.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
11
frontend/public/js/ScrollTrigger.min.js
vendored
Normal file
1
frontend/public/js/ScrollTrigger.min.js.map
Normal file
11
frontend/public/js/gsap.min.js
vendored
Normal file
1
frontend/public/js/gsap.min.js.map
Normal file
|
|
@ -93,6 +93,10 @@ export default defineConfig((/* ctx */) => {
|
||||||
plugins: []
|
plugins: []
|
||||||
},
|
},
|
||||||
|
|
||||||
|
htmlVariables: {
|
||||||
|
fonts: '<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">'
|
||||||
|
},
|
||||||
|
|
||||||
// animations: 'all', // --- includes all animations
|
// animations: 'all', // --- includes all animations
|
||||||
// https://v2.quasar.dev/options/animations
|
// https://v2.quasar.dev/options/animations
|
||||||
animations: [],
|
animations: [],
|
||||||
|
|
|
||||||
|
|
@ -1 +1,175 @@
|
||||||
// app global css in SCSS form
|
// Variables
|
||||||
|
$primary-color: #4f46e5;
|
||||||
|
$gradient-colors: (45deg, #8634f9, #ffab1a, #ff2fa2);
|
||||||
|
$button-radius: 4px;
|
||||||
|
$button-padding: 6px 12px;
|
||||||
|
$tooltip-radius: 4px;
|
||||||
|
$image-size: 80px;
|
||||||
|
|
||||||
|
// Mixins
|
||||||
|
@mixin flex-center {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global Styles
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: $button-padding;
|
||||||
|
background-color: $primary-color;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: $button-radius;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wave Visualization Styles
|
||||||
|
.visualization-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100vh - 86px);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-bg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: -1;
|
||||||
|
background: linear-gradient(45deg, #8634f9, #ffab1a, #ff2fa2);
|
||||||
|
background-size: 200% 200%;
|
||||||
|
animation: gradientAnimation 20s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradientAnimation {
|
||||||
|
0% { background-position: 0% 0%; }
|
||||||
|
25% { background-position: 100% 0%; }
|
||||||
|
50% { background-position: 100% 100%; }
|
||||||
|
75% { background-position: 0% 100%; }
|
||||||
|
100% { background-position: 0% 0%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.median {
|
||||||
|
position: absolute;
|
||||||
|
top: 54.75%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background-color: rgba(255,255,255,0.3);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
|
.smooth-scroll {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-tooltip {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 1;
|
||||||
|
|
||||||
|
.tooltip-background {
|
||||||
|
fill: rgba(0, 0, 0, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-content {
|
||||||
|
@include flex-center;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image_container {
|
||||||
|
margin-top: 8px;
|
||||||
|
box-shadow: 0 0 20px 0 rgba(255, 255, 255, 0.25);
|
||||||
|
transition: box-shadow 0.25s ease-in-out;
|
||||||
|
width: $image-size;
|
||||||
|
height: $image-size;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid white;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 0 30px 0 rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-image {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
text-align: center;
|
||||||
|
text-wrap: balance;
|
||||||
|
hyphens: auto;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-description {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-arrow {
|
||||||
|
width: 1px;
|
||||||
|
height: 30px;
|
||||||
|
background: linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.5), transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
transition: r 0.2s ease, fill 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
fill: rgba(255, 255, 255, 0.9);
|
||||||
|
filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: $tooltip-radius;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,29 +6,22 @@
|
||||||
MIIUS <!-- Me You Us -->
|
MIIUS <!-- Me You Us -->
|
||||||
</q-toolbar-title>
|
</q-toolbar-title>
|
||||||
|
|
||||||
<q-btn
|
<!-- Add login status indicator -->
|
||||||
flat
|
<q-chip v-if="isLoggedIn" color="green" text-color="white" icon="check_circle">
|
||||||
dense
|
Logged In
|
||||||
round
|
</q-chip>
|
||||||
icon="menu"
|
<q-chip v-else color="grey" text-color="white" icon="person_off">
|
||||||
aria-label="Menu"
|
Not Logged In
|
||||||
@click="toggleDrawer"
|
</q-chip>
|
||||||
/>
|
|
||||||
|
<q-btn flat dense round icon="menu" aria-label="Menu" @click="toggleDrawer" />
|
||||||
|
|
||||||
</q-toolbar>
|
</q-toolbar>
|
||||||
</q-header>
|
</q-header>
|
||||||
|
|
||||||
<!-- Off-Canvas Drawer -->
|
<!-- Off-Canvas Drawer -->
|
||||||
<q-drawer
|
<q-drawer v-model="drawer" show-if-above side="right" :width="280" :breakpoint="768" class="bg-grey-1"
|
||||||
v-model="drawer"
|
style="overflow: hidden;">
|
||||||
show-if-above
|
|
||||||
side="right"
|
|
||||||
:width="280"
|
|
||||||
:breakpoint="768"
|
|
||||||
|
|
||||||
class="bg-grey-1"
|
|
||||||
style="overflow: hidden;"
|
|
||||||
>
|
|
||||||
<!-- Sliding Menu Container -->
|
<!-- Sliding Menu Container -->
|
||||||
<div class="menu-container" :style="{ transform: `translateX(${slideOffset}px)` }">
|
<div class="menu-container" :style="{ transform: `translateX(${slideOffset}px)` }">
|
||||||
|
|
||||||
|
|
@ -37,16 +30,28 @@
|
||||||
|
|
||||||
<!-- Main Menu List -->
|
<!-- Main Menu List -->
|
||||||
<q-list>
|
<q-list>
|
||||||
<q-item clickable v-ripple @click="navigateTo('life-wave')">
|
|
||||||
|
<q-item clickable v-ripple @click="navigateTo('login')" v-if="!isLoggedIn" to="/login">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="login" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Login</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<!-- Move password reset to Account submenu and only show when logged in -->
|
||||||
|
|
||||||
|
<q-item clickable v-ripple @click="navigateTo('wave')" v-if="isLoggedIn" to="/wave">
|
||||||
<q-item-section avatar>
|
<q-item-section avatar>
|
||||||
<q-icon name="line_axis" />
|
<q-icon name="line_axis" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label>Life Wave</q-item-label>
|
<q-item-label>Wave</q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('new-entry')">
|
<q-item clickable v-ripple @click="navigateTo('new-entry')" v-if="isLoggedIn">
|
||||||
<q-item-section avatar>
|
<q-item-section avatar>
|
||||||
<q-icon name="add_circle" />
|
<q-icon name="add_circle" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
|
|
@ -55,7 +60,7 @@
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="openSubmenu('my-people', 'My People')">
|
<q-item clickable v-ripple @click="openSubmenu('my-people', 'My People')" v-if="isLoggedIn">
|
||||||
<q-item-section avatar>
|
<q-item-section avatar>
|
||||||
<q-icon name="people" />
|
<q-icon name="people" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
|
|
@ -67,7 +72,7 @@
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="openSubmenu('messages', 'Messages')">
|
<q-item clickable v-ripple @click="openSubmenu('messages', 'Messages')" v-if="isLoggedIn">
|
||||||
<q-item-section avatar>
|
<q-item-section avatar>
|
||||||
<q-icon name="mail" />
|
<q-icon name="mail" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
|
|
@ -81,7 +86,7 @@
|
||||||
|
|
||||||
<q-separator />
|
<q-separator />
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="openSubmenu('profile', 'Profile')">
|
<q-item clickable v-ripple @click="openSubmenu('profile', 'Profile')" v-if="isLoggedIn">
|
||||||
<q-item-section avatar>
|
<q-item-section avatar>
|
||||||
<q-icon name="person" />
|
<q-icon name="person" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
|
|
@ -93,7 +98,7 @@
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="openSubmenu('account', 'Account')">
|
<q-item clickable v-ripple @click="openSubmenu('account', 'Account')" v-if="isLoggedIn">
|
||||||
<q-item-section avatar>
|
<q-item-section avatar>
|
||||||
<q-icon name="account_circle" />
|
<q-icon name="account_circle" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
|
|
@ -105,30 +110,16 @@
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
|
|
||||||
|
<q-item clickable v-ripple @click="navigateTo('sign-up')" to="/sign-up" v-if="!isLoggedIn">
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="openSubmenu('login', 'Login')">
|
|
||||||
<q-item-section avatar>
|
<q-item-section avatar>
|
||||||
<q-icon name="login" />
|
<q-icon name="person_add" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label>Login</q-item-label>
|
<q-item-label>Sign Up</q-item-label>
|
||||||
</q-item-section>
|
|
||||||
<q-item-section side>
|
|
||||||
<q-icon name="chevron_right" />
|
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="navigateTo('sign-up')">
|
<q-separator />
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon name="person_add" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Sign Up</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
|
|
||||||
<q-separator />
|
|
||||||
|
|
||||||
<q-item clickable v-ripple @click="openSubmenu('support', 'Need Help?')">
|
<q-item clickable v-ripple @click="openSubmenu('support', 'Need Help?')">
|
||||||
<q-item-section avatar>
|
<q-item-section avatar>
|
||||||
|
|
@ -159,14 +150,7 @@
|
||||||
<!-- Second Level Menu Panel -->
|
<!-- Second Level Menu Panel -->
|
||||||
<div class="menu-panel">
|
<div class="menu-panel">
|
||||||
<div class="q-pa-md bg-secondary text-white">
|
<div class="q-pa-md bg-secondary text-white">
|
||||||
<q-btn
|
<q-btn flat round icon="arrow_back" @click="goBack" class="q-mr-sm" size="sm" />
|
||||||
flat
|
|
||||||
round
|
|
||||||
icon="arrow_back"
|
|
||||||
@click="goBack"
|
|
||||||
class="q-mr-sm"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<span class="text-h6">{{ currentSubmenuTitle }}</span>
|
<span class="text-h6">{{ currentSubmenuTitle }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -288,6 +272,17 @@
|
||||||
<q-icon name="chevron_right" />
|
<q-icon name="chevron_right" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
|
|
||||||
|
<!-- Add a logout button that only appears when logged in -->
|
||||||
|
<q-item clickable v-ripple @click="logout" v-if="isLoggedIn">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="logout" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Logout</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
</q-list>
|
</q-list>
|
||||||
|
|
||||||
<!-- Login Submenu -->
|
<!-- Login Submenu -->
|
||||||
|
|
@ -375,19 +370,12 @@
|
||||||
<!-- Third Level Menu Panel -->
|
<!-- Third Level Menu Panel -->
|
||||||
<div class="menu-panel">
|
<div class="menu-panel">
|
||||||
<div class="q-pa-md bg-accent text-white">
|
<div class="q-pa-md bg-accent text-white">
|
||||||
<q-btn
|
<q-btn flat round icon="arrow_back" @click="goBack" class="q-mr-sm" size="sm" />
|
||||||
flat
|
|
||||||
round
|
|
||||||
icon="arrow_back"
|
|
||||||
@click="goBack"
|
|
||||||
class="q-mr-sm"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<span class="text-h6">{{ currentSubmenuTitle }}</span>
|
<span class="text-h6">{{ currentSubmenuTitle }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- Message Center Submenu -->
|
<!-- Message Center Submenu -->
|
||||||
|
|
||||||
<q-list v-if="currentSubmenu === 'message-center'">
|
<q-list v-if="currentSubmenu === 'message-center'">
|
||||||
<q-item clickable v-ripple @click="navigateTo('new-message')">
|
<q-item clickable v-ripple @click="navigateTo('new-message')">
|
||||||
|
|
@ -504,7 +492,7 @@
|
||||||
<q-route-tab to="/people" icon="people" />
|
<q-route-tab to="/people" icon="people" />
|
||||||
<q-route-tab to="/entry" icon="add_circle_outline" />
|
<q-route-tab to="/entry" icon="add_circle_outline" />
|
||||||
<q-route-tab to="/messages" icon="mail" />
|
<q-route-tab to="/messages" icon="mail" />
|
||||||
<q-route-tab to="/profile" icon="person"/>
|
<q-route-tab to="/profile" icon="person" />
|
||||||
|
|
||||||
</q-tabs>
|
</q-tabs>
|
||||||
</q-footer>
|
</q-footer>
|
||||||
|
|
@ -513,11 +501,13 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'MultiLevelSlidingMenu',
|
name: 'MultiLevelSlidingMenu',
|
||||||
setup() {
|
setup() {
|
||||||
|
const router = useRouter()
|
||||||
const drawer = ref(false)
|
const drawer = ref(false)
|
||||||
const currentRoute = ref('home')
|
const currentRoute = ref('home')
|
||||||
const menuLevel = ref(0)
|
const menuLevel = ref(0)
|
||||||
|
|
@ -525,6 +515,22 @@ export default {
|
||||||
const currentSubmenuTitle = ref('')
|
const currentSubmenuTitle = ref('')
|
||||||
const menuHistory = ref([])
|
const menuHistory = ref([])
|
||||||
|
|
||||||
|
// Make isLoggedIn reactive to localStorage changes
|
||||||
|
const isLoggedIn = ref(localStorage.getItem('isLoggedIn') === 'true')
|
||||||
|
|
||||||
|
// Listen for storage changes (when logging in from another tab/component)
|
||||||
|
const updateLoginStatus = () => {
|
||||||
|
isLoggedIn.value = localStorage.getItem('isLoggedIn') === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('storage', updateLoginStatus)
|
||||||
|
// Also check on route changes
|
||||||
|
router.afterEach(() => {
|
||||||
|
updateLoginStatus()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const slideOffset = computed(() => {
|
const slideOffset = computed(() => {
|
||||||
return menuLevel.value * -280 // Each level slides 280px (drawer width) to the left
|
return menuLevel.value * -280 // Each level slides 280px (drawer width) to the left
|
||||||
})
|
})
|
||||||
|
|
@ -590,11 +596,21 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the login function
|
||||||
|
const login = () => {
|
||||||
|
isLoggedIn.value = true
|
||||||
|
localStorage.setItem('isLoggedIn', 'true')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the logout function
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
console.log('Logging out...')
|
console.log('Logging out...')
|
||||||
|
isLoggedIn.value = false
|
||||||
|
localStorage.setItem('isLoggedIn', 'false')
|
||||||
currentRoute.value = 'login'
|
currentRoute.value = 'login'
|
||||||
drawer.value = false
|
drawer.value = false
|
||||||
resetMenu()
|
resetMenu()
|
||||||
|
router.push('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -609,7 +625,9 @@ export default {
|
||||||
openSubmenu,
|
openSubmenu,
|
||||||
goBack,
|
goBack,
|
||||||
navigateTo,
|
navigateTo,
|
||||||
logout
|
logout,
|
||||||
|
isLoggedIn,
|
||||||
|
login
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -627,23 +645,34 @@ export default {
|
||||||
|
|
||||||
@keyframes gradientAnimation {
|
@keyframes gradientAnimation {
|
||||||
0% {
|
0% {
|
||||||
background-position: 0% 0%; /* Start Links Mitte */
|
background-position: 0% 0%;
|
||||||
|
/* Start Links Mitte */
|
||||||
}
|
}
|
||||||
|
|
||||||
25% {
|
25% {
|
||||||
background-position: 100% 0%; /* Oben Rechts */
|
background-position: 100% 0%;
|
||||||
|
/* Oben Rechts */
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
background-position: 100% 100%; /* Unten Rechts */
|
background-position: 100% 100%;
|
||||||
|
/* Unten Rechts */
|
||||||
}
|
}
|
||||||
|
|
||||||
75% {
|
75% {
|
||||||
background-position: 0% 100%; /* Unten Links */
|
background-position: 0% 100%;
|
||||||
|
/* Unten Links */
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
background-position: 0% 0%; /* Zurück zum Start Links Mitte */
|
background-position: 0% 0%;
|
||||||
|
/* Zurück zum Start Links Mitte */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.q-footer .q-tab__label {
|
.q-footer .q-tab__label {
|
||||||
font-size: 0.7rem; /* Passe diesen Wert nach Bedarf an */
|
font-size: 0.7rem;
|
||||||
|
/* Passe diesen Wert nach Bedarf an */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -655,7 +684,8 @@ export default {
|
||||||
/* Menu container for sliding panels */
|
/* Menu container for sliding panels */
|
||||||
.menu-container {
|
.menu-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: calc(100% * 3); /* Width for 3 levels */
|
width: calc(100% * 3);
|
||||||
|
/* Width for 3 levels */
|
||||||
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
68
frontend/src/pages/LoginPage.vue
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<template>
|
||||||
|
<q-page padding class="flex flex-center">
|
||||||
|
<q-card style="width: 350px">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6">Login</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section>
|
||||||
|
<q-form @submit.prevent="onSubmit" class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
label="Email"
|
||||||
|
filled
|
||||||
|
:rules="[val => !!val || 'Email is required']"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
label="Password"
|
||||||
|
filled
|
||||||
|
:rules="[val => !!val || 'Password is required']"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="q-mt-md">
|
||||||
|
<q-btn label="Login" type="submit" color="primary" class="full-width"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center q-mt-sm">
|
||||||
|
<router-link to="/password-reset" class="text-primary">Forgot your password?</router-link>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'LoginPage',
|
||||||
|
setup() {
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
|
||||||
|
const onSubmit = () => {
|
||||||
|
console.log('Login attempt with:', email.value, password.value)
|
||||||
|
|
||||||
|
// Save login status
|
||||||
|
localStorage.setItem('isLoggedIn', 'true')
|
||||||
|
window.dispatchEvent(new Event('storage'))
|
||||||
|
|
||||||
|
console.log('Redirecting to wave page...')
|
||||||
|
|
||||||
|
// Direct navigation
|
||||||
|
window.location.href = '/#/wave'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
onSubmit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
95
frontend/src/pages/PasswordResetPage.vue
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
<template>
|
||||||
|
<q-page padding class="flex flex-center">
|
||||||
|
<q-card style="width: 350px">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6">Password Reset</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section>
|
||||||
|
<p class="text-body2 q-mb-md">
|
||||||
|
Enter your email address and we'll send you a link to reset your password.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<q-form @submit="onSubmit" class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
label="Email"
|
||||||
|
filled
|
||||||
|
:rules="[val => !!val || 'Email is required']"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="q-mt-md">
|
||||||
|
<q-btn
|
||||||
|
label="Send Reset Link"
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
class="full-width"
|
||||||
|
:loading="loading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center q-mt-sm">
|
||||||
|
<router-link to="/login" class="text-primary">Back to login</router-link>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-dialog v-model="successDialog">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6">Email Sent</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section>
|
||||||
|
<p>If an account exists with that email, we've sent instructions to reset your password.</p>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn flat label="Close" color="primary" v-close-popup @click="goToLogin" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</q-card>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'PasswordResetPage',
|
||||||
|
setup() {
|
||||||
|
const router = useRouter()
|
||||||
|
const email = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const successDialog = ref(false)
|
||||||
|
|
||||||
|
const onSubmit = () => {
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
// Here you would call your API to request password reset
|
||||||
|
console.log('Password reset requested for:', email.value)
|
||||||
|
|
||||||
|
// Simulate API call
|
||||||
|
setTimeout(() => {
|
||||||
|
loading.value = false
|
||||||
|
successDialog.value = true
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToLogin = () => {
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
email,
|
||||||
|
loading,
|
||||||
|
onSubmit,
|
||||||
|
successDialog,
|
||||||
|
goToLogin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
138
frontend/src/pages/SignUpPage.vue
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
<template>
|
||||||
|
<q-page padding class="flex flex-center">
|
||||||
|
<q-card style="width: 400px">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6">Sign Up</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<q-form @submit="onSubmit" class="q-gutter-md">
|
||||||
|
<q-input v-model="firstName" label="First Name" filled :rules="[val => !!val || 'First name is required']" />
|
||||||
|
<q-input v-model="lastName" label="Last Name" filled :rules="[val => !!val || 'Last name is required']" />
|
||||||
|
<!-- Add gender selection -->
|
||||||
|
<q-select v-model="gender" :options="genderOptions" label="Gender" filled
|
||||||
|
:rules="[val => !!val || 'Please select a gender']" />
|
||||||
|
<q-input v-model="email" type="email" label="Email" filled :rules="[
|
||||||
|
val => !!val || 'Email is required',
|
||||||
|
val => /^[^@]+@[^@]+\.[^@]+$/.test(val) || 'Please enter a valid email'
|
||||||
|
]" />
|
||||||
|
<q-input v-model="password" type="password" label="Password" filled :rules="[
|
||||||
|
val => !!val || 'Password is required',
|
||||||
|
val => val.length >= 8 || 'Password must be at least 8 characters'
|
||||||
|
]" />
|
||||||
|
<q-input v-model="confirmPassword" type="password" label="Confirm Password" filled :rules="[
|
||||||
|
val => !!val || 'Please confirm your password',
|
||||||
|
val => val === password || 'Passwords do not match'
|
||||||
|
]" />
|
||||||
|
<q-checkbox v-model="termsAccepted" label="I agree to the Terms of Service">
|
||||||
|
<template v-slot:default>
|
||||||
|
I agree to the <a href="#" @click.prevent="showTerms = true" class="text-primary">Terms of Service</a>
|
||||||
|
</template>
|
||||||
|
</q-checkbox>
|
||||||
|
<div class="q-mt-md">
|
||||||
|
<q-btn label="Sign Up" type="submit" color="primary" class="full-width" :loading="loading"
|
||||||
|
:disable="!termsAccepted" />
|
||||||
|
</div>
|
||||||
|
<div class="text-center q-mt-sm">
|
||||||
|
Already have an account? <router-link to="/login" class="text-primary">Log in</router-link>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card-section>
|
||||||
|
<q-dialog v-model="successDialog">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6">Registration Successful</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<p>Your account has been created successfully. You can now log in.</p>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn flat label="Go to Login" color="primary" v-close-popup @click="goToLogin" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
<q-dialog v-model="showTerms">
|
||||||
|
<q-card style="width: 700px; max-width: 80vw">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6">Terms of Service</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section style="max-height: 70vh" class="scroll">
|
||||||
|
<p>These Terms of Service ("Terms") govern your use of our website and services.</p>
|
||||||
|
<p>By using our services, you agree to these Terms. Please read them carefully.</p>
|
||||||
|
<h6 class="text-subtitle1 q-mt-md">1. Your Account</h6>
|
||||||
|
<p>You are responsible for safeguarding your account and for any activities or actions under your account.
|
||||||
|
</p>
|
||||||
|
<h6 class="text-subtitle1 q-mt-md">2. Privacy</h6>
|
||||||
|
<p>Our Privacy Policy explains how we treat your personal data and protect your privacy.</p>
|
||||||
|
<!-- Add more terms as needed -->
|
||||||
|
<h6 class="text-subtitle1 q-mt-md">3. Termination</h6>
|
||||||
|
<p>We may terminate or suspend your account at any time for any reason.</p>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn flat label="Close" color="primary" v-close-popup />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</q-card>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
export default {
|
||||||
|
name: 'SignUpPage',
|
||||||
|
setup() {
|
||||||
|
const router = useRouter()
|
||||||
|
const firstName = ref('')
|
||||||
|
const lastName = ref('')
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const termsAccepted = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const successDialog = ref(false)
|
||||||
|
const showTerms = ref(false)
|
||||||
|
// Add gender field
|
||||||
|
const gender = ref('')
|
||||||
|
const genderOptions = [
|
||||||
|
'Male',
|
||||||
|
'Female',
|
||||||
|
'Non-binary',
|
||||||
|
'Prefer not to say'
|
||||||
|
]
|
||||||
|
const onSubmit = () => {
|
||||||
|
loading.value = true
|
||||||
|
// Here you would call your API to register the user
|
||||||
|
console.log('Sign up attempt with:', {
|
||||||
|
firstName: firstName.value,
|
||||||
|
lastName: lastName.value,
|
||||||
|
gender: gender.value,
|
||||||
|
email: email.value,
|
||||||
|
password: password.value
|
||||||
|
})
|
||||||
|
// Simulate API call
|
||||||
|
setTimeout(() => {
|
||||||
|
loading.value = false
|
||||||
|
successDialog.value = true
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
const goToLogin = () => {
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
gender,
|
||||||
|
genderOptions,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
confirmPassword,
|
||||||
|
termsAccepted,
|
||||||
|
loading,
|
||||||
|
onSubmit,
|
||||||
|
successDialog,
|
||||||
|
showTerms,
|
||||||
|
goToLogin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
313
frontend/src/pages/WavePage.vue
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
<template>
|
||||||
|
<q-page>
|
||||||
|
<div class="visualization-container">
|
||||||
|
<div class="gradient-bg"></div>
|
||||||
|
<div class="scroll-container" id="scroll-container"></div>
|
||||||
|
<div class="median"></div>
|
||||||
|
</div>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { onMounted, onBeforeUnmount, defineComponent } from 'vue'
|
||||||
|
import { ConnectedDotsVisualization } from "../utils/ConnectedDotsVisualization"
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'WavePage',
|
||||||
|
setup() {
|
||||||
|
let visualization = null;
|
||||||
|
let isDown = false;
|
||||||
|
let startX;
|
||||||
|
let scrollLeft;
|
||||||
|
|
||||||
|
// Function to handle cleanup of event listeners
|
||||||
|
const cleanupEventListeners = () => {
|
||||||
|
const scrollContainer = document.querySelector('.scroll-container');
|
||||||
|
if (scrollContainer) {
|
||||||
|
scrollContainer.removeEventListener('mousedown', handleMouseDown);
|
||||||
|
scrollContainer.removeEventListener('mouseleave', handleMouseLeave);
|
||||||
|
scrollContainer.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
scrollContainer.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
scrollContainer.removeEventListener('touchstart', handleTouchStart);
|
||||||
|
scrollContainer.removeEventListener('touchend', handleTouchEnd);
|
||||||
|
scrollContainer.removeEventListener('touchmove', handleTouchMove);
|
||||||
|
}
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
const handleResize = () => {
|
||||||
|
if (visualization) {
|
||||||
|
visualization.resize();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseDown = (e) => {
|
||||||
|
const scrollContainer = e.currentTarget;
|
||||||
|
isDown = true;
|
||||||
|
scrollContainer.classList.add('active');
|
||||||
|
startX = e.pageX - scrollContainer.offsetLeft;
|
||||||
|
scrollLeft = scrollContainer.scrollLeft;
|
||||||
|
scrollContainer.classList.remove('smooth-scroll');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = (e) => {
|
||||||
|
if (!isDown) return;
|
||||||
|
isDown = false;
|
||||||
|
e.currentTarget.classList.remove('active');
|
||||||
|
e.currentTarget.classList.add('smooth-scroll');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = (e) => {
|
||||||
|
if (!isDown) return;
|
||||||
|
isDown = false;
|
||||||
|
e.currentTarget.classList.remove('active');
|
||||||
|
e.currentTarget.classList.add('smooth-scroll');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e) => {
|
||||||
|
if (!isDown) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const scrollContainer = e.currentTarget;
|
||||||
|
const x = e.pageX - scrollContainer.offsetLeft;
|
||||||
|
const walk = (x - startX) * 3;
|
||||||
|
scrollContainer.scrollLeft = scrollLeft - walk;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchStart = (e) => {
|
||||||
|
const scrollContainer = e.currentTarget;
|
||||||
|
isDown = true;
|
||||||
|
scrollContainer.classList.add('active');
|
||||||
|
startX = e.touches[0].pageX - scrollContainer.offsetLeft;
|
||||||
|
scrollLeft = scrollContainer.scrollLeft;
|
||||||
|
scrollContainer.classList.remove('smooth-scroll');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = (e) => {
|
||||||
|
if (!isDown) return;
|
||||||
|
isDown = false;
|
||||||
|
e.currentTarget.classList.remove('active');
|
||||||
|
e.currentTarget.classList.add('smooth-scroll');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e) => {
|
||||||
|
if (!isDown) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const scrollContainer = e.currentTarget;
|
||||||
|
const x = e.touches[0].pageX - scrollContainer.offsetLeft;
|
||||||
|
const walk = (x - startX) * 3;
|
||||||
|
scrollContainer.scrollLeft = scrollLeft - walk;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
try {
|
||||||
|
console.log("Initializing Wave visualization...");
|
||||||
|
|
||||||
|
// Example sample dots data
|
||||||
|
const sampleDots = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
value: -1.8,
|
||||||
|
x: -2,
|
||||||
|
imageUrl: "/images/0_3.png",
|
||||||
|
title: "Beginn des neuen Abenteuers",
|
||||||
|
description: "01.10.2024",
|
||||||
|
link: "/page1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
value: 1.2,
|
||||||
|
x: 0,
|
||||||
|
imageUrl: "/images/0_2.png",
|
||||||
|
title: "Omas Annis Geburtstag",
|
||||||
|
description: "02.10.2024",
|
||||||
|
link: "/page2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
value: -0.6,
|
||||||
|
x: 2,
|
||||||
|
imageUrl: "/images/disco.png",
|
||||||
|
title: "Konzertbesuch mit Freunden",
|
||||||
|
description: "03.10.2024",
|
||||||
|
link: "/page3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
value: 3,
|
||||||
|
x: 4,
|
||||||
|
imageUrl: "/images/pferd.png",
|
||||||
|
title: "Wanderreiten in den Bergen",
|
||||||
|
description: "04.10.2024",
|
||||||
|
link: "/page4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
value: 1,
|
||||||
|
x: 6,
|
||||||
|
imageUrl: "/images/gpt.png",
|
||||||
|
title: "Ruhiger Tag zu Hause",
|
||||||
|
description: "05.10.2024",
|
||||||
|
link: "/page5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
value: -3,
|
||||||
|
x: 8,
|
||||||
|
imageUrl: "/images/oma.png",
|
||||||
|
title: "Oma Erna verstorben",
|
||||||
|
description: "06.10.2024",
|
||||||
|
link: "/page6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
value: 1.5,
|
||||||
|
x: 10,
|
||||||
|
imageUrl: "/images/see.png",
|
||||||
|
title: "Erholungsausflug zum See",
|
||||||
|
description: "07.10.2024",
|
||||||
|
link: "/page7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
value: 0,
|
||||||
|
x: 12,
|
||||||
|
imageUrl: "/images/feier.png",
|
||||||
|
title: "Kleine Wochenendsfeier",
|
||||||
|
description: "08.10.2024",
|
||||||
|
link: "/page8",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
value: 3,
|
||||||
|
x: 14,
|
||||||
|
imageUrl: "/images/hochzeit.png",
|
||||||
|
title: "Hochzeit von Cousine Tatjana",
|
||||||
|
description: "09.10.2024",
|
||||||
|
link: "/page9",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
value: 1,
|
||||||
|
x: 16,
|
||||||
|
imageUrl: "/images/work.png",
|
||||||
|
title: "Erster Tag im neuen Job",
|
||||||
|
description: "10.10.2024",
|
||||||
|
link: "/page10",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
value: -1.2,
|
||||||
|
x: 18,
|
||||||
|
imageUrl: "/images/klasse.png",
|
||||||
|
title: "Klassentreffen nach vielen Jahren",
|
||||||
|
description: "11.10.2024",
|
||||||
|
link: "/page11",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 12,
|
||||||
|
value: -0.6,
|
||||||
|
x: 20,
|
||||||
|
imageUrl: "/images/familie.png",
|
||||||
|
title: "Familienabendessen",
|
||||||
|
description: "12.10.2024",
|
||||||
|
link: "/page12",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 13,
|
||||||
|
value: 2.7,
|
||||||
|
x: 22,
|
||||||
|
imageUrl:
|
||||||
|
"/images/kinobesuch.png",
|
||||||
|
title: "Kinobesuch mit der ganzen Familie",
|
||||||
|
description: "13.10.2024",
|
||||||
|
link: "/page13",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 14,
|
||||||
|
value: 0,
|
||||||
|
x: 24,
|
||||||
|
imageUrl:
|
||||||
|
"/images/entspannung.png",
|
||||||
|
title: "Entspannung",
|
||||||
|
description: "14.10.2024",
|
||||||
|
link: "/page14",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 15,
|
||||||
|
value: -2.9,
|
||||||
|
x: 26,
|
||||||
|
imageUrl: "/images/sonntag.png",
|
||||||
|
title: "Geruhsamer Sonntag",
|
||||||
|
description: "15.10.2024",
|
||||||
|
link: "/page15",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 16,
|
||||||
|
value: 1.5,
|
||||||
|
x: 28,
|
||||||
|
imageUrl:
|
||||||
|
"/images/kindergeburtstag.png",
|
||||||
|
title: "Kindergeburtstag",
|
||||||
|
description: "16.10.2024",
|
||||||
|
link: "/page16",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 17,
|
||||||
|
value: 0,
|
||||||
|
x: 30,
|
||||||
|
imageUrl:
|
||||||
|
"/images/familie2.png",
|
||||||
|
title: "Spaziergang mit der Familie",
|
||||||
|
description: "17.10.2024",
|
||||||
|
link: "/page17",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 18,
|
||||||
|
value: 2.1,
|
||||||
|
x: 32,
|
||||||
|
imageUrl:
|
||||||
|
"/images/grosseltern.png",
|
||||||
|
title: "Familienfeier bei den Großeltern",
|
||||||
|
description: "18.10.2024",
|
||||||
|
link: "/page18",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Initialize the visualization with the sample dots
|
||||||
|
visualization = new ConnectedDotsVisualization('scroll-container', sampleDots, {
|
||||||
|
// Optional custom configuration
|
||||||
|
dotRadius: 6,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle window resize
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
// Set up scroll interactions
|
||||||
|
const scrollContainer = document.querySelector('.scroll-container');
|
||||||
|
if (scrollContainer) {
|
||||||
|
// Mouse events
|
||||||
|
scrollContainer.addEventListener('mousedown', handleMouseDown);
|
||||||
|
scrollContainer.addEventListener('mouseleave', handleMouseLeave);
|
||||||
|
scrollContainer.addEventListener('mouseup', handleMouseUp);
|
||||||
|
scrollContainer.addEventListener('mousemove', handleMouseMove);
|
||||||
|
|
||||||
|
// Touch events
|
||||||
|
scrollContainer.addEventListener('touchstart', handleTouchStart);
|
||||||
|
scrollContainer.addEventListener('touchend', handleTouchEnd);
|
||||||
|
scrollContainer.addEventListener('touchmove', handleTouchMove);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error initializing visualization:", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up event listeners when component is unmounted
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
cleanupEventListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
@ -3,16 +3,35 @@ const routes = [
|
||||||
path: '/',
|
path: '/',
|
||||||
component: () => import('layouts/MainLayout.vue'),
|
component: () => import('layouts/MainLayout.vue'),
|
||||||
children: [
|
children: [
|
||||||
{ path: '', component: () => import('pages/IndexPage.vue') }
|
{ path: '', component: () => import('pages/IndexPage.vue') },
|
||||||
]
|
{
|
||||||
|
path: 'login',
|
||||||
|
name: 'login',
|
||||||
|
component: () => import('pages/LoginPage.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sign-up',
|
||||||
|
name: 'sign-up',
|
||||||
|
component: () => import('pages/SignUpPage.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'password-reset',
|
||||||
|
name: 'password-reset',
|
||||||
|
component: () => import('pages/PasswordResetPage.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'wave',
|
||||||
|
name: 'wave',
|
||||||
|
component: () => import('pages/WavePage.vue')
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// Always leave this as last one,
|
// Always leave this as last one
|
||||||
// but you can also remove it
|
|
||||||
{
|
{
|
||||||
path: '/:catchAll(.*)*',
|
path: '/:catchAll(.*)*',
|
||||||
component: () => import('pages/ErrorNotFound.vue')
|
component: () => import('pages/ErrorNotFound.vue'),
|
||||||
}
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export default routes
|
export default routes
|
||||||
|
|
|
||||||
519
frontend/src/utils/ConnectedDotsVisualization.ts
Normal file
|
|
@ -0,0 +1,519 @@
|
||||||
|
// 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 || 100;
|
||||||
|
let calculatedWidth = 0;
|
||||||
|
if (this.dots.length > 0) {
|
||||||
|
// Find the minimum and maximum x values
|
||||||
|
const minX = Math.min(...this.dots.map((dot) => dot.x));
|
||||||
|
const maxX = Math.max(...this.dots.map((dot) => dot.x));
|
||||||
|
// Calculate width based on the range of x values
|
||||||
|
// Add padding on both sides (3 units on each side)
|
||||||
|
calculatedWidth = (maxX - minX + 6) * xUnitSize;
|
||||||
|
} else {
|
||||||
|
calculatedWidth = 6 * xUnitSize; // Default width if no dots
|
||||||
|
}
|
||||||
|
// Default configuration
|
||||||
|
this.config = {
|
||||||
|
totalWidth: calculatedWidth,
|
||||||
|
height: window.innerHeight,
|
||||||
|
dotRadius: 6,
|
||||||
|
xUnitSize: xUnitSize,
|
||||||
|
tension: 0.5,
|
||||||
|
showGrid: false,
|
||||||
|
tooltipWidth: 128,
|
||||||
|
tooltipHeight: 128,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
// Initialize DOM elements
|
||||||
|
this.scrollContainer = document.getElementById(containerId) as HTMLElement;
|
||||||
|
// 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 = `
|
||||||
|
|
||||||
|
// `;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private initializeSVG(): void {
|
||||||
|
// Configure SVG
|
||||||
|
this.svg.setAttribute("width", `${this.config.totalWidth}`);
|
||||||
|
this.svg.setAttribute("height", `${this.config.height}`);
|
||||||
|
this.svg.style.overflow = "visible";
|
||||||
|
this.scrollContainer.appendChild(this.svg);
|
||||||
|
// Configure grid group
|
||||||
|
this.gridGroup.classList.add("grid");
|
||||||
|
this.svg.appendChild(this.gridGroup);
|
||||||
|
// Configure curve path
|
||||||
|
this.curvePath.setAttribute("fill", "none");
|
||||||
|
this.curvePath.setAttribute("stroke", "white");
|
||||||
|
this.curvePath.setAttribute("stroke-width", "2");
|
||||||
|
this.curvePath.setAttribute("stroke-linecap", "round");
|
||||||
|
this.curvePath.classList.add("curve-path");
|
||||||
|
this.svg.appendChild(this.curvePath);
|
||||||
|
// Configure dots group
|
||||||
|
this.svg.appendChild(this.dotsGroup);
|
||||||
|
// Configure tooltip group (always on top)
|
||||||
|
this.tooltipGroup.classList.add("tooltips");
|
||||||
|
this.svg.appendChild(this.tooltipGroup);
|
||||||
|
}
|
||||||
|
private setupEventListeners(): void {
|
||||||
|
// Event listeners removed as the controls were removed
|
||||||
|
}
|
||||||
|
private getDotX(x: number): number {
|
||||||
|
return (x + 3) * this.config.xUnitSize;
|
||||||
|
}
|
||||||
|
private getDotY(value: number): number {
|
||||||
|
const centerY = this.config.height / 1.95;
|
||||||
|
// Calculate raw Y position
|
||||||
|
// height of the amplitude
|
||||||
|
const rawY = centerY - (value / 3) * ((this.config.height / 2) * 0.6);
|
||||||
|
// Calculate minimum Y position to ensure tooltip fits
|
||||||
|
const minY = this.config.tooltipHeight + 40; // tooltip height + some padding
|
||||||
|
// Ensure Y is never less than minimum (never too high on screen)
|
||||||
|
return Math.max(rawY, minY);
|
||||||
|
}
|
||||||
|
private calculateBezierControlPoints(
|
||||||
|
dots: DotConfig[],
|
||||||
|
index: number
|
||||||
|
): ControlPoints {
|
||||||
|
const tension = this.config.tension * 500; // Scale tension for Bezier curve Rundung Kurve
|
||||||
|
// Get current point and its neighbors
|
||||||
|
const curr = dots[index];
|
||||||
|
const next = dots[index + 1];
|
||||||
|
// Calculate control points for a smooth bezier curve
|
||||||
|
const x1 = this.getDotX(curr.x) + tension;
|
||||||
|
const y1 = this.getDotY(curr.value);
|
||||||
|
const x2 = this.getDotX(next.x) - tension;
|
||||||
|
const y2 = this.getDotY(next.value);
|
||||||
|
return { x1, y1, x2, y2 };
|
||||||
|
}
|
||||||
|
private generateBezierPath(): string {
|
||||||
|
if (this.dots.length < 2) return "";
|
||||||
|
let path = `M ${this.getDotX(this.dots[0].x)} ${this.getDotY(
|
||||||
|
this.dots[0].value
|
||||||
|
)}`;
|
||||||
|
for (let i = 0; i < this.dots.length - 1; i++) {
|
||||||
|
const { x1, y1, x2, y2 } = this.calculateBezierControlPoints(
|
||||||
|
this.dots,
|
||||||
|
i
|
||||||
|
);
|
||||||
|
const nextX = this.getDotX(this.dots[i + 1].x);
|
||||||
|
const nextY = this.getDotY(this.dots[i + 1].value);
|
||||||
|
path += ` C ${x1} ${y1}, ${x2} ${y2}, ${nextX} ${nextY}`;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
private drawGrid(): void {
|
||||||
|
// Clear previous grid
|
||||||
|
while (this.gridGroup.firstChild) {
|
||||||
|
this.gridGroup.removeChild(this.gridGroup.firstChild);
|
||||||
|
}
|
||||||
|
if (!this.config.showGrid) return;
|
||||||
|
// Horizontal grid lines
|
||||||
|
for (const value of [-3, -2, -1, 0, 1, 2, 3]) {
|
||||||
|
const line = document.createElementNS(
|
||||||
|
"http://www.w3.org/2000/svg",
|
||||||
|
"line"
|
||||||
|
);
|
||||||
|
line.setAttribute("x1", "0");
|
||||||
|
line.setAttribute("y1", this.getDotY(value).toString());
|
||||||
|
line.setAttribute("x2", this.config.totalWidth.toString());
|
||||||
|
line.setAttribute("y2", this.getDotY(value).toString());
|
||||||
|
line.setAttribute("stroke", "rgba(219, 39, 119, 0.4)");
|
||||||
|
line.setAttribute("stroke-width", "1");
|
||||||
|
this.gridGroup.appendChild(line);
|
||||||
|
const text = document.createElementNS(
|
||||||
|
"http://www.w3.org/2000/svg",
|
||||||
|
"text"
|
||||||
|
);
|
||||||
|
text.setAttribute("x", "10");
|
||||||
|
text.setAttribute("y", (this.getDotY(value) + 4).toString());
|
||||||
|
text.setAttribute("fill", "rgba(219, 39, 119, 0.8)");
|
||||||
|
text.setAttribute("font-size", "12");
|
||||||
|
text.textContent = value.toString();
|
||||||
|
this.gridGroup.appendChild(text);
|
||||||
|
}
|
||||||
|
// Vertical grid lines
|
||||||
|
const numVertLines = Math.ceil(
|
||||||
|
this.config.totalWidth / this.config.xUnitSize
|
||||||
|
);
|
||||||
|
for (let i = 0; i < numVertLines; i++) {
|
||||||
|
const x = i * this.config.xUnitSize;
|
||||||
|
const xValue = i - 3; // Starting from -3
|
||||||
|
const line = document.createElementNS(
|
||||||
|
"http://www.w3.org/2000/svg",
|
||||||
|
"line"
|
||||||
|
);
|
||||||
|
line.setAttribute("x1", x.toString());
|
||||||
|
line.setAttribute("y1", "0");
|
||||||
|
line.setAttribute("x2", x.toString());
|
||||||
|
line.setAttribute("y2", this.config.height.toString());
|
||||||
|
line.setAttribute("stroke", "rgba(219, 39, 119, 0.4)");
|
||||||
|
line.setAttribute("stroke-width", "1");
|
||||||
|
this.gridGroup.appendChild(line);
|
||||||
|
if (xValue !== 0) {
|
||||||
|
const text = document.createElementNS(
|
||||||
|
"http://www.w3.org/2000/svg",
|
||||||
|
"text"
|
||||||
|
);
|
||||||
|
text.setAttribute("x", x.toString());
|
||||||
|
text.setAttribute("y", (this.config.height / 2 + 20).toString());
|
||||||
|
text.setAttribute("fill", "rgba(219, 39, 119, 0.8)");
|
||||||
|
text.setAttribute("font-size", "12");
|
||||||
|
text.setAttribute("text-anchor", "middle");
|
||||||
|
text.textContent = xValue.toString();
|
||||||
|
this.gridGroup.appendChild(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createTooltip(dot: DotConfig, x: number, y: number): SVGElement {
|
||||||
|
const tooltip = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
||||||
|
tooltip.classList.add("dot-tooltip");
|
||||||
|
tooltip.setAttribute("data-dot-id", dot.id.toString());
|
||||||
|
|
||||||
|
// Calculate tooltip dimensions and position
|
||||||
|
const tooltipWidth = 128; // Base width for your tooltip
|
||||||
|
const tooltipHeight = (4 / 3) * tooltipWidth;
|
||||||
|
const tooltipX = x - tooltipWidth / 2;
|
||||||
|
let tooltipY = y - tooltipHeight - 10; // Positioned above the dot
|
||||||
|
tooltipY = Math.max(tooltipY, 10); // Ensure it doesn't go above the view
|
||||||
|
|
||||||
|
// Create background rectangle
|
||||||
|
const bg = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
||||||
|
bg.setAttribute("x", tooltipX.toString());
|
||||||
|
bg.setAttribute("y", tooltipY.toString());
|
||||||
|
bg.setAttribute("width", tooltipWidth.toString());
|
||||||
|
bg.setAttribute("height", tooltipHeight.toString());
|
||||||
|
bg.setAttribute("rx", "0"); // Rounded corners
|
||||||
|
bg.classList.add("tooltip-background");
|
||||||
|
tooltip.appendChild(bg);
|
||||||
|
|
||||||
|
// Create foreignObject for the content
|
||||||
|
const contentContainer = document.createElementNS(
|
||||||
|
"http://www.w3.org/2000/svg",
|
||||||
|
"foreignObject"
|
||||||
|
);
|
||||||
|
contentContainer.setAttribute("x", tooltipX.toString());
|
||||||
|
contentContainer.setAttribute("y", tooltipY.toString());
|
||||||
|
contentContainer.setAttribute("width", tooltipWidth.toString());
|
||||||
|
contentContainer.setAttribute("height", tooltipHeight.toString());
|
||||||
|
|
||||||
|
// Create a div to contain the content
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.classList.add("tooltip-content");
|
||||||
|
|
||||||
|
// Add title if available
|
||||||
|
if (dot.title) {
|
||||||
|
const title = document.createElement("div");
|
||||||
|
title.textContent = dot.title;
|
||||||
|
title.classList.add("tooltip-title");
|
||||||
|
div.appendChild(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add description if available
|
||||||
|
if (dot.description) {
|
||||||
|
const desc = document.createElement("div");
|
||||||
|
desc.textContent = dot.description;
|
||||||
|
desc.classList.add("tooltip-description");
|
||||||
|
div.appendChild(desc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add image if available
|
||||||
|
// Create a container div
|
||||||
|
const imageContainer = document.createElement("div");
|
||||||
|
imageContainer.classList.add("image_container"); // Add image_container class
|
||||||
|
|
||||||
|
// Define a variable for handling case with or without link
|
||||||
|
let imgWrapper: HTMLElement;
|
||||||
|
|
||||||
|
// if (dot.imageUrl) {
|
||||||
|
if (dot.link) {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = dot.link;
|
||||||
|
link.target = "_self"; // Opens in the same window
|
||||||
|
|
||||||
|
const imgElement = document.createElement("img");
|
||||||
|
imgElement.src = dot.imageUrl;
|
||||||
|
imgElement.classList.add("tooltip-image");
|
||||||
|
|
||||||
|
// Append the image element to the link
|
||||||
|
link.appendChild(imgElement);
|
||||||
|
imgWrapper = link; // Use the link as the wrapper
|
||||||
|
|
||||||
|
// Add the event listener to the link
|
||||||
|
link.addEventListener("click", () => {
|
||||||
|
if (dot.link) {
|
||||||
|
window.location.href = dot.link;
|
||||||
|
} else {
|
||||||
|
console.error("Dot has no link");
|
||||||
|
throw new Error("Dot has no link");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = dot.imageUrl;
|
||||||
|
img.classList.add("tooltip-image");
|
||||||
|
imgWrapper = img; // Use the image directly as the wrapper
|
||||||
|
}
|
||||||
|
// } else {
|
||||||
|
// console.error("Dot has no image URL");
|
||||||
|
// throw new Error("Dot has no image URL");
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Append imageWrapper to the container
|
||||||
|
imageContainer.appendChild(imgWrapper);
|
||||||
|
|
||||||
|
|
||||||
|
// Append the image container to the main div
|
||||||
|
div.appendChild(imageContainer);
|
||||||
|
|
||||||
|
const arrow = document.createElement("div");
|
||||||
|
|
||||||
|
arrow.classList.add("tooltip-arrow");
|
||||||
|
|
||||||
|
div.appendChild(arrow); // Append the arrow to the tooltip-content div
|
||||||
|
|
||||||
|
contentContainer.appendChild(div);
|
||||||
|
tooltip.appendChild(contentContainer);
|
||||||
|
|
||||||
|
return tooltip;
|
||||||
|
}
|
||||||
|
|
||||||
|
private showTooltip(dot: DotConfig, x: number, y: number): void {
|
||||||
|
// Create tooltip
|
||||||
|
const tooltip = this.createTooltip(dot, x, y);
|
||||||
|
this.tooltipGroup.appendChild(tooltip);
|
||||||
|
this.activeTooltip = tooltip;
|
||||||
|
}
|
||||||
|
private hideTooltip(): void {
|
||||||
|
// This method is kept for compatibility but doesn't hide tooltips anymore
|
||||||
|
}
|
||||||
|
private drawCurve(): void {
|
||||||
|
const pathData = this.generateBezierPath();
|
||||||
|
this.curvePath.setAttribute("d", pathData);
|
||||||
|
}
|
||||||
|
private calculateTooltipEdges(): TooltipEdges {
|
||||||
|
let leftmost = 0;
|
||||||
|
let rightmost = 0;
|
||||||
|
let firstTooltipFound = false;
|
||||||
|
// If no dots with tooltips, return default values
|
||||||
|
if (this.dots.length === 0) {
|
||||||
|
return { leftmost: 0, rightmost: this.config.totalWidth };
|
||||||
|
}
|
||||||
|
// Calculate the leftmost and rightmost edges of all tooltips
|
||||||
|
for (const dot of this.dots) {
|
||||||
|
// Skip dots without tooltip content
|
||||||
|
if (!dot.imageUrl && !dot.title && !dot.description) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const x = this.getDotX(dot.x);
|
||||||
|
const tooltipWidth = this.config.tooltipWidth;
|
||||||
|
const tooltipX = x - tooltipWidth / 2;
|
||||||
|
if (!firstTooltipFound) {
|
||||||
|
leftmost = tooltipX;
|
||||||
|
rightmost = tooltipX + tooltipWidth;
|
||||||
|
firstTooltipFound = true;
|
||||||
|
} else {
|
||||||
|
// Update leftmost and rightmost values
|
||||||
|
leftmost = Math.min(leftmost, tooltipX);
|
||||||
|
rightmost = Math.max(rightmost, tooltipX + tooltipWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If no dots with tooltips were found, use default values
|
||||||
|
if (!firstTooltipFound) {
|
||||||
|
return { leftmost: 0, rightmost: this.config.totalWidth };
|
||||||
|
}
|
||||||
|
return { leftmost, rightmost };
|
||||||
|
}
|
||||||
|
private drawDots(): void {
|
||||||
|
// Clear previous dots
|
||||||
|
while (this.dotsGroup.firstChild) {
|
||||||
|
this.dotsGroup.removeChild(this.dotsGroup.firstChild);
|
||||||
|
}
|
||||||
|
// Clear previous tooltips
|
||||||
|
while (this.tooltipGroup.firstChild) {
|
||||||
|
this.tooltipGroup.removeChild(this.tooltipGroup.firstChild);
|
||||||
|
}
|
||||||
|
for (const dot of this.dots) {
|
||||||
|
const x = this.getDotX(dot.x);
|
||||||
|
const y = this.getDotY(dot.value);
|
||||||
|
const circle = document.createElementNS(
|
||||||
|
"http://www.w3.org/2000/svg",
|
||||||
|
"circle"
|
||||||
|
);
|
||||||
|
circle.setAttribute("cx", x.toString());
|
||||||
|
circle.setAttribute("cy", y.toString());
|
||||||
|
circle.setAttribute("r", this.config.dotRadius.toString());
|
||||||
|
circle.setAttribute("fill", "white");
|
||||||
|
circle.setAttribute("data-dot-id", dot.id.toString());
|
||||||
|
circle.classList.add("dot");
|
||||||
|
// Always show tooltip if it has content
|
||||||
|
if (dot.imageUrl || dot.title || dot.description) {
|
||||||
|
this.showTooltip(dot, x, y);
|
||||||
|
}
|
||||||
|
// Click event for navigation
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||