diff --git a/introduction.mdx b/introduction.mdx index 645102c..e7d2ca7 100644 --- a/introduction.mdx +++ b/introduction.mdx @@ -3,6 +3,10 @@ title: "Introduction" description: "Geospatial policy engine for Ethereum" --- +
+ +
+ **Research Preview** — Astral Location Services are under active development and not yet production-ready. APIs may change. We're building in public and welcome your feedback! diff --git a/mint.json b/mint.json index 575ceaf..835316d 100644 --- a/mint.json +++ b/mint.json @@ -1,6 +1,7 @@ { "$schema": "https://mintlify.com/schema.json", "name": "Astral Location Services", + "js": "/scripts/ascii-globe.js", "redirects": [ { "source": "/", diff --git a/scripts/ascii-globe.js b/scripts/ascii-globe.js new file mode 100644 index 0000000..97be82c --- /dev/null +++ b/scripts/ascii-globe.js @@ -0,0 +1,264 @@ +/** + * Interactive ASCII Globe with Mouse Repulsion + * A rotating 3D globe made of ASCII characters that react to mouse movement + */ + +(function() { + 'use strict'; + + // Wait for DOM to be ready + function initGlobe() { + const canvas = document.getElementById('ascii-globe-canvas'); + if (!canvas) { + // Retry if canvas not found yet + setTimeout(initGlobe, 100); + return; + } + + const ctx = canvas.getContext('2d'); + const container = document.getElementById('ascii-globe-container'); + if (!container) return; + + // Configuration + const GLOBE_CHARS = ['@', '#', '*', '+', '=', '-', ':', '.', 'o', 'O', '0', '%', '&']; + const POINT_COUNT = 600; + const GLOBE_RADIUS = 110; + const ROTATION_SPEED = 0.002; + const MOUSE_RADIUS = 100; + const REPEL_STRENGTH = 25; + const RETURN_SPEED = 0.06; + const FRICTION = 0.88; + + let width, height; + let mouseX = -1000; + let mouseY = -1000; + let isMouseInCanvas = false; + let rotation = 0; + let animationId; + + // Particle class for each ASCII character + class Particle { + constructor(theta, phi) { + this.theta = theta; + this.phi = phi; + this.char = GLOBE_CHARS[Math.floor(Math.random() * GLOBE_CHARS.length)]; + this.x = 0; + this.y = 0; + this.targetX = 0; + this.targetY = 0; + this.vx = 0; + this.vy = 0; + this.baseSize = 8 + Math.random() * 6; + this.depth = 0; + this.originalTheta = theta; + this.originalPhi = phi; + } + + update(rotationY) { + // Calculate 3D position on sphere + const x3d = GLOBE_RADIUS * Math.sin(this.phi) * Math.cos(this.theta + rotationY); + const y3d = GLOBE_RADIUS * Math.cos(this.phi); + const z3d = GLOBE_RADIUS * Math.sin(this.phi) * Math.sin(this.theta + rotationY); + + // Project to 2D with perspective + const perspective = 350; + const scale = perspective / (perspective + z3d + GLOBE_RADIUS); + + this.targetX = width / 2 + x3d * scale; + this.targetY = height / 2 + y3d * scale; + this.depth = z3d; + + // Mouse repulsion with explosive effect + if (isMouseInCanvas) { + const dx = this.x - mouseX; + const dy = this.y - mouseY; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < MOUSE_RADIUS && dist > 0) { + const force = Math.pow((MOUSE_RADIUS - dist) / MOUSE_RADIUS, 2) * REPEL_STRENGTH; + const angle = Math.atan2(dy, dx); + + // Add some randomness for more organic shattering + const randomAngle = angle + (Math.random() - 0.5) * 0.5; + this.vx += Math.cos(randomAngle) * force; + this.vy += Math.sin(randomAngle) * force; + } + } + + // Spring back to target position + const springX = (this.targetX - this.x) * RETURN_SPEED; + const springY = (this.targetY - this.y) * RETURN_SPEED; + + this.vx += springX; + this.vy += springY; + + // Apply friction + this.vx *= FRICTION; + this.vy *= FRICTION; + + // Update position + this.x += this.vx; + this.y += this.vy; + } + + draw(ctx) { + // Only draw particles on the front half of the globe (with some buffer) + if (this.depth < 30) { + const normalizedDepth = (this.depth + GLOBE_RADIUS) / (GLOBE_RADIUS * 2); + const alpha = Math.max(0.1, 0.15 + normalizedDepth * 0.85); + const size = this.baseSize * (0.4 + normalizedDepth * 0.6); + + // Calculate displacement for visual feedback + const displacement = Math.sqrt( + Math.pow(this.x - this.targetX, 2) + + Math.pow(this.y - this.targetY, 2) + ); + + // Gold theme colors matching the site (#D4A63A) + // Shift toward brighter/whiter when displaced + const hue = 43; + const saturation = Math.max(30, 70 - displacement * 0.5); + const lightness = Math.min(80, 45 + normalizedDepth * 25 + displacement * 0.3); + + ctx.fillStyle = `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`; + ctx.font = `${size}px "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", monospace`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(this.char, this.x, this.y); + } + } + } + + let particles = []; + + function init() { + // Set canvas size + const rect = container.getBoundingClientRect(); + width = rect.width || 600; + height = Math.min(400, window.innerHeight * 0.5); + + const dpr = window.devicePixelRatio || 1; + canvas.width = width * dpr; + canvas.height = height * dpr; + canvas.style.width = width + 'px'; + canvas.style.height = height + 'px'; + ctx.scale(dpr, dpr); + + // Create particles distributed on sphere using golden spiral (Fibonacci sphere) + particles = []; + const goldenRatio = (1 + Math.sqrt(5)) / 2; + + for (let i = 0; i < POINT_COUNT; i++) { + const theta = 2 * Math.PI * i / goldenRatio; + const phi = Math.acos(1 - 2 * (i + 0.5) / POINT_COUNT); + + const particle = new Particle(theta, phi); + // Initialize at center, will animate to position + particle.x = width / 2; + particle.y = height / 2; + particles.push(particle); + } + } + + function animate() { + // Clear canvas + ctx.clearRect(0, 0, width, height); + + // Slow rotation + rotation += ROTATION_SPEED; + + // Update all particles + particles.forEach(p => p.update(rotation)); + + // Sort by depth (back to front) for proper layering + particles.sort((a, b) => a.depth - b.depth); + + // Draw all visible particles + particles.forEach(p => p.draw(ctx)); + + animationId = requestAnimationFrame(animate); + } + + // Mouse event handlers + function handleMouseMove(e) { + const rect = canvas.getBoundingClientRect(); + mouseX = e.clientX - rect.left; + mouseY = e.clientY - rect.top; + isMouseInCanvas = true; + } + + function handleMouseLeave() { + isMouseInCanvas = false; + mouseX = -1000; + mouseY = -1000; + } + + function handleTouchMove(e) { + const rect = canvas.getBoundingClientRect(); + const touch = e.touches[0]; + mouseX = touch.clientX - rect.left; + mouseY = touch.clientY - rect.top; + isMouseInCanvas = true; + } + + function handleTouchEnd() { + isMouseInCanvas = false; + mouseX = -1000; + mouseY = -1000; + } + + // Handle window resize + let resizeTimeout; + function handleResize() { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(() => { + const rect = container.getBoundingClientRect(); + if (Math.abs(rect.width - width) > 10) { + init(); + } + }, 150); + } + + // Set up event listeners + canvas.addEventListener('mousemove', handleMouseMove, { passive: true }); + canvas.addEventListener('mouseleave', handleMouseLeave); + canvas.addEventListener('touchmove', handleTouchMove, { passive: true }); + canvas.addEventListener('touchend', handleTouchEnd); + window.addEventListener('resize', handleResize, { passive: true }); + + // Initialize and start animation + init(); + animate(); + + // Store cleanup function + window._asciiGlobeCleanup = function() { + if (animationId) { + cancelAnimationFrame(animationId); + } + canvas.removeEventListener('mousemove', handleMouseMove); + canvas.removeEventListener('mouseleave', handleMouseLeave); + canvas.removeEventListener('touchmove', handleTouchMove); + canvas.removeEventListener('touchend', handleTouchEnd); + window.removeEventListener('resize', handleResize); + }; + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initGlobe); + } else { + initGlobe(); + } + + // Also try on page navigation (for SPA behavior) + if (typeof window !== 'undefined') { + let lastUrl = location.href; + new MutationObserver(() => { + const url = location.href; + if (url !== lastUrl) { + lastUrl = url; + setTimeout(initGlobe, 200); + } + }).observe(document, { subtree: true, childList: true }); + } +})();