add several pages and life wave

This commit is contained in:
Tilman Behrend 2025-08-31 22:58:40 +02:00
parent 734255aad2
commit 7b55e8770c
33 changed files with 1488 additions and 96 deletions

View file

@ -10,6 +10,7 @@
"hasInstallScript": true,
"dependencies": {
"@quasar/extras": "^1.16.4",
"gsap": "^3.13.0",
"pinia": "^3.0.1",
"quasar": "^2.16.0",
"vue": "^3.4.18",
@ -3819,6 +3820,12 @@
"dev": true,
"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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",

View file

@ -15,23 +15,24 @@
"postinstall": "quasar prepare"
},
"dependencies": {
"pinia": "^3.0.1",
"@quasar/extras": "^1.16.4",
"gsap": "^3.13.0",
"pinia": "^3.0.1",
"quasar": "^2.16.0",
"vue": "^3.4.18",
"vue-router": "^4.0.0"
},
"devDependencies": {
"@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-plugin-vue": "^9.30.0",
"globals": "^15.12.0",
"vite-plugin-checker": "^0.9.0",
"@vue/eslint-config-prettier": "^10.1.0",
"postcss": "^8.4.14",
"prettier": "^3.3.3",
"@quasar/app-vite": "^2.1.0",
"autoprefixer": "^10.4.2",
"postcss": "^8.4.14"
"vite-plugin-checker": "^0.9.0"
},
"engines": {
"node": "^28 || ^26 || ^24 || ^22 || ^20 || ^18",

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

11
frontend/public/js/ScrollTrigger.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

11
frontend/public/js/gsap.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -93,6 +93,10 @@ export default defineConfig((/* ctx */) => {
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
// https://v2.quasar.dev/options/animations
animations: [],

View file

@ -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;
}

View file

@ -6,29 +6,22 @@
MIIUS <!-- Me You Us -->
</q-toolbar-title>
<q-btn
flat
dense
round
icon="menu"
aria-label="Menu"
@click="toggleDrawer"
/>
<!-- Add login status indicator -->
<q-chip v-if="isLoggedIn" color="green" text-color="white" icon="check_circle">
Logged In
</q-chip>
<q-chip v-else color="grey" text-color="white" icon="person_off">
Not Logged In
</q-chip>
<q-btn flat dense round icon="menu" aria-label="Menu" @click="toggleDrawer" />
</q-toolbar>
</q-header>
<!-- Off-Canvas Drawer -->
<q-drawer
v-model="drawer"
show-if-above
side="right"
:width="280"
:breakpoint="768"
class="bg-grey-1"
style="overflow: hidden;"
>
<!-- Off-Canvas Drawer -->
<q-drawer v-model="drawer" show-if-above side="right" :width="280" :breakpoint="768" class="bg-grey-1"
style="overflow: hidden;">
<!-- Sliding Menu Container -->
<div class="menu-container" :style="{ transform: `translateX(${slideOffset}px)` }">
@ -37,16 +30,28 @@
<!-- Main Menu 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-icon name="line_axis" />
</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>
<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-icon name="add_circle" />
</q-item-section>
@ -55,7 +60,7 @@
</q-item-section>
</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-icon name="people" />
</q-item-section>
@ -67,7 +72,7 @@
</q-item-section>
</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-icon name="mail" />
</q-item-section>
@ -81,7 +86,7 @@
<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-icon name="person" />
</q-item-section>
@ -93,7 +98,7 @@
</q-item-section>
</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-icon name="account_circle" />
</q-item-section>
@ -105,30 +110,16 @@
</q-item-section>
</q-item>
<q-item clickable v-ripple @click="openSubmenu('login', 'Login')">
<q-item clickable v-ripple @click="navigateTo('sign-up')" to="/sign-up" v-if="!isLoggedIn">
<q-item-section avatar>
<q-icon name="login" />
<q-icon name="person_add" />
</q-item-section>
<q-item-section>
<q-item-label>Login</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="chevron_right" />
<q-item-label>Sign Up</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-ripple @click="navigateTo('sign-up')">
<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-separator />
<q-item clickable v-ripple @click="openSubmenu('support', 'Need Help?')">
<q-item-section avatar>
@ -159,14 +150,7 @@
<!-- Second Level Menu Panel -->
<div class="menu-panel">
<div class="q-pa-md bg-secondary text-white">
<q-btn
flat
round
icon="arrow_back"
@click="goBack"
class="q-mr-sm"
size="sm"
/>
<q-btn flat round icon="arrow_back" @click="goBack" class="q-mr-sm" size="sm" />
<span class="text-h6">{{ currentSubmenuTitle }}</span>
</div>
@ -288,6 +272,17 @@
<q-icon name="chevron_right" />
</q-item-section>
</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>
<!-- Login Submenu -->
@ -375,19 +370,12 @@
<!-- Third Level Menu Panel -->
<div class="menu-panel">
<div class="q-pa-md bg-accent text-white">
<q-btn
flat
round
icon="arrow_back"
@click="goBack"
class="q-mr-sm"
size="sm"
/>
<q-btn flat round icon="arrow_back" @click="goBack" class="q-mr-sm" size="sm" />
<span class="text-h6">{{ currentSubmenuTitle }}</span>
</div>
<!-- Message Center Submenu -->
<!-- Message Center Submenu -->
<q-list v-if="currentSubmenu === 'message-center'">
<q-item clickable v-ripple @click="navigateTo('new-message')">
@ -504,7 +492,7 @@
<q-route-tab to="/people" icon="people" />
<q-route-tab to="/entry" icon="add_circle_outline" />
<q-route-tab to="/messages" icon="mail" />
<q-route-tab to="/profile" icon="person"/>
<q-route-tab to="/profile" icon="person" />
</q-tabs>
</q-footer>
@ -513,11 +501,13 @@
</template>
<script>
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
export default {
name: 'MultiLevelSlidingMenu',
setup() {
const router = useRouter()
const drawer = ref(false)
const currentRoute = ref('home')
const menuLevel = ref(0)
@ -525,6 +515,22 @@ export default {
const currentSubmenuTitle = 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(() => {
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 = () => {
console.log('Logging out...')
isLoggedIn.value = false
localStorage.setItem('isLoggedIn', 'false')
currentRoute.value = 'login'
drawer.value = false
resetMenu()
router.push('/login')
}
return {
@ -609,7 +625,9 @@ export default {
openSubmenu,
goBack,
navigateTo,
logout
logout,
isLoggedIn,
login
}
}
}
@ -627,23 +645,34 @@ export default {
@keyframes gradientAnimation {
0% {
background-position: 0% 0%; /* Start Links Mitte */
background-position: 0% 0%;
/* Start Links Mitte */
}
25% {
background-position: 100% 0%; /* Oben Rechts */
background-position: 100% 0%;
/* Oben Rechts */
}
50% {
background-position: 100% 100%; /* Unten Rechts */
background-position: 100% 100%;
/* Unten Rechts */
}
75% {
background-position: 0% 100%; /* Unten Links */
background-position: 0% 100%;
/* Unten Links */
}
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 {
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 {
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);
}

View 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>

View 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>

View 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>

View 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>

View file

@ -3,16 +3,35 @@ const routes = [
path: '/',
component: () => import('layouts/MainLayout.vue'),
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,
// but you can also remove it
// Always leave this as last one
{
path: '/:catchAll(.*)*',
component: () => import('pages/ErrorNotFound.vue')
}
component: () => import('pages/ErrorNotFound.vue'),
},
]
export default routes

View 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();
}
}