Files

301 lines
13 KiB
JavaScript
Raw Permalink Normal View History

// رصد · Astrolabe scene. Framework-agnostic Three.js module.
// The same file drives the React preview and the Nuxt <AstrolabeCanvas>.
//
// The instrument is drawn as an engraving: line work only, plate projected
// for Tehran's latitude (35.7°N). Four physical layers:
// 0 ام (mater) · 1 صفیحه (plate) · 2 عنکبوت (rete, 15 product stars) · 3 عضاده (rule)
import * as THREE from "three";
const INK = new THREE.Color("#14213A");
const LAPIS = new THREE.Color("#2341B8");
const VERM = new THREE.Color("#C2330F");
const D = Math.PI / 180;
const R_CAP = 0.893; // tropic of Capricorn = plate edge
const EPS = 23.44 * D, LAT = 35.7 * D;
const R_EQ = R_CAP / Math.tan((Math.PI / 2 + EPS) / 2);
const R_CAN = R_EQ * Math.tan((Math.PI / 2 - EPS) / 2);
function circle(out, cx, cy, r, n, clip, a0 = 0, a1 = Math.PI * 2) {
for (let k = 0; k < n; k++) {
const t0 = a0 + ((a1 - a0) * k) / n, t1 = a0 + ((a1 - a0) * (k + 1)) / n;
const x0 = cx + r * Math.cos(t0), y0 = cy + r * Math.sin(t0);
const x1 = cx + r * Math.cos(t1), y1 = cy + r * Math.sin(t1);
if (!clip || clip((x0 + x1) / 2, (y0 + y1) / 2)) out.push(x0, y0, 0, x1, y1, 0);
}
}
const line = (out, x0, y0, x1, y1) => out.push(x0, y0, 0, x1, y1, 0);
function bez(out, p0, p1, p2, n = 24) {
let px = p0[0], py = p0[1];
for (let k = 1; k <= n; k++) {
const t = k / n, u = 1 - t;
const x = u * u * p0[0] + 2 * u * t * p1[0] + t * t * p2[0];
const y = u * u * p0[1] + 2 * u * t * p1[1] + t * t * p2[1];
out.push(px, py, 0, x, y, 0); px = x; py = y;
}
}
function segments(arr, color, opacity) {
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.Float32BufferAttribute(arr, 3));
const m = new THREE.LineBasicMaterial({ color, transparent: true, opacity, depthWrite: false });
const ls = new THREE.LineSegments(g, m);
ls.userData.count = arr.length / 3;
ls.userData.base = opacity;
return ls;
}
function buildMater(q) {
const a = [];
[1.0, 0.986, 0.9, 0.893].forEach((r) => circle(a, 0, 0, r, 256 * q));
for (let d = 0; d < 360; d += q > 0.6 ? 1 : 2) {
const t = (90 - d) * D, len = d % 10 === 0 ? 0.075 : d % 5 === 0 ? 0.05 : 0.028;
line(a, 0.9 * Math.cos(t), 0.9 * Math.sin(t), (0.9 + len) * Math.cos(t), (0.9 + len) * Math.sin(t));
}
// kursi (throne) + shackle
bez(a, [-0.2, 0.98], [-0.13, 1.06], [-0.06, 1.1]);
bez(a, [0.2, 0.98], [0.13, 1.06], [0.06, 1.1]);
line(a, -0.06, 1.1, 0.06, 1.1);
bez(a, [-0.15, 0.99], [-0.1, 1.04], [-0.045, 1.065]);
bez(a, [0.15, 0.99], [0.1, 1.04], [0.045, 1.065]);
circle(a, 0, 1.075, 0.016, 24);
circle(a, 0, 1.17, 0.06, 48);
circle(a, 0, 1.17, 0.042, 48);
return segments(a, INK, 0.62);
}
function buildPlate(q) {
const a = [];
const inside = (x, y) => x * x + y * y <= R_CAP * R_CAP;
[R_EQ, R_CAN].forEach((r) => circle(a, 0, 0, r, 160 * q));
// horizon + almucantars, projected for Tehran
const hz = { c: (R_EQ * Math.cos(LAT)) / Math.sin(LAT), r: R_EQ / Math.sin(LAT) };
for (let h = 0; h < 90; h += q > 0.6 ? 6 : 12) {
const s = Math.sin(LAT) + Math.sin(h * D);
circle(a, 0, (R_EQ * Math.cos(LAT)) / s, (R_EQ * Math.cos(h * D)) / s, 180 * q, inside);
}
// azimuth arcs: circles through zenith and nadir, kept above the horizon
const yZ = R_EQ * Math.tan((Math.PI / 2 - LAT) / 2);
const yN = -R_EQ / Math.tan((Math.PI / 2 - LAT) / 2);
const ym = (yZ + yN) / 2, hd = (yZ - yN) / 2;
const above = (x, y) => inside(x, y) && x * x + (y - hz.c) ** 2 <= hz.r * hz.r;
for (let A = 15; A < 180; A += q > 0.6 ? 15 : 30) {
if (A === 90) continue;
const xc = hd / Math.tan(A * D), r = Math.hypot(xc, hd);
circle(a, xc, ym, r, 320 * q, above);
}
line(a, 0, -R_CAP, 0, R_CAP);
line(a, -R_CAP, 0, R_CAP, 0);
// unequal-hour arcs below the horizon (simplified)
for (let k = 1; k < 12; k++) {
const t = Math.PI + (k * Math.PI) / 12;
circle(a, 0, 0, R_CAP * 0.995, 2, null, t - 0.004, t + 0.004);
}
return segments(a, INK, 0.3);
}
function buildRete(stars, q) {
const a = [];
const eC = (R_CAP - R_CAN) / 2, eR = (R_CAP + R_CAN) / 2;
const inRim = (x, y) => x * x + y * y <= 0.866 * 0.866;
circle(a, 0, 0, 0.88, 220 * q);
circle(a, 0, 0, 0.866, 220 * q);
circle(a, 0, eC, eR, 200 * q, inRim);
circle(a, 0, eC, eR - 0.045, 200 * q, inRim);
for (let d = 0; d < 360; d += 5) {
const t = d * D, l = d % 30 === 0 ? 0.045 : 0.018;
const x0 = eR * Math.cos(t), y0 = eC + eR * Math.sin(t);
const x1 = (eR - l) * Math.cos(t), y1 = eC + (eR - l) * Math.sin(t);
if (inRim(x0, y0)) line(a, x0, y0, x1, y1);
}
circle(a, 0, 0, 0.07, 48); circle(a, 0, 0, 0.045, 48);
// four curved struts hub → rim
for (let k = 0; k < 4; k++) {
const t = (45 + k * 90) * D, n = t + 0.35;
bez(a, [0.07 * Math.cos(t), 0.07 * Math.sin(t)], [0.5 * Math.cos(n), 0.5 * Math.sin(n)], [0.866 * Math.cos(t + 0.15), 0.866 * Math.sin(t + 0.15)]);
}
// flame-shaped star pointers
const tips = [];
stars.forEach((s, i) => {
const t = Math.PI / 2 + i * s.step;
const tip = [s.r * Math.cos(t), s.r * Math.sin(t)];
const w = 0.055;
const b0 = [0.866 * Math.cos(t - w), 0.866 * Math.sin(t - w)];
const b1 = [0.866 * Math.cos(t + w), 0.866 * Math.sin(t + w)];
const mr = (s.r + 0.866) / 2, curl = 0.035;
bez(a, b0, [mr * Math.cos(t - curl), mr * Math.sin(t - curl)], tip, 14);
bez(a, b1, [mr * Math.cos(t + curl * 2.2), mr * Math.sin(t + curl * 2.2)], tip, 14);
tips.push(tip);
});
return { lines: segments(a, INK, 0.72), tips };
}
function buildRule() {
const g = new THREE.Group();
const a = [];
const W = 0.024, L = 0.98;
line(a, -W, -L + 0.08, -W, L - 0.08); line(a, W, -L + 0.08, W, L - 0.08);
line(a, -W, L - 0.08, 0, L); line(a, W, L - 0.08, 0, L);
line(a, -W, -L + 0.08, 0, -L); line(a, W, -L + 0.08, 0, -L);
line(a, 0, 0.1, 0, L - 0.02);
circle(a, 0, 0, 0.032, 40); circle(a, 0, 0, 0.012, 24);
const ls = segments(a, LAPIS, 0.95);
const sh = new THREE.Shape();
sh.moveTo(0, L); sh.lineTo(W, L - 0.08); sh.lineTo(W, -L + 0.08); sh.lineTo(0, -L);
sh.lineTo(-W, -L + 0.08); sh.lineTo(-W, L - 0.08); sh.closePath();
const fill = new THREE.Mesh(new THREE.ShapeGeometry(sh), new THREE.MeshBasicMaterial({ color: LAPIS, transparent: true, opacity: 0.1, depthWrite: false }));
fill.userData.base = 0.1;
g.add(fill, ls);
g.userData.parts = [fill, ls];
return g;
}
const damp = (c, t, k, dt) => c + (t - c) * (1 - Math.exp(-k * dt));
const wrap = (a) => Math.atan2(Math.sin(a), Math.cos(a));
export function createAstrolabe(canvas, { products = [], step = 0, reducedMotion = false, lowPower = false } = {}) {
if (!canvas) {
console.warn("createAstrolabe: Canvas is null or not provided");
return {
set() {},
pointer() {},
onFrame() {},
resize() {},
dispose() {},
};
}
const q = lowPower ? 0.5 : 1;
let renderer;
try {
renderer = new THREE.WebGLRenderer({ canvas, antialias: !lowPower, alpha: true, powerPreference: "high-performance" });
} catch (err) {
console.warn("WebGL initialization failed:", err);
return {
set() {},
pointer() {},
onFrame() {},
resize() {},
dispose() {},
};
}
renderer.setClearColor(0x000000, 0);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(28, 1, 0.1, 50);
camera.position.set(0, 0, 6);
const FOVH = 2 * 6 * Math.tan(14 * D);
const root = new THREE.Group(); // position + scale
const tiltG = new THREE.Group(); // tilt
root.add(tiltG); scene.add(root);
const mater = buildMater(q);
const plate = buildPlate(q);
const stars = products.map((p) => ({ r: p.r, step }));
const rete = new THREE.Group();
const { lines: reteLines, tips } = buildRete(stars, q);
rete.add(reteLines);
const dotGeo = new THREE.CircleGeometry(0.016, 20);
const dots = tips.map(([x, y]) => {
const m = new THREE.Mesh(dotGeo, new THREE.MeshBasicMaterial({ color: VERM, transparent: true, opacity: 1, depthWrite: false }));
m.position.set(x, y, 0.001); rete.add(m); return m;
});
const haloA = []; circle(haloA, 0, 0, 1, 64);
const halo = segments(haloA, VERM, 0.9);
halo.scale.setScalar(0.045); rete.add(halo);
// constellation lines between stars of the same family
const famA = [];
products.forEach((p, i) => { const n = products[i + 1]; if (n && n.family === p.family) line(famA, tips[i][0], tips[i][1], tips[i + 1][0], tips[i + 1][1]); });
const famLines = segments(famA, VERM, 0); rete.add(famLines);
const rule = buildRule();
const layers = [mater, plate, rete, rule];
layers.forEach((l) => tiltG.add(l));
const target = { cx: 0.5, cy: 0.5, size: 0.9, tilt: 0, turn: 0, explode: 0, focus: -1, dim: 1, draw: 1, spin: 1, active: -1, constellations: 0 };
const cur = { ...target, draw: reducedMotion ? 1 : 0, focusMix: [1, 1, 1, 1], halo: 0, reteAngle: 0, dotScale: tips.map(() => 1) };
let spinAcc = 0, W = 1, H = 1, raf = 0, last = performance.now(), frameCb = null, running = true;
const pointer = { x: 0, y: 0, tx: 0, ty: 0 };
function resize() {
if (!canvas || !renderer) return;
W = canvas.clientWidth || innerWidth || 1;
H = canvas.clientHeight || innerHeight || 1;
renderer.setPixelRatio(Math.min(devicePixelRatio || 1, lowPower ? 1.25 : 2));
renderer.setSize(W, H, false);
camera.aspect = W / H;
camera.updateProjectionMatrix();
}
resize();
const v = new THREE.Vector3();
const screenOf = (obj, x, y) => { v.set(x, y, 0).applyMatrix4(obj.matrixWorld).project(camera); return [(v.x + 1) / 2 * W, (1 - v.y) / 2 * H]; };
function frame(now) {
const dt = Math.min(0.05, (now - last) / 1000); last = now;
const k = reducedMotion ? 60 : 4.2;
for (const key of ["cx", "cy", "size", "tilt", "turn", "explode", "dim", "constellations"]) cur[key] = damp(cur[key], target[key], k, dt);
cur.draw = damp(cur.draw, target.draw, reducedMotion ? 60 : 1.15, dt);
if (!reducedMotion && target.spin) spinAcc += dt * 0.035 * target.spin;
const pxPerWorld = H / FOVH;
const s = (cur.size * Math.min(W, H)) / 2 / pxPerWorld;
root.position.set(((cur.cx - 0.5) * W) / pxPerWorld, ((0.5 - cur.cy) * H) / pxPerWorld, 0);
root.scale.setScalar(s);
pointer.x = damp(pointer.x, pointer.tx, 2, dt); pointer.y = damp(pointer.y, pointer.ty, 2, dt);
tiltG.rotation.set(-cur.tilt + pointer.y * 0.05, cur.tilt * 0.28 + pointer.x * 0.06, cur.turn);
layers.forEach((l, i) => { l.position.z = cur.explode * i * 0.42; });
// rete angle: shortest path to put the active star under the rule
const want = target.active >= 0 ? -target.active * step : spinAcc;
cur.reteAngle += wrap(want - cur.reteAngle) * (1 - Math.exp(-(reducedMotion ? 60 : 3.4) * dt));
rete.rotation.z = cur.reteAngle;
if (target.active >= 0) spinAcc = cur.reteAngle;
const setOp = (obj, op) => { obj.material.opacity = obj.userData.base * op; };
layers.forEach((l, i) => {
cur.focusMix[i] = damp(cur.focusMix[i], target.focus < 0 || target.focus === i ? 1 : 0.3, 5, dt);
const op = cur.focusMix[i] * cur.dim;
if (l === rete) { setOp(reteLines, op); dots.forEach((d) => (d.material.opacity = op)); }
else if (l === rule) rule.userData.parts.forEach((p) => setOp(p, op));
else setOp(l, op);
});
// draw-on: engrave the lines in order
[mater, plate, reteLines].forEach((l, i) => {
const local = Math.min(1, Math.max(0, cur.draw * 1.6 - i * 0.3));
l.geometry.setDrawRange(0, Math.floor((l.userData.count * local) / 2) * 2);
});
const ruleIn = Math.min(1, Math.max(0, cur.draw * 2 - 1));
rule.scale.set(1, Math.max(0.0001, ruleIn), 1);
dots.forEach((d, i) => {
cur.dotScale[i] = damp(cur.dotScale[i], i === target.active ? 1.9 : 1, 6, dt);
d.scale.setScalar(cur.dotScale[i] * Math.min(1, Math.max(0, cur.draw * 2 - 0.9)));
});
cur.halo = damp(cur.halo, target.active >= 0 ? 1 : 0, 5, dt);
if (target.active >= 0) halo.position.set(tips[target.active][0], tips[target.active][1], 0.002);
halo.material.opacity = 0.9 * cur.halo * cur.dim;
halo.scale.setScalar(0.03 + 0.025 * cur.halo + (reducedMotion ? 0 : Math.sin(now / 600) * 0.004));
famLines.material.opacity = cur.constellations * 0.55 * cur.dim;
renderer.render(scene, camera);
if (frameCb) {
root.updateMatrixWorld();
const c = screenOf(rete, 0, 0);
frameCb({ center: c, stars: tips.map(([x, y]) => screenOf(rete, x, y)), radius: s * pxPerWorld, W, H });
}
if (running) raf = requestAnimationFrame(frame);
}
raf = requestAnimationFrame(frame);
const onVis = () => { if (document.hidden) { running = false; cancelAnimationFrame(raf); } else if (!running) { running = true; last = performance.now(); raf = requestAnimationFrame(frame); } };
document.addEventListener("visibilitychange", onVis);
return {
set(partial) { Object.assign(target, partial); },
pointer(nx, ny) { pointer.tx = nx; pointer.ty = ny; },
onFrame(cb) { frameCb = cb; },
resize,
dispose() { running = false; cancelAnimationFrame(raf); document.removeEventListener("visibilitychange", onVis); renderer.dispose(); },
};
}