Files
2026-10-03 20:07:49 +02:00

305 lines
10 KiB
Vue

<script setup lang="ts">
import { hexToRgbTuple, shadeHex } from '@/lib/color'
// Portiert aus Vue Bits (DavidHDev/vue-bits), vereinfacht auf color-Prop,
// Scroll-Grid + Hover-Fill/Trail auf 2D-Canvas
const props = withDefaults(defineProps<{
color?: string
shape?: 'square' | 'hexagon' | 'circle' | 'triangle'
}>(), {
color: '#6366f1',
shape: 'square',
})
const canvasRef = ref<HTMLCanvasElement | null>(null)
let cleanup: (() => void) | null = null
onMounted(() => {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const squareSize = 30
const speed = 0.6
const hoverTrailAmount = 10
let borderColor = ''
let hoverFill = ''
let edgeRgba = ''
const updateColors = () => {
const [r, g, b] = hexToRgbTuple(props.color)
borderColor = `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, 0.3)`
hoverFill = props.color
const edgeColor = hexToRgbTuple(shadeHex(props.color, -0.85))
edgeRgba = `rgba(${Math.round(edgeColor[0] * 255)}, ${Math.round(edgeColor[1] * 255)}, ${Math.round(edgeColor[2] * 255)}, 0.9)`
}
updateColors()
// Farbwechsel live übernehmen (Settings-Vorschau)
watch(() => props.color, updateColors)
const isHex = props.shape === 'hexagon'
const isTri = props.shape === 'triangle'
const hexHoriz = squareSize * 1.5
const hexVert = squareSize * Math.sqrt(3)
const offset = { x: 0, y: 0 }
let hovered: { x: number, y: number } | null = null
const trail: { x: number, y: number }[] = []
const opacities = new Map<string, number>()
const resizeCanvas = () => {
canvas.width = canvas.offsetWidth
canvas.height = canvas.offsetHeight
}
const drawHex = (cx: number, cy: number, size: number) => {
ctx.beginPath()
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 3) * i
const vx = cx + size * Math.cos(angle)
const vy = cy + size * Math.sin(angle)
if (i === 0) ctx.moveTo(vx, vy)
else ctx.lineTo(vx, vy)
}
ctx.closePath()
}
const drawCircle = (cx: number, cy: number, size: number) => {
ctx.beginPath()
ctx.arc(cx, cy, size / 2, 0, Math.PI * 2)
}
const drawTriangle = (cx: number, cy: number, size: number, flip: boolean) => {
ctx.beginPath()
if (flip) {
ctx.moveTo(cx, cy + size / 2)
ctx.lineTo(cx + size / 2, cy - size / 2)
ctx.lineTo(cx - size / 2, cy - size / 2)
}
else {
ctx.moveTo(cx, cy - size / 2)
ctx.lineTo(cx + size / 2, cy + size / 2)
ctx.lineTo(cx - size / 2, cy + size / 2)
}
ctx.closePath()
}
const drawGrid = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height)
const cell = (key: string, draw: () => void) => {
const alpha = opacities.get(key)
ctx.strokeStyle = borderColor
ctx.globalAlpha = 0.55
if (alpha) {
ctx.globalAlpha = alpha
ctx.fillStyle = hoverFill
draw()
ctx.fill()
ctx.globalAlpha = 1
draw()
ctx.stroke()
}
else {
draw()
ctx.stroke()
}
ctx.globalAlpha = 1
}
if (isHex) {
const colShift = Math.floor(offset.x / hexHoriz)
const offsetX = ((offset.x % hexHoriz) + hexHoriz) % hexHoriz
const offsetY = ((offset.y % hexVert) + hexVert) % hexVert
const cols = Math.ceil(canvas.width / hexHoriz) + 3
const rows = Math.ceil(canvas.height / hexVert) + 3
for (let col = -2; col < cols; col++) {
for (let row = -2; row < rows; row++) {
const cx = col * hexHoriz + offsetX
const cy = row * hexVert + ((col + colShift) % 2 !== 0 ? hexVert / 2 : 0) + offsetY
cell(`${col},${row}`, () => drawHex(cx, cy, squareSize))
}
}
}
else if (isTri) {
const halfW = squareSize / 2
const colShift = Math.floor(offset.x / halfW)
const rowShift = Math.floor(offset.y / squareSize)
const offsetX = ((offset.x % halfW) + halfW) % halfW
const offsetY = ((offset.y % squareSize) + squareSize) % squareSize
const cols = Math.ceil(canvas.width / halfW) + 4
const rows = Math.ceil(canvas.height / squareSize) + 4
for (let col = -2; col < cols; col++) {
for (let row = -2; row < rows; row++) {
const cx = col * halfW + offsetX
const cy = row * squareSize + squareSize / 2 + offsetY
const flip = (((col + colShift + row + rowShift) % 2) + 2) % 2 !== 0
cell(`${col},${row}`, () => drawTriangle(cx, cy, squareSize, flip))
}
}
}
else if (props.shape === 'circle') {
const offsetX = ((offset.x % squareSize) + squareSize) % squareSize
const offsetY = ((offset.y % squareSize) + squareSize) % squareSize
const cols = Math.ceil(canvas.width / squareSize) + 3
const rows = Math.ceil(canvas.height / squareSize) + 3
for (let col = -2; col < cols; col++) {
for (let row = -2; row < rows; row++) {
const cx = col * squareSize + squareSize / 2 + offsetX
const cy = row * squareSize + squareSize / 2 + offsetY
cell(`${col},${row}`, () => drawCircle(cx, cy, squareSize))
}
}
}
else {
const offsetX = ((offset.x % squareSize) + squareSize) % squareSize
const offsetY = ((offset.y % squareSize) + squareSize) % squareSize
const cols = Math.ceil(canvas.width / squareSize) + 3
const rows = Math.ceil(canvas.height / squareSize) + 3
for (let col = -2; col < cols; col++) {
for (let row = -2; row < rows; row++) {
const sx = col * squareSize + offsetX
const sy = row * squareSize + offsetY
cell(`${col},${row}`, () => {
ctx.beginPath()
ctx.rect(sx, sy, squareSize, squareSize)
})
}
}
}
// dunkle Rand-Vignette, aus der Effektfarbe abgeleitet
const gradient = ctx.createRadialGradient(
canvas.width / 2, canvas.height / 2, 0,
canvas.width / 2, canvas.height / 2,
Math.sqrt(canvas.width ** 2 + canvas.height ** 2) / 2,
)
gradient.addColorStop(0, 'rgba(0, 0, 0, 0)')
gradient.addColorStop(1, edgeRgba)
ctx.fillStyle = gradient
ctx.fillRect(0, 0, canvas.width, canvas.height)
}
const updateCellOpacities = () => {
const targets = new Map<string, number>()
if (hovered) targets.set(`${hovered.x},${hovered.y}`, 1)
for (let i = 0; i < trail.length; i++) {
const t = trail[i]
const key = `${t.x},${t.y}`
if (!targets.has(key)) targets.set(key, (trail.length - i) / (trail.length + 1))
}
for (const [key] of targets) {
if (!opacities.has(key)) opacities.set(key, 0)
}
for (const [key, opacity] of opacities) {
const next = opacity + ((targets.get(key) || 0) - opacity) * 0.15
if (next < 0.005) opacities.delete(key)
else opacities.set(key, next)
}
}
let raf = 0
let visible = true
const updateAnimation = () => {
if (!visible || document.hidden) {
raf = 0
return
}
raf = requestAnimationFrame(updateAnimation)
const wrapX = isHex ? hexHoriz * 2 : squareSize
const wrapY = isHex ? hexVert : isTri ? squareSize * 2 : squareSize
// diagonal
offset.x = (offset.x - speed + wrapX) % wrapX
offset.y = (offset.y - speed + wrapY) % wrapY
updateCellOpacities()
drawGrid()
}
const updateHovered = (col: number, row: number) => {
if (!hovered || hovered.x !== col || hovered.y !== row) {
if (hovered) {
trail.unshift({ ...hovered })
if (trail.length > hoverTrailAmount) trail.length = hoverTrailAmount
}
hovered = { x: col, y: row }
}
}
const onPointerMove = (event: PointerEvent) => {
const rect = canvas.getBoundingClientRect()
if (event.clientX < rect.left || event.clientX > rect.right
|| event.clientY < rect.top || event.clientY > rect.bottom) {
if (hovered) {
trail.unshift({ ...hovered })
if (trail.length > hoverTrailAmount) trail.length = hoverTrailAmount
hovered = null
}
return
}
const mouseX = event.clientX - rect.left
const mouseY = event.clientY - rect.top
if (isHex) {
const colShift = Math.floor(offset.x / hexHoriz)
const offsetX = ((offset.x % hexHoriz) + hexHoriz) % hexHoriz
const offsetY = ((offset.y % hexVert) + hexVert) % hexVert
const adjustedX = mouseX - offsetX
const adjustedY = mouseY - offsetY
const col = Math.round(adjustedX / hexHoriz)
const rowOffset = (col + colShift) % 2 !== 0 ? hexVert / 2 : 0
const row = Math.round((adjustedY - rowOffset) / hexVert)
updateHovered(col, row)
}
else if (isTri) {
const halfW = squareSize / 2
const offsetX = ((offset.x % halfW) + halfW) % halfW
const offsetY = ((offset.y % squareSize) + squareSize) % squareSize
const col = Math.round((mouseX - offsetX) / halfW)
const row = Math.floor((mouseY - offsetY) / squareSize)
updateHovered(col, row)
}
else {
const offsetX = ((offset.x % squareSize) + squareSize) % squareSize
const offsetY = ((offset.y % squareSize) + squareSize) % squareSize
const rounded = props.shape === 'circle' ? Math.round : Math.floor
const col = rounded((mouseX - offsetX) / squareSize)
const row = rounded((mouseY - offsetY) / squareSize)
updateHovered(col, row)
}
}
const io = new IntersectionObserver(([entry]) => {
visible = entry?.isIntersecting ?? true
if (visible && !raf) raf = requestAnimationFrame(updateAnimation)
})
io.observe(canvas)
const ro = new ResizeObserver(resizeCanvas)
ro.observe(canvas)
window.addEventListener('pointermove', onPointerMove, { passive: true })
resizeCanvas()
raf = requestAnimationFrame(updateAnimation)
cleanup = () => {
cancelAnimationFrame(raf)
io.disconnect()
ro.disconnect()
window.removeEventListener('pointermove', onPointerMove)
}
})
onBeforeUnmount(() => {
cleanup?.()
cleanup = null
})
</script>
<template>
<canvas ref="canvasRef" class="absolute inset-0 block size-full rounded-[inherit] border-none" />
</template>