Files
Aether/frontend/src/components/AetherLineByLineLogo.vue

435 lines
11 KiB
Vue
Raw Normal View History

2025-12-10 20:52:44 +08:00
<template>
<div
class="line-by-line-logo"
:style="{ width: `${size}px`, height: `${size}px` }"
>
2025-12-10 20:52:44 +08:00
<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"
/>
2025-12-10 20:52:44 +08:00
</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
}
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
}>()
// 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' },
]
2025-12-10 20:52:44 +08:00
// 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>