web-design-skill/demo/demo1-with-skill.html
lishiqi.conard 2e214c5b76 Add initial project files including .gitignore, LICENSE, README, and skill definitions
- Created .gitignore to exclude unnecessary files.
- Added MIT License for project licensing.
- Introduced README.md and README.zh-CN.md for documentation in English and Chinese.
- Implemented web design engineer skill with detailed workflow and design principles.
- Included advanced patterns and code templates for reference in the skill.
- Added demo HTML files showcasing the skill's capabilities.
2026-04-21 19:53:49 +08:00

2382 lines
78 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>STELLARIA · 星海博物馆 — 人类太空探索的档案与现场</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Space+Grotesk:wght@300;400;500;600&family=JetBrains+Mono:wght@300;400;500&display=swap" rel="stylesheet">
<script type="importmap">
{ "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js" } }
</script>
<style>
:root {
--ink: oklch(0.10 0.015 250);
--void: oklch(0.06 0.012 260);
--panel: oklch(0.13 0.018 250);
--panel-2: oklch(0.16 0.020 250);
--line: oklch(0.28 0.020 250);
--line-soft: oklch(0.22 0.018 250);
--mist: oklch(0.66 0.012 250);
--bone: oklch(0.94 0.012 80);
--bone-dim: oklch(0.82 0.012 80);
--ember: oklch(0.78 0.13 65);
--ember-deep: oklch(0.62 0.14 55);
--cosmic: oklch(0.66 0.085 220);
--font-display: 'Instrument Serif', 'Songti SC', serif;
--font-sans: 'Space Grotesk', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
--pad-x: clamp(24px, 5vw, 80px);
--gap-section: clamp(96px, 14vw, 180px);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
background: var(--ink);
color: var(--bone);
font-family: var(--font-sans);
font-weight: 300;
font-size: 16px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow-x: hidden;
}
body {
/* very subtle vignette to deepen edges */
background:
radial-gradient(ellipse at 50% 0%, oklch(0.14 0.02 250) 0%, var(--ink) 55%, var(--void) 100%);
background-attachment: fixed;
}
::selection { background: var(--ember); color: var(--ink); }
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: none; cursor: pointer; }
/* ---------- typography helpers ---------- */
.display {
font-family: var(--font-display);
font-weight: 400;
line-height: 0.95;
letter-spacing: -0.015em;
}
.italic { font-style: italic; color: var(--ember); }
.eyebrow {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 400;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--mist);
}
.num {
font-family: var(--font-mono);
font-feature-settings: "tnum" 1;
}
p { text-wrap: pretty; max-width: 62ch; }
/* ---------- starfield canvas ---------- */
.stars {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
}
/* ---------- nav ---------- */
.nav {
position: fixed;
top: 0; left: 0; right: 0;
z-index: 50;
padding: 18px var(--pad-x);
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
transition: background 0.4s ease, border-color 0.4s ease, backdrop-filter 0.4s ease;
border-bottom: 1px solid transparent;
}
.nav.scrolled {
background: oklch(0.10 0.015 250 / 0.78);
backdrop-filter: blur(16px) saturate(140%);
-webkit-backdrop-filter: blur(16px) saturate(140%);
border-bottom: 1px solid var(--line-soft);
}
.brand {
display: flex; align-items: center; gap: 10px;
font-family: var(--font-display);
font-size: 22px;
letter-spacing: 0.02em;
}
.brand-mark {
width: 26px; height: 26px;
border: 1px solid var(--bone);
border-radius: 50%;
position: relative;
flex-shrink: 0;
}
.brand-mark::before, .brand-mark::after {
content: ""; position: absolute; inset: 0;
border-radius: 50%;
border: 1px solid var(--bone-dim);
}
.brand-mark::before { transform: scale(0.5); opacity: 0.6; }
.brand-mark::after { transform: scale(0.18); background: var(--ember); border-color: var(--ember); opacity: 0.9; }
.brand-meta {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--mist);
margin-left: 6px;
border-left: 1px solid var(--line);
padding-left: 10px;
}
.nav-links {
display: flex; gap: 36px;
justify-self: center;
font-size: 13px;
letter-spacing: 0.04em;
}
.nav-links a {
color: var(--bone-dim);
transition: color 0.2s;
position: relative;
padding: 4px 0;
}
.nav-links a:hover { color: var(--bone); }
.nav-links a::after {
content: ""; position: absolute; left: 0; bottom: 0;
width: 100%; height: 1px;
background: var(--ember);
transform: scaleX(0); transform-origin: left;
transition: transform 0.3s ease;
}
.nav-links a:hover::after { transform: scaleX(1); }
.nav-cta {
justify-self: end;
font-size: 13px;
letter-spacing: 0.06em;
border: 1px solid var(--bone);
padding: 10px 20px;
border-radius: 999px;
transition: background 0.2s, color 0.2s;
}
.nav-cta:hover { background: var(--bone); color: var(--ink); }
@media (max-width: 760px) {
.nav { grid-template-columns: 1fr auto; }
.nav-links { display: none; }
.brand-meta { display: none; }
}
/* ---------- hero ---------- */
.hero {
position: relative;
min-height: 100vh;
padding: 0 var(--pad-x);
display: flex;
flex-direction: column;
justify-content: flex-end;
z-index: 1;
overflow: hidden;
}
.hero-planet {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
display: block;
}
.hero-orbit-line {
position: absolute;
right: -10vw; top: 18%;
width: min(70vh, 60vw);
aspect-ratio: 1 / 1;
border: 1px solid oklch(0.32 0.02 250 / 0.22);
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
.hero-orbit-line::before {
content: ""; position: absolute; inset: 0;
border: 1px dashed oklch(0.28 0.02 250 / 0.18);
border-radius: 50%;
transform: scale(1.35) rotate(-20deg);
}
@media (max-width: 760px) {
.hero-orbit-line { display: none; }
}
.hero-coords {
position: absolute;
z-index: 3;
top: 90px;
right: var(--pad-x);
font-family: var(--font-mono);
font-size: 11px;
color: var(--mist);
text-align: right;
line-height: 1.7;
letter-spacing: 0.05em;
}
.hero-coords b { color: var(--bone); font-weight: 400; }
.hero-coords .live { color: var(--ember); }
.hero-meta-l {
position: absolute;
z-index: 3;
top: 90px;
left: var(--pad-x);
font-family: var(--font-mono);
font-size: 11px;
color: var(--mist);
letter-spacing: 0.05em;
line-height: 1.7;
}
.hero-content {
position: relative;
z-index: 2;
padding-bottom: clamp(60px, 10vh, 140px);
max-width: 1400px;
width: 100%;
margin: 0 auto;
}
.hero-eyebrow {
display: flex; align-items: center; gap: 14px;
margin-bottom: 32px;
}
.hero-eyebrow .dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--ember);
box-shadow: 0 0 10px var(--ember);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.hero-title {
font-size: clamp(60px, 11vw, 168px);
line-height: 1.0;
letter-spacing: 0;
margin-bottom: 36px;
}
.hero-title .row {
display: block;
overflow: hidden;
padding-bottom: 0.04em;
}
.hero-title .row + .row { margin-top: 4px; }
.hero-title .italic {
margin-left: 0.04em;
letter-spacing: 0.01em;
}
.hero-title .row span {
display: inline-block;
transform: translateY(105%);
animation: rise 1.1s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
}
.hero-title .row:nth-child(2) span { animation-delay: 0.12s; }
@keyframes rise {
to { transform: translateY(0); }
}
.hero-bottom {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 40px;
align-items: end;
padding-top: 40px;
border-top: 1px solid var(--line-soft);
}
.hero-bottom-l {
color: var(--bone-dim);
max-width: 44ch;
display: flex; flex-direction: column; gap: 14px;
}
.hero-bottom-l .lead-label {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--mist);
display: inline-flex; align-items: center; gap: 10px;
}
.hero-bottom-l .lead-label::before {
content: "";
width: 24px; height: 1px;
background: var(--mist);
}
.hero-bottom-l p {
font-size: 15.5px;
line-height: 1.65;
color: var(--bone-dim);
margin: 0;
text-wrap: pretty;
}
.hero-bottom-c {
display: flex; flex-direction: column; align-items: center; gap: 12px;
color: var(--mist);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.scroll-line {
width: 1px; height: 60px;
background: linear-gradient(to bottom, transparent, var(--bone));
position: relative;
overflow: hidden;
}
.scroll-line::after {
content: ""; position: absolute; left: 0; right: 0;
height: 30%;
background: var(--ember);
animation: scrollHint 2.4s ease-in-out infinite;
}
@keyframes scrollHint {
0% { top: -30%; }
100% { top: 100%; }
}
.hero-bottom-r {
display: flex; gap: 24px; justify-content: flex-end;
font-family: var(--font-mono);
font-size: 11px;
color: var(--mist);
letter-spacing: 0.05em;
}
.hero-bottom-r div b { display: block; color: var(--bone); font-weight: 400; font-size: 13px; margin-bottom: 4px; }
@media (max-width: 760px) {
.hero-coords, .hero-meta-l { font-size: 10px; }
.hero-bottom { grid-template-columns: 1fr; gap: 24px; }
.hero-bottom-r { justify-content: flex-start; }
.hero-bottom-c { display: none; }
}
/* ---------- section base ---------- */
section { position: relative; z-index: 1; }
.container {
max-width: 1400px;
margin: 0 auto;
padding: 0 var(--pad-x);
}
.section-head {
display: grid;
grid-template-columns: auto 1fr;
gap: 60px;
align-items: end;
padding-bottom: 56px;
border-bottom: 1px solid var(--line-soft);
margin-bottom: 64px;
}
.section-head .num-tag {
font-family: var(--font-mono);
font-size: 12px;
color: var(--mist);
letter-spacing: 0.1em;
}
.section-head h2 {
font-size: clamp(36px, 5vw, 68px);
}
.section-head .desc {
grid-column: 2;
color: var(--bone-dim);
font-size: 15px;
max-width: 56ch;
justify-self: end;
}
@media (max-width: 760px) {
.section-head { grid-template-columns: 1fr; gap: 24px; }
.section-head .desc { grid-column: 1; justify-self: start; }
}
/* ---------- intro / mission ---------- */
.intro {
padding: var(--gap-section) 0 calc(var(--gap-section) * 0.6);
}
.intro-grid {
display: grid;
grid-template-columns: 1fr 1.4fr;
gap: clamp(40px, 8vw, 120px);
align-items: start;
}
.intro-grid .label {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--mist);
padding-top: 14px;
border-top: 1px solid var(--line);
}
.intro-grid .body {
font-family: var(--font-display);
font-size: clamp(28px, 3.4vw, 46px);
line-height: 1.2;
letter-spacing: -0.01em;
color: var(--bone);
}
.intro-grid .body em {
font-style: italic;
color: var(--ember);
}
.intro-grid .body .small {
display: block;
font-family: var(--font-sans);
font-size: 15px;
line-height: 1.6;
color: var(--mist);
margin-top: 36px;
max-width: 50ch;
}
@media (max-width: 760px) {
.intro-grid { grid-template-columns: 1fr; gap: 24px; }
}
/* ---------- exhibitions ---------- */
.exhibits { padding: calc(var(--gap-section) * 0.5) 0 var(--gap-section); }
.exhibit-list {
display: grid;
grid-template-columns: 1fr;
}
.exhibit {
display: grid;
grid-template-columns: 80px 1fr 1.1fr;
gap: 48px;
padding: 48px 0;
border-top: 1px solid var(--line-soft);
align-items: center;
position: relative;
transition: padding 0.4s ease;
cursor: pointer;
}
.exhibit:last-child { border-bottom: 1px solid var(--line-soft); }
.exhibit:hover { padding: 56px 0; }
.exhibit:hover .exhibit-title { color: var(--ember); }
.exhibit:hover .exhibit-image { transform: scale(1.02); }
.exhibit:hover .exhibit-arrow { transform: translateX(8px); color: var(--ember); }
.exhibit-num {
font-family: var(--font-mono);
font-size: 13px;
color: var(--mist);
letter-spacing: 0.1em;
align-self: start;
padding-top: 4px;
}
.exhibit-text { display: flex; flex-direction: column; gap: 18px; max-width: 42ch; }
.exhibit-tag {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ember);
display: inline-flex; align-items: center; gap: 8px;
}
.exhibit-tag::before {
content: ""; width: 16px; height: 1px; background: var(--ember);
}
.exhibit-title {
font-family: var(--font-display);
font-size: clamp(36px, 4vw, 56px);
line-height: 1.05;
transition: color 0.3s ease;
}
.exhibit-title em { font-style: italic; color: var(--ember); }
.exhibit-desc { color: var(--bone-dim); font-size: 15px; line-height: 1.6; }
.exhibit-foot {
display: flex; gap: 28px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--mist);
letter-spacing: 0.05em;
padding-top: 8px;
}
.exhibit-foot b { color: var(--bone); font-weight: 400; }
.exhibit-image {
position: relative;
aspect-ratio: 16 / 10;
background:
radial-gradient(ellipse at 50% 50%, oklch(0.13 0.02 250) 0%, var(--void) 80%);
border: 1px solid var(--line-soft);
overflow: hidden;
transition: transform 0.5s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.exhibit-image canvas.planet-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.exhibit-image .scene-stars {
position: absolute;
inset: 0;
background-image:
radial-gradient(1px 1px at 12% 18%, rgba(220,230,245,0.7), transparent),
radial-gradient(1px 1px at 78% 32%, rgba(220,230,245,0.5), transparent),
radial-gradient(1px 1px at 34% 64%, rgba(220,230,245,0.6), transparent),
radial-gradient(1px 1px at 88% 78%, rgba(245,200,140,0.5), transparent),
radial-gradient(1px 1px at 56% 12%, rgba(220,230,245,0.4), transparent),
radial-gradient(1px 1px at 22% 84%, rgba(220,230,245,0.5), transparent),
radial-gradient(1px 1px at 64% 48%, rgba(220,230,245,0.3), transparent);
pointer-events: none;
}
.exhibit-image .label-tag {
position: absolute;
bottom: 14px; left: 14px;
z-index: 3;
font-family: var(--font-mono);
font-size: 10px;
color: var(--bone-dim);
letter-spacing: 0.14em;
text-transform: uppercase;
background: oklch(0.06 0.012 260 / 0.55);
padding: 6px 10px;
border: 1px solid var(--line);
backdrop-filter: blur(6px);
display: flex; align-items: center; gap: 8px;
}
.exhibit-image .label-tag::before {
content: "";
width: 6px; height: 6px; border-radius: 50%;
background: var(--ember);
box-shadow: 0 0 8px var(--ember);
}
.exhibit-image .corner {
position: absolute;
width: 18px; height: 18px;
border-color: var(--bone-dim);
border-style: solid;
border-width: 0;
z-index: 3;
}
.exhibit-image .corner.tl { top: 12px; left: 12px; border-top-width: 1px; border-left-width: 1px; }
.exhibit-image .corner.tr { top: 12px; right: 12px; border-top-width: 1px; border-right-width: 1px; }
.exhibit-image .corner.bl { bottom: 12px; left: 12px; border-bottom-width: 1px; border-left-width: 1px; }
.exhibit-image .corner.br { bottom: 12px; right: 12px; border-bottom-width: 1px; border-right-width: 1px; }
.exhibit-image .coord {
position: absolute;
top: 14px; right: 14px;
z-index: 3;
font-family: var(--font-mono);
font-size: 9.5px;
color: var(--mist);
letter-spacing: 0.12em;
text-align: right;
line-height: 1.6;
}
.exhibit-image .coord b { color: var(--bone); font-weight: 400; }
.exhibit-arrow {
position: absolute;
top: 48px; right: 0;
font-family: var(--font-mono);
font-size: 13px;
color: var(--bone);
letter-spacing: 0.05em;
transition: transform 0.3s ease, color 0.3s ease;
display: flex; align-items: center; gap: 8px;
}
@media (max-width: 960px) {
.exhibit { grid-template-columns: 1fr; gap: 24px; padding: 40px 0; }
.exhibit:hover { padding: 40px 0; }
.exhibit-num { padding-top: 0; }
.exhibit-arrow { position: static; margin-top: 8px; }
}
/* ---------- timeline ---------- */
.timeline { padding: var(--gap-section) 0; }
.timeline-wrap {
position: relative;
max-width: 1100px;
margin: 0 auto;
padding: 0 var(--pad-x);
}
.timeline-rail {
position: absolute;
top: 0; bottom: 0;
left: clamp(120px, 18vw, 220px);
width: 1px;
background: var(--line);
}
.timeline-rail::before, .timeline-rail::after {
content: "";
position: absolute; left: -3px;
width: 7px; height: 7px;
border: 1px solid var(--bone);
border-radius: 50%;
background: var(--ink);
}
.timeline-rail::before { top: -4px; }
.timeline-rail::after { bottom: -4px; }
.milestone {
display: grid;
grid-template-columns: clamp(120px, 18vw, 220px) 1fr;
gap: clamp(40px, 6vw, 88px);
padding: 40px 0;
align-items: start;
position: relative;
}
.milestone-year {
font-family: var(--font-display);
font-size: clamp(40px, 5vw, 64px);
line-height: 1;
color: var(--bone);
text-align: right;
padding-right: clamp(20px, 3vw, 32px);
position: relative;
}
.milestone-year .month {
display: block;
font-family: var(--font-mono);
font-size: 11px;
color: var(--mist);
letter-spacing: 0.1em;
text-transform: uppercase;
margin-top: 6px;
font-feature-settings: "tnum" 1;
}
.milestone-dot {
position: absolute;
right: -5px;
top: 18px;
width: 9px; height: 9px;
background: var(--ember);
border-radius: 50%;
box-shadow: 0 0 0 4px var(--ink), 0 0 14px var(--ember);
}
.milestone-body {
padding-left: clamp(20px, 3vw, 32px);
border-left: 0;
}
.milestone-tag {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--cosmic);
margin-bottom: 12px;
}
.milestone-title {
font-family: var(--font-display);
font-size: clamp(24px, 2.6vw, 34px);
line-height: 1.15;
margin-bottom: 14px;
color: var(--bone);
}
.milestone-desc {
color: var(--bone-dim);
font-size: 14.5px;
line-height: 1.7;
max-width: 56ch;
}
.milestone-meta {
display: flex; gap: 24px;
margin-top: 18px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--mist);
letter-spacing: 0.04em;
}
.milestone-meta b { color: var(--bone); font-weight: 400; }
@media (max-width: 760px) {
.timeline-rail { left: 16px; }
.milestone { grid-template-columns: 90px 1fr; gap: 24px; padding: 28px 0; }
.milestone-year { font-size: 32px; padding-right: 12px; }
.milestone-dot { right: -5px; top: 12px; }
}
/* ---------- reservation CTA ---------- */
.reserve {
padding: var(--gap-section) 0;
}
.reserve-card {
position: relative;
background:
radial-gradient(ellipse at 80% 100%, oklch(0.17 0.025 250) 0%, transparent 60%),
var(--panel);
border: 1px solid var(--line);
padding: clamp(40px, 6vw, 80px);
overflow: hidden;
display: grid;
grid-template-columns: 1.1fr 1fr;
gap: clamp(40px, 6vw, 80px);
align-items: center;
}
.reserve-card::before {
content: "";
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='0.5'/></svg>");
opacity: 0.06;
mix-blend-mode: overlay;
pointer-events: none;
}
.reserve-card::after {
content: "";
position: absolute;
top: 30px; right: 30px;
width: 140px; height: 140px;
border: 1px solid oklch(0.30 0.02 250 / 0.4);
border-radius: 50%;
box-shadow:
inset 0 0 0 30px oklch(0.30 0.02 250 / 0.15),
inset 0 0 0 60px oklch(0.30 0.02 250 / 0.08);
pointer-events: none;
}
.reserve-left { position: relative; z-index: 2; }
.reserve-eyebrow {
color: var(--ember);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.2em;
text-transform: uppercase;
margin-bottom: 24px;
}
.reserve-title {
font-family: var(--font-display);
font-size: clamp(40px, 5.4vw, 80px);
line-height: 1;
margin-bottom: 28px;
}
.reserve-title em { font-style: italic; color: var(--ember); }
.reserve-desc {
color: var(--bone-dim);
font-size: 16px;
margin-bottom: 36px;
max-width: 44ch;
}
.reserve-actions {
display: flex; gap: 16px; align-items: center; flex-wrap: wrap;
}
.btn-primary {
background: var(--bone);
color: var(--ink);
padding: 18px 32px;
border-radius: 999px;
font-size: 14px;
letter-spacing: 0.08em;
display: inline-flex; align-items: center; gap: 10px;
transition: transform 0.2s ease, background 0.2s ease;
}
.btn-primary:hover { background: var(--ember); transform: translateY(-1px); }
.btn-ghost {
color: var(--bone);
padding: 18px 24px;
font-size: 14px;
letter-spacing: 0.08em;
display: inline-flex; align-items: center; gap: 10px;
border-bottom: 1px solid var(--line);
transition: border-color 0.2s, color 0.2s;
}
.btn-ghost:hover { color: var(--ember); border-color: var(--ember); }
/* ticket */
.ticket {
position: relative;
background: var(--panel-2);
border: 1px solid var(--line);
padding: 32px;
z-index: 2;
/* perforated edge with mask */
--perf: radial-gradient(circle at 0 50%, transparent 6px, black 6.5px) left/12px 12px repeat-y,
radial-gradient(circle at 100% 50%, transparent 6px, black 6.5px) right/12px 12px repeat-y;
}
.ticket::before, .ticket::after {
content: "";
position: absolute;
width: 12px; height: 12px;
background: var(--ink);
border-radius: 50%;
top: 50%;
transform: translateY(-50%);
}
.ticket::before { left: -6px; }
.ticket::after { right: -6px; }
.ticket-head {
display: flex; justify-content: space-between; align-items: start;
padding-bottom: 20px;
border-bottom: 1px dashed var(--line);
margin-bottom: 24px;
}
.ticket-brand {
font-family: var(--font-display);
font-size: 20px;
letter-spacing: 0.04em;
}
.ticket-brand small {
display: block;
font-family: var(--font-mono);
font-size: 9px;
color: var(--mist);
letter-spacing: 0.18em;
text-transform: uppercase;
margin-top: 2px;
}
.ticket-id {
font-family: var(--font-mono);
font-size: 10px;
color: var(--mist);
text-align: right;
letter-spacing: 0.1em;
}
.ticket-id b { color: var(--bone); font-weight: 400; display: block; font-size: 13px; margin-bottom: 2px; }
.ticket-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px 16px;
margin-bottom: 24px;
}
.ticket-cell .label {
font-family: var(--font-mono);
font-size: 9.5px;
color: var(--mist);
letter-spacing: 0.18em;
text-transform: uppercase;
margin-bottom: 6px;
}
.ticket-cell .value {
font-family: var(--font-display);
font-size: 22px;
color: var(--bone);
line-height: 1.1;
}
.ticket-cell .value small {
font-family: var(--font-sans);
font-size: 12px;
color: var(--mist);
margin-left: 6px;
letter-spacing: 0;
}
.ticket-foot {
padding-top: 20px;
border-top: 1px dashed var(--line);
display: flex;
justify-content: space-between;
align-items: center;
}
.ticket-bars {
display: flex; gap: 2px; align-items: end;
}
.ticket-bars span {
display: block;
width: 2px;
background: var(--bone);
}
.ticket-stamp {
font-family: var(--font-mono);
font-size: 10px;
color: var(--ember);
letter-spacing: 0.15em;
border: 1px solid var(--ember);
padding: 6px 10px;
text-transform: uppercase;
}
@media (max-width: 880px) {
.reserve-card { grid-template-columns: 1fr; }
.reserve-card::after { display: none; }
}
/* ---------- footer ---------- */
footer {
border-top: 1px solid var(--line-soft);
padding: 80px 0 40px;
margin-top: 40px;
position: relative;
z-index: 2;
background: var(--void);
}
.footer-grid {
display: grid;
grid-template-columns: 1.6fr repeat(3, 1fr);
gap: 60px;
padding-bottom: 60px;
border-bottom: 1px solid var(--line-soft);
}
.footer-brand .logo {
font-family: var(--font-display);
font-size: 28px;
letter-spacing: 0.02em;
margin-bottom: 16px;
}
.footer-brand .tagline {
color: var(--mist);
font-size: 14px;
max-width: 32ch;
margin-bottom: 28px;
}
.newsletter {
display: flex;
border: 1px solid var(--line);
border-radius: 999px;
padding: 4px 4px 4px 20px;
max-width: 340px;
align-items: center;
transition: border-color 0.2s;
}
.newsletter:focus-within { border-color: var(--bone); }
.newsletter input {
flex: 1;
background: none;
border: none;
color: var(--bone);
font: inherit;
font-size: 13px;
padding: 10px 0;
outline: none;
}
.newsletter input::placeholder { color: var(--mist); }
.newsletter button {
background: var(--bone);
color: var(--ink);
padding: 10px 18px;
border-radius: 999px;
font-size: 12px;
letter-spacing: 0.08em;
transition: background 0.2s;
}
.newsletter button:hover { background: var(--ember); }
.footer-col h4 {
font-family: var(--font-mono);
font-size: 11px;
color: var(--mist);
letter-spacing: 0.18em;
text-transform: uppercase;
font-weight: 400;
margin-bottom: 20px;
}
.footer-col ul { list-style: none; }
.footer-col li {
font-size: 14px;
margin-bottom: 12px;
color: var(--bone-dim);
transition: color 0.2s;
}
.footer-col li:hover { color: var(--bone); cursor: pointer; }
.footer-col li b { color: var(--bone); font-weight: 400; display: block; }
.footer-col li small { color: var(--mist); font-size: 12px; }
.footer-mega {
font-family: var(--font-display);
font-size: clamp(80px, 22vw, 320px);
line-height: 0.85;
letter-spacing: -0.02em;
color: oklch(0.18 0.02 250);
margin: 60px 0 40px;
text-align: center;
user-select: none;
}
.footer-bot {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--mist);
letter-spacing: 0.05em;
}
.footer-bot a:hover { color: var(--bone); }
.footer-status { display: flex; align-items: center; gap: 8px; }
.footer-status .dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--ember);
box-shadow: 0 0 8px var(--ember);
}
@media (max-width: 880px) {
.footer-grid { grid-template-columns: 1fr 1fr; gap: 40px; }
.footer-brand { grid-column: span 2; }
}
@media (max-width: 520px) {
.footer-grid { grid-template-columns: 1fr; }
.footer-brand { grid-column: span 1; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
</style>
</head>
<body>
<canvas class="stars" id="stars"></canvas>
<!-- ============ NAV ============ -->
<nav class="nav" id="nav">
<div class="brand">
<span class="brand-mark"></span>
<span>STELLARIA</span>
<span class="brand-meta">Est. 2031 · MOSCOW · TOKYO · 上海</span>
</div>
<div class="nav-links">
<a href="#exhibits">展厅</a>
<a href="#timeline">编年史</a>
<a href="#reserve">参观</a>
<a href="#archive">档案</a>
<a href="#about">关于</a>
</div>
<a href="#reserve" class="nav-cta">预约参观 →</a>
</nav>
<!-- ============ HERO ============ -->
<header class="hero">
<canvas class="hero-planet" id="hero-planet"></canvas>
<div class="hero-orbit-line"></div>
<div class="hero-meta-l">
<div><b>ARCHIVE No. 014</b></div>
<div>Spring — Autumn, 2046</div>
<div style="margin-top: 16px;">策展 / 林知远</div>
<div>STELLARIA 馆藏部</div>
</div>
<div class="hero-coords">
<div class="live">● LIVE FROM ORBIT · ISS</div>
<div><b>51.6446° N</b></div>
<div><b>· 82.7° W</b></div>
<div style="margin-top: 14px;">ALT 408 KM</div>
<div>VEL 27 600 KM/H</div>
</div>
<div class="hero-content">
<div class="hero-eyebrow">
<span class="dot"></span>
<span class="eyebrow">A Museum Without Gravity · 一座没有重力的博物馆</span>
</div>
<h1 class="hero-title display">
<span class="row"><span>穿越<em class="italic">浩瀚</em></span></span>
<span class="row"><span>收藏<em class="italic">星辰</em></span></span>
</h1>
<div class="hero-bottom">
<div class="hero-bottom-l">
<span class="lead-label">馆所宣言 / Manifesto</span>
<p>从 1957 年第一颗人造卫星离开地球,到 2046 年人类首次踏上火卫一。我们用 80 余件原件、3 万册档案、9 个沉浸展厅,重述这场尚未结束的旅程。</p>
</div>
<div class="hero-bottom-c">
<span>SCROLL</span>
<div class="scroll-line"></div>
</div>
<div class="hero-bottom-r">
<div><b>09:00 — 21:30</b><span>每日开馆</span></div>
<div><b>9 个展厅</b><span>常设 + 临展</span></div>
</div>
</div>
</div>
</header>
<!-- ============ INTRO ============ -->
<section class="intro" id="about">
<div class="container">
<div class="intro-grid">
<div class="label">001 — 馆长寄语</div>
<div class="body">
我们不<em>展示</em>太空。<br>
我们让你<em>站在</em>太空里,<br>
听 1969 年那一声 <em>"a small step"</em><br>
在你耳边复活。
<span class="small">
STELLARIA 由前 NASA 工程师与艺术家共同筹建,是全球首个以"沉浸式档案"为策展语言的太空主题博物馆。从苏联第一颗 R-7 火箭的钛合金残片,到中国天宫空间站的训练实物,每一件展品都附有 7 至 90 分钟的环境重建。
</span>
</div>
</div>
</div>
</section>
<!-- ============ EXHIBITS ============ -->
<section class="exhibits" id="exhibits">
<div class="container">
<div class="section-head">
<span class="num-tag">II / 核心展厅</span>
<h2 class="display">四个展厅<em class="italic">,</em><br>四种<em class="italic">看见太空的方式</em></h2>
<p class="desc">每一个展厅都是一段独立的旅程,平均观展时长 45 分钟。建议按编号顺序参观,亦可凭兴趣自由组合 —— 我们准备了 12 条延伸观展路线供你选择。</p>
</div>
<div class="exhibit-list">
<article class="exhibit">
<div class="exhibit-num">001 / 04</div>
<div class="exhibit-text">
<span class="exhibit-tag">PERMANENT · HALL A</span>
<h3 class="exhibit-title">登月时代<em>.</em></h3>
<p class="exhibit-desc">从 1961 年肯尼迪的演讲,到 1972 年阿波罗 17 号最后一次月面驻留 —— 重现那段被 4 亿人共同凝视的 11 年。展厅中央是 1:1 复刻的鹰号着陆器舱内空间。</p>
<div class="exhibit-foot">
<span><b>1961 — 1972</b></span>
<span><b>展厅 A</b> · 1 200㎡</span>
<span><b>45 分钟</b></span>
</div>
</div>
<div class="exhibit-image">
<div class="scene-stars"></div>
<canvas class="planet-canvas" data-planet="moon"></canvas>
<span class="corner tl"></span><span class="corner tr"></span>
<span class="corner bl"></span><span class="corner br"></span>
<div class="coord"><b>LUNA</b><br>3 474 km · ⌀</div>
<span class="label-tag">HALL A · MOON</span>
</div>
<span class="exhibit-arrow">进入展厅 →</span>
</article>
<article class="exhibit">
<div class="exhibit-num">002 / 04</div>
<div class="exhibit-text">
<span class="exhibit-tag">PERMANENT · HALL B</span>
<h3 class="exhibit-title">空间站<em>生活</em></h3>
<p class="exhibit-desc">在失重中如何吃饭、睡觉、剪指甲、读一封家书?联合 7 位前驻站航天员口述史1:1 还原天宫与国际空间站的居住舱。来访者可以躺进真正的睡袋。</p>
<div class="exhibit-foot">
<span><b>1971 — 至今</b></span>
<span><b>展厅 B</b> · 980㎡</span>
<span><b>40 分钟</b></span>
</div>
</div>
<div class="exhibit-image">
<div class="scene-stars"></div>
<canvas class="planet-canvas" data-planet="station"></canvas>
<span class="corner tl"></span><span class="corner tr"></span>
<span class="corner bl"></span><span class="corner br"></span>
<div class="coord"><b>ISS</b><br>109 m · ↔</div>
<span class="label-tag">HALL B · SPACE STATION</span>
</div>
<span class="exhibit-arrow">进入展厅 →</span>
</article>
<article class="exhibit">
<div class="exhibit-num">003 / 04</div>
<div class="exhibit-text">
<span class="exhibit-tag">PERMANENT · HALL C</span>
<h3 class="exhibit-title">火星<em>计划</em></h3>
<p class="exhibit-desc">从 Viking 1 号到祝融号,再到 2046 年 Ares 五号载人任务 —— 红色行星上的 70 年勘测史,与一座按真实地形复刻的杰泽罗陨石坑步道。</p>
<div class="exhibit-foot">
<span><b>1976 — 进行中</b></span>
<span><b>展厅 C</b> · 1 600㎡</span>
<span><b>55 分钟</b></span>
</div>
</div>
<div class="exhibit-image">
<div class="scene-stars"></div>
<canvas class="planet-canvas" data-planet="mars"></canvas>
<span class="corner tl"></span><span class="corner tr"></span>
<span class="corner bl"></span><span class="corner br"></span>
<div class="coord"><b>MARS</b><br>6 779 km · ⌀</div>
<span class="label-tag">HALL C · RED PLANET</span>
</div>
<span class="exhibit-arrow">进入展厅 →</span>
</article>
<article class="exhibit">
<div class="exhibit-num">004 / 04</div>
<div class="exhibit-text">
<span class="exhibit-tag">PERMANENT · HALL D</span>
<h3 class="exhibit-title">深空<em>探索</em></h3>
<p class="exhibit-desc">两台旅行者号正在 240 亿公里外回望我们James Webb 在 L2 拉格朗日点拍下宇宙最早的婴儿照。展厅里悬挂一张直径 9 米的实时星图。</p>
<div class="exhibit-foot">
<span><b>1977 — 永远</b></span>
<span><b>展厅 D</b> · 1 100㎡</span>
<span><b>50 分钟</b></span>
</div>
</div>
<div class="exhibit-image">
<div class="scene-stars"></div>
<canvas class="planet-canvas" data-planet="galaxy"></canvas>
<span class="corner tl"></span><span class="corner tr"></span>
<span class="corner bl"></span><span class="corner br"></span>
<div class="coord"><b>DEEP FIELD</b><br>13.5 B LY</div>
<span class="label-tag">HALL D · DEEP FIELD</span>
</div>
<span class="exhibit-arrow">进入展厅 →</span>
</article>
</div>
</div>
</section>
<!-- ============ TIMELINE ============ -->
<section class="timeline" id="timeline">
<div class="container">
<div class="section-head">
<span class="num-tag">III / 编年史</span>
<h2 class="display">人类离开<br>地球的<em class="italic">每一步</em></h2>
<p class="desc">不到 90 年时间,从一颗哔哔作响的小球,到把一台钢琴大小的望远镜送到 150 万公里外。这些是被馆藏档案永久保留的关键节点。</p>
</div>
</div>
<div class="timeline-wrap">
<div class="timeline-rail"></div>
<div class="milestone">
<div class="milestone-year">
1957
<span class="month">10 / 04 · USSR</span>
<span class="milestone-dot"></span>
</div>
<div class="milestone-body">
<div class="milestone-tag">SPUTNIK 1 · 第一颗人造卫星</div>
<h3 class="milestone-title">第一次,人造物体绕地球而行</h3>
<p class="milestone-desc">直径 58 厘米的金属球在拜科努尔升空,每 96 分钟绕地球一圈,发出"哔——哔——"的无线电信号。这一刻,太空时代正式开始。</p>
<div class="milestone-meta">
<span><b>质量</b> 83.6 kg</span>
<span><b>轨道</b> 215 — 939 km</span>
<span><b>馆藏</b> 1/1 复刻原件</span>
</div>
</div>
</div>
<div class="milestone">
<div class="milestone-year">
1961
<span class="month">04 / 12 · USSR</span>
<span class="milestone-dot"></span>
</div>
<div class="milestone-body">
<div class="milestone-tag">VOSTOK 1 · 第一位人类宇航员</div>
<h3 class="milestone-title">尤里·加加林:「我看见了地球,多么美丽」</h3>
<p class="milestone-desc">27 岁的少校升空,在轨 108 分钟。当他回到地面,整个 20 世纪的科学想象第一次被身体证实。馆内陈列其原版头盔记录册。</p>
<div class="milestone-meta">
<span><b>飞行时长</b> 108 min</span>
<span><b>最大高度</b> 327 km</span>
<span><b>馆藏</b> 头盔 · 飞行日志</span>
</div>
</div>
</div>
<div class="milestone">
<div class="milestone-year">
1969
<span class="month">07 / 20 · USA</span>
<span class="milestone-dot"></span>
</div>
<div class="milestone-body">
<div class="milestone-tag">APOLLO 11 · 月面着陆</div>
<h3 class="milestone-title">"这是个人的一小步,是人类的一大步"</h3>
<p class="milestone-desc">阿姆斯特朗踏上静海基地的时刻,全球约 6 亿人同时凝视屏幕。鹰号着陆器、月面采样工具、原始通讯录音,构成本馆 A 厅的核心叙事。</p>
<div class="milestone-meta">
<span><b>月面驻留</b> 21h 36m</span>
<span><b>采样质量</b> 21.5 kg</span>
<span><b>馆藏</b> 月壤碎片 0.4 g</span>
</div>
</div>
</div>
<div class="milestone">
<div class="milestone-year">
1990
<span class="month">04 / 24 · NASA / ESA</span>
<span class="milestone-dot"></span>
</div>
<div class="milestone-body">
<div class="milestone-tag">HUBBLE · 哈勃空间望远镜入轨</div>
<h3 class="milestone-title">人类的第一只"轨道之眼"</h3>
<p class="milestone-desc">在 547 公里高度持续工作 35 年,拍下超过 150 万张照片,重写了宇宙学教科书。"哈勃深场"那张布满数千个星系的照片,至今仍在馆内 D 厅放大投影。</p>
<div class="milestone-meta">
<span><b>主镜直径</b> 2.4 m</span>
<span><b>已运行</b> 35 年+</span>
<span><b>馆藏</b> 备份镜片 1 / 7</span>
</div>
</div>
</div>
<div class="milestone">
<div class="milestone-year">
1998
<span class="month">11 / 20 · 国际合作</span>
<span class="milestone-dot"></span>
</div>
<div class="milestone-body">
<div class="milestone-tag">ISS · 国际空间站第一个舱段</div>
<h3 class="milestone-title">人类在轨道上有了"家"</h3>
<p class="milestone-desc">15 个国家 / 50 余次发射 / 累计已驻 270+ 人。这是人类有史以来最大规模的和平合作工程。B 厅可进入 1:1 复刻的"团结号"节点舱。</p>
<div class="milestone-meta">
<span><b>轨道高度</b> 408 km</span>
<span><b>累计驻留</b> 9 800+ 天</span>
<span><b>馆藏</b> 工具袋 · 太阳翼样品</span>
</div>
</div>
</div>
<div class="milestone">
<div class="milestone-year">
2012
<span class="month">08 / 06 · NASA</span>
<span class="milestone-dot"></span>
</div>
<div class="milestone-body">
<div class="milestone-tag">CURIOSITY · 好奇号火星着陆</div>
<h3 class="milestone-title">"7 分钟恐惧",一辆小汽车降落火星</h3>
<p class="milestone-desc">通过空中吊车系统精准放置在盖尔陨石坑。它在火星上工作至今 14 年,行驶 33 公里,发现古河床与有机化合物,确认火星曾经宜居。</p>
<div class="milestone-meta">
<span><b>质量</b> 899 kg</span>
<span><b>已行驶</b> 33 km</span>
<span><b>馆藏</b> 工程备份件 1:1</span>
</div>
</div>
</div>
<div class="milestone">
<div class="milestone-year">
2021
<span class="month">12 / 25 · NASA / ESA / CSA</span>
<span class="milestone-dot"></span>
</div>
<div class="milestone-body">
<div class="milestone-tag">JWST · 詹姆斯·韦伯空间望远镜</div>
<h3 class="milestone-title">看到 135 亿年前的第一缕星光</h3>
<p class="milestone-desc">部署在距地球 150 万公里的 L2 点,主镜由 18 块六边形金箔反射镜拼合。它正在告诉我们关于宇宙最早期的故事 —— 这部故事我们才刚翻开第一页。</p>
<div class="milestone-meta">
<span><b>主镜直径</b> 6.5 m</span>
<span><b>距地距离</b> 1.5 M km</span>
<span><b>馆藏</b> 全尺寸主镜模型</span>
</div>
</div>
</div>
<div class="milestone">
<div class="milestone-year">
2046
<span class="month">03 / 14 · INTL.</span>
<span class="milestone-dot"></span>
</div>
<div class="milestone-body">
<div class="milestone-tag">ARES V · 火卫一首次载人任务</div>
<h3 class="milestone-title">人类第一次抵达另一颗行星的卫星</h3>
<p class="milestone-desc">由 17 国联合发起的 Ares V 任务6 名宇航员在火卫一驻留 32 天。本馆 C 厅 2046 春季临展 —— 「红色海岸线」 —— 即基于这次任务全部原始数据策划。</p>
<div class="milestone-meta">
<span><b>船员</b> 6 人</span>
<span><b>驻留</b> 32 天</span>
<span><b>馆藏</b> 火卫一表岩屑 12 g</span>
</div>
</div>
</div>
</div>
</section>
<!-- ============ RESERVATION ============ -->
<section class="reserve" id="reserve">
<div class="container">
<div class="section-head">
<span class="num-tag">IV / 参观</span>
<h2 class="display">预约一段<br><em class="italic">无重力的</em>下午</h2>
<p class="desc">所有展厅采用预约制。日票包含 4 个常设展厅,可选 1 场策展人导览。学生与学龄前儿童免费70 岁以上长者享专属时段。</p>
</div>
<div class="reserve-card">
<div class="reserve-left">
<div class="reserve-eyebrow">VISIT · 预约参观</div>
<h3 class="reserve-title">三步,<br>抵达<em>星海</em></h3>
<p class="reserve-desc">选择日期与时段,填写参观信息,凭电子票券于馆门口星图扫码入馆。每日开放 1 800 人次,建议提前 7 天预约。</p>
<div class="reserve-actions">
<a href="#" class="btn-primary">立即预约 →</a>
<a href="#" class="btn-ghost">查看导览路线</a>
</div>
</div>
<div class="ticket">
<div class="ticket-head">
<div class="ticket-brand">
STELLARIA
<small>Day Pass · 日票</small>
</div>
<div class="ticket-id">
<b>No. 04 / 28 / 2046</b>
ID · ST-46-04-2810
</div>
</div>
<div class="ticket-grid">
<div class="ticket-cell">
<div class="label">DATE / 日期</div>
<div class="value">04. 28<small>SUN</small></div>
</div>
<div class="ticket-cell">
<div class="label">TIME / 时段</div>
<div class="value">14:00<small>— 17:00</small></div>
</div>
<div class="ticket-cell">
<div class="label">HALL / 展厅</div>
<div class="value">A · B · C · D</div>
</div>
<div class="ticket-cell">
<div class="label">GUESTS / 人数</div>
<div class="value">2<small>位成人</small></div>
</div>
</div>
<div class="ticket-foot">
<div class="ticket-bars">
<span style="height: 22px;"></span><span style="height: 14px;"></span>
<span style="height: 26px;"></span><span style="height: 10px;"></span>
<span style="height: 18px;"></span><span style="height: 24px;"></span>
<span style="height: 12px;"></span><span style="height: 20px;"></span>
<span style="height: 28px;"></span><span style="height: 14px;"></span>
<span style="height: 22px;"></span><span style="height: 16px;"></span>
<span style="height: 26px;"></span><span style="height: 10px;"></span>
<span style="height: 18px;"></span>
</div>
<div class="ticket-stamp">CONFIRMED</div>
</div>
</div>
</div>
</div>
</section>
<!-- ============ FOOTER ============ -->
<footer id="archive">
<div class="container">
<div class="footer-grid">
<div class="footer-brand">
<div class="logo">STELLARIA · 星海博物馆</div>
<div class="tagline">人类太空探索的档案、现场与下一站。我们不只是回望过去 —— 我们也为未来 50 年的太空文明留下证据。</div>
<form class="newsletter" onsubmit="event.preventDefault(); this.querySelector('button').textContent='已订阅';">
<input type="email" placeholder="你的邮箱地址" required>
<button type="submit">订阅</button>
</form>
</div>
<div class="footer-col">
<h4>展览</h4>
<ul>
<li>登月时代</li>
<li>空间站生活</li>
<li>火星计划</li>
<li>深空探索</li>
<li>临展 · 红色海岸线</li>
</ul>
</div>
<div class="footer-col">
<h4>访问</h4>
<ul>
<li><b>09:00 — 21:30</b><small>每日开馆 · 周一闭馆</small></li>
<li><b>+86 21 5555 0408</b><small>访客服务</small></li>
<li><b>上海市浦东新区</b><small>张衡路 1957 号</small></li>
</ul>
</div>
<div class="footer-col">
<h4>档案 / 联系</h4>
<ul>
<li>研究档案库</li>
<li>策展人计划</li>
<li>学校与团体</li>
<li>媒体咨询</li>
<li>加入 STELLARIA</li>
</ul>
</div>
</div>
<div class="footer-mega">STELLARIA</div>
<div class="footer-bot">
<div class="footer-status">
<span class="dot"></span>
<span>系统运行中 · UPLINK STABLE · 51.6446° N · 82.7° W</span>
</div>
<div>© 2046 STELLARIA Foundation · 沪 ICP 备 2031-0408 号</div>
</div>
</div>
</footer>
<script type="module">
import * as THREE from 'three';
/* ===================================================================
PROCEDURAL PLANET SHADERS
- One mesh per planet, shared SphereGeometry
- FBM (simplex) noise drives surface variation
- Per-type branch (earth / moon / mars / gas / ocean-world)
- Separate atmosphere shell with Fresnel + additive blend
================================================================= */
const NOISE_GLSL = `
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); }
vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
float snoise(vec3 v) {
const vec2 C = vec2(1.0/6.0, 1.0/3.0);
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
vec3 i = floor(v + dot(v, C.yyy));
vec3 x0 = v - i + dot(i, C.xxx);
vec3 g = step(x0.yzx, x0.xyz);
vec3 l = 1.0 - g;
vec3 i1 = min(g.xyz, l.zxy);
vec3 i2 = max(g.xyz, l.zxy);
vec3 x1 = x0 - i1 + C.xxx;
vec3 x2 = x0 - i2 + C.yyy;
vec3 x3 = x0 - D.yyy;
i = mod289(i);
vec4 p = permute(permute(permute(
i.z + vec4(0.0, i1.z, i2.z, 1.0))
+ i.y + vec4(0.0, i1.y, i2.y, 1.0))
+ i.x + vec4(0.0, i1.x, i2.x, 1.0));
float n_ = 0.142857142857;
vec3 ns = n_ * D.wyz - D.xzx;
vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
vec4 x_ = floor(j * ns.z);
vec4 y_ = floor(j - 7.0 * x_);
vec4 x = x_ * ns.x + ns.yyyy;
vec4 y = y_ * ns.x + ns.yyyy;
vec4 h = 1.0 - abs(x) - abs(y);
vec4 b0 = vec4(x.xy, y.xy);
vec4 b1 = vec4(x.zw, y.zw);
vec4 s0 = floor(b0) * 2.0 + 1.0;
vec4 s1 = floor(b1) * 2.0 + 1.0;
vec4 sh = -step(h, vec4(0.0));
vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
vec3 p0 = vec3(a0.xy, h.x);
vec3 p1 = vec3(a0.zw, h.y);
vec3 p2 = vec3(a1.xy, h.z);
vec3 p3 = vec3(a1.zw, h.w);
vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3)));
p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
m = m * m;
return 42.0 * dot(m * m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3)));
}
float fbm(vec3 p) {
float v = 0.0;
float a = 0.5;
for (int i = 0; i < 5; i++) {
v += a * snoise(p);
p *= 2.02;
a *= 0.5;
}
return v;
}
`;
const PLANET_V = `
varying vec3 vNormalObj; // object-space (for caps / bands / noise sampling alignment)
varying vec3 vNormalWorld; // world-space (correct lighting against fixed sun)
varying vec3 vViewNormal; // view-space (Fresnel rim against camera)
varying vec3 vModelPos;
void main() {
vNormalObj = normalize(normal);
vNormalWorld = normalize((modelMatrix * vec4(normal, 0.0)).xyz);
vViewNormal = normalize(normalMatrix * normal);
vModelPos = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const PLANET_F = `
precision highp float;
varying vec3 vNormalObj;
varying vec3 vNormalWorld;
varying vec3 vViewNormal;
varying vec3 vModelPos;
uniform float uTime;
uniform vec3 uLightDir; // world space
uniform vec3 uColA;
uniform vec3 uColB;
uniform vec3 uColC;
uniform vec3 uColD;
uniform float uType; // 0 ocean, 1 moon, 2 mars, 3 gas, 4 earth
uniform float uNoiseScale;
uniform vec3 uAtmoColor;
uniform float uAtmoStrength;
uniform float uAmbient;
${NOISE_GLSL}
void main() {
// Lighting uses WORLD-space normal so the sun stays fixed while the planet spins.
vec3 Nw = normalize(vNormalWorld);
vec3 L = normalize(uLightDir);
float NdotL = dot(Nw, L);
float light = clamp(NdotL, 0.0, 1.0);
// Object-space normal for features that should follow the spin axis (poles / bands)
vec3 N = normalize(vNormalObj);
vec3 p = vModelPos * uNoiseScale;
vec3 surface;
if (uType < 0.5) {
// ----- OCEAN WORLD (hero) -----
float h = fbm(p);
float deep = smoothstep(-0.4, 0.05, h);
vec3 water = mix(uColC * 0.55, uColC, deep);
vec3 land = mix(uColA, uColB, smoothstep(0.05, 0.35, h));
float landMask = smoothstep(0.18, 0.26, h);
surface = mix(water, land, landMask);
// wispy upper-atmosphere clouds (slow drift)
float clouds = fbm(p * 1.3 + vec3(uTime * 0.012, 0.0, uTime * 0.006));
surface = mix(surface, vec3(0.92, 0.96, 1.0), smoothstep(0.30, 0.55, clouds) * 0.40);
// ice toward poles
float lat = abs(N.y);
surface = mix(surface, vec3(0.88, 0.93, 0.96), smoothstep(0.78, 0.95, lat) * 0.7);
} else if (uType < 1.5) {
// ----- MOON -----
float h = fbm(p * 1.4);
float craters = fbm(p * 4.5);
surface = mix(uColC, uColA, smoothstep(-0.3, 0.45, h));
surface = mix(surface, uColB, smoothstep(0.4, 0.75, craters) * 0.55);
// dark mare patches (large smooth basins)
float mare = fbm(p * 0.7);
surface = mix(surface, uColC * 0.55, smoothstep(0.45, 0.85, mare) * 0.85);
// tiny crater-rim brightening
float rim = abs(fbm(p * 8.0));
surface += vec3(0.06) * smoothstep(0.65, 0.85, rim);
} else if (uType < 2.5) {
// ----- MARS -----
float h = fbm(p);
surface = mix(uColC, uColA, smoothstep(-0.25, 0.25, h));
surface = mix(surface, uColB, smoothstep(0.30, 0.55, h));
// dust/canyon detail
float detail = fbm(p * 3.0);
surface = mix(surface, surface * 0.78, smoothstep(0.25, 0.6, detail) * 0.4);
// polar ice caps
float lat = abs(N.y);
float capNoise = fbm(p * 2.0) * 0.05;
surface = mix(surface, vec3(0.96, 0.95, 0.92),
smoothstep(0.82 + capNoise, 0.92 + capNoise, lat) * 0.95);
} else if (uType < 3.5) {
// ----- GAS GIANT -----
// latitudinal bands warped by noise
float warp = fbm(p * 0.8) * 0.25;
float band = sin((vModelPos.y + warp) * 14.0);
float bandMix = smoothstep(-0.2, 0.4, band);
surface = mix(uColC, uColA, bandMix);
surface = mix(surface, uColB, smoothstep(0.5, 1.0, band));
// turbulent storms
float storm = fbm(p * 2.2 + vec3(uTime * 0.02, 0.0, 0.0));
surface = mix(surface, uColD, smoothstep(0.45, 0.72, storm) * 0.55);
// giant great-spot-style oval near a latitude
float spot = exp(-pow((vModelPos.y + 0.18) * 5.0, 2.0)
- pow(vModelPos.x * 2.0, 2.0)) * step(0.0, vModelPos.z);
surface = mix(surface, uColD * 1.1, spot * 0.6);
} else {
// ----- EARTH (Hall B) -----
float h = fbm(p * 1.15);
float landMask = smoothstep(0.0, 0.08, h);
vec3 ocean = mix(uColC * 0.7, uColC, smoothstep(-0.4, 0.0, h));
// land variation: coast / forest / mountain / desert
float climate = fbm(p * 2.0 + 11.0);
vec3 land = mix(uColA, uColB, smoothstep(0.1, 0.55, h + climate * 0.3));
// arid/desert biomes
land = mix(land, vec3(0.78, 0.62, 0.42), smoothstep(0.55, 0.85, climate) * 0.5);
surface = mix(ocean, land, landMask);
// clouds
float clouds = fbm(p * 1.7 + vec3(uTime * 0.025, 0.0, uTime * 0.012));
surface = mix(surface, vec3(0.97), smoothstep(0.30, 0.58, clouds) * 0.65);
// ice caps
float lat = abs(N.y);
surface = mix(surface, vec3(0.95, 0.97, 1.0), smoothstep(0.83, 0.95, lat) * 0.85);
}
// ---- lighting model ----
// Wrap-around diffuse: dark side gets a touch of fill instead of pitch black
float wrap = clamp(NdotL * 0.5 + 0.5, 0.0, 1.0);
float diffuse = mix(wrap * wrap * 0.30, 1.0, light);
vec3 lit = surface * diffuse;
// Cool ambient bounce in atmosphere color
lit += surface * uAtmoColor * uAmbient;
// Warm terminator glow at the day/night boundary
float term = smoothstep(-0.20, 0.02, NdotL) * (1.0 - smoothstep(0.02, 0.30, NdotL));
lit += vec3(1.0, 0.55, 0.25) * term * 0.22;
// Atmospheric rim on the planet's lit hemisphere (bridges into the outer halo)
float fres = pow(1.0 - max(0.0, vViewNormal.z), 2.2);
lit += uAtmoColor * fres * uAtmoStrength * smoothstep(-0.20, 0.55, NdotL);
gl_FragColor = vec4(lit, 1.0);
}
`;
const ATMO_V = `
varying vec3 vViewNormal;
varying vec3 vNormalWorld;
void main() {
vViewNormal = normalize(normalMatrix * normal);
vNormalWorld = normalize((modelMatrix * vec4(normal, 0.0)).xyz);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const ATMO_F = `
precision highp float;
varying vec3 vViewNormal;
varying vec3 vNormalWorld;
uniform vec3 uColor;
uniform float uIntensity;
uniform vec3 uLightDir;
void main() {
// BackSide: visible faces have normals pointing away from camera (z < 0).
// 'edge' = 1 at silhouette, 0 at center. Higher exponent = thinner, sharper rim.
float edge = pow(clamp(1.0 + vViewNormal.z, 0.0, 1.0), 3.5);
// Modulate by sun-facing — atmosphere glows brighter on lit hemisphere,
// dim but still slightly visible on the night side for a "twilight halo"
float lit = clamp(dot(normalize(vNormalWorld), normalize(uLightDir)), -1.0, 1.0);
float litFactor = mix(0.18, 1.0, smoothstep(-0.35, 0.40, lit));
gl_FragColor = vec4(uColor * litFactor, edge * uIntensity * litFactor);
}
`;
/* ----------------- presets ----------------- */
const PRESETS = {
hero: {
// Cinematic Earth-like world (uses richer EARTH shader branch)
type: 4.0,
colA: [0.32, 0.55, 0.38], // verdant land
colB: [0.74, 0.62, 0.40], // warm sand / mountain
colC: [0.08, 0.30, 0.52], // vivid ocean
colD: [0.0, 0.0, 0.0],
noiseScale: 1.55,
atmoColor: [0.50, 0.78, 1.0],
atmoStrength: 0.55, // inner rim glow on planet body
atmoOuter: 0.85, // outer halo intensity (thinner shell now)
atmoSize: 1.025, // very thin shell — atmosphere reads as glow, not ring
ambient: 0.06, // tinted ambient on dark side
lightDir: [0.50, 0.20, 0.85], // light comes from front-right, lights ~70% of visible disc
tilt: 0.38,
rotSpeed: 0.040, fov: 26, dist: 5.4,
planetScale: 1.0, position: [1.2, 0.55, 0],
},
moon: {
type: 1.0,
colA: [0.66, 0.62, 0.55],
colB: [0.85, 0.82, 0.74],
colC: [0.22, 0.20, 0.18],
colD: [0.0, 0.0, 0.0],
noiseScale: 2.1,
atmoColor: [0.55, 0.55, 0.55],
atmoStrength: 0.04, atmoOuter: 0.0, atmoSize: 1.0,
ambient: 0.025,
lightDir: [0.45, 0.20, 0.85], tilt: 0.18,
rotSpeed: 0.030, fov: 26, dist: 5.2,
planetScale: 1.0, position: [0, 0, 0],
},
earth: {
type: 4.0,
colA: [0.30, 0.50, 0.30], // forest land
colB: [0.62, 0.50, 0.34], // mountain
colC: [0.07, 0.27, 0.45], // ocean
colD: [0.0, 0.0, 0.0],
noiseScale: 1.85,
atmoColor: [0.45, 0.74, 1.0],
atmoStrength: 0.55, atmoOuter: 0.7, atmoSize: 1.025,
ambient: 0.05,
lightDir: [0.50, 0.25, 0.85], tilt: 0.40,
rotSpeed: 0.058, fov: 26, dist: 5.2,
planetScale: 1.0, position: [0, 0, 0],
},
mars: {
type: 2.0,
colA: [0.72, 0.36, 0.20],
colB: [0.86, 0.55, 0.34],
colC: [0.32, 0.12, 0.07],
colD: [0.0, 0.0, 0.0],
noiseScale: 1.85,
atmoColor: [0.92, 0.62, 0.42],
atmoStrength: 0.20, atmoOuter: 0.20, atmoSize: 1.018,
ambient: 0.04,
lightDir: [0.50, 0.25, 0.85], tilt: 0.42,
rotSpeed: 0.040, fov: 26, dist: 5.2,
planetScale: 1.0, position: [0, 0, 0],
},
gas: {
type: 3.0,
colA: [0.82, 0.66, 0.46],
colB: [0.92, 0.83, 0.65],
colC: [0.40, 0.27, 0.18],
colD: [0.78, 0.40, 0.26],
noiseScale: 1.6,
atmoColor: [0.96, 0.78, 0.55],
atmoStrength: 0.28, atmoOuter: 0.38, atmoSize: 1.022,
ambient: 0.05,
lightDir: [0.55, 0.15, 0.85], tilt: 0.10,
rotSpeed: 0.082, fov: 26, dist: 5.0,
planetScale: 1.05, position: [0, 0, 0],
},
};
/* ----------------- planet factory ----------------- */
const SHARED_GEO = new THREE.SphereGeometry(1, 128, 128);
const SHARED_ATMO_GEO = new THREE.SphereGeometry(1, 64, 64);
function makePlanet(canvas, presetKey) {
const p = PRESETS[presetKey];
const renderer = new THREE.WebGLRenderer({
canvas, antialias: true, alpha: true, powerPreference: 'high-performance',
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(p.fov, 1, 0.1, 100);
camera.position.set(0, 0, p.dist);
const lightDir = new THREE.Vector3(...p.lightDir).normalize();
const planetMat = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uLightDir: { value: lightDir.clone() },
uColA: { value: new THREE.Color(...p.colA) },
uColB: { value: new THREE.Color(...p.colB) },
uColC: { value: new THREE.Color(...p.colC) },
uColD: { value: new THREE.Color(...p.colD) },
uType: { value: p.type },
uNoiseScale: { value: p.noiseScale },
uAtmoColor: { value: new THREE.Color(...p.atmoColor) },
uAtmoStrength: { value: p.atmoStrength },
uAmbient: { value: p.ambient ?? 0.05 },
},
vertexShader: PLANET_V,
fragmentShader: PLANET_F,
});
const planet = new THREE.Mesh(SHARED_GEO, planetMat);
planet.scale.setScalar(p.planetScale);
planet.position.set(...p.position);
planet.rotation.z = p.tilt;
scene.add(planet);
let atmo = null, atmoMat = null;
if (p.atmoOuter > 0.01) {
atmoMat = new THREE.ShaderMaterial({
uniforms: {
uColor: { value: new THREE.Color(...p.atmoColor) },
uIntensity: { value: p.atmoOuter },
uLightDir: { value: lightDir.clone() },
},
vertexShader: ATMO_V,
fragmentShader: ATMO_F,
side: THREE.BackSide,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
atmo = new THREE.Mesh(SHARED_ATMO_GEO, atmoMat);
atmo.scale.setScalar(p.planetScale * p.atmoSize);
atmo.position.copy(planet.position);
scene.add(atmo);
}
function resize() {
const w = canvas.clientWidth | 0;
const h = canvas.clientHeight | 0;
if (w === 0 || h === 0) return;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
resize();
new ResizeObserver(resize).observe(canvas);
return {
planet, planetMat, scene, camera, renderer,
visible: true,
preset: p,
render() { renderer.render(scene, camera); },
};
}
/* ----------------- space station factory ----------------- */
function makeStation(canvas) {
const renderer = new THREE.WebGLRenderer({
canvas, antialias: true, alpha: true, powerPreference: 'high-performance',
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(26, 1, 0.1, 100);
// Move camera further away to make the station look smaller (from 5.2 to 7.2)
camera.position.set(0, 0, 7.2);
// Warm main light (sunlight)
const dirLight = new THREE.DirectionalLight(0xfff4e6, 3.5);
dirLight.position.set(3, 2, 4);
scene.add(dirLight);
// Cool rim light (earth bounce)
const rimLight = new THREE.DirectionalLight(0x4080ff, 2.0);
rimLight.position.set(-3, -2, -2);
scene.add(rimLight);
const ambLight = new THREE.AmbientLight(0x202a3a, 1.5);
scene.add(ambLight);
const outerGroup = new THREE.Group();
const station = new THREE.Group();
// materials
const matSilver = new THREE.MeshStandardMaterial({ color: 0xf0f0f0, metalness: 0.9, roughness: 0.2 });
const matDark = new THREE.MeshStandardMaterial({ color: 0x333333, metalness: 0.8, roughness: 0.5 });
const matBlue = new THREE.MeshStandardMaterial({
color: 0x051a4a,
metalness: 0.6,
roughness: 0.3,
emissive: 0x021030,
emissiveIntensity: 0.5
});
const matGold = new THREE.MeshStandardMaterial({ color: 0xcca300, metalness: 0.8, roughness: 0.3 });
const matGlow = new THREE.MeshBasicMaterial({ color: 0xffddaa });
// main cylinder core
const coreGeo = new THREE.CylinderGeometry(0.08, 0.08, 1.6, 16);
const core = new THREE.Mesh(coreGeo, matSilver);
core.rotation.z = Math.PI / 2;
station.add(core);
// central node
const centerGeo = new THREE.SphereGeometry(0.16, 16, 16);
const centerMod = new THREE.Mesh(centerGeo, matSilver);
station.add(centerMod);
// solar panels
const panelGeo = new THREE.BoxGeometry(0.5, 0.015, 2.0);
// Group panels to add structural details
const createPanel = (x) => {
const group = new THREE.Group();
const p = new THREE.Mesh(panelGeo, matBlue);
group.add(p);
// Panel spine
const spineGeo = new THREE.BoxGeometry(0.04, 0.03, 2.05);
const spine = new THREE.Mesh(spineGeo, matDark);
group.add(spine);
group.position.set(x, 0, 0);
return group;
};
station.add(createPanel(-0.8));
station.add(createPanel(-1.5));
station.add(createPanel(0.8));
station.add(createPanel(1.5));
// truss
const trussGeo = new THREE.BoxGeometry(3.8, 0.04, 0.04);
const truss = new THREE.Mesh(trussGeo, matGold);
station.add(truss);
// lower modules (habitat/labs)
const botModGeo = new THREE.CylinderGeometry(0.12, 0.12, 0.7, 16);
const botMod = new THREE.Mesh(botModGeo, matSilver);
botMod.position.set(0, -0.45, 0);
station.add(botMod);
// upper modules
const topModGeo = new THREE.CylinderGeometry(0.1, 0.1, 0.5, 16);
const topMod = new THREE.Mesh(topModGeo, matSilver);
topMod.position.set(0.2, 0.35, 0);
station.add(topMod);
// add some tiny lights (windows / beacons)
const lightGeo = new THREE.SphereGeometry(0.015, 8, 8);
const l1 = new THREE.Mesh(lightGeo, matGlow);
l1.position.set(0, -0.6, 0.12);
station.add(l1);
const l2 = new THREE.Mesh(lightGeo, matGlow);
l2.position.set(0.2, 0.4, 0.1);
station.add(l2);
// Initial rotation for a nice isometric angle
station.rotation.x = 0.4;
station.rotation.y = -0.2;
station.rotation.z = 0.3;
// Scale down the entire station slightly to make it look more delicate
station.scale.setScalar(0.85);
outerGroup.add(station);
scene.add(outerGroup);
function resize() {
const w = canvas.clientWidth | 0;
const h = canvas.clientHeight | 0;
if (w === 0 || h === 0) return;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
resize();
new ResizeObserver(resize).observe(canvas);
return {
planet: outerGroup,
planetMat: { uniforms: { uTime: { value: 0 } } },
scene, camera, renderer,
visible: true,
preset: { rotSpeed: 0.12 }, // slightly slower, more majestic rotation
render() { renderer.render(scene, camera); },
};
}
/* ----------------- galaxy factory ----------------- */
function makeGalaxy(canvas) {
const renderer = new THREE.WebGLRenderer({
canvas, antialias: true, alpha: true, powerPreference: 'high-performance',
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(30, 1, 0.1, 100);
camera.position.set(0, 3.5, 7.0);
camera.lookAt(0, 0, 0);
// Create a soft glowing particle texture using canvas
const texCanvas = document.createElement('canvas');
texCanvas.width = 64;
texCanvas.height = 64;
const ctx = texCanvas.getContext('2d');
const grad = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
grad.addColorStop(0, 'rgba(255,255,255,1)');
grad.addColorStop(0.2, 'rgba(255,255,255,0.8)');
grad.addColorStop(0.5, 'rgba(255,255,255,0.2)');
grad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 64, 64);
const texture = new THREE.CanvasTexture(texCanvas);
const count = 12000;
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3);
const colorCore = new THREE.Color(0xffeebb);
const colorEdge = new THREE.Color(0x1155ff);
const colorDust = new THREE.Color(0xcc4466);
for(let i = 0; i < count; i++) {
const radius = Math.random() * 3.2;
const spinAngle = radius * 3.0;
const branchAngle = ((i % 4) / 4) * Math.PI * 2;
// Add organic noise to the spiral arms
const randomX = (Math.random() - 0.5) * 1.2 * (2.0 - radius * 0.4);
const randomY = (Math.random() - 0.5) * 0.6 * Math.exp(-radius * 1.5); // Thinner at the edges
const randomZ = (Math.random() - 0.5) * 1.2 * (2.0 - radius * 0.4);
positions[i*3] = Math.cos(branchAngle + spinAngle) * radius + randomX;
positions[i*3+1] = randomY;
positions[i*3+2] = Math.sin(branchAngle + spinAngle) * radius + randomZ;
const mixed = colorCore.clone();
if (Math.random() > 0.85 && radius > 0.8) {
mixed.lerp(colorDust, Math.random());
} else {
mixed.lerp(colorEdge, Math.min(radius / 2.8, 1.0));
}
colors[i*3] = mixed.r;
colors[i*3+1] = mixed.g;
colors[i*3+2] = mixed.b;
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const material = new THREE.PointsMaterial({
size: 0.18,
map: texture,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
vertexColors: true,
opacity: 0.9
});
const galaxy = new THREE.Points(geometry, material);
// Central supermassive glow
const coreGeo = new THREE.SphereGeometry(0.3, 16, 16);
const coreMat = new THREE.MeshBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.8,
blending: THREE.AdditiveBlending
});
const coreGlow = new THREE.Mesh(coreGeo, coreMat);
const group = new THREE.Group();
group.add(galaxy);
group.add(coreGlow);
group.rotation.x = 0.5;
group.rotation.z = 0.2;
scene.add(group);
function resize() {
const w = canvas.clientWidth | 0;
const h = canvas.clientHeight | 0;
if (w === 0 || h === 0) return;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
resize();
new ResizeObserver(resize).observe(canvas);
return {
planet: group,
planetMat: null,
scene, camera, renderer,
visible: true,
preset: { rotSpeed: 0.08 },
render() { renderer.render(scene, camera); },
};
}
/* ----------------- init all planets ----------------- */
const planets = [];
// --- hero ---
const heroCanvas = document.getElementById('hero-planet');
if (heroCanvas) {
const hero = heroCanvas.parentElement;
function sizeHero() {
heroCanvas.style.width = hero.clientWidth + 'px';
heroCanvas.style.height = hero.clientHeight + 'px';
}
sizeHero();
new ResizeObserver(sizeHero).observe(hero);
const heroPlanet = makePlanet(heroCanvas, 'hero');
// Adjust composition based on aspect ratio. The planet sits in the
// upper-right quadrant so the title at bottom-left has clean breathing room.
function reposeHero() {
const aspect = hero.clientWidth / hero.clientHeight;
let pos, scale;
if (aspect > 1.9) {
// ultrawide
pos = [1.15, 0.30, 0]; scale = 0.68;
} else if (aspect > 1.4) {
// standard desktop
pos = [0.85, 0.30, 0]; scale = 0.58;
} else if (aspect > 1.0) {
// narrow desktop / landscape tablet
pos = [0.55, 0.45, 0]; scale = 0.48;
} else if (aspect > 0.7) {
// portrait tablet
pos = [0.20, 0.80, 0]; scale = 0.45;
} else {
// mobile portrait
pos = [0.0, 0.95, 0]; scale = 0.42;
}
heroPlanet.planet.position.set(...pos);
heroPlanet.planet.scale.setScalar(scale);
const atmo = heroPlanet.scene.children.find(o => o !== heroPlanet.planet && o.isMesh);
if (atmo) {
atmo.position.copy(heroPlanet.planet.position);
atmo.scale.setScalar(scale * heroPlanet.preset.atmoSize);
}
}
reposeHero();
new ResizeObserver(reposeHero).observe(hero);
heroCanvas._planet = heroPlanet;
planets.push(heroPlanet);
}
// --- exhibits ---
document.querySelectorAll('canvas.planet-canvas').forEach(c => {
const key = c.dataset.planet;
if (key === 'station') {
const inst = makeStation(c);
c._planet = inst;
planets.push(inst);
return;
}
if (key === 'galaxy') {
const inst = makeGalaxy(c);
c._planet = inst;
planets.push(inst);
return;
}
if (!PRESETS[key]) return;
const inst = makePlanet(c, key);
c._planet = inst;
planets.push(inst);
});
/* ----------------- visibility pause ----------------- */
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
const p = e.target._planet;
if (p) p.visible = e.isIntersecting;
}
}, { rootMargin: '120px', threshold: 0.0 });
for (const p of planets) io.observe(p.renderer.domElement);
/* ----------------- single render loop ----------------- */
const start = performance.now();
let lastT = 0;
function loop(now) {
const t = (now - start) / 1000;
for (const p of planets) {
if (!p.visible) continue;
// Safely update uTime uniform if it exists
if (p.planetMat && p.planetMat.uniforms && p.planetMat.uniforms.uTime) {
p.planetMat.uniforms.uTime.value = t;
}
if (!reduced) {
// Make sure we rotate the planet object, not something else
if (p.planet) {
if (p.planet.rotation) {
p.planet.rotation.y = t * (p.preset?.rotSpeed || 0.05);
}
}
}
if (p.render) {
p.render();
}
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
<script>
/* ----------- Starfield ----------- */
(function () {
const canvas = document.getElementById('stars');
const ctx = canvas.getContext('2d');
let stars = [];
let w, h, dpr;
function resize() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
w = canvas.width = window.innerWidth * dpr;
h = canvas.height = window.innerHeight * dpr;
canvas.style.width = window.innerWidth + 'px';
canvas.style.height = window.innerHeight + 'px';
generate();
}
function generate() {
const count = Math.floor((window.innerWidth * window.innerHeight) / 4500);
stars = new Array(count).fill(0).map(() => ({
x: Math.random() * w,
y: Math.random() * h,
r: Math.random() * 1.2 * dpr + 0.2 * dpr,
a: Math.random() * 0.6 + 0.2,
s: Math.random() * 0.012 + 0.003,
phase: Math.random() * Math.PI * 2,
warm: Math.random() < 0.08, // a few warm-tinted stars
}));
}
let t = 0;
function frame() {
t += 0.6;
ctx.clearRect(0, 0, w, h);
for (const s of stars) {
const alpha = s.a * (0.55 + 0.45 * Math.sin(t * s.s + s.phase));
ctx.beginPath();
ctx.fillStyle = s.warm
? `rgba(245, 200, 140, ${alpha})`
: `rgba(220, 230, 245, ${alpha})`;
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fill();
}
requestAnimationFrame(frame);
}
resize();
window.addEventListener('resize', resize);
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
requestAnimationFrame(frame);
} else {
// single static draw
for (const s of stars) {
ctx.beginPath();
ctx.fillStyle = `rgba(220, 230, 245, ${s.a})`;
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fill();
}
}
})();
/* ----------- Nav scroll state ----------- */
(function () {
const nav = document.getElementById('nav');
function onScroll() {
if (window.scrollY > 60) nav.classList.add('scrolled');
else nav.classList.remove('scrolled');
}
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
})();
/* ----------- Live UTC clock for hero ----------- */
(function () {
const live = document.querySelector('.hero-coords .live');
if (!live) return;
function tick() {
const d = new Date();
const hh = String(d.getUTCHours()).padStart(2, '0');
const mm = String(d.getUTCMinutes()).padStart(2, '0');
const ss = String(d.getUTCSeconds()).padStart(2, '0');
live.textContent = `● LIVE · UTC ${hh}:${mm}:${ss}`;
}
tick();
setInterval(tick, 1000);
})();
/* ----------- Smooth anchor scroll (no scrollIntoView) ----------- */
(function () {
document.querySelectorAll('a[href^="#"]').forEach(a => {
a.addEventListener('click', e => {
const id = a.getAttribute('href').slice(1);
const target = document.getElementById(id);
if (!target) return;
e.preventDefault();
const top = target.getBoundingClientRect().top + window.scrollY - 60;
window.scrollTo({ top, behavior: 'smooth' });
});
});
})();
</script>
</body>
</html>