2025-12-10 20:52:44 +08:00
|
|
|
<template>
|
2025-12-12 16:15:16 +08:00
|
|
|
<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 -->
|
2025-12-12 16:15:16 +08:00
|
|
|
<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
|
|
|
|
|
}>()
|
2025-12-12 16:15:16 +08:00
|
|
|
// 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>
|