mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-09 19:22:26 +08:00
Initial commit
This commit is contained in:
412
frontend/src/components/AetherLineByLineLogo.vue
Normal file
412
frontend/src/components/AetherLineByLineLogo.vue
Normal file
@@ -0,0 +1,412 @@
|
||||
<template>
|
||||
<div class="line-by-line-logo" :style="{ width: `${size}px`, height: `${size}px` }">
|
||||
<svg
|
||||
:viewBox="viewBox"
|
||||
class="logo-svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<!-- Metallic gradient -->
|
||||
<linearGradient :id="gradientId" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" :stop-color="metallicColors.dark" />
|
||||
<stop offset="25%" :stop-color="metallicColors.base" />
|
||||
<stop offset="50%" :stop-color="metallicColors.light" />
|
||||
<stop offset="75%" :stop-color="metallicColors.base" />
|
||||
<stop offset="100%" :stop-color="metallicColors.dark" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Layer 0: Ghost Tracks (Always visible, faint) -->
|
||||
<g class="ghost-layer">
|
||||
<path
|
||||
v-for="(path, index) in linePaths"
|
||||
:key="`ghost-${index}`"
|
||||
:d="path"
|
||||
class="ghost-path"
|
||||
fill="none"
|
||||
:stroke="currentColors.primary"
|
||||
:stroke-width="strokeWidth"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Layer 1: Fill (fade in/out) -->
|
||||
<path
|
||||
class="fill-path"
|
||||
:d="fullPath"
|
||||
:fill="`url(#${gradientId})`"
|
||||
fill-rule="evenodd"
|
||||
:style="fillStyle"
|
||||
/>
|
||||
|
||||
<!-- Layer 2: Animated lines -->
|
||||
<g class="lines-layer">
|
||||
<path
|
||||
v-for="(path, index) in linePaths"
|
||||
:key="`line-${index}`"
|
||||
:ref="(el) => setPathRef(el as SVGPathElement, index)"
|
||||
:d="path"
|
||||
class="line-path"
|
||||
fill="none"
|
||||
:stroke="currentColors.primary"
|
||||
:stroke-width="strokeWidth"
|
||||
:style="getLineStyle(index)"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
|
||||
import { AETHER_SVG_VIEWBOX, AETHER_LINE_PATHS, AETHER_FULL_PATH } from '@/constants/logoPaths'
|
||||
|
||||
// Animation phases
|
||||
type AnimationPhase = 'idle' | 'drawOutline' | 'fillFadeIn' | 'hold' | 'fillFadeOut' | 'eraseOutline'
|
||||
|
||||
// Color scheme type
|
||||
interface ColorScheme {
|
||||
primary: string
|
||||
secondary: string
|
||||
}
|
||||
|
||||
// Constants
|
||||
const LINE_COUNT = AETHER_LINE_PATHS.length
|
||||
const DEFAULT_PATH_LENGTH = 3000
|
||||
|
||||
// Light mode color schemes
|
||||
const LIGHT_MODE_SCHEMES: ColorScheme[] = [
|
||||
{ primary: '#9a5a42', secondary: '#c4866a' },
|
||||
{ primary: '#8b4557', secondary: '#b87a8a' },
|
||||
{ primary: '#996b2e', secondary: '#c49a5c' },
|
||||
{ primary: '#7a5c3a', secondary: '#a8896a' },
|
||||
{ primary: '#6b4d82', secondary: '#9a7eb5' },
|
||||
{ primary: '#2d6a7a', secondary: '#5a9aaa' },
|
||||
{ primary: '#4a6b3a', secondary: '#7a9a6a' },
|
||||
{ primary: '#8a5a5a', secondary: '#b88a8a' },
|
||||
{ primary: '#5a6a7a', secondary: '#8a9aaa' },
|
||||
{ primary: '#6a5a4a', secondary: '#9a8a7a' },
|
||||
{ primary: '#7a4a5a', secondary: '#aa7a8a' },
|
||||
{ primary: '#4a5a6a', secondary: '#7a8a9a' },
|
||||
]
|
||||
|
||||
// Dark mode color schemes
|
||||
const DARK_MODE_SCHEMES: ColorScheme[] = [
|
||||
{ primary: '#f59e0b', secondary: '#fcd34d' },
|
||||
{ primary: '#ec4899', secondary: '#f9a8d4' },
|
||||
{ primary: '#22d3ee', secondary: '#a5f3fc' },
|
||||
{ primary: '#a855f7', secondary: '#d8b4fe' },
|
||||
{ primary: '#4ade80', secondary: '#bbf7d0' },
|
||||
{ primary: '#f472b6', secondary: '#fbcfe8' },
|
||||
{ primary: '#38bdf8', secondary: '#bae6fd' },
|
||||
{ primary: '#fb923c', secondary: '#fed7aa' },
|
||||
{ primary: '#a78bfa', secondary: '#ddd6fe' },
|
||||
{ primary: '#2dd4bf', secondary: '#99f6e4' },
|
||||
{ primary: '#facc15', secondary: '#fef08a' },
|
||||
{ primary: '#e879f9', secondary: '#f5d0fe' },
|
||||
]
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
lineDelay?: number
|
||||
strokeDuration?: number
|
||||
fillDuration?: number
|
||||
autoStart?: boolean
|
||||
loop?: boolean
|
||||
loopPause?: number
|
||||
outlineColor?: string
|
||||
gradientColor?: string
|
||||
strokeWidth?: number
|
||||
cycleColors?: boolean
|
||||
isDark?: boolean
|
||||
}>(),
|
||||
{
|
||||
size: 400,
|
||||
lineDelay: 60,
|
||||
strokeDuration: 1200,
|
||||
fillDuration: 800,
|
||||
autoStart: true,
|
||||
loop: true,
|
||||
loopPause: 600,
|
||||
outlineColor: '#cc785c',
|
||||
gradientColor: '#e8a882',
|
||||
strokeWidth: 2.5,
|
||||
cycleColors: false,
|
||||
isDark: false
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'animationComplete'): void
|
||||
(e: 'phaseChange', phase: AnimationPhase): void
|
||||
(e: 'colorChange', colors: ColorScheme): void
|
||||
}>()
|
||||
|
||||
// Unique ID for gradient
|
||||
const gradientId = `aether-gradient-${Math.random().toString(36).slice(2, 9)}`
|
||||
|
||||
const viewBox = AETHER_SVG_VIEWBOX
|
||||
const linePaths = AETHER_LINE_PATHS
|
||||
const fullPath = AETHER_FULL_PATH
|
||||
|
||||
// Path refs and lengths
|
||||
const pathRefs = ref<(SVGPathElement | null)[]>(new Array(LINE_COUNT).fill(null))
|
||||
const pathLengths = ref<number[]>(new Array(LINE_COUNT).fill(DEFAULT_PATH_LENGTH))
|
||||
|
||||
// Animation states
|
||||
const lineDrawn = ref<boolean[]>(new Array(LINE_COUNT).fill(false))
|
||||
const isFilled = ref(false)
|
||||
const currentPhase = ref<AnimationPhase>('idle')
|
||||
const isAnimating = ref(false)
|
||||
|
||||
// Timer cleanup
|
||||
let animationAborted = false
|
||||
let startTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
let hasStartedOnce = false
|
||||
|
||||
// Color cycling state
|
||||
const colorIndex = ref(0)
|
||||
|
||||
// Computed
|
||||
const activeSchemes = computed(() => props.isDark ? DARK_MODE_SCHEMES : LIGHT_MODE_SCHEMES)
|
||||
|
||||
const currentColors = computed<ColorScheme>(() => {
|
||||
if (props.cycleColors) {
|
||||
return activeSchemes.value[colorIndex.value % activeSchemes.value.length]
|
||||
}
|
||||
return { primary: props.outlineColor, secondary: props.gradientColor }
|
||||
})
|
||||
|
||||
const metallicColors = computed(() => ({
|
||||
dark: adjustColor(currentColors.value.primary, -20),
|
||||
base: currentColors.value.primary,
|
||||
light: currentColors.value.secondary,
|
||||
highlight: adjustColor(currentColors.value.secondary, 30)
|
||||
}))
|
||||
|
||||
// Fill style with fade transition
|
||||
const fillStyle = computed(() => ({
|
||||
opacity: isFilled.value ? 0.85 : 0,
|
||||
transition: `opacity ${props.fillDuration}ms ease-in-out`
|
||||
}))
|
||||
|
||||
// Helper functions
|
||||
function adjustColor(hex: string, amount: number): string {
|
||||
const num = parseInt(hex.replace('#', ''), 16)
|
||||
const r = Math.min(255, Math.max(0, (num >> 16) + amount))
|
||||
const g = Math.min(255, Math.max(0, ((num >> 8) & 0x00FF) + amount))
|
||||
const b = Math.min(255, Math.max(0, (num & 0x0000FF) + amount))
|
||||
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`
|
||||
}
|
||||
|
||||
const setPathRef = (el: SVGPathElement | null, index: number) => {
|
||||
pathRefs.value[index] = el
|
||||
}
|
||||
|
||||
const calculatePathLengths = () => {
|
||||
pathRefs.value.forEach((path, index) => {
|
||||
if (path) {
|
||||
try {
|
||||
pathLengths.value[index] = path.getTotalLength()
|
||||
} catch {
|
||||
pathLengths.value[index] = DEFAULT_PATH_LENGTH
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Line style with stroke drawing animation
|
||||
const getLineStyle = (index: number) => {
|
||||
const pathLength = pathLengths.value[index]
|
||||
const isDrawn = lineDrawn.value[index]
|
||||
const phase = currentPhase.value
|
||||
|
||||
// Only enable transition during actual draw/erase phases
|
||||
let transition = 'none'
|
||||
if (phase === 'drawOutline' || phase === 'eraseOutline') {
|
||||
transition = `stroke-dashoffset ${props.strokeDuration}ms cubic-bezier(0.4, 0, 0.2, 1)`
|
||||
}
|
||||
|
||||
return {
|
||||
strokeDasharray: pathLength,
|
||||
strokeDashoffset: isDrawn ? 0 : pathLength,
|
||||
transition
|
||||
}
|
||||
}
|
||||
|
||||
// Abortable wait
|
||||
const wait = (ms: number) => new Promise<void>((resolve, reject) => {
|
||||
if (animationAborted) {
|
||||
reject(new Error('Animation aborted'))
|
||||
return
|
||||
}
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (animationAborted) {
|
||||
reject(new Error('Animation aborted'))
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
}, ms)
|
||||
|
||||
if (animationAborted) {
|
||||
clearTimeout(timeoutId)
|
||||
reject(new Error('Animation aborted'))
|
||||
}
|
||||
})
|
||||
|
||||
const nextColor = () => {
|
||||
colorIndex.value = (colorIndex.value + 1) % activeSchemes.value.length
|
||||
emit('colorChange', currentColors.value)
|
||||
}
|
||||
|
||||
// Animation instance counter to prevent multiple concurrent animations
|
||||
let animationInstanceId = 0
|
||||
|
||||
// Main animation sequence
|
||||
const startAnimation = async () => {
|
||||
if (isAnimating.value) return
|
||||
|
||||
const currentInstanceId = ++animationInstanceId
|
||||
isAnimating.value = true
|
||||
animationAborted = false
|
||||
|
||||
try {
|
||||
// Reset states
|
||||
lineDrawn.value = new Array(LINE_COUNT).fill(false)
|
||||
isFilled.value = false
|
||||
currentPhase.value = 'idle'
|
||||
|
||||
await nextTick()
|
||||
calculatePathLengths()
|
||||
await nextTick()
|
||||
|
||||
// Phase 1: Draw outlines (line by line)
|
||||
currentPhase.value = 'drawOutline'
|
||||
emit('phaseChange', 'drawOutline')
|
||||
|
||||
for (let i = 0; i < LINE_COUNT; i++) {
|
||||
lineDrawn.value[i] = true
|
||||
if (i < LINE_COUNT - 1) await wait(props.lineDelay)
|
||||
}
|
||||
await wait(props.strokeDuration)
|
||||
|
||||
// Phase 2: Fill fade in
|
||||
currentPhase.value = 'fillFadeIn'
|
||||
emit('phaseChange', 'fillFadeIn')
|
||||
isFilled.value = true
|
||||
await wait(props.fillDuration)
|
||||
|
||||
// Hold
|
||||
currentPhase.value = 'hold'
|
||||
await wait(props.loopPause / 2)
|
||||
|
||||
// Phase 3: Fill fade out
|
||||
currentPhase.value = 'fillFadeOut'
|
||||
emit('phaseChange', 'fillFadeOut')
|
||||
isFilled.value = false
|
||||
await wait(props.fillDuration)
|
||||
|
||||
// Phase 4: Erase outlines (line by line)
|
||||
currentPhase.value = 'eraseOutline'
|
||||
emit('phaseChange', 'eraseOutline')
|
||||
|
||||
for (let i = 0; i < LINE_COUNT; i++) {
|
||||
lineDrawn.value[i] = false
|
||||
if (i < LINE_COUNT - 1) await wait(props.lineDelay)
|
||||
}
|
||||
await wait(props.strokeDuration)
|
||||
|
||||
currentPhase.value = 'idle'
|
||||
isAnimating.value = false
|
||||
emit('animationComplete')
|
||||
|
||||
// Check if this animation instance is still valid before looping
|
||||
if (props.loop && !animationAborted && currentInstanceId === animationInstanceId) {
|
||||
if (props.cycleColors) nextColor()
|
||||
await wait(props.loopPause / 2)
|
||||
// Double check before recursing
|
||||
if (!animationAborted && currentInstanceId === animationInstanceId) {
|
||||
startAnimation()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
isAnimating.value = false
|
||||
currentPhase.value = 'idle'
|
||||
}
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
animationAborted = true
|
||||
lineDrawn.value = new Array(LINE_COUNT).fill(false)
|
||||
isFilled.value = false
|
||||
currentPhase.value = 'idle'
|
||||
isAnimating.value = false
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
animationAborted = true
|
||||
}
|
||||
|
||||
watch(() => props.isDark, () => {
|
||||
colorIndex.value = 0
|
||||
})
|
||||
|
||||
defineExpose({ startAnimation, reset, stop, isAnimating, currentPhase, nextColor, colorIndex })
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
calculatePathLengths()
|
||||
if (props.autoStart && !hasStartedOnce) {
|
||||
hasStartedOnce = true
|
||||
startTimeoutId = setTimeout(startAnimation, 300)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
animationAborted = true
|
||||
if (startTimeoutId) {
|
||||
clearTimeout(startTimeoutId)
|
||||
startTimeoutId = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.autoStart, (newVal) => {
|
||||
if (newVal && !isAnimating.value && !hasStartedOnce) {
|
||||
hasStartedOnce = true
|
||||
startAnimation()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-by-line-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.line-path {
|
||||
will-change: stroke-dashoffset;
|
||||
}
|
||||
|
||||
.fill-path {
|
||||
will-change: opacity;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ghost-path {
|
||||
opacity: 0.06;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user