/* жила · рельеф A single contour field over the whole surface: domain-warped fbm for the strata, a radial well under the eye, perspective compression below the horizon, and black pools cut where the interface needs to speak. */ (function () { 'use strict'; const P = { scale: 1 / 1.35, // field resolution vs. screen density: 17, // contour levels per unit of the field freq: 4.7, // base frequency of the strata oct: 5, warp: 0.66, well: 2.35, line: 0.68, // half-width of a contour, in pixels bloom: 0.5 }; const SCALE = P.scale; const canvas = document.getElementById('terrain'); const ctx = canvas.getContext('2d', { alpha: false }); const spiresCanvas = document.getElementById('spires'); if (!canvas) return; const anchors = { eye: { x: 0.5, y: 0.3, sig: 0.13 }, hor: 0.6 }; /* ————— noise ————— */ function hash(x, y) { let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263); h = Math.imul(h ^ (h >>> 13), 1274126177); h ^= h >>> 16; return (h >>> 0) / 4294967296; } function noise(x, y) { const xi = Math.floor(x), yi = Math.floor(y); const xf = x - xi, yf = y - yi; const u = xf * xf * (3 - 2 * xf); const v = yf * yf * (3 - 2 * yf); const a = hash(xi, yi), b = hash(xi + 1, yi); const c = hash(xi, yi + 1), d = hash(xi + 1, yi + 1); const ab = a + (b - a) * u; const cd = c + (d - c) * u; return ab + (cd - ab) * v; } function fbm(x, y, oct) { let s = 0, amp = 0.5, f = 1, norm = 0; for (let i = 0; i < oct; i++) { s += amp * noise(x * f, y * f); norm += amp; f *= 2.04; amp *= 0.5; } return s / norm; } const smooth = (a, b, x) => { let t = (x - a) / (b - a); t = t < 0 ? 0 : t > 1 ? 1 : t; return t * t * (3 - 2 * t); }; const clamp01 = v => v < 0 ? 0 : v > 1 ? 1 : v; /* ————— anchors: where the eye and the horizon actually are ————— */ function measure() { const voidEl = document.querySelector('.void'); const stage = document.getElementById('stage'); const h = window.innerHeight; if (voidEl) { const r = voidEl.getBoundingClientRect(); anchors.eye.x = (r.left + r.width / 2) / h; anchors.eye.y = (r.top + r.height / 2) / h; anchors.eye.sig = (r.width * 0.95) / h; } if (stage) { const sr = stage.getBoundingClientRect(); anchors.hor = (sr.top + sr.height * 0.28) / h; } } /* ————— black pools around the interface ————— */ function blob(g, x, y, w, h, seed) { const pad = 15; x -= pad; y -= pad; w += pad * 2; h += pad * 2; let s = seed * 9301 + 49297; const rnd = () => { s = (s * 9301 + 49297) % 233280; return s / 233280; }; const jit = 9; const step = 34; const nx = Math.max(2, Math.round(w / step)); const ny = Math.max(2, Math.round(h / step)); const pts = []; for (let i = 0; i <= nx; i++) pts.push([x + (w * i) / nx, y + (rnd() - 0.5) * jit]); for (let i = 1; i <= ny; i++) pts.push([x + w + (rnd() - 0.5) * jit, y + (h * i) / ny]); for (let i = nx - 1; i >= 0; i--) pts.push([x + (w * i) / nx, y + h + (rnd() - 0.5) * jit]); for (let i = ny - 1; i > 0; i--) pts.push([x + (rnd() - 0.5) * jit, y + (h * i) / ny]); g.beginPath(); g.moveTo(pts[0][0], pts[0][1]); for (let i = 1; i < pts.length; i++) g.lineTo(pts[i][0], pts[i][1]); g.closePath(); } let maskCache = null; function buildMask(mw, mh) { const c = document.createElement('canvas'); c.width = mw; c.height = mh; const g = c.getContext('2d'); g.clearRect(0, 0, mw, mh); g.fillStyle = '#fff'; g.filter = 'blur(11px)'; const els = Array.prototype.slice.call(document.querySelectorAll('[data-pool]')); els.forEach((el, i) => { const r = el.getBoundingClientRect(); if (r.width < 4 || r.height < 4) return; if (r.bottom < -40 || r.top > window.innerHeight + 40) return; g.globalAlpha = parseFloat(el.dataset.pool) || 0.9; blob(g, r.left * SCALE, r.top * SCALE, r.width * SCALE, r.height * SCALE, i + 3); g.fill(); }); g.filter = 'none'; const data = g.getImageData(0, 0, mw, mh).data; maskCache = data; return data; } /* ————— the field ————— */ function render() { measure(); const W = canvas.clientWidth || window.innerWidth; const H = canvas.clientHeight || window.innerHeight; const dpr = Math.min(1.6, window.devicePixelRatio || 1); canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.fillStyle = '#080504'; ctx.fillRect(0, 0, canvas.width, canvas.height); const mw = Math.max(8, Math.round(W * SCALE)); const mh = Math.max(8, Math.round(H * SCALE)); const mask = buildMask(mw, mh); const e = anchors.eye; const hor = anchors.hor; const sig2 = 2 * e.sig * e.sig; const field = new Float32Array(mw * mh); /* pass 1: the terrain itself */ for (let j = 0; j < mh; j++) { const py = (j + 0.5) / mh; const below = smooth(hor - 0.01, hor + 0.12, py); const dd = Math.max(py - hor, 0.006); let persp = 1.55 / dd; if (persp > 2.15) persp = 2.15; const sy = 1 + below * (persp - 1) * 0.8; const sx = 1 + below * 0.3; for (let i = 0; i < mw; i++) { const px = (i + 0.5) / mh; const wx = fbm(px * 2.05 + 13.7, py * 2.42 + 4.2, 3); const wy = fbm(px * 2.05 + 7.1, py * 2.42 + 9.6, 3); const qx = px + (wx - 0.5) * P.warp; const qy = py + (wy - 0.5) * P.warp; const dx = px - e.x, dy = py - e.y; const well = Math.exp(-(dx * dx + dy * dy) / sig2); let n = fbm(qx * P.freq * sx + 19.1, qy * P.freq * sy + 2.7, P.oct); n -= P.well * well; field[j * mw + i] = n; } } /* pass 2: contours with a constant ink width */ const img = new ImageData(mw, mh); const data = img.data; const px2 = 1 / mh; for (let j = 0; j < mh; j++) { const py = (j + 0.5) / mh; const below = smooth(hor - 0.01, hor + 0.12, py); const dim = 1 - 0.4 * below; const j0 = Math.max(0, j - 1), j1 = Math.min(mh - 1, j + 1); for (let i = 0; i < mw; i++) { const idx = j * mw + i; const n = field[idx]; const i0 = Math.max(0, i - 1), i1 = Math.min(mw - 1, i + 1); const gx = (field[j * mw + i1] - field[j * mw + i0]) * 0.5; const gy = (field[j1 * mw + i] - field[j0 * mw + i]) * 0.5; let g = Math.sqrt(gx * gx + gy * gy); if (g < 0.00035) g = 0.00035; const v = n * P.density; const fr = v - Math.floor(v); const edge = (fr < 0.5 ? fr : 1 - fr) / P.density; // distance to the line, in field units const ink = P.line * g * (0.82 + 0.5 * Math.min(1, Math.abs(n) * 2)); let line = 1 - edge / ink; if (line < 0) line = 0; line = Math.pow(line, 0.8); const px = (i + 0.5) / mh; const dx = px - e.x, dy = py - e.y; const well = Math.exp(-(dx * dx + dy * dy) / sig2); const m = 1 - (mask[idx * 4 + 3] / 255) * 0.975; const vy = 1 - Math.abs(py - 0.12) * 0.9; const a = clamp01(line * m * dim * (1 + 0.4 * well) * (0.82 + 0.3 * vy)); const wash = clamp01((0.018 + 0.13 * well) * m * dim * (1 - line) * (0.7 + 0.6 * vy)); const hot = clamp01(well * 1.1 + n * 0.42); const p = idx * 4; data[p] = 196 + 52 * hot; data[p + 1] = 40 + 30 * hot + 14 * n; data[p + 2] = 28 + 16 * hot; data[p + 3] = (a + wash * (1 - a)) * 255; } } const tmp = document.createElement('canvas'); tmp.width = mw; tmp.height = mh; tmp.getContext('2d').putImageData(img, 0, 0); ctx.save(); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = P.bloom; ctx.filter = 'blur(8px)'; ctx.drawImage(tmp, 0, 0, canvas.width, canvas.height); ctx.filter = 'none'; ctx.globalAlpha = 1; ctx.drawImage(tmp, 0, 0, canvas.width, canvas.height); ctx.restore(); const g = ctx.createRadialGradient( canvas.width * 0.5, canvas.height * 0.36, 0, canvas.width * 0.5, canvas.height * 0.36, Math.max(canvas.width, canvas.height) * 0.78 ); g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(1, 'rgba(0,0,0,.38)'); ctx.fillStyle = g; ctx.fillRect(0, 0, canvas.width, canvas.height); } /* ————— the city at the end of the road ————— */ function mulberry32(a) { return function () { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; } function spire(g, x, base, h, w, rnd, alpha) { const tipX = x + (rnd() - 0.5) * w * 0.32; const tipY = base - h; const halfAt = function (t) { let k = Math.pow(1 - t, 0.62); k *= 1 + 0.1 * Math.sin(t * 7 + w * 0.5); if (t > 0.34) k *= 1 - 0.15 * (t - 0.34); return Math.max(0.5, w * 0.5 * k); }; const cxAt = function (t) { return x + (tipX - x) * Math.pow(t, 1.6); }; const N = 20; const pts = []; for (let i = 0; i <= N; i++) { const t = i / N; pts.push([cxAt(t) - halfAt(t), base - h * t]); } pts.push([tipX, tipY]); for (let i = N; i >= 0; i--) { const t = i / N; pts.push([cxAt(t) + halfAt(t), base - h * t]); } const body = new Path2D(); body.moveTo(pts[0][0], pts[0][1]); for (let i = 1; i < pts.length; i++) body.lineTo(pts[i][0], pts[i][1]); body.closePath(); g.fillStyle = 'rgba(58,42,35,.34)'; g.fill(body); g.strokeStyle = 'rgba(220,206,190,' + (0.5 * alpha).toFixed(2) + ')'; g.lineWidth = 0.8; g.stroke(body); /* ribs: the vertical grain of the crystal */ const ribs = 4; g.lineWidth = 0.6; g.strokeStyle = 'rgba(208,194,180,' + (0.26 * alpha).toFixed(2) + ')'; for (let k = -ribs; k <= ribs; k++) { const f = k / ribs; g.beginPath(); g.moveTo(x + f * w * 0.48, base); g.lineTo(cxAt(1) + f * 1.6, tipY + 1); g.stroke(); } /* strata, straight: a wireframe, not a vase */ const bands = 7 + Math.floor(rnd() * 5); g.strokeStyle = 'rgba(208,194,180,' + (0.16 * alpha).toFixed(2) + ')'; for (let i = 1; i <= bands; i++) { const t = Math.pow(i / (bands + 1), 1.1); const cx = cxAt(t); const y = base - h * t; const hw = halfAt(t) * 0.97; g.beginPath(); g.moveTo(cx - hw, y); g.lineTo(cx + hw, y); g.stroke(); } /* a few braces, to catch the light */ g.strokeStyle = 'rgba(208,194,180,' + (0.13 * alpha).toFixed(2) + ')'; for (let i = 1; i <= 2; i++) { const t0 = 0.16 + i * 0.22; const t1 = t0 + 0.12; const hw0 = halfAt(t0), hw1 = halfAt(t1); g.beginPath(); g.moveTo(cxAt(t0) - hw0, base - h * t0); g.lineTo(cxAt(t1) + hw1, base - h * t1); g.moveTo(cxAt(t0) + hw0, base - h * t0); g.lineTo(cxAt(t1) - hw1, base - h * t1); g.stroke(); } if (rnd() > 0.35) { g.beginPath(); g.moveTo(tipX, tipY + 2); g.lineTo(tipX, tipY - h * 0.14); g.strokeStyle = 'rgba(224,210,194,' + (0.4 * alpha).toFixed(2) + ')'; g.lineWidth = 0.7; g.stroke(); } } function drawSpires() { if (!spiresCanvas) return; const W = spiresCanvas.clientWidth; const H = spiresCanvas.clientHeight; if (!W || !H) return; const dpr = Math.min(2, window.devicePixelRatio || 1); spiresCanvas.width = Math.round(W * dpr); spiresCanvas.height = Math.round(H * dpr); const g = spiresCanvas.getContext('2d'); g.setTransform(dpr, 0, 0, dpr, 0, 0); g.clearRect(0, 0, W, H); const horizon = H * 0.7; const dark = g.createLinearGradient(0, 0, 0, horizon); dark.addColorStop(0, 'rgba(5,3,2,.62)'); dark.addColorStop(0.72, 'rgba(5,3,2,.2)'); dark.addColorStop(1, 'rgba(5,3,2,0)'); g.fillStyle = dark; g.fillRect(0, 0, W, horizon + 2); const haze = g.createLinearGradient(0, horizon - H * 0.55, 0, horizon + H * 0.14); haze.addColorStop(0, 'rgba(232,64,42,0)'); haze.addColorStop(1, 'rgba(232,64,42,.1)'); g.fillStyle = haze; g.fillRect(0, horizon - H * 0.55, W, H); const layers = [ { hMin: 0.14, hMax: 0.42, alpha: 0.42, wid: 0.8, seed: 41, spread: 0.045 }, { hMin: 0.3, hMax: 1.0, alpha: 1, wid: 1, seed: 94, spread: 0.05 } ]; const clusters = [0.045, 0.15, 0.27, 0.335, 0.665, 0.73, 0.85, 0.955]; layers.forEach(function (L) { const rnd = mulberry32(L.seed); const picked = []; clusters.forEach(function (cx) { const n = 1 + Math.floor(rnd() * 2.6); for (let i = 0; i < n; i++) { const t = cx + (rnd() - 0.5) * L.spread * 2; if (t < 0.02 || t > 0.98) continue; if (t > 0.39 && t < 0.61) continue; const edge = Math.min(1, Math.abs(t - 0.5) * 2.1); picked.push({ x: t * W, h: H * (L.hMin + rnd() * (L.hMax - L.hMin)) * (0.55 + 0.6 * edge), w: (7 + rnd() * 16) * L.wid * (0.75 + 0.5 * edge), a: L.alpha * (0.75 + rnd() * 0.25) }); } }); picked.sort(function (a, b) { return a.h - b.h; }); picked.forEach(function (p) { spire(g, p.x, horizon + 2, p.h, p.w, rnd, p.a); }); }); } window.ZHILA_TERRAIN = { render, drawSpires, measure, P }; })();