feat: initial commit for landing rotating clock

This commit is contained in:
s.esmaeili
2026-10-04 06:54:23 +03:30
commit 2f0b60c42b
22 changed files with 13386 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# Nuxt dev / build outputs
.output
.data
.nuxt
.nitro
.cache
# Node dependencies
node_modules
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Environment variables
.env
.env.*
!.env.example
# OS files
.DS_Store
Thumbs.db
+33
View File
@@ -0,0 +1,33 @@
# رصد · Rasad — product-studio website (Nuxt 3 + Vue 3 + Tailwind + Three.js + GSAP)
```bash
npm install
npm run dev # http://localhost:3000
npm run generate # static, SEO-complete build in .output/public
```
## Concept
The whole site is one engraved **astrolabe** (اسطرلاب), the Persian precision instrument, operated by scrolling.
The plate is projected for Tehran (35.7°N). Its rete (عنکبوت) carries **15 star pointers = 15 products** in 4 constellations.
Signature moments
1. **Engraving** – on load the instrument draws itself line by line (hero).
2. **The rete** – `#products` pins for 15 steps; each step turns the rete clockwise so the next star (waiting on the left, RTL "next") lands under the lapis rule. Swipe, arrow keys, the progress rule and the star labels all navigate.
3. **Anatomy** – the instrument tilts and separates into its four physical layers: shared infra / data model / products / team.
## Architecture
| Path | Role |
|---|---|
| `lib/astrolabe.js` | Framework-agnostic Three.js scene. Damped targets (`set({cx, cy, size, tilt, explode, focus, active…})`), line draw-on, label projection, low-power mode (half density, DPR cap), reduced-motion (no spin, instant), pauses when tab hidden. |
| `lib/story.js` | The camera path: scroll position + section metrics → scene target. One continuous interpolation, no section-by-section cuts. Separate desktop/mobile compositions of the *same* states. |
| `components/AstrolabeStage.client.vue` | Client-only canvas. GSAP ticker drives frames; ScrollTrigger measures sections and refreshes on resize/fonts. |
| `components/*Section.vue`, `ZijTable.vue` | Server-rendered content. Every product is real HTML in the Zij table + JSON-LD `ItemList` of `SoftwareApplication`. |
| `data/products.js` | Single content source (15 products, 4 families, Persian digit helpers). |
## RTL notes
- `<html lang="fa" dir="rtl">` from `nuxt.config`, never mirrored after the fact.
- Only logical utilities (`ms-/me-/ps-/pe-/start-/end-`, `text-start`). The physical `left` on star labels is intentional: they are placed in canvas pixel space.
- Latin (product names, URLs, code, phone) is isolated with `<bdi dir="ltr">`; numbers use Persian digits in Persian copy.
- No letter-spacing on Persian (breaks joins). Directional motion is RTL-semantic: next enters from the left, ArrowLeft = next, rightward swipe = next.
Content (company, figures, contact) is placeholder.
+3
View File
@@ -0,0 +1,3 @@
<template>
<NuxtPage />
</template>
+14
View File
@@ -0,0 +1,14 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html { @apply bg-paper text-ink antialiased; }
body { @apply font-sans; text-rendering: optimizeLegibility; }
/* Persian is a joined script: tracking breaks the joins. */
[lang="fa"] * { letter-spacing: 0; }
::selection { @apply bg-lapis text-white; }
:focus-visible { outline: 2px solid theme("colors.lapis"); outline-offset: 4px; border-radius: 4px; }
}
.star-label { transition: opacity .4s, color .3s; will-change: transform; }
.header-veil { background: linear-gradient(to bottom, #EEF1F4 0%, rgb(238 241 244 / .94) 55%, rgb(238 241 244 / 0) 100%); }
+33
View File
@@ -0,0 +1,33 @@
<!-- Signature 3 · the instrument comes apart: four physical layers = the shared platform. -->
<script setup lang="ts">
const story = useStory();
const layers = [
{ term: "اُم", title: "زیرساخت مشترک", text: "هویت، پرداخت، پایش و استقرار؛ یک بار ساخته شده، پانزده بار به کار رفته." },
{ term: "صفیحه", title: "یک زبان داده", text: "همهٔ محصولات روی یک مدل داده و یک تقویم شمسی با هم حرف می‌زنند." },
{ term: "عنکبوت", title: "پانزده محصول", text: "هر محصول جای ثابت خودش را دارد. چرخ می‌چرخد، نظم نمی‌شکند." },
{ term: "عضاده", title: "یک تیم", text: "چهل‌ودو مهندس و طراح که هر بار فقط یک چیز را نشانه می‌گیرند." },
];
</script>
<template>
<section id="anatomy" aria-labelledby="anat-h" class="relative" style="height: 340svh">
<div class="sticky top-0 flex h-[100svh] items-end px-[6vw] pb-[9vh] md:items-center md:pb-0">
<div class="md:w-[38vw]">
<h2 id="anat-h" class="text-[clamp(2.4rem,4.6vw,4.8rem)] font-black leading-[1.08]">یک سامانه،<br><span class="font-extralight">چهار لایه.</span></h2>
<ol class="mt-8 space-y-4 md:mt-12 md:space-y-6">
<li v-for="(l, i) in layers" :key="l.term" class="transition-all duration-700"
:class="[story.focus === -1 ? 'opacity-70' : story.focus === i ? 'opacity-100' : 'opacity-25',
story.mobile && story.focus !== -1 && story.focus !== i ? 'hidden' : '']">
<p class="flex items-baseline gap-3 text-[20px] font-bold md:text-[22px]">
<span :class="story.focus === i ? 'text-lapis' : ''">{{ l.title }}</span>
<span class="text-[15px] font-normal text-mute">«{{ l.term }}»</span>
</p>
<!-- Text is always in the DOM for screen readers; only its visual height animates. -->
<p class="max-w-[34ch] overflow-hidden text-[16px] leading-7 text-ink/80 transition-all duration-700 md:text-[17px]"
:class="story.focus === i ? 'mt-2 max-h-24' : 'max-h-0'">{{ l.text }}</p>
</li>
</ol>
</div>
</div>
</section>
</template>
+101
View File
@@ -0,0 +1,101 @@
<!--
The one canvas behind the whole page. Client-only: the HTML ships complete without it,
WebGL is created after hydration and never blocks first paint.
GSAP's ticker drives the frame; ScrollTrigger measures the sections and keeps them in sync on refresh.
-->
<script setup lang="ts">
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { createAstrolabe } from "~/lib/astrolabe";
import { storyAt, ecoScrollFor } from "~/lib/story";
import { products, STEP } from "~/data/products";
const canvas = ref<HTMLCanvasElement | null>(null);
const labels = ref<HTMLElement[]>([]);
const story = useStory();
let scene: ReturnType<typeof createAstrolabe> | null = null;
let metrics: any = null;
const triggers: ScrollTrigger[] = [];
onMounted(async () => {
await nextTick();
if (!canvas.value) {
canvas.value = document.querySelector("canvas");
}
if (!canvas.value) return;
gsap.registerPlugin(ScrollTrigger);
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
// Lower-powered devices keep the same instrument, with half the engraving density and a capped DPR.
const lowPower = (navigator.hardwareConcurrency || 8) <= 4 || innerWidth < 600;
scene = createAstrolabe(canvas.value, { products, step: STEP, reducedMotion: reduced, lowPower });
if (!scene) return;
scene.set({ draw: 1 });
const eco = ScrollTrigger.create({ trigger: "#products", start: "top top", end: "bottom bottom" });
const anat = ScrollTrigger.create({ trigger: "#anatomy", start: "top top", end: "bottom bottom" });
const zij = ScrollTrigger.create({ trigger: "#zij", start: "top bottom", end: "bottom top" });
triggers.push(eco, anat, zij);
const measure = () => {
metrics = { vh: innerHeight, ecoTop: eco.start, ecoEnd: eco.end, zijTop: zij.start + innerHeight, anatTop: anat.start, anatEnd: anat.end };
story.value.mobile = innerWidth < 768 || innerWidth / innerHeight < 0.8;
scene?.resize();
};
ScrollTrigger.addEventListener("refresh", measure);
ScrollTrigger.refresh();
document.fonts?.ready?.then(() => ScrollTrigger.refresh());
scene.onFrame(({ center, stars }) => {
const s = story.value;
stars?.forEach(([x, y]: number[], i: number) => {
const el = labels.value[i]; if (!el) return;
let dx = x - center[0], dy = y - center[1]; const l = Math.hypot(dx, dy) || 1; dx /= l; dy /= l;
const show = s.inEco && (!s.mobile || i === s.active);
const off = i === s.active ? 26 : 16;
el.style.opacity = String(show ? (i === s.active ? 1 : 0.62) : 0);
el.style.pointerEvents = show ? "auto" : "none";
el.style.transform = `translate(${x + dx * off}px, ${y + dy * off}px) translate(${dx < -0.2 ? "-100%" : dx > 0.2 ? "0" : "-50%"}, -50%)`;
});
});
gsap.ticker.add(tick);
addEventListener("pointermove", onMove, { passive: true });
story.value.go = (i: number) => {
if (!metrics) return;
const n = Math.max(0, Math.min(14, i));
gsap.killTweensOf(window);
window.scrollTo({ top: ecoScrollFor(n, metrics), behavior: reduced ? "auto" : "smooth" });
};
});
function tick() {
if (!metrics || !scene) return;
const s = story.value;
const r = storyAt(window.scrollY, metrics, s.mobile);
scene.set({ ...r.target, active: r.inEco ? r.ecoIndex : s.hover, focus: r.focus });
if (r.inEco && r.ecoIndex !== s.active) { s.dir = r.ecoIndex > s.active ? 1 : -1; s.active = r.ecoIndex; }
if (r.inEco !== s.inEco) s.inEco = r.inEco;
if (r.focus !== s.focus) s.focus = r.focus;
}
const onMove = (e: PointerEvent) => scene?.pointer(e.clientX / innerWidth - 0.5, e.clientY / innerHeight - 0.5);
onBeforeUnmount(() => {
gsap.ticker.remove(tick);
removeEventListener("pointermove", onMove);
triggers.forEach((t) => t.kill());
scene?.dispose();
});
</script>
<template>
<canvas ref="canvas" aria-hidden="true" class="pointer-events-none fixed inset-0 z-0 h-[100lvh] w-full" />
<div class="pointer-events-none fixed inset-0 z-10" :aria-hidden="!story.inEco">
<button
v-for="(p, i) in products" :key="p.slug" :ref="(el) => (labels[i] = el as HTMLElement)"
tabindex="-1" class="star-label absolute left-0 top-0 whitespace-nowrap px-1 text-[14px] opacity-0"
:class="i === story.active ? 'font-bold text-verm' : 'text-ink'" @click="story.go?.(i)"
>{{ p.fa }}</button>
</div>
</template>
+20
View File
@@ -0,0 +1,20 @@
<template>
<section aria-label="مقیاس" class="px-[6vw] py-[20vh] md:py-[26vh]">
<p class="text-[18px] text-mute md:text-[20px]">هر روز</p>
<p class="mt-2 text-[clamp(3.4rem,10vw,11rem)] font-black leading-none">۲٫۴ میلیون نفر</p>
<p class="mt-5 max-w-[30ch] text-[clamp(1.4rem,2.6vw,2.4rem)] font-extralight leading-snug">با دست‌کم یکی از ابزارهای رصد کار می‌کنند.</p>
</section>
<section id="contact" aria-labelledby="contact-h" class="relative flex min-h-[100svh] items-end px-[6vw] pb-[14vh] md:items-center md:pb-0">
<div class="md:w-[46vw]">
<h2 id="contact-h" class="text-[clamp(2.6rem,5.4vw,5.8rem)] font-black leading-[1.05]">ابزار بعدی را<br><span class="font-extralight">با هم بسازیم.</span></h2>
<a href="mailto:hello@rasad.ir" class="mt-10 inline-block border-b-2 border-lapis pb-1 font-mono text-[clamp(1.2rem,2.2vw,1.9rem)] text-lapis md:mt-14"><bdi dir="ltr">hello@rasad.ir</bdi></a>
<dl class="mt-10 grid max-w-[520px] grid-cols-2 gap-6 text-[15px] md:mt-14">
<div><dt class="text-mute">تلفن</dt><dd class="mt-1 font-mono"><bdi dir="ltr">+98 21 8800 1500</bdi></dd></div>
<div><dt class="text-mute">نشانی</dt><dd class="mt-1 leading-7">تهران، خیابان ولیعصر، پلاک ۲۱۱۲</dd></div>
</dl>
</div>
</section>
<footer class="flex justify-between px-[6vw] pb-8 text-[14px] text-mute">
<span>© ۱۴۰۵ رصد</span><span>ساخته‌شده با دقت، در تهران</span>
</footer>
</template>
+74
View File
@@ -0,0 +1,74 @@
<!--
Signature 2 · the rete. 15 scroll steps inside one pinned viewport.
RTL semantics throughout: the next product waits on the inline-end (left) side, so
panels enter from the left, the progress rule fills right→left, ArrowLeft = next,
and a rightward swipe pulls the next star under the rule.
-->
<script setup lang="ts">
import { gsap } from "gsap";
import { products, families, pad } from "~/data/products";
const story = useStory();
const panel = ref<HTMLElement | null>(null);
const p = computed(() => products[story.value.active]);
const fam = computed(() => families.find((f) => f.id === p.value.family)!);
watch(() => story.value.active, async () => {
await nextTick();
if (!panel.value || matchMedia("(prefers-reduced-motion: reduce)").matches) return;
gsap.fromTo(panel.value.children, { x: story.value.dir > 0 ? -36 : 36, opacity: 0 }, { x: 0, opacity: 1, duration: 0.7, ease: "expo.out", stagger: 0.06, overwrite: true });
});
let t0: [number, number] | null = null;
const onTouchStart = (e: TouchEvent) => { const t = e.touches[0]; t0 = [t.clientX, t.clientY]; };
const onTouchEnd = (e: TouchEvent) => {
if (!t0) return; const t = e.changedTouches[0];
const dx = t.clientX - t0[0], dy = t.clientY - t0[1]; t0 = null;
if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.3) story.value.go?.(story.value.active + (dx > 0 ? 1 : -1));
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "ArrowLeft") { e.preventDefault(); story.value.go?.(story.value.active + 1); }
if (e.key === "ArrowRight") { e.preventDefault(); story.value.go?.(story.value.active - 1); }
};
</script>
<template>
<section id="products" aria-labelledby="eco-h" class="relative" style="height: calc(100svh + 15 * 62svh)">
<div
class="sticky top-0 h-[100svh] touch-pan-y overflow-hidden outline-none" tabindex="0" role="group"
aria-roledescription="چرخ محصولات" aria-label="پانزده محصول رصد؛ با کلیدهای جهت حرکت کنید"
@touchstart.passive="onTouchStart" @touchend="onTouchEnd" @keydown="onKey"
>
<h2 id="eco-h" class="sr-only">پانزده محصول، یک آسمان</h2>
<div class="absolute inset-x-0 bottom-[84px] px-[6vw] md:inset-y-0 md:bottom-auto md:start-0 md:end-auto md:flex md:w-[40vw] md:flex-col md:justify-center md:pb-[72px] md:pe-0 md:pt-[124px]" aria-live="polite">
<div ref="panel">
<div>
<p class="mb-2 flex items-baseline gap-3 text-[15px] md:mb-4">
<span class="font-medium tabular-nums">{{ pad(story.active + 1) }}<span class="text-mute"> / ۱۵</span></span>
<span class="text-verm">{{ fam.fa }}</span>
<span class="ms-auto text-[14px] text-mute md:hidden">بکشید ↔</span>
</p>
<h3 class="flex items-baseline gap-4 leading-none">
<span class="text-[clamp(3rem,min(7vw,12vh),7.4rem)] font-black">{{ p.fa }}</span>
<bdi dir="ltr" class="font-mono text-[15px] text-mute">{{ p.en }}</bdi>
</h3>
<p class="mt-3 max-w-[32ch] text-[17px] leading-7 md:mt-5 md:text-[20px] md:leading-8">{{ p.line }}</p>
</div>
<div class="mt-5 max-w-[470px] md:mt-8">
<ProductScreen :product="p" :compact="story.mobile" />
<p class="mt-3 flex items-center gap-2 text-[15px] md:mt-4"><span class="h-[6px] w-[6px] rounded-full bg-verm" />{{ p.value }}</p>
</div>
</div>
</div>
<nav aria-label="فهرست محصولات" class="absolute inset-x-[6vw] bottom-7 flex items-end gap-[6px] md:bottom-9">
<button
v-for="(pr, i) in products" :key="pr.slug" :aria-label="`${pr.fa} (${pr.en})`" :aria-current="i === story.active"
class="group flex h-6 flex-1 items-end" :class="i > 0 && pr.family !== products[i - 1].family ? 'ms-3 md:ms-5' : ''"
@click="story.go?.(i)"
>
<span class="block w-full transition-all duration-500"
:class="i === story.active ? 'h-[18px] bg-verm' : i < story.active ? 'h-[3px] bg-ink' : 'h-[3px] bg-ink/20 group-hover:bg-ink/50'" />
</button>
</nav>
</div>
</section>
</template>
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { gsap } from "gsap";
const root = ref<HTMLElement | null>(null);
onMounted(() => {
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
// Text arrives after the engraving has started drawing itself (~0.9s).
gsap.from(root.value!.querySelectorAll("[data-rise]"), { y: 18, opacity: 0, duration: 1, ease: "power3.out", stagger: 0.2, delay: 0.9 });
});
</script>
<template>
<section ref="root" class="relative flex h-[100svh] items-end pb-[6vh] md:items-center md:pb-0" aria-labelledby="h1">
<div class="px-[6vw] md:w-[47vw] md:pe-0">
<p data-rise class="mb-5 text-[15px] text-mute md:mb-8">
استودیوی محصول‌سازی · تهران <bdi dir="ltr" class="font-mono text-[13px]">35°42′N</bdi>
</p>
<h1 id="h1" class="leading-[1.02]">
<span data-rise class="block text-[clamp(3.3rem,7.4vw,8.6rem)] font-black">ابزار دقیق</span>
<span data-rise class="block text-[clamp(2.2rem,4.6vw,5.4rem)] font-extralight">برای کارِ هر روز.</span>
</h1>
<p data-rise class="mt-6 max-w-[30ch] text-[18px] leading-8 text-ink/80 md:mt-9 md:text-[20px]">
رصد <b class="font-bold text-ink">پانزده محصول نرم‌افزاری</b> می‌سازد و نگه می‌دارد. هرکدام دقیق، همه روی یک سامانه.
</p>
<a data-rise href="#products" class="mt-8 inline-flex items-center gap-3 text-[16px] font-medium text-lapis md:mt-12">
<span class="h-px w-10 bg-lapis" />چرخ را بچرخانید
</a>
</div>
</section>
</template>
+126
View File
@@ -0,0 +1,126 @@
<!-- Product-specific miniature interfaces, built from type and line, no screenshots to download. -->
<script setup lang="ts">
const props = defineProps<{ product: { fa: string; en: string; kind: string }; compact?: boolean }>();
const k = computed(() => props.product.kind);
const ledger = [["فروش آنلاین", "۱۲٬۴۰۰٬۰۰۰"], ["کارمزد درگاه", "۱۸۶٬۰۰۰"], ["مالیات بر ارزش افزوده", "۱٬۱۱۶٬۰۰۰"], ["حمل و ارسال", "۹۴۰٬۰۰۰"]];
const payroll = [["مریم احمدی", "۴۲٬۸۰۰٬۰۰۰"], ["علی رضایی", "۳۸٬۲۰۰٬۰۰۰"], ["سارا کریمی", "۳۶٬۵۰۰٬۰۰۰"], ["رضا موسوی", "۳۱٬۹۰۰٬۰۰۰"]];
const bars = [30, 42, 38, 55, 48, 62, 58, 70, 66, 80, 76, 92];
const store = [["کوزهٔ لعابی", "۸۲۰", "#2341B8"], ["گلیم ۲×۳", "۴٬۹۰۰", "#C2330F"], ["قوری مسی", "۱٬۲۵۰", "#14213A"]];
const services = [["auth", true], ["checkout", true], ["search", false], ["media", true]] as const;
const results = ["پیش‌نویس قرارداد اجاره — واحد حقوقی", "صورت‌جلسهٔ تمدید اجاره — شعبهٔ شیراز", "پیوست مالی قرارداد — ۱۴۰۲/۰۶"];
const wave = Array.from({ length: 48 }, (_, i) => Math.min(100, 18 + Math.abs(Math.sin(i * 1.7) * 60) + (i % 5) * 4));
const apps = [["دفتر", true], ["پیام", true], ["سنجه", false], ["بندر", true]] as const;
const buckets = [["media-prod", "۷۸%", "78%"], ["backups", "۵۲%", "52%"], ["logs-archive", "۳۱%", "31%"]];
const days = ["ش", "ی", "د", "س", "چ", "پ"];
const booked = new Set([1, 2, 7, 9, 10, 14, 15, 16, 20, 22]);
</script>
<template>
<figure class="flex flex-col overflow-hidden rounded-[12px] border border-ink/10 bg-surface text-ink" :class="compact ? 'h-[min(176px,22svh)]' : 'h-[min(270px,32vh)]'">
<figcaption class="flex items-center justify-between border-b border-ink/[0.07] px-4 py-[9px] text-[14px] text-mute">
<span>{{ product.fa }}</span>
<bdi dir="ltr" class="font-mono text-[13px] tracking-wide">{{ product.en.toLowerCase() }}.rasad.ir</bdi>
</figcaption>
<div class="min-h-0 flex-1 overflow-hidden" :class="compact ? 'p-3' : 'p-5'">
<div v-if="k === 'pay'" class="flex h-full flex-col justify-between">
<div>
<p class="text-[14px] text-mute">تسویهٔ امروز</p>
<p class="mt-1 text-[clamp(1.6rem,3vw,2.4rem)] font-extrabold leading-none">۲٬۴۸۰٬۰۰۰<span class="ms-2 text-[14px] font-normal text-mute">تومان</span></p>
</div>
<div class="flex h-16 items-end gap-[5px]">
<span v-for="(h, i) in bars" :key="i" class="flex-1 rounded-t-[2px]" :class="i === 11 ? 'bg-verm' : 'bg-lapis/25'" :style="{ height: h + '%' }" />
</div>
</div>
<div v-else-if="k === 'ledger' || k === 'payroll'" class="flex h-full flex-col justify-center gap-[10px] text-[14px]">
<div v-for="([a, b], i) in (k === 'ledger' ? ledger : payroll)" :key="i" class="flex items-center justify-between gap-3" :class="i < 3 ? 'border-b border-ink/[0.07] pb-[9px]' : ''">
<span>{{ a }}</span><span class="font-medium tabular-nums">{{ b }}</span>
</div>
</div>
<div v-else-if="k === 'store'" class="grid h-full grid-cols-3 gap-3">
<div v-for="[n, p, c] in store" :key="n" class="flex flex-col gap-2">
<div class="flex-1 rounded-[6px]" :style="{ background: `repeating-linear-gradient(135deg, ${c}22 0 6px, ${c}0d 6px 12px)` }" />
<p class="truncate text-[14px]">{{ n }}</p><p class="text-[14px] text-mute">{{ p }} هزار</p>
</div>
</div>
<div v-else-if="k === 'chart'" class="flex h-full flex-col gap-2">
<p class="text-[14px] text-mute">«فروش هفتگی تهران در برابر اصفهان»</p>
<svg viewBox="0 0 300 110" class="w-full flex-1" preserveAspectRatio="none" aria-hidden="true">
<line v-for="y in [20, 50, 80]" :key="y" x1="0" x2="300" :y1="y" :y2="y" stroke="#14213A" stroke-opacity=".08" />
<polyline fill="none" stroke="#2341B8" stroke-width="2" points="300,80 260,70 220,74 180,52 140,58 100,36 60,40 20,18" />
<polyline fill="none" stroke="#14213A" stroke-opacity=".35" stroke-width="1.5" stroke-dasharray="4 4" points="300,92 260,88 220,84 180,80 140,70 100,72 60,62 20,58" />
<circle cx="100" cy="36" r="4" fill="#C2330F" />
</svg>
</div>
<div v-else-if="k === 'monitor'" class="grid h-full grid-cols-2 gap-x-5 gap-y-3 text-[14px]">
<div v-for="[n, ok] in services" :key="n" class="flex flex-col justify-center gap-1">
<div class="flex items-center justify-between"><bdi dir="ltr" class="font-mono">{{ n }}</bdi><span :class="ok ? 'text-lapis' : 'text-verm'">{{ ok ? 'سالم' : 'کُند' }}</span></div>
<svg viewBox="0 0 100 24" class="h-6 w-full" preserveAspectRatio="none" aria-hidden="true">
<polyline fill="none" :stroke="ok ? '#2341B8' : '#C2330F'" stroke-width="1.5" :points="ok ? '0,16 15,14 30,17 45,12 60,15 75,13 100,14' : '0,18 20,16 40,15 55,6 70,9 85,4 100,7'" />
</svg>
</div>
</div>
<div v-else-if="k === 'search'" class="flex h-full flex-col gap-3 text-[14px]">
<div class="rounded-[6px] border border-ink/15 px-3 py-2">قرارداد اجارهٔ دفتر <span class="text-mute">۱۴۰۲</span></div>
<p v-for="t in results" :key="t" class="truncate"><mark class="bg-lapis/[0.12] text-inherit">{{ t.split(' ')[0] }}</mark> {{ t.split(' ').slice(1).join(' ') }}</p>
</div>
<div v-else-if="k === 'wave'" class="flex h-full flex-col justify-between gap-3">
<div class="flex h-14 items-center gap-[3px]">
<span v-for="(h, i) in wave" :key="i" class="flex-1 rounded-full" :class="i > 30 ? 'bg-ink/15' : 'bg-lapis'" :style="{ height: h + '%' }" />
</div>
<p class="text-[15px] leading-7">سلام، برای پیگیری سفارشم تماس گرفتم، <span class="rounded-[3px] bg-verm/[0.12] px-1 text-verm">کِی</span> می‌رسه؟</p>
</div>
<div v-else-if="k === 'terminal'" dir="ltr" class="flex h-full flex-col justify-center gap-[7px] text-left font-mono text-[14px]">
<p><span class="text-mute">$</span> bandar deploy --prod</p>
<p class="text-mute">build ······ 14.2s</p><p class="text-mute">health ····· 3/3 ok</p>
<p class="text-mute">traffic ···· 0 → 100%</p><p class="text-lapis">● live in 41s</p>
</div>
<div v-else-if="k === 'keys'" class="flex h-full flex-col justify-center gap-[10px] text-[14px]">
<div v-for="[n, on] in apps" :key="n" class="flex items-center justify-between">
<span>{{ n }}</span>
<span class="relative h-[18px] w-8 rounded-full" :class="on ? 'bg-lapis' : 'bg-ink/15'">
<span class="absolute top-[3px] h-3 w-3 rounded-full bg-white" :class="on ? 'end-[3px]' : 'start-[3px]'" />
</span>
</div>
</div>
<div v-else-if="k === 'storage'" class="flex h-full flex-col justify-center gap-3 text-[14px]">
<div v-for="[n, p, w] in buckets" :key="n" class="flex flex-col gap-[6px]">
<div class="flex items-center justify-between"><bdi dir="ltr" class="font-mono">{{ n }}</bdi><span class="text-mute">{{ p }}</span></div>
<span class="h-[6px] rounded-full bg-ink/[0.07]"><span class="block h-[6px] rounded-full bg-lapis" :style="{ width: w }" /></span>
</div>
</div>
<div v-else-if="k === 'api'" dir="ltr" class="flex h-full flex-col justify-center gap-[6px] text-left font-mono text-[14px]">
<p><span class="text-lapis">GET</span> /v2/orders/8841 <span class="text-mute">200 · 2.1ms</span></p>
<p class="text-mute">{</p><p class="ps-4">"status": <span class="text-verm">"shipped"</span>,</p><p class="ps-4">"city": "Tabriz"</p><p class="text-mute">}</p>
</div>
<div v-else-if="k === 'chat'" class="flex h-full flex-col justify-center gap-2 text-[14px]">
<p class="max-w-[80%] self-start rounded-[12px] rounded-ss-[3px] bg-ink/[0.06] px-3 py-2">نسخهٔ جدید «بندر» آماده است؟</p>
<p class="max-w-[80%] self-end rounded-[12px] rounded-se-[3px] bg-lapis px-3 py-2 text-white">بله، ساعت ۱۶ منتشر می‌شود.</p>
<p class="self-start text-mute">نیلوفر در حال نوشتن…</p>
</div>
<div v-else-if="k === 'calendar'" class="grid h-full grid-cols-6 gap-[5px] text-center text-[14px]">
<span v-for="d in days" :key="d" class="text-mute">{{ d }}</span>
<span v-for="i in 24" :key="i" class="rounded-[4px]" :class="i - 1 === 9 ? 'bg-verm' : booked.has(i - 1) ? 'bg-lapis/70' : 'bg-ink/[0.06]'" />
</div>
<svg v-else-if="k === 'map'" viewBox="0 0 300 140" class="h-full w-full" aria-hidden="true">
<line v-for="i in 7" :key="'v' + i" :x1="(i - 1) * 50" :x2="(i - 1) * 50 + 20" y1="0" y2="140" stroke="#14213A" stroke-opacity=".07" />
<line v-for="i in 4" :key="'h' + i" x1="0" x2="300" :y1="(i - 1) * 40 + 10" :y2="(i - 1) * 40 + 18" stroke="#14213A" stroke-opacity=".07" />
<path d="M280 120 C 240 110, 230 70, 190 72 S 130 40, 100 52 S 40 30, 22 20" fill="none" stroke="#2341B8" stroke-width="2.5" />
<circle v-for="([x, y], i) in [[280, 120], [190, 72], [100, 52], [22, 20]]" :key="i" :cx="x" :cy="y" :r="i === 3 ? 6 : 4"
:fill="i === 3 ? '#C2330F' : '#F8F9FB'" :stroke="i === 3 ? 'none' : '#2341B8'" stroke-width="2" />
</svg>
</div>
</figure>
</template>
+14
View File
@@ -0,0 +1,14 @@
<template>
<header class="header-veil fixed inset-x-0 top-0 z-30 flex items-center justify-between px-[6vw] pb-8 pt-5">
<a href="#top" class="flex items-baseline gap-2" aria-label="رصد، صفحهٔ اصلی">
<span class="text-[28px] font-black leading-none">رصد</span>
<span class="h-[7px] w-[7px] rounded-full bg-verm" />
</a>
<nav aria-label="اصلی" class="flex items-center gap-7 text-[15px]">
<a href="#products" class="hidden hover:text-lapis md:inline">محصولات</a>
<a href="#zij" class="hidden hover:text-lapis md:inline">زیج</a>
<a href="#anatomy" class="hidden hover:text-lapis md:inline">ساختار</a>
<a href="#contact" class="border-b border-ink pb-[2px] hover:border-lapis hover:text-lapis">گفت‌وگو</a>
</nav>
</header>
</template>
+45
View File
@@ -0,0 +1,45 @@
<!-- The star table: every product in real, crawlable HTML. Hover / focus turns the rete to that star. -->
<script setup lang="ts">
import { products, families, fa } from "~/data/products";
const story = useStory();
const set = (i: number) => (story.value.hover = i);
</script>
<template>
<section id="zij" aria-labelledby="zij-h" class="relative px-[6vw] pb-[18vh] pt-[46vh] md:pt-[20vh]">
<div class="md:w-[58vw]">
<h2 id="zij-h" class="text-[clamp(3rem,6vw,6.4rem)] font-black leading-none">زیج</h2>
<p class="mt-4 max-w-[36ch] text-[18px] leading-8 text-ink/80 md:text-[20px]">
منجم‌ها ستاره‌ها را در زیج می‌نوشتند. این زیجِ رصد است: پانزده محصول در چهار صورت فلکی.
</p>
<table class="mt-12 w-full border-collapse text-start text-[15px] md:mt-16 md:text-[16px]">
<caption class="sr-only">فهرست کامل محصولات رصد</caption>
<thead class="text-[14px] text-mute">
<tr class="border-b border-ink/15">
<th scope="col" class="py-3 text-start font-normal">محصول</th>
<th scope="col" class="hidden py-3 text-start font-normal md:table-cell">کار</th>
<th scope="col" class="py-3 text-end font-normal">مقیاس</th>
<th scope="col" class="hidden py-3 text-end font-normal md:table-cell">از</th>
</tr>
</thead>
<tbody v-for="f in families" :key="f.id">
<tr><th colspan="4" scope="rowgroup" class="pb-2 pt-9 text-start text-[14px] font-medium text-verm">{{ f.fa }}</th></tr>
<template v-for="(pr, i) in products" :key="pr.slug">
<tr v-if="pr.family === f.id" tabindex="0"
class="border-b border-ink/[0.08] outline-none transition-colors hover:bg-ink/[0.035] focus-visible:bg-lapis/[0.06]"
@mouseenter="set(i)" @mouseleave="set(-1)" @focus="set(i)" @blur="set(-1)" @click="set(i)">
<th scope="row" class="py-4 text-start align-top font-normal">
<span class="text-[19px] font-bold">{{ pr.fa }}</span>
<bdi dir="ltr" class="ms-2 font-mono text-[13px] text-mute">{{ pr.en }}</bdi>
<span class="mt-1 block text-[15px] leading-6 text-ink/75 md:hidden">{{ pr.line }}</span>
</th>
<td class="hidden py-4 align-top leading-7 text-ink/80 md:table-cell">{{ pr.line }}</td>
<td class="whitespace-nowrap py-4 ps-4 text-end align-top tabular-nums">{{ pr.users }}</td>
<td class="hidden py-4 ps-4 text-end align-top tabular-nums text-mute md:table-cell">{{ fa(pr.year) }}</td>
</tr>
</template>
</tbody>
</table>
</div>
</section>
</template>
+3
View File
@@ -0,0 +1,3 @@
// Shared story state: written by the scroll orchestrator, read by sections.
export const useStory = () =>
useState("story", () => ({ active: 0, dir: 1, inEco: false, focus: -1, mobile: false, hover: -1, go: null as null | ((i: number) => void) }));
+50
View File
@@ -0,0 +1,50 @@
// Shared content model: 15 products, 4 families ("constellations").
// Used verbatim by the React preview and the Nuxt source.
export const families = [
{ id: "trade", fa: "تجارت و مالی", short: "مالی" },
{ id: "data", fa: "داده و هوش", short: "داده" },
{ id: "infra", fa: "زیرساخت", short: "زیرساخت" },
{ id: "work", fa: "کار و ارتباط", short: "ارتباط" },
];
export const products = [
{ slug: "dinar", fa: "دینار", en: "Dinar", family: "trade", kind: "pay", year: 1396, users: "۱٫۲ میلیون", r: 0.74,
line: "درگاه پرداخت و تسویهٔ خودکار برای فروشگاه‌های آنلاین.", value: "تسویه در کمتر از ۹۰ ثانیه" },
{ slug: "daftar", fa: "دفتر", en: "Daftar", family: "trade", kind: "ledger", year: 1397, users: "۸۴ هزار", r: 0.63,
line: "حسابداری ابری که سند را از تراکنش، خودش می‌نویسد.", value: "۷۰٪ سند کمتر با دست" },
{ slug: "bazaar", fa: "بازار", en: "Bazaar", family: "trade", kind: "store", year: 1398, users: "۱۲ هزار فروشگاه", r: 0.79,
line: "فروشگاه‌ساز برای برندهایی که نمی‌خواهند شبیه همه باشند.", value: "راه‌اندازی در یک بعدازظهر" },
{ slug: "mozd", fa: "مزد", en: "Mozd", family: "trade", kind: "payroll", year: 1400, users: "۲۱۰ هزار", r: 0.58,
line: "حقوق، بیمه و مالیات؛ در یک روز، بدون فایل اکسل.", value: "فیش حقوقی بی‌خطا، هر ماه" },
{ slug: "zij", fa: "زیج", en: "Zij", family: "data", kind: "chart", year: 1397, users: "۹ هزار تیم", r: 0.81,
line: "تحلیل محصول؛ رفتار کاربر را مثل جدول ستاره‌ها می‌خواند.", value: "پرسش به زبان فارسی، پاسخ با نمودار" },
{ slug: "sanjeh", fa: "سنجه", en: "Sanjeh", family: "data", kind: "monitor", year: 1399, users: "۳٬۴۰۰ سرویس", r: 0.66,
line: "پایش سرویس‌ها؛ پیش از آنکه کاربر چیزی حس کند.", value: "هشدار در ۱۲ ثانیه" },
{ slug: "kavosh", fa: "کاوش", en: "Kavosh", family: "data", kind: "search", year: 1400, users: "۴۰۰ سازمان", r: 0.76,
line: "جست‌وجوی سازمانی که املای فارسی را می‌فهمد.", value: "۴۰ میلیون سند در ۸۰ میلی‌ثانیه" },
{ slug: "goya", fa: "گویا", en: "Goya", family: "data", kind: "wave", year: 1401, users: "۶ میلیون دقیقه/ماه", r: 0.6,
line: "تبدیل گفتار فارسی به متن، با لهجه‌ها و اصطلاح‌ها.", value: "دقت ۹۶٪ روی گفتار طبیعی" },
{ slug: "bandar", fa: "بندر", en: "Bandar", family: "infra", kind: "terminal", year: 1398, users: "۱۸ هزار استقرار/روز", r: 0.8,
line: "سکوی استقرار؛ از کد تا سرور، با یک فرمان.", value: "استقرار بدون قطعی" },
{ slug: "kelid", fa: "کلید", en: "Kelid", family: "infra", kind: "keys", year: 1399, users: "۳ میلیون هویت", r: 0.64,
line: "هویت و ورود یکپارچه برای همهٔ سامانه‌های یک سازمان.", value: "یک ورود، همه‌جا" },
{ slug: "anbar", fa: "انبار", en: "Anbar", family: "infra", kind: "storage", year: 1400, users: "۹ پتابایت", r: 0.74,
line: "ذخیره‌سازی شیء، سازگار با S3 و میزبانی‌شده در ایران.", value: "دسترس‌پذیری ۹۹٫۹۹٪" },
{ slug: "pol", fa: "پل", en: "Pol", family: "infra", kind: "api", year: 1401, users: "۲ میلیارد درخواست/ماه", r: 0.57,
line: "دروازهٔ API؛ سرویس‌های قدیمی و جدید را به هم وصل می‌کند.", value: "تأخیر افزودهٔ زیر ۳ میلی‌ثانیه" },
{ slug: "payam", fa: "پیام", en: "Payam", family: "work", kind: "chat", year: 1399, users: "۵۵۰ هزار", r: 0.82,
line: "پیام‌رسان تیمی، با داده‌ای که از سازمان بیرون نمی‌رود.", value: "رمزنگاری سرتاسری" },
{ slug: "nobat", fa: "نوبت", en: "Nobat", family: "work", kind: "calendar", year: 1400, users: "۲٫۸ میلیون نوبت/ماه", r: 0.67,
line: "نوبت‌دهی آنلاین برای مطب، سالن و هر جایی که صف دارد.", value: "۴۰٪ غیبت کمتر" },
{ slug: "masir", fa: "مسیر", en: "Masir", family: "work", kind: "map", year: 1402, users: "۹ هزار ناوگان", r: 0.75,
line: "مسیریابی ناوگان پخش؛ کوتاه‌ترین راه، با ترافیک امروز.", value: "۱۸٪ سوخت کمتر" },
];
// Step between stars on the rete. Star i sits i*STEP counter-clockwise from
// the top: in an RTL reading the *next* product waits on the left and the
// rete turns clockwise (left → top → right) to bring it under the rule.
export const STEP = (24 * Math.PI) / 180;
const FA = "۰۱۲۳۴۵۶۷۸۹";
export const fa = (v) => String(v).replace(/\d/g, (d) => FA[d]);
export const pad = (n) => fa(String(n).padStart(2, "0"));
+300
View File
@@ -0,0 +1,300 @@
// رصد · 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(); },
};
}
+50
View File
@@ -0,0 +1,50 @@
// Scroll story: one continuous camera path through the page.
// Given the scroll position and section metrics, returns the scene target.
// Framework-agnostic: React preview reads metrics itself, Nuxt feeds it from GSAP ScrollTrigger.
const base = { tilt: 0, turn: 0, explode: 0, dim: 1, spin: 0, constellations: 0 };
export const STATES = {
desktop: {
hero: { ...base, cx: 0.27, cy: 0.58, size: 0.78, spin: 1 },
eco: { ...base, cx: 0.28, cy: 0.55, size: 0.76 },
zij: { ...base, cx: 0.17, cy: 0.52, size: 0.56, constellations: 1 },
anatA: { ...base, cx: 0.3, cy: 0.52, size: 0.72 },
anatB: { ...base, cx: 0.32, cy: 0.56, size: 0.66, tilt: 0.88, turn: -0.42, explode: 1 },
contact: { ...base, cx: 0.27, cy: 0.52, size: 0.78, spin: 1 },
},
mobile: {
hero: { ...base, cx: 0.5, cy: 0.29, size: 1.1, spin: 1 },
eco: { ...base, cx: 0.5, cy: 0.245, size: 0.92 },
zij: { ...base, cx: 0.5, cy: 0.1, size: 1.5, dim: 0.12, constellations: 1 },
anatA: { ...base, cx: 0.5, cy: 0.3, size: 0.95 },
anatB: { ...base, cx: 0.5, cy: 0.3, size: 0.8, tilt: 1.05, turn: -0.42, explode: 1 },
contact: { ...base, cx: 0.5, cy: 0.3, size: 1.1, spin: 1 },
},
};
const ease = (t) => t * t * (3 - 2 * t);
export function storyAt(y, m, mobile) {
const S = mobile ? STATES.mobile : STATES.desktop;
const anat = m.anatTop + (m.anatEnd - m.anatTop) * 0.38;
const stops = [
[0, S.hero], [m.ecoTop, S.eco], [m.ecoEnd, S.eco],
[m.zijTop - m.vh * 0.25, S.zij], [m.anatTop - m.vh * 0.6, S.zij],
[m.anatTop, S.anatA], [anat, S.anatB], [m.anatEnd, S.anatB],
[m.anatEnd + m.vh * 0.9, S.contact],
];
let i = 0;
while (i < stops.length - 2 && y > stops[i + 1][0]) i++;
const [y0, a] = stops[i], [y1, b] = stops[i + 1];
const t = ease(Math.min(1, Math.max(0, (y - y0) / Math.max(1, y1 - y0))));
const out = {};
for (const k in a) out[k] = a[k] + (b[k] - a[k]) * t;
const pe = (y - m.ecoTop) / Math.max(1, m.ecoEnd - m.ecoTop);
const inEco = y > m.ecoTop - m.vh * 0.35 && y < m.ecoEnd + m.vh * 0.35;
const ecoIndex = Math.min(14, Math.max(0, Math.floor(pe * 15)));
const pa = (y - m.anatTop) / Math.max(1, m.anatEnd - m.anatTop);
const focus = pa < 0.4 || pa > 1.15 ? -1 : Math.min(3, Math.floor(((pa - 0.4) / 0.6) * 4));
return { target: out, inEco, ecoIndex, focus, pe };
}
// Scroll position that lands on product i inside the pinned ecosystem.
export const ecoScrollFor = (i, m) => m.ecoTop + ((i + 0.5) / 15) * (m.ecoEnd - m.ecoTop);
+22
View File
@@ -0,0 +1,22 @@
// رصد · Nuxt config. Persian-first: lang/dir are set on <html> at render time, not patched later.
export default defineNuxtConfig({
compatibilityDate: "2025-01-01",
modules: ["@nuxtjs/tailwindcss"],
css: ["~/assets/css/main.css"],
// Fully static output: every product is in the HTML for crawlers; WebGL hydrates on top.
nitro: { prerender: { routes: ["/"] } },
app: {
head: {
htmlAttrs: { lang: "fa", dir: "rtl" },
charset: "utf-8",
viewport: "width=device-width, initial-scale=1, viewport-fit=cover",
meta: [{ name: "theme-color", content: "#EEF1F4" }],
link: [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{ rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "" },
{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100..900&family=JetBrains+Mono:wght@400;500&display=swap" },
],
},
},
vite: { build: { target: "es2020" }, optimizeDeps: { include: ["three", "gsap", "gsap/ScrollTrigger"] } },
});
+12361
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "rasad-site",
"private": true,
"type": "module",
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"generate": "nuxt generate",
"preview": "nuxt preview"
},
"dependencies": {
"gsap": "^3.13.0",
"nuxt": "^3.17.0",
"three": "^0.170.0",
"vue": "^3.5.0"
},
"devDependencies": {
"@nuxtjs/tailwindcss": "^6.14.0"
}
}
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { products } from "~/data/products";
const title = "رصد · ابزار دقیق برای کارِ هر روز";
const description = "رصد، استودیوی محصول‌سازی در تهران: پانزده محصول نرم‌افزاری در مالی، داده، زیرساخت و ارتباط؛ همه روی یک سامانه.";
useSeoMeta({
title, description,
ogTitle: title, ogDescription: description, ogType: "website", ogLocale: "fa_IR", ogImage: "/og.png",
twitterCard: "summary_large_image",
});
useHead({
link: [{ rel: "canonical", href: "https://rasad.ir/" }],
script: [{
type: "application/ld+json",
innerHTML: JSON.stringify({
"@context": "https://schema.org",
"@graph": [
{ "@type": "Organization", name: "رصد", alternateName: "Rasad", url: "https://rasad.ir", email: "hello@rasad.ir",
address: { "@type": "PostalAddress", addressLocality: "تهران", addressCountry: "IR" } },
{ "@type": "ItemList", name: "محصولات رصد", itemListElement: products.map((p, i) => ({
"@type": "ListItem", position: i + 1,
item: { "@type": "SoftwareApplication", name: `${p.fa} (${p.en})`, description: p.line, applicationCategory: "BusinessApplication", inLanguage: "fa" },
})) },
],
}),
}],
});
</script>
<template>
<div class="relative min-h-screen">
<a href="#products" class="sr-only focus:not-sr-only focus:fixed focus:start-4 focus:top-4 focus:z-50 focus:bg-white focus:px-4 focus:py-2">پرش به محصولات</a>
<ClientOnly>
<AstrolabeStage />
</ClientOnly>
<SiteHeader />
<main id="top" class="relative z-20">
<HeroSection />
<EcosystemSection />
<ZijTable />
<AnatomySection />
<ContactSection />
</main>
</div>
</template>
+1
View File
@@ -0,0 +1 @@
# Keep public directory
+17
View File
@@ -0,0 +1,17 @@
import type { Config } from "tailwindcss";
// Logical utilities (ms-/me-/ps-/pe-/start-/end-) are used everywhere instead of left/right.
export default <Partial<Config>>{
content: ["./components/**/*.vue", "./pages/**/*.vue", "./app.vue"],
theme: {
extend: {
colors: {
paper: "#EEF1F4", surface: "#F8F9FB", ink: "#14213A",
lapis: "#2341B8", verm: "#C2330F", mute: "#5D6A80",
},
fontFamily: {
sans: ['"Vazirmatn"', "system-ui", "sans-serif"],
mono: ['"JetBrains Mono"', "ui-monospace", "monospace"],
},
},
},
};