forked from BRETT/BRETT
305 lines
10 KiB
Vue
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>
|