301 lines
13 KiB
JavaScript
301 lines
13 KiB
JavaScript
// رصد · 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(); },
|
||
|
|
};
|
||
|
|
}
|